Skip to content

roadmap fragments (§1): one file per ticket, so the merge path has no shared mutable file - #3297

Merged
noahgift merged 12 commits into
mainfrom
PMAT-3296-roadmap-fragments
Sep 16, 2026
Merged

noahgift merged 12 commits into
mainfrom
PMAT-3296-roadmap-fragments

Conversation

@noahgift

@noahgift noahgift commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

§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 the
merge 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.

.gitattributes declares 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 placed PMAT-3226 after PMAT-3229 and
check_roadmap_sorted.sh went RED, while all nine inputs were individually sorted and
green.

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.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 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.yaml carries
created: &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 cannot
resolve. 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.sh correctly refuses it:

VIOLATION reserialised: id=PERF-010 (bytes differ, no field actually changed)
reserialised=18   PMAT-980 (#2874)

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

check result
aggregate with 0 fragments vs roadmap.yaml byte-identical, 17,785 lines
roadmap_fragments.py --selftest 9 rows, 0 red
↳ includes a mutation row: forcing append-only placement must turn the sorted-slot row RED caught
check_roadmap_sorted.sh on the aggregate rc=0
check_roadmap_diff_additive.sh (post-commit, reads HEAD) rc=0 added=1 lifecycle=0 reserialised=0 deleted=0

PMAT-3296 lands after PMAT-3229, its sorted slot. This PR's own roadmap entry is the
first fragment, so the mechanism is dogfooded by the change that introduces it.

Not in this PR — named, not hidden

  • The enforcement guard: trailer ↔ filename parity, and refusing a PR that hand-edits
    roadmap.yaml. Until that lands this is opt-in, not structural.
  • The post-merge regeneration workflow on main, and make roadmap-aggregate.
  • Raising max_entries_to_merge (live ruleset 17836320 is max_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 ticket
that 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

…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
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

§13.11 rung 1 — quorum shadow verdict

S13-SHADOW pr=3297 head=70b24f8db037bd0919b82e07a233d76ce28adb13 verdict=REFUSE class=Q1 arm_rc=1

Shadow mode: this records a verdict and merges nothing. A refusal
to arm is not a block (§13 adds zero rows to §7) — the pull request is
exactly as green as it was.

…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
@noahgift
noahgift enabled auto-merge September 15, 2026 07:49
…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
@noahgift noahgift added this to the 0.68.0 milestone Sep 15, 2026
@noahgift

Copy link
Copy Markdown
Contributor Author

Flagging before this lands, per the operator's ruling today: scripts/lib/roadmap_fragments.py is a second writer of docs/roadmaps/roadmap.yaml. APR-RELEASE-001 §1 (line 89) records RoadmapServiceIo::save() as the sole locked roadmap writer (#1364/#1368) — a fragment aggregator outside pmat is the same defect class in a different language.

pmat already has both halves: a per-ticket work store (.pmat-work/{ledger.jsonl, <id>/contract.json}) and a deterministic renderer (pmat roadmap sync --dry-run, content-hashed, 260 items on infra today). What it lacks is the path (ROADMAP.yaml at root vs docs/roadmaps/roadmap.yaml), a tracked store, and work add writing only the fragment — asked for in paiml/paiml-mcp-agent-toolkit#1370. Suggest this PR keeps its contract (contracts/apr-roadmap-fragments-v1.yaml, the entries/ layout, the parity gate from paiml/.github#73) and drops the Python aggregator in favour of pmat roadmap sync --check as the gate. infra#608 is scoped the same way: read-only checker until pmat lands the writer.

… 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>
guyernest pushed a commit to guyernest/aprender that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

roadmap fragments: one file per ticket so the merge path has no shared mutable file — Amdahl serial fraction is currently 1

1 participant