roadmap fragments (§1): one file per ticket, so the merge path has no shared mutable file - #3297
Conversation
…red mutable file (#3296) Every PR writes docs/roadmaps/roadmap.yaml, so the Amdahl serial fraction on the merge path is 1: N pull requests contend on one file however disjoint their code. `.gitattributes` says merge=roadmap, but that driver is CUSTOM and registered per-invocation by ci_resolve_dirty.sh, so GitHub's merge-queue server never runs it. Measured on batch #3295: the textual merge put PMAT-3226 after PMAT-3229 and check_roadmap_sorted.sh went RED while all nine inputs were individually green. A ticket now writes docs/roadmaps/entries/<ID>.yaml. Unique filename by construction => pull requests pairwise disjoint on the roadmap => conflict rate 0 by proof, not by luck. roadmap.yaml is the AGGREGATE, and ordering becomes the aggregator's job: an entry is placed at the slot check_roadmap_sorted.sh demands, using parse_id imported from roadmap_merge.py so the two cannot drift. BASE + FRAGMENTS, not a full split. The base keeps its bytes exactly, which is what row 1 proves and what keeps this diff additive. It also sidesteps a real obstacle: roadmap.yaml carries `created: &id001` on entry 620 aliased by 17 later entries, so a full 878-way split would emit 17 fragments that are individually unparseable YAML. Inlining that anchor is a pure re-serialisation and check_roadmap_diff_additive.sh correctly refuses it (measured: reserialised=18, 'bytes differ, no field actually changed'). The base is never rewritten, so the question does not arise. Verification: aggregate with 0 fragments == roadmap.yaml BYTE-IDENTICAL over 17,785 lines selftest 9 rows, 0 red, including a mutation row: forcing append-only placement must turn the sorted-slot row RED, and does check_roadmap_sorted.sh rc=0 on the aggregate PMAT-3296 placed after PMAT-3229, which is its sorted slot Pmat-Ticket: PMAT-3296
|
§13.11 rung 1 — quorum shadow verdict Shadow mode: this records a verdict and merges nothing. A refusal |
…ed on its own output
`make roadmap-aggregate` runs post-merge on main, so a generator that is not a
pure function of (base, fragments) churns a commit on every merge. The cut in
the previous commit read the LIVE roadmap.yaml as its base and raised
`duplicate id: PMAT-3296 is in the base AND in entries/` the moment its own
output was fed back. It did not fail on the second run — it failed on the
FIRST, against the tree it had just produced. `make roadmap-aggregate` was
broken on this branch as pushed.
A fragment now SUPERSEDES a base entry of the same id rather than colliding
with it: drop, then insert at the sorted slot. aggregate(x) and
aggregate(aggregate(x)) are the same bytes.
Also here:
split() — monolith -> one fragment per ticket, each PROVED to parse alone
and to carry the same mapping, per entry, before anything is
written. roadmap.yaml carries `created: &id001` on entry 620
aliased by 17 later entries, so a fragment carrying `*id001` is
unparseable YAML; split inlines the literal. The base is NOT
rewritten — doing so in place is a pure re-serialisation and
check_roadmap_diff_additive.sh correctly refuses it (measured:
reserialised=18, "bytes differ, no field actually changed").
--check — roadmap.yaml == aggregate(entries/), and re-aggregating is a no-op
make roadmap-aggregate / roadmap-aggregate-check
contracts/apr-roadmap-fragments-v1.yaml — idempotence and determinism are
proof obligations, not prose
Verification:
selftest 12 rows, 0 red (was 9)
+ idempotence, determinism, supersession, duplicate-among-fragments
+ the registered mutation: append-only placement must turn row 2 RED
three consecutive make roadmap-aggregate over the real 879-entry file:
byte-identical to each other AND to the committed file
make roadmap-aggregate-check rc=0
check_roadmap_sorted.sh rc=0
check_roadmap_diff_additive.sh rc=0 added=1 reserialised=0 deleted=0
pv validate 0 error(s), 0 warning(s)
pre-commit complexity: main 49 -> 20 cognitive, split 34 -> under, by
decomposition (_proved_fragment, _refuse_duplicates, _write_fragments,
_check, _emit, _parser) — never by relaxing the ceiling
Pmat-Ticket: PMAT-3296
… is measured
Two gaps, both of the same kind — a claim with nothing under it.
1. SUPERSESSION was documented only as a corollary of idempotence. It is the
load-bearing rule: a fragment supersedes a base entry of the same id, which
is what makes entries/ the ONLY edit path for an existing PMAT-NNNN entry,
which is in turn what lets the gate forbid every PR write to roadmap.yaml.
Someone "fixing" the duplicate-id refusal back would re-open the base as an
edit surface and silently undo the gate. Now RMFR-OB-004.
2. RMFR-F-004 asserted an id-shape census and pointed at a selftest that did
not measure it. census() now runs over the REAL file inside --selftest:
fragmentable (safe + PREFIX-N) 780
safe but legacy-shaped 47
NOT filename-safe 52 (44 prose + 8 PREFIX-N + prose tail)
total 879 no remainder
A second row asserts a real id CONTAINS A PATH SEPARATOR — `Push completed
work to origin/main (5 commits)` is a literal roadmap id — so the
filename-safety regex is load-bearing rather than defensive.
RMFR-OB-005 states the consequence plainly instead of leaving it implied: those
52 have no fragment path and, once PR writes to roadmap.yaml are forbidden, no
write path at all. That is a scope boundary, not an exemption — no gate to
bypass, because no path exists. Re-iding them is refused by
check_roadmap_diff_additive.sh as delete+add (measured: "deleted: 1 base id(s)
missing at head"), so it needs that guard to learn a re-id operation first.
Tracked separately, NOT waived here.
Verification:
selftest 14 rows, 0 red (was 12)
make roadmap-aggregate-check rc=0
pv status 5 obligations, 4 falsification tests
pv validate schema-conformant, 0 schema errors
(it evaluates NO obligations — those are
discharged by the falsification tests above)
complexity: selftest 39 -> 18 cognitive by extracting _census_rows(),
never by relaxing the ceiling
Pmat-Ticket: PMAT-3296
…on purpose
The rule PR2 will enforce: a PR writes ONE fragment named for its own
Pmat-Ticket trailer, and never touches the GENERATED aggregate.
WIRED NOWHERE YET, and that is the point. Run against this very branch it is
RED:
FAIL docs/roadmaps/roadmap.yaml is GENERATED — run `make roadmap-aggregate`
on main, never edit it in a PR
because this branch commits the regenerated aggregate. The migration PR
violates the gate it introduces — the bootstrap problem, demonstrated rather
than argued. It can only be wired after roadmap.yaml stops being PR-written,
which is the pin-bump PR. A guard with no caller is normally theater
(feedback_a_facility_with_a_selftest_and_no_caller); this one is dark for a
sequenced reason, and the reason is testable: wire it today and this PR cannot
land.
OPT-IN ON entries/, deliberately. This logic is destined for `pmat comply`,
called from the SHARED sovereign-ci.yml, which runs in rmedia, infra, apex and
the rest. Those repos still write roadmap.yaml legitimately, so failing them
would be the "UPSTREAM armed a gate, blocked every merge" shape. No
docs/roadmaps/entries/ => NOT-RUN, said out loud, never a silent pass — the
same discipline as the roadmap-valid step it will sit beside.
Case table, 6 rows 0 red, including a REGISTERED MUTATION: removing the
aggregate check must make row 4 pass, and does. The mutation is applied in
python, not sed — the line it replaces contains a pipe and every sed delimiter
worth using appears in it.
Fixtures set core.hooksPath=/dev/null so a hermetic throwaway repo does not run
the developer's global pmat hook. That is the fixture, never the real tree.
bashrs: 0 errors, 5 warnings (mktemp traps added; the rest are info-class).
Pmat-Ticket: PMAT-3296
…tream, and this one ran I said this guard was "wired nowhere, dark on purpose". That was WRONG. `guard_tree.sh` discovers every script in `scripts/` and runs it; `skip_reason` exempts a guard only when it is wired WITH ARGS in a workflow, release-time, or an unwired baseline. Mine is none of those, so guard_tree ran it BARE — with its default `--base $(git merge-base origin/main HEAD)` — against the one branch that legitimately rewrites roadmap.yaml, and it failed exactly as designed: FAIL docs/roadmaps/roadmap.yaml is GENERATED — run `make roadmap-aggregate` It also pushed check_no_pipe_into_grep_q.sh over its ceiling: baseline 75, measured 78, all three sites mine (lines 49, 61 and the mutation needle at 157). Deleting it rather than exempting it, on merit: * The real gate SHIPPED UPSTREAM in paiml/.github#73 (merged, f02067e), inline in sovereign-ci.yml where the consumer repo cannot edit it. * Keeping a copy here is precisely the producer-owns-the-gate problem that PR's own body gives as the reason for inlining it. * Its case table did its job: 6 rows 0 red with a registered mutation, and that is what the upstream logic was proved against. It is preserved in this branch's history at 411e718 and cited from #73. Also regenerates the README CONTRACT_COUNT block: this branch adds contracts/apr-roadmap-fragments-v1.yaml, and FALSIFY-README-002 compares the block against the MERGE tree, not the diff — 1840 -> 1841. The block is generated, so it is an equality and not a ratchet. Verified after: check_no_pipe_into_grep_q.sh baseline 75 measured 75 rc=0 check_readme_claims.sh rc=0 (5/5) make roadmap-aggregate-check rc=0 roadmap_fragments --selftest 14 rows, 0 red Pmat-Ticket: PMAT-3296
|
Flagging before this lands, per the operator's ruling today: pmat already has both halves: a per-ticket work store ( |
… never re-ran the generator guard-cargo refused #3297 with FALSIFY-README-002: the generated CONTRACT_COUNT block said 1841 while the merge tree carries 1842. The extra one is contracts/apr-roadmap-fragments-v1.yaml, which this PR adds. The block is generated, not hand-written, so it was re-run via `make readme-sync` after merging main (so the count is the MERGED tree's), and checked with `make readme-sync-check`. Pmat-Ticket: PMAT-3296
… not by line The textual merge conflicted in two hunks in docs/roadmaps/roadmap.yaml and nothing else. Resolved with the repo's own driver, scripts/lib/roadmap_merge.py, bound per invocation exactly as scripts/ci_resolve_dirty.sh binds it — no shared git config is written. Union proved, not assumed: 881 base + 6 main-added + 1 branch-added = 888 ids, every one of main's 887 entry blocks byte-identical after the merge. Pmat-Ticket: PMAT-3296 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aiml#3297 removed cannot come back (paiml#3352) * a roadmap edit without its fragment is refused (PMAT-3296, paiml#3296) paiml#3297 made docs/roadmaps/roadmap.yaml a GENERATED aggregate of docs/roadmaps/entries/ so that two PRs touching the roadmap touch two different files. Nothing enforced it. Measured on a clean worktree cut from origin/main, 2026-09-16: $ pmat work add "..." --github-issue 3294 M docs/roadmaps/roadmap.yaml <- the MONOLITH, 16 lines, no fragment $ python3 scripts/lib/roadmap_fragments.py aggregate --check ok roadmap.yaml == aggregate(1 fragment(s)), idempotent # rc=0 The existing aggregate check PASSES that edit and cannot do otherwise: `aggregate` takes roadmap.yaml as its own base, so an entry written straight into the base is a fixed point. It answers "is the aggregate consistent?", never "did this change come through the fragment path?". check_roadmap_fragment_required.sh asks the second question, over the base..head diff: 1. every top-level entry whose BYTES change in roadmap.yaml must have docs/roadmaps/entries/<ID>.yaml changing in the same diff; 2. whenever either side changes, head's roadmap.yaml must equal aggregate(head's entries/) -- IMPORTED from roadmap_fragments.py, never restated, so guard and generator cannot drift. A preamble-only change and a diff touching neither side still pass; an id that cannot be a filename has no fragment path at all and is refused with that stated (RMFR-OB-005). Retro-verdicts over the last 8 first-parent commits of origin/main: the one PR that used the fragment path (paiml#3297) PASSES; the two fragment-less roadmap edits (paiml#3348, 3346466) are refused. `pmat work add` writes the monolith, so the gate would block every future ticket. The remedy is real and is NAMED IN THE FAILURE MESSAGE: `roadmap_fragments.py adopt <ID>` moves the entry into entries/<ID>.yaml (proved to parse alone and to carry the same mapping) and regenerates the aggregate, which also moves it to its sorted slot. This commit's own PMAT-3294 entry took that path: check_roadmap_sorted.sh PASS, check_roadmap_diff_additive.sh added=1 reserialised=0. Case table: 12 rows, hermetic fixture repos. Mutations: neutering rule 2 turns row 3 (drift) RED; comparing id SETS only turns rows 5, 6, 8 and 11 RED. bashrs lint --no-ignore --level error: 0 errors. Pmat-Ticket: PMAT-3296 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * push shape is not judged: the guard declines, out loud (PMAT-3296, §8) A differential guard run bare on main is handed HEAD^1 as its base, because resolve_base's push-shape arm exists to avoid judging a commit against itself. For THIS guard that base is wrong: HEAD^1..HEAD is the PREVIOUS merge's diff, not this change. Measured before the fix: 2 of the last 8 first-parent commits of origin/main (paiml#3348, 3346466) are fragment-less, so a bare run on main refused changes that landed before the guard existed and would have red-lined guard_tree from the moment it merged. That is re-litigating history, not measuring this change. In push shape the guard now reports a named SKIP and exits 0, and says what it did NOT check: the shape detected, the base it would have used, and the roadmap.yaml <-> entries/ pairing it therefore left ungraded -- which the change's own pull_request / merge_group run grades against a real merge-base. PR shape is untouched. The decision is read from BASE_HOW, the one place resolve_base makes it, so this cannot drift from it. Two case-table rows, over the real DISPATCH rather than judge() -- the fixture carries its own copy of the guard and the libraries it sources, so $REPO_ROOT is the fixture and origin/main is whatever the row points at. Same commit, same fragment-less content, two verdicts: row 13 origin/main IS this commit -> SKIP, exit 0 (the row that would have red-lined main) row 14 origin/main is its parent -> REFUSE, exit 1 14/14 rows, 0 failed. bashrs lint --no-ignore --level error: 0 errors. RETRO over the last 8 first-parent commits, after the fix: 8/8 exit 0 in push shape (none refused), while PR shape still refuses the same 2. Note 7 of those 8 exit 0 via base==head rather than via SKIP: resolve_base's "behind the tip" arm tests `git rev-list --first-parent | grep -qx`, and grep -q's early exit SIGPIPEs rev-list (rc 141 under the `set -o pipefail` every caller sets), so only the tip itself -- which short-circuits on string equality -- reaches the push-shape arm. Both routes exit 0, so the §8 requirement holds either way; the SIGPIPE is a pre-existing defect in scripts/lib/resolve_base.sh (shared with check_roadmap_diff_additive.sh), OUT OF SCOPE here and reported rather than fixed. Pmat-Ticket: PMAT-3296 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(guard): the new guard's own case table piped into grep -q check_no_pipe_into_grep_q refuses it, and correctly: under pipefail the producer's SIGPIPE is what the pipeline reports, not grep's verdict — so a case-table row could pass on a death rather than on a match. The three sites read a here-string now. No row's meaning changes. Pmat-Ticket: PMAT-3296 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
§1 of the queue architecture. Removes the shared mutable file from the merge path.
The mechanism
Every PR writes
docs/roadmaps/roadmap.yaml, so the Amdahl serial fraction on themerge path is 1: N pull requests contend on one file however disjoint their code is.
That is why 27 runners running 5 jobs cannot raise a drain of 8.8/day.
.gitattributesdeclaresmerge=roadmap, but that driver is custom and registeredper-invocation by
ci_resolve_dirty.sh, so GitHub's merge-queue server never runs it.Measured on batch #3295: the textual merge placed
PMAT-3226afterPMAT-3229andcheck_roadmap_sorted.shwent RED, while all nine inputs were individually sorted andgreen.
The change
A ticket writes
docs/roadmaps/entries/<ID>.yaml. Unique filename by construction ⟹PRs pairwise disjoint on the roadmap ⟹ conflict rate 0 by proof, not by luck.
roadmap.yamlis the aggregate, and ordering becomes the aggregator's job: an entry isplaced at the slot
check_roadmap_sorted.shdemands, usingparse_idimported fromroadmap_merge.pyso the guard and the generator cannot drift.Base + fragments, not a full split — and the obstacle that decided it
The base keeps its bytes exactly. That is what row 1 proves, and it is what keeps this
diff additive.
It also sidesteps a real blocker found by measurement.
roadmap.yamlcarriescreated: &id001 …on entry 620 (APR-PERF-GATE-001), aliased by 17 later entries—
roadmap_diff.py's own docstring names this as real data its block parser cannotresolve. A full 878-way split would emit 17 fragments that are individually unparseable
YAML.
I tried inlining that anchor as a precondition. It is a pure re-serialisation, and
check_roadmap_diff_additive.shcorrectly refuses it:The guard is right — so the base is never rewritten and the question does not arise.
That commit was dropped rather than worked around.
Verification
roadmap.yamlroadmap_fragments.py --selftestcheck_roadmap_sorted.shon the aggregatecheck_roadmap_diff_additive.sh(post-commit, reads HEAD)added=1 lifecycle=0 reserialised=0 deleted=0PMAT-3296lands afterPMAT-3229, its sorted slot. This PR's own roadmap entry is thefirst fragment, so the mechanism is dogfooded by the change that introduces it.
Not in this PR — named, not hidden
roadmap.yaml. Until that lands this is opt-in, not structural.main, andmake roadmap-aggregate.max_entries_to_merge(live ruleset17836320ismax_entries_to_build=3,max_entries_to_merge=1). That is §2 and must follow this, not precede it.Closes #3296
no-close: #2874 appears only inside quoted guard output (
PMAT-980 (#2874)), the ticketthat authored the additive rule. This PR obeys that rule rather than resolving it — it is
cited as the authority for dropping the anchor-inlining commit, and stays open.
🤖 Generated with Claude Code
ont-delta: shape apr-roadmap-fragments-v1