Skip to content

refactor(docs): one file per fork-change entry; ancestry-checked, merge-resolved shas (#473 #472 #476) - #480

Merged
jphein merged 5 commits into
mainfrom
feat/473-fork-changes-per-entry
Sep 11, 2026
Merged

jphein merged 5 commits into
mainfrom
feat/473-fork-changes-per-entry

Conversation

@jphein

@jphein jphein commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Closes the P6 docs-pipeline item: #473 (layout), #472 (ancestry check), #476 (resolver).
Three commits, each independently reviewable.

1. efe8afc — one file per entry (#473)

Every PR in a wave inserted at the top of entries: in one docs/fork-changes.yaml, so
every PR conflicted with every other one on that file plus the four artefacts rendered
from it — measured across a 10-PR wave on 2026-09-10/11 with zero source conflicts.
Entries now live in docs/fork-changes/<date>-<id>.yaml; scripts/fork_changes.py is the
single loader for the renderer, check-docs and maintain-fork-changes, so they cannot drift
on ordering or validation.

ACCEPTANCE: FORK_CHANGELOG.md renders BYTE-FOR-BYTE IDENTICAL from the new layout
(4104 lines, diff clean against the pre-migration file). The entry files were generated
from HEAD:docs/fork-changes.yaml, with the loader asserted to reproduce the original
order exactly and every field value round-tripped — the migration is mechanical, not
retyped. README changes only the numbering column and the generated-from comment.

One deviation from #473, with the measurement behind it

#473 proposed sorting by (date, id). Measured: that moves 112 of 137 entries, because
the file is in hand-curated narrative order and is not even date-descending — FORK_CHANGELOG
would have become a full rewrite and this PR unreviewable. #473's stated goal is
"concurrent PRs then touch disjoint files", which one-file-per-entry achieves regardless of
sort key, so ordering stays explicit via seq.

The property that makes seq safe: a collision is harmless. Two lanes both picking
max+1 write separate files and order deterministically on (date desc, id asc). The
conflict was never the number — it was a shared insertion line in a shared file. A
numeric collision costs nothing; a textual one blocks a merge.

Also: the README table is unnumbered (one new row used to renumber every row below it),
and merged_upstream moves to docs/fork-changes-meta.yaml beside the entries dir — it
changes when an upstream PR merges, not once per fork PR. That one was caught because the
first loader returned only entries and silently dropped 19 rendered lines.

2. a8ad716 — resolve shas from fork_pr + subject match (#476)

The old resolver scanned the 12 lines after a commit: line for any #NN. Those lines are
prose. Measured on the 12 entries this repo needed fixed: 3 resolved, all three wrong,
one matched against upstream #1829, and 9 were silently left on HEAD while the run
reported success.

Now: commit: HEAD resolves from fork_pr: via REST pulls/N → merge_commit_sha (exact
— the API reports the commit the merge actually created); a stale sha is repaired by
matching the dangling commit's subject to the squash subject, requiring a unique
match. Both passes refuse ambiguity instead of picking.

fork_pr is a new field and deliberately not pr: pr already means the upstream PR
(it renders as "Upstream:" and check-docs queries it against MemPalace/mempalace), so
reusing it would have conflated the two repos — the same unqualified-reference bug in a new
place.

The failure direction is the design: an unresolved entry is harmless and visible, a
confident wrong sha is neither. 16 tests, all offline (git and the API injected).

3. 753decf — ancestry check + derived test count (#472, #473 item 3)

check-docs.sh asserted git cat-file -e <sha> — but for entry shas it asserted
nothing at all, which is worse and is the real root cause. Credit to
@morpheus-single-file-mine (L2) for spotting it; measured:

  0 of 137  entry commit-LINK lines survive check #2's exclusion filter
     25     hashes it does verify are prose mentions in entry bodies
     10     of those 25 coincide with an entry sha
      7     of those 25 are NOT ancestors of main — and it passes all 7

Check #2 drops any line matching /(jphein|techempower-org)/[a-z-]+/commit/ as
"cross-repo" — exactly how the renderer emits every entry's commit link. So no entry
commit: field was ever checked as such, and none for ancestry
; 10 entry shas were
verified only incidentally, via prose mentions on non-URL lines.

The sharper corroboration is the last row: 7 of the 25 hashes check #2 does enumerate
are unreachable from main and it green-lights every one. 2ffe652 and ba16b82 sit in
both sets at once — entry shas that check #2 verifies and passes while being
non-ancestors. So cat-file -e is not merely weak in principle; it is passing stale
hashes today.

Two distinct failures, then: the check that looks like it covers entry shas does not
enumerate them, and the hash class it does enumerate it tests with the wrong
predicate. Step 2b fixes both — it reads shas from the entries rather than by scraping
markdown, and asserts ancestry.

(Measurement corrected twice: I first said cat-file -e was too weak, @morpheus-single-file-mine
showed entry shas were excluded entirely, and the reviewer's re-check caught that "0 were
ever checked" overshot by 10.)

Step 2b is therefore the first verification of entry shas. It reads them from the
entries rather than by scraping markdown, so it cannot be fooled by how a line happens to
be formatted, and requires git merge-base --is-ancestor <sha> HEAD per entry so a
failure names the file to edit.

Positive control — the check is proven to fail, not merely to pass. With the pre-rebase
sha 5383040 planted in one entry:

git cat-file -e 5383040            -> succeeds   (old rule PASSES it)
git merge-base --is-ancestor ...   -> fails      (new rule REJECTS it)

and 2b reports the entry id. Restored afterwards.

Sweep: the tool re-pointed 3 of 27 non-ancestor entries and refused the other 24.
Those are in docs/fork-changes-legacy-shas.txt: their change is on main, but the commit
carrying it cannot be named because the squash subject was rewritten at merge. A plausible
wrong sha passes every check and misleads a reader; an allowlist line is a visible diff. A
looser token-similarity pass would recover ~8 more and was deliberately not applied —
hand-applying matches the tool refuses would undercut the contract commit 2 establishes. Main
stays green either way, and shrinking that list is a good follow-up.

Test count: the committed literal is gone from README and CLAUDE.md. It meant every PR
that added a test edited the same two lines. check-docs derives and reports it (7128 here)
instead of asserting it.

Net effect on the next wave

A PR now adds one new file and touches no shared docs source. The remaining shared
artefacts (FORK_CHANGELOG.md, README table, llms-full.txt) are fully generated, so a
conflict there is resolved by re-running the renderers rather than by hand-merging.

Verification

  • Full suite 7041 passed, 82 skipped. The 5 test_init::test_init_filters_sys_path_*
    failures are a linked-worktree artifact (the shared venv's editable install resolves
    mempalace to the main tree) — they pass in the main checkout and are unrelated to this
    branch.
  • 52 docs-pipeline tests (14 loader + 16 resolver + 22 render-docs).
  • scripts/check-docs.sh → ✦ docs clean, including the new 2b (113 entry commits checked,
    24 documented-legacy skipped).
  • ruff check and ruff format --check clean.

Added after review feedback

  • docs/fork-changes/README.md — documents the seq / date / id tie-break and, explicitly,
    why it is not a date sort (a date sort moves 112 of 137 entries), so the next author
    does not "fix" it. Also documents that commit: HEAD + fork_pr: is what belongs on a
    branch, and that fork_pr and pr are different repositories.

Part of #473, #472, #476

Copilot AI lite review requested due to automatic review settings September 11, 2026 05:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 159 files, which is 59 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Check out review usage here.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 45b46ae2-3fb9-4352-aff9-67c2137a7de5

📥 Commits

Reviewing files that changed from the base of the PR and between 17aa6e5 and e0dd4a5.

📒 Files selected for processing (159)
  • .claude/commands/verify-docs.md
  • .github/workflows/check-docs.yml
  • CLAUDE.md
  • FORK_CHANGELOG.md
  • README.md
  • docs/BIBLIOGRAPHY.md
  • docs/fork-changes-legacy-shas.txt
  • docs/fork-changes-meta.yaml
  • docs/fork-changes.yaml
  • docs/fork-changes/2026-04-25-hnsw-cold-start-gate.yaml
  • docs/fork-changes/2026-04-25-kind-filter.yaml
  • docs/fork-changes/2026-04-25-palace-graph-none-guard.yaml
  • docs/fork-changes/2026-04-25-phase-a-c-checkpoint-split.yaml
  • docs/fork-changes/2026-04-26-cherry-pick-1085.yaml
  • docs/fork-changes/2026-04-26-cherry-pick-1087-rewrite.yaml
  • docs/fork-changes/2026-04-26-cherry-pick-1094.yaml
  • docs/fork-changes/2026-04-26-deploy-script.yaml
  • docs/fork-changes/2026-04-26-doc-canonical-source.yaml
  • docs/fork-changes/2026-04-26-drawer-id-surfacing.yaml
  • docs/fork-changes/2026-04-26-hnsw-integrity-gate.yaml
  • docs/fork-changes/2026-04-26-phase-d-precompact.yaml
  • docs/fork-changes/2026-04-27-closet-boost-ablation.yaml
  • docs/fork-changes/2026-04-27-kind-filter-retired.yaml
  • docs/fork-changes/2026-04-27-plugin-manifest-scrub.yaml
  • docs/fork-changes/2026-05-03-chunking-defaults-no-materialize.yaml
  • docs/fork-changes/2026-05-05-dashed-project-wing-fix.yaml
  • docs/fork-changes/2026-05-05-drop-checkpoint-write-path.yaml
  • docs/fork-changes/2026-05-05-mining-management-cli.yaml
  • docs/fork-changes/2026-05-05-retire-recovery-collection.yaml
  • docs/fork-changes/2026-05-05-transcript-ingest-via-daemon.yaml
  • docs/fork-changes/2026-05-05-verbatim-mode-toggle.yaml
  • docs/fork-changes/2026-05-05-wing-prefix-drop.yaml
  • docs/fork-changes/2026-05-07-cli-daemon-routing.yaml
  • docs/fork-changes/2026-05-07-mcp-server-daemon-routing.yaml
  • docs/fork-changes/2026-05-11-age-knowledge-graph-skeleton.yaml
  • docs/fork-changes/2026-05-11-chroma-add-upsert-meta-sanitizer-chokepoint.yaml
  • docs/fork-changes/2026-05-11-ci-postgres-backend-job.yaml
  • docs/fork-changes/2026-05-11-convo-miner-bulk-prefetch-already-mined.yaml
  • docs/fork-changes/2026-05-11-hooks-routing-via-palace-daemon.yaml
  • docs/fork-changes/2026-05-11-pgvector-substrate.yaml
  • docs/fork-changes/2026-05-11-readme-four-layer-pivot.yaml
  • docs/fork-changes/2026-05-11-repair-empty-meta-sentinel.yaml
  • docs/fork-changes/2026-05-17-backfill-age-from-existing-drawers.yaml
  • docs/fork-changes/2026-05-17-kg-age-api-parity.yaml
  • docs/fork-changes/2026-05-17-kg-writethrough-middleware.yaml
  • docs/fork-changes/2026-05-17-mempalace-walk-palace-mcp-tool.yaml
  • docs/fork-changes/2026-05-17-palace-structure-as-age-nodes.yaml
  • docs/fork-changes/2026-05-21-backend-unreachable-error-mapping.yaml
  • docs/fork-changes/2026-05-21-mcp-stub-resources-prompts-list.yaml
  • docs/fork-changes/2026-05-21-opencode-integration-recipe.yaml
  • docs/fork-changes/2026-05-21-opencode-live-capture-plugin.yaml
  • docs/fork-changes/2026-05-21-opencode-mcp-config-cherry-pick-1567.yaml
  • docs/fork-changes/2026-05-21-opencode-source-adapter-cherry-pick-1484.yaml
  • docs/fork-changes/2026-05-21-power-resilience-pending-queue.yaml
  • docs/fork-changes/2026-05-22-cli-json-quiet-flags.yaml
  • docs/fork-changes/2026-05-22-local-palace-retired-marker.yaml
  • docs/fork-changes/2026-05-22-multi-palace-separation-design.yaml
  • docs/fork-changes/2026-05-22-opencode-repo-config-empty-mcp.yaml
  • docs/fork-changes/2026-05-22-opencode-repo-mcp-disable-flag.yaml
  • docs/fork-changes/2026-05-22-opencode-repo-mcp-drop-comment-key.yaml
  • docs/fork-changes/2026-05-22-scope-collection-filter-design.yaml
  • docs/fork-changes/2026-05-22-sh-shim-strategy-doc.yaml
  • docs/fork-changes/2026-05-22-uncertainty-aware-retrieval-research.yaml
  • docs/fork-changes/2026-05-22-verbatim-derivative-essay.yaml
  • docs/fork-changes/2026-05-23-auto-query-signal-fix.yaml
  • docs/fork-changes/2026-05-23-cli-source-flag.yaml
  • docs/fork-changes/2026-05-23-native-rename-wing.yaml
  • docs/fork-changes/2026-05-23-opencode-smoke-test.yaml
  • docs/fork-changes/2026-05-23-source-adapters-codex-gemini-aider.yaml
  • docs/fork-changes/2026-05-23-source-adapters-filesystem-conversations.yaml
  • docs/fork-changes/2026-05-23-warp-adapter.yaml
  • docs/fork-changes/2026-05-24-cli-stats-dashboard.yaml
  • docs/fork-changes/2026-05-24-verbatim-derivative-essay-promotion.yaml
  • docs/fork-changes/2026-05-25-kg-triple-extraction-llm.yaml
  • docs/fork-changes/2026-05-26-age-statement-timeout-applied.yaml
  • docs/fork-changes/2026-05-26-calibrated-confidence-brier-eval.yaml
  • docs/fork-changes/2026-05-26-cli-cypher-readonly.yaml
  • docs/fork-changes/2026-05-26-cli-graph-kg-snapshot.yaml
  • docs/fork-changes/2026-05-26-cli-list-drawer-browser.yaml
  • docs/fork-changes/2026-05-26-cli-move-drawer-relocation.yaml
  • docs/fork-changes/2026-05-26-cli-stats-rest-migration.yaml
  • docs/fork-changes/2026-05-26-cli-stats-rooms-breakdown.yaml
  • docs/fork-changes/2026-05-26-hierarchy-derivation-order.yaml
  • docs/fork-changes/2026-05-26-multi-palace-separation-eval.yaml
  • docs/fork-changes/2026-05-26-rate-memory-feedback-loop.yaml
  • docs/fork-changes/2026-05-26-recency-decay-prune.yaml
  • docs/fork-changes/2026-05-26-rrf-fusion-mode-ab-harness.yaml
  • docs/fork-changes/2026-05-27-cli-bulk-move-relocation.yaml
  • docs/fork-changes/2026-05-28-check-docs-worktree-venv-fallback.yaml
  • docs/fork-changes/2026-05-28-cli-tags-and-overlap-fast-path.yaml
  • docs/fork-changes/2026-05-28-cli-why-and-tunnels-fast-path.yaml
  • docs/fork-changes/2026-05-28-dialect-expand-aaak-for-embedding.yaml
  • docs/fork-changes/2026-05-28-docs-engram2-framing-correction.yaml
  • docs/fork-changes/2026-05-28-kg-add-triple-retry-within-worker.yaml
  • docs/fork-changes/2026-05-28-kg-extractor-age-dq-tag-sanitize.yaml
  • docs/fork-changes/2026-05-28-kg-spoc-temporal-validity.yaml
  • docs/fork-changes/2026-05-28-mcp-kg-stats-structured-error-envelope.yaml
  • docs/fork-changes/2026-05-28-mcp-search-fusion-mode-input-schema.yaml
  • docs/fork-changes/2026-05-28-scripts-maintain-fork-changes.yaml
  • docs/fork-changes/2026-05-28-scripts-ship-prep.yaml
  • docs/fork-changes/2026-05-29-embedding-adaptmem-ft-backend.yaml
  • docs/fork-changes/2026-05-31-age-graph-walk-followups.yaml
  • docs/fork-changes/2026-06-10-auto-wake.yaml
  • docs/fork-changes/2026-06-11-sync-upstream-340.yaml
  • docs/fork-changes/2026-06-14-restore-concurrent-mining.yaml
  • docs/fork-changes/2026-06-26-sync-upstream-350.yaml
  • docs/fork-changes/2026-06-29-auto-query-depth-signal.yaml
  • docs/fork-changes/2026-07-01-auto-query-firing-fixes.yaml
  • docs/fork-changes/2026-07-02-auto-query-depth-cache.yaml
  • docs/fork-changes/2026-07-02-sync-upstream-368.yaml
  • docs/fork-changes/2026-08-09-postgres-wing-write-guard.yaml
  • docs/fork-changes/2026-08-09-searcher-union-postgres-unbound.yaml
  • docs/fork-changes/2026-08-09-sync-outline-env-and-schedule.yaml
  • docs/fork-changes/2026-08-09-sync-upstream-v370.yaml
  • docs/fork-changes/2026-08-20-sync-upstream-v380.yaml
  • docs/fork-changes/2026-08-21-cli-wave-drawer-family.yaml
  • docs/fork-changes/2026-08-21-cli-wave-graph-diary.yaml
  • docs/fork-changes/2026-08-21-cli-wave-read-family.yaml
  • docs/fork-changes/2026-09-01-sync-upstream-v390.yaml
  • docs/fork-changes/2026-09-03-auto-query-signal-quality.yaml
  • docs/fork-changes/2026-09-03-cli-doctor.yaml
  • docs/fork-changes/2026-09-03-cli-hallways-deprecated.yaml
  • docs/fork-changes/2026-09-03-convo-miner-incremental.yaml
  • docs/fork-changes/2026-09-03-hooks-background-mine.yaml
  • docs/fork-changes/2026-09-03-hooks-mine-single-transcript.yaml
  • docs/fork-changes/2026-09-03-kg-age-connection-guard.yaml
  • docs/fork-changes/2026-09-03-mcp-checkpoint-session-id.yaml
  • docs/fork-changes/2026-09-03-mcp-server-argv-guard.yaml
  • docs/fork-changes/2026-09-03-pending-queue-drop-legacy-dir-requests.yaml
  • docs/fork-changes/2026-09-03-postgres-hnsw-iterative-scan.yaml
  • docs/fork-changes/2026-09-03-search-source-aware-floor-prompt-echo.yaml
  • docs/fork-changes/2026-09-03-searcher-thin-vector-warning-backend-aware.yaml
  • docs/fork-changes/2026-09-03-wakeup-l1-exhaust-filter-curated-first.yaml
  • docs/fork-changes/2026-09-10-auto-query-resumption-signal.yaml
  • docs/fork-changes/2026-09-10-checkpoint-drawer-session-id.yaml
  • docs/fork-changes/2026-09-10-cli-mine-background-replay-drain.yaml
  • docs/fork-changes/2026-09-10-cli-mine-single-file.yaml
  • docs/fork-changes/2026-09-10-cli-mine-wing-never-guessed.yaml
  • docs/fork-changes/2026-09-10-compact-recovery-reinjection.yaml
  • docs/fork-changes/2026-09-10-hallways-delegates-to-hallway-list.yaml
  • docs/fork-changes/2026-09-10-postgres-cached-conn-reconnect.yaml
  • docs/fork-changes/2026-09-10-postgres-embed-through-resolver.yaml
  • docs/fork-changes/2026-09-10-postgres-scrub-unstorable-bytes.yaml
  • docs/fork-changes/2026-09-10-purge-sync-backend-before-precheck.yaml
  • docs/fork-changes/2026-09-10-readme-mcp-bridge-on-path.yaml
  • docs/fork-changes/2026-09-10-search-source-provenance.yaml
  • docs/fork-changes/2026-09-11-fork-changes-per-entry-pipeline.yaml
  • docs/fork-changes/README.md
  • docs/integrations/opencode.md
  • scripts/check-docs.sh
  • scripts/fork_changes.py
  • scripts/maintain-fork-changes.py
  • scripts/render-docs.py
  • scripts/ship-prep.sh
  • tests/test_fork_changes_loader.py
  • tests/test_maintain_fork_changes.py
  • tests/test_render_docs.py
  • website/public/llms-full.txt
  • website/public/llms.txt

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jphein
jphein force-pushed the feat/473-fork-changes-per-entry branch from 753decf to 5a48baf Compare September 11, 2026 05:14
@jphein
jphein force-pushed the feat/473-fork-changes-per-entry branch from dc43c65 to fa1c15e Compare September 11, 2026 05:29
jphein pushed a commit that referenced this pull request Sep 11, 2026
Review fixes on #478.

(a) `mempalace mine --daemon --no-tunnels` silently did the expensive thing.
The --daemon local-job-queue branch returns BEFORE the daemon-strict warning
below it, its payload carries no tunnels field, and service.execute_job calls
mine() without compute_derived. It now warns on that branch too. Accepting a
skip and not honouring it is the failure shape this flag exists to avoid.

(b) Two tests pin that `_validate_palace_fts5_after_mine` still runs with
compute_derived=False. It already did — the gate was placed inside
`if not dry_run:` but outside the validation deliberately, because integrity
work is not a derived analytic. Nothing pinned that placement, and it is
exactly the kind of thing an editor's re-indent breaks silently. Both tests
passed on first run; they are pins, not fixes.

(c) The zero-upsert short-circuit already keys on DRAWERS upserted, not files
processed; the docstring now says so and why. Since palace-daemon#262 most
drain mines walk their files and upsert nothing because the stored
source_mtime still matches, so a files-processed test would almost never fire.

Also drops this branch's docs/fork-changes.yaml entry: #480 replaces the
monolith with one file per entry under docs/fork-changes/, and the wave
cascade lands that first, so an entry written here would land in a deleted
file. The entry is re-added in the new format, with `commit: HEAD` and
`fork_pr:`, once #480 merges. The README/CLAUDE test count bump stays — it is
independent and check-docs requires it.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
Replaces the branch sha with 'HEAD' plus fork_pr: 479, so the entry stops
pointing at a commit that the squash-merge will delete. Verified the
pre-#480 renderer and check-docs both accept it: hash-resolution (check 2)
passes because HEAD resolves, and the changelog renders unchanged.

Test count 7156 -> 7157 for the wing-normalization test.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…ld (#478)

* feat(cli): mine --no-tunnels — skip the post-mine derived-graph rebuild

Every projects-mode mine recomputes three derived analytics for the whole
wing, however few files changed: cross-wing topic tunnels, within-wing
hallways, and cross-wing entity tunnels. The cost scales with the WING, not
with the change.

Measured on the palace host: a 31-file (148 KB) memory sweep spent 29+ min
of CPU and 1.6-4.1 GB RSS in that block, wrote zero drawers in that window,
and held the exclusive mine lock throughout, with twelve more sweeps queued
behind it. A sweep of a handful of memory files does not need the cross-wing
graph at all, so the cheapest fix is to not do the work.

`mine(compute_derived=False)` / `mempalace mine --no-tunnels` skips it. The
drawers filed are byte-identical either way — pinned by a test — and only
the derived graph is left un-refreshed until the next full mine.

All three steps are gated together on purpose. They are a dependency chain
(entity tunnels read the hallways the step above wrote), and gating only the
two `_compute_*_tunnels_*` calls the issue names would leave the hallways
load + full rewrite in place, which is most of the I/O. The help text says
so rather than letting the flag's name under-promise what it turns off.

Default is unchanged and pinned in three places, including the two existing
exact-call assertions, so a silent flip of the default fails loudly.

In daemon-strict mode the flag is listed in the existing ignored-local-flags
warning: the daemon's /mine body has no `tunnels` field yet
(palace-daemon#262), and a skip the operator asked for and did not get is
worse than one they were told about.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): mine --no-tunnels

Entry + all three render targets regenerated; README/CLAUDE test count
re-derived 7108 -> 7118. scripts/check-docs.sh clean on all seven checks.

The entry also records the measured root cause for the follow-up: the cost
is create_tunnel's per-call load+rewrite of tunnels.json (O(n^2)), not the
993 MB hallways.json (~37 s per mine at production size).

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(miner): a mine that filed no drawers skips the derived-graph rebuild

No flag needed: a mine that upserted nothing has nothing to recompute for.

Measured on the palace host: a requeued CLAUDE.md mine wrote zero drawers —
all 661 of that file's drawers still carried the earlier filed_at, so the
stored-mtime check skipped the file — and still spent 14+ minutes at 3.5 GB
in the derived-graph block, against a 4G cgroup cap, while the palace wrote
nothing at all.

This is a trade, not a free win, and the code says so:
_compute_entity_tunnels_for_wing reads EVERY wing's hallways, so a no-op
mine of wing A could in principle have picked up a cross-wing tunnel created
by a mine of wing B. The next mine of A that actually files something, or a
full recompute, picks it up. That is a fair price for not burning 14 minutes
and 3.5 GB to rewrite identical output.

The skip prints a line rather than being silent, so an operator reading mine
output can tell "nothing to do" from "something went wrong".

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): record the zero-upsert short-circuit

Entry body + test count extended; all three renderers re-run; README/CLAUDE
count 7118 -> 7121. check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): --daemon must not accept --no-tunnels and ignore it

Review fixes on #478.

(a) `mempalace mine --daemon --no-tunnels` silently did the expensive thing.
The --daemon local-job-queue branch returns BEFORE the daemon-strict warning
below it, its payload carries no tunnels field, and service.execute_job calls
mine() without compute_derived. It now warns on that branch too. Accepting a
skip and not honouring it is the failure shape this flag exists to avoid.

(b) Two tests pin that `_validate_palace_fts5_after_mine` still runs with
compute_derived=False. It already did — the gate was placed inside
`if not dry_run:` but outside the validation deliberately, because integrity
work is not a derived analytic. Nothing pinned that placement, and it is
exactly the kind of thing an editor's re-indent breaks silently. Both tests
passed on first run; they are pins, not fixes.

(c) The zero-upsert short-circuit already keys on DRAWERS upserted, not files
processed; the docstring now says so and why. Since palace-daemon#262 most
drain mines walk their files and upsert nothing because the stored
source_mtime still matches, so a files-processed test would almost never fire.

Also drops this branch's docs/fork-changes.yaml entry: #480 replaces the
monolith with one file per entry under docs/fork-changes/, and the wave
cascade lands that first, so an entry written here would land in a deleted
file. The entry is re-added in the new format, with `commit: HEAD` and
`fork_pr:`, once #480 merges. The README/CLAUDE test count bump stays — it is
independent and check-docs requires it.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: jp <claude2@techempower.org>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein and others added 5 commits September 10, 2026 22:42
Every pull request in a wave inserted at the top of `entries:` in one
`docs/fork-changes.yaml`, so every PR conflicted with every other PR on
that file and the four artefacts rendered from it — measured across a
10-PR wave on 2026-09-10/11 with ZERO source conflicts. Each merge then
forced every remaining PR through a full docs rebuild.

Entries now live one per file in `docs/fork-changes/<date>-<id>.yaml`, so
concurrent PRs touch disjoint paths. `scripts/fork_changes.py` is the
single loader — the renderer, check-docs and maintain-fork-changes all
go through it, so they cannot drift on ordering or validation.

Ordering is `seq` descending, ties on (date desc, id asc). #473 proposed
sorting by (date, id) instead; MEASURED, that moves 112 of 137 entries,
because the file is in hand-curated narrative order and is not even
date-descending. That would have made FORK_CHANGELOG a full rewrite and
the migration unreviewable, so ordering stays explicit.

The key property is that a `seq` COLLISION is harmless: two lanes both
picking max+1 write separate files and order deterministically. The
conflict was never the number — it was a shared insertion line in a
shared file. A numeric collision costs nothing; a textual one blocks a
merge. So ordering stops being a coordination point while staying
explicit, which is what preserves the narrative order.

Also here:
- The README fork-change table is UNNUMBERED. A leading row number meant
  one new entry renumbered every row below it, so any two PRs conflicted
  across the whole table rather than on one line.
- `merged_upstream` (which renders the changelog's closing sections)
  moves to `docs/fork-changes-meta.yaml`, beside the entries directory
  rather than inside it: it changes when an UPSTREAM PR merges, not once
  per fork PR, so a shared file on that cadence does not recreate the
  problem. Caught because the first loader returned only `entries` and
  silently dropped 19 rendered lines.

ACCEPTANCE: `FORK_CHANGELOG.md` renders BYTE-FOR-BYTE IDENTICAL from the
new layout (4104 lines, `diff` clean against the pre-migration file).
README changes only the numbering column and the generated-from comment.
The entry files were generated from `HEAD:docs/fork-changes.yaml` and the
loader is asserted to reproduce the original order exactly, with every
field value round-tripped — so the migration is mechanical, not retyped.

14 tests. Part of #473

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ose (#476)

The old resolver scanned the 12 lines after a `commit:` line for any
`#NN` and mapped that number to a recent commit. Those lines are prose,
and the first issue number in an entry's prose is usually not that
entry's PR; `#NN` was also unqualified, so an upstream number was
indistinguishable from a fork one.

MEASURED on the 12 entries this repo actually needed fixed: it resolved
3 and got all three WRONG — one matched against upstream MemPalace#1829 — and
silently left 9 on HEAD while reporting success.

Two passes now, both entry-wise:

1. `commit: HEAD` resolves from the entry's `fork_pr:` via REST
   `pulls/N` → `merge_commit_sha`. Exact, because the API reports the
   commit the merge actually created. REST not GraphQL: GraphQL has been
   rate-limited for this account during waves, and this is the one
   lookup that must work at merge time.
2. A sha that is no longer an ancestor of the branch is repaired by
   matching the dangling commit's SUBJECT to the squash subject (squash
   subject = branch subject + trailing `(#PR)`), requiring a UNIQUE
   match.

`fork_pr` is a NEW field and deliberately not `pr`: `pr` already means
the UPSTREAM PR number (it renders as "*Upstream:*" and check-docs
queries it against MemPalace/mempalace), so reusing it would have
conflated the two repos — the same unqualified-reference bug in a new
place.

Both passes refuse ambiguity instead of picking, and an unresolved entry
makes `--check` exit non-zero. The failure direction is the whole point:
leaving an entry unresolved is harmless and visible, while a confident
wrong sha is neither, and check-docs cannot catch it because asserting a
sha *resolves* is satisfied by any real commit.

Dedup is removed — one file per entry makes a duplicate id a hard error
in the loader, not something to clean up afterwards.

16 tests, all offline (git and the API are injected). Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…test count (#472 #473)

An earlier draft of this said check #2's `git cat-file -e` was too weak
for entry shas. L2 (morpheus-single-file-mine) pointed out it is worse
than that: check #2 drops any line matching
`/(jphein|techempower-org)/[a-z-]+/commit/` as "cross-repo", which is
EXACTLY how the renderer emits every entry's commit link. A reviewer
re-check then corrected my correction, which had overshot. Measured:

  0 of 137 entry commit-LINK lines survive the filter; the 25 hashes
  check #2 does verify are prose mentions inside entry bodies ("the
  first was ``b46f18d``"), 10 of which coincide with an entry sha — so
  no entry `commit:` field was ever checked AS SUCH, and none for
  ancestry.

Corroboration, and it is the sharper number: 7 of check #2's own 25
hashes are NOT ancestors of main, and it green-lights all of them.
`2ffe652` and `ba16b82` are in both sets at once — entry shas that
check #2 does verify and passes, while being unreachable from main. So
`cat-file -e` is not merely weak in principle; it is actively passing
stale hashes today.

That is why 13 stale entry shas shipped green: the check that looks
like it covers them does not enumerate them, and the one hash class it
does enumerate it tests with the wrong predicate.

Step 2b is therefore the FIRST verification of entry shas. It reads them
from the entries rather than by scraping markdown, so it cannot be
fooled by how a line happens to be formatted, and a failure names the
file a human has to edit.

POSITIVE CONTROL (the check is proven to fail, not just to pass): with
the pre-rebase sha `5383040` planted in one entry —
  git cat-file -e 5383040          -> succeeds  (old rule would PASS it)
  git merge-base --is-ancestor ... -> fails     (new rule REJECTS it)
and step 2b reports the entry id. Restored afterwards.

Sweep: `maintain-fork-changes.py` re-pointed 3 of the 27 non-ancestor
entries by exact subject match and REFUSED the other 24 rather than
guess. Those are recorded in `docs/fork-changes-legacy-shas.txt` — their
change is on main but the commit carrying it cannot be named, because
the squash subject was rewritten at merge. A wrong sha would pass every
check and mislead a reader; an allowlist line is a visible diff. The
header notes that a looser token-similarity pass would recover ~8 more
and was deliberately not applied, since hand-applying matches the tool
refuses would undercut its contract. Main stays green either way.

A literal in README and CLAUDE.md meant every PR that added a test
edited the same two lines, so a 10-PR wave conflicted there every time
and each merge forced the rest to re-derive it. check-docs now derives
the count and reports it (7128 at this commit) instead of asserting a
number; README points at `pytest --collect-only -q`. A literal that
creeps back is warned about, not failed, since it may be a deliberate
release note — but nothing depends on it any more.

`docs/fork-changes/README.md` documents the layout for the next author:
the `seq` / date / id tie-break and why it is NOT a date sort (a date
sort moves 112 of 137 entries), and that `commit: HEAD` + `fork_pr:` is
correct on a branch. Requested so nobody "fixes" the ordering later.

`tests/test_render_docs.py` updated: four tests asserted the row
numbering this change removes, and one asserted a numbered first row.

Reported-by: morpheus-single-file-mine (check #2 exclusion)
Part of #472, #473

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on (id, sha)

Two review fix-firsts on 5a48baf.

## ship-prep.sh step 2/6 would have died on this very branch

The step grepped README for "<N> tests pass on `main`" and hard-failed
when absent — and the commit that removes the count literal removes
exactly that phrase, so the next release prep would have stopped to ask
whether the wording changed. The step's other job, `sed`-ing the number
in place, is equally obsolete.

It now REPORTS the derived count (still useful during release prep) and
treats the absent literal as the expected state, warning only if one
creeps back. Same contract as check-docs step 1, so the two cannot
disagree. Header and step title updated to match.

⭐ Worth naming: I removed a literal and updated the checker that
asserted it, but not the *other* script that also asserted it. The grep
that finds every reader of a thing you are deleting is the cheap step I
skipped.

## The legacy allowlist was keyed on the entry id alone

That granted an entry a PERMANENT exemption rather than recording one
known-bad sha. Its `commit:` could later be edited to any value —
including a plausible WRONG sha, which is a real ancestor and therefore
passes every check — and nobody would be prompted again. The file would
have quietly become a list of entries nothing verifies.

Now keyed on the pair: `<id> <sha>`, and both the shell check and the
resolver skip only on an exact match. A resolved or altered entry starts
failing until its line is removed, which is the prompt we want. An id
with no sha is a hard error rather than a silent full exemption.

POSITIVE CONTROL (the new refusal is proven, not assumed): with
`cli-cypher-readonly` allowlisted for `32a41b1`, planting a DIFFERENT
dangling sha `0c34464` on it makes step 2b report it again instead of
skipping. Restored afterwards.

Also: `docs/fork-changes/README.md` documents the pair format and that
the exemption covers one sha, not the entry.

2 tests (allowlist pair parsing, exemption lost when the sha changes),
18 in the resolver file. Full suite 7043 passed.

Part of #472, #473

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lychee caught two dead links to the file the migration deletes —
docs/BIBLIOGRAPHY.md:82 and README.md:284. Grepping the repo for the rest
found nine LIVE instruction sites, not two, so this fixes the class
rather than the two failures CI happened to surface:

  docs/BIBLIOGRAPHY.md      dead link  -> fork-changes/README.md
  README.md (x3)            dead link + machinery row + setup comment
  scripts/render-docs.py    module docstring, AND the "This file is
                            generated. Edit ..." header it EMITS into
                            FORK_CHANGELOG.md — so the generated file was
                            telling readers to edit a deleted path
  scripts/ship-prep.sh      step 1 comment (also retitled: de-dup is gone,
                            resolution is #476's job now)
  website/public/llms.txt   prose
  docs/integrations/*       two prose mentions naming the path
  .claude/commands/verify-docs.md   the worst one: a LIVE slash command
                            that told an agent to `yaml.safe_load(
                            open('docs/fork-changes.yaml'))`. Rewritten to
                            go through scripts/fork_changes.py, and it now
                            says `pr:` is the UPSTREAM number while
                            `fork_pr:` is this repo's. Verified by running
                            the rewritten snippet: 12 entries with
                            pr + pr_state reach it.

## CLAUDE.md "Documentation maintenance" rewritten

The repo's own instructions still described editing one YAML at the top
of `entries:`, so the next agent would have been sent to a deleted file
by the very document that is supposed to orient it. It now describes the
directory, why it is a directory, and points at
docs/fork-changes/README.md for the schema and the ordering rule.

The "Lint" subsection's numbered list of check-docs checks was also
stale in three of four items — it promised a README-test-count
comparison that no longer exists, described the hash check as
`cat-file -e` without the ancestry requirement, and called the manifest
"the YAML". Updated, including the new 2b and the (id, sha) allowlist.
A stale description of a checker is worse than none: it tells you the
guarantee you have, and you stop looking.

## Deliberately NOT changed, having read each one

- dated research/spec/integration docs (2026-05-05, 2026-05-15,
  2026-05-24, 2026-05-26) — historical snapshots that were accurate when
  written; rewriting them would falsify the record
- FORK_CHANGELOG.md body text in older entries, and in the new entry,
  describing the old single file — historically correct, and generated
- docs/research/*.json and scripts/probes_v2_git_derived.json — retrieval
  EVAL CORPORA with `"expected": "fork-changes.yaml"`. These are
  benchmark fixtures; editing them would silently change eval results,
  which is a worse outcome than a stale string in a fixture
- scripts/fork_changes.py and tests/test_fork_changes_loader.py
  docstrings, which describe what the split replaced

No dead links remain: `grep -rnE '\]\([^)]*fork-changes\.yaml'` over all
markdown returns nothing. markdownlint clean on the five touched docs,
check-docs exit 0, full suite 7043 passed.

Part of #473

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jphein
jphein force-pushed the feat/473-fork-changes-per-entry branch from 5451dad to e0dd4a5 Compare September 11, 2026 05:49
@jphein
jphein merged commit 6da8775 into main Sep 11, 2026
17 checks passed
@jphein
jphein deleted the feat/473-fork-changes-per-entry branch September 11, 2026 05:56
jphein added a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ per #480's per-entry format, with
`commit: HEAD` and `fork_pr: 482` so the merge step resolves the squash sha
rather than the lane recording a branch sha that goes unreachable. `seq` 139
from `scripts/fork_changes.py --next-seq`.

Renders FORK_CHANGELOG.md, the README table and website/public/llms-full.txt.
No test-count literal: #480 removed it and check-docs derives the count.

Part of #454

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
First entry in #480's format: one new file, seq from --next-seq, `commit: HEAD`
plus `fork_pr: 487` so the merge step resolves the squash sha instead of the
lane recording a branch sha that becomes unreachable the moment it lands.

No test-count literal to bump — #480 removed it.

Part of #461

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…n from a worktree (#454) (#482)

* test(init): compare sys.path entries after resolving them against cwd

`test_init_filters_sys_path_from_leaked_pythonpath` failed all five
sentinel params in any linked worktree, so every lane tonight ran the suite
with it deselected and paid a false red before working that out.

The behaviour under test was fine throughout. The assertion was the problem:
it iterated `for p in sys.path if p`, which deliberately excludes the empty
string — and the empty string is the cwd marker `python -c` puts on the
path, which is what actually makes the child's import resolve. Measured in
both trees with PYTHONPATH set to the sentinel, as the test sets it:

  main tree   RESOLVED_TO <main>/mempalace/__init__.py   PARENT_ON_SYS_PATH True
  worktree    RESOLVED_TO <wt>/mempalace/__init__.py     PARENT_ON_SYS_PATH False
  both        EMPTY_IN_PATH True

The child imports the tree it was launched from, in both cases, via that
cwd entry. The absolute-entry match succeeded in the main tree for an
incidental reason: the shared venv's editable install adds exactly
`/home/jp/Projects/memorypalace` to sys.path, so the comparison found it
there. From a worktree the editable entry points at the main tree, the
absolute match fails, and the test reports an over-strip that never
happened.

Fix: normalise each entry with `os.path.abspath(p or os.curdir)` before
comparing, so the cwd marker counts as the entry it is. That is faithful to
the assertion's stated purpose — "the mempalace package itself must remain
importable, so its parent directory must survive on sys.path" — because the
cwd entry is how it remained importable.

It keeps its teeth for the real failure: the assertion can now only fail if
the filter removed both the cwd marker and any absolute entry providing the
package, which is exactly an over-strip. A child that cannot import
mempalace at all still trips the earlier `returncode == 0` assertion.

`MEMPALACE_IMPORTED_FROM` is printed for diagnosis and deliberately not
asserted on: which tree the child resolves depends on its cwd, and pinning
that would re-introduce the coupling this fixes.

Verified both directions: 8/8 from a linked worktree, and 8/8 with cwd set
to the main tree. The full suite now runs green from a worktree with no
manual deselect — 7026 passed, 82 skipped.

Fixes #454

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): entry for the test_init cwd-relative sys.path fix

One new file under docs/fork-changes/ per #480's per-entry format, with
`commit: HEAD` and `fork_pr: 482` so the merge step resolves the squash sha
rather than the lane recording a branch sha that goes unreachable. `seq` 139
from `scripts/fork_changes.py --next-seq`.

Renders FORK_CHANGELOG.md, the README table and website/public/llms-full.txt.
No test-count literal: #480 removed it and check-docs derives the count.

Part of #454

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
One entry file under docs/fork-changes/ per #480's layout, seq 140,
commit: HEAD + fork_pr: 479 so the merge step resolves the squash sha.
Replaces the five fork-changes.yaml commits this branch carried before
the rebase — that file no longer exists, and per-entry files mean this
branch no longer conflicts with any other lane's docs.

No test-count literal: #480 removed that check, and with it the
per-rebase arithmetic that broke three times on this branch alone.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…aming migration (#442) (#479)

* feat(hallways): postgres-backed hallway store behind an opt-in flag

hallways.json on the production palace host is 1,041,537,215 bytes (1.04 GB)
holding ~797K records across 21 wings, and growing — it was 479 MB one week
earlier. Every hallway operation loads and JSON-parses the whole thing:

- list_hallways(wing=...) filters in Python AFTER the full load, so the wing
  argument does not reduce the work at all;
- the daemon cannot fast-intercept it (palace-daemon#255): it runs under a
  2 GB cgroup cap already sitting at 98.4%, and parsing a 1 GB JSON array
  materializes several GB of dicts;
- compute_hallways_for_wing loads it to recover four dynamics fields per
  pair, and _compute_entity_tunnels_for_wing loads it AGAIN via
  list_hallways, so two parsed copies are live at once — that double parse
  is where a mine subprocess's 1.6-4.1 GB RSS comes from;
- _save_hallways rewrites all 1.04 GB per mine that produces a hallway.

Sized honestly: at production scale the two-loads-plus-one-save is ~37 s of
JSON I/O per mine (measured: json.load 3.1 s, json.dump 8.4 s on a 900K /
396 MB file). An earlier draft of this message attributed #442's reported
"29+ minutes" to this step. That was wrong. Those 29 minutes are
create_tunnel calling _load_tunnels + atomic _save_tunnels on EVERY call
from inside a loop (palace_graph.py :840/:861, called at :1145 and :1278) —
O(n^2) in the tunnel count, against a tunnels.json that is only 837 KB at
2000 tunnels. The sweep's own open file was tunnels.json.tmp, which said so.
Measured by the #474 lane, mechanism confirmed independently here. This
change removes the ~37 s and the RSS and unblocks the daemon fast-intercept;
it does not make a memory sweep faster.

The postgres store makes each of those proportional to the wing rather than
to the whole palace: an indexed WHERE wing = %s, a wing-scoped dynamics read,
and a delete+insert of one wing.

JSON REMAINS THE DEFAULT. The flag is hallway_backend (config.json) or
MEMPALACE_HALLWAY_BACKEND (env); the production cutover is a separate,
deliberate step after the migration is verified against real data. Nothing
changes until someone opts in, and a chroma/sqlite install never needs
postgres for hallways at all. A postgres selection with no DSN falls back to
JSON with a warning rather than raising: a misconfigured flag must not take
down a mine, and an empty store would read as "this wing has no hallways",
which is worse than slow.

Schema notes:

- named columns for what is queried or ordered by, plus dynamics jsonb and
  extra jsonb so a record round-trips losslessly and a newer writer's fields
  are not truncated by an older reader (verbatim-always applies to derived
  records too);
- created_at/last_activated stay text rather than timestamptz on purpose:
  the records carry ISO-8601 strings, and round-tripping them through a
  timestamp type normalizes offsets and sub-second precision — it rewrites
  the user's data to say something slightly different. ISO-8601 UTC sorts
  correctly lexicographically and nothing range-queries them.
- writes scrub NUL and lone surrogates through the backend's own scrubber
  (#411). Entity names, labels and room names are all transcript-derived, so
  they carry exactly those bytes; one of them would otherwise abort a write,
  and on a 797K-record import that is #411's failure mode with a much larger
  blast radius. One scrub at the storage boundary, so every writer gets it.

Connections are opened per operation rather than cached: hallway reads and
writes are infrequent, so the per-call connect is cheap, and it makes this
store immune by construction to the stale-cached-connection failure #385 had
to add a retry seam for. The migration passes its own long-lived connection.

Tests: tests/test_hallway_store.py, 16 cases — JSON is the default, postgres
only by explicit flag, a DSN-less postgres flag falls back loudly, lossless
record/row round trip including unknown fields and dynamics, unstorable bytes
scrubbed, the wing filter and pagination are in SQL, ordering by
co-occurrence, dynamics read scoped to one wing and keyed by the sorted pair,
replace_wing touching only that wing (and still clearing an emptied one),
delete reporting whether a row went, and idempotent schema creation that
never drops.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(hallways): route reads and writes through the store

list_hallways, delete_hallway and compute_hallways_for_wing now go through
the configured store instead of _load_hallways/_save_hallways directly. On
the JSON store the behaviour is byte-for-byte what it was; on postgres the
wing-scoped paths stop reading the whole palace.

The recompute change is the one that matters most. It loaded every record in
the store to recover four dynamics fields for one wing — a full parse of a
1.04 GB file, and the second of the two parses the post-mine block performs,
which together are where a mine subprocess's 1.6-4.1 GB RSS comes from — and
now asks the store for that wing's dynamics only. (The 29 minutes #442
reports for the post-mine block is create_tunnel's per-call persist in
palace_graph, not this load; see the first commit in this branch.) The lookup is still keyed by the SORTED entity
pair, matching the symmetric id in _hallway_id: without that canonicalization
a record persisted with the pair reversed misses the lookup and silently
loses its accumulated weights on every recompute (PR MemPalace#1578 review, HIGH).

The two early-return paths now clear the wing rather than returning without
writing. Previously a wing whose pairs all fell below min_count kept its
previous snapshot, because the function returned before reaching the persist
step; the store call makes "no surviving pairs" mean no rows.

list_hallways gains optional limit/offset. 797K records is not a sane payload
at any speed, and the daemon needs a bounded page to answer
mempalace_list_hallways at all (palace-daemon#255).

_load_hallways, _save_hallways, _get_hallway_file and _legacy_hallway_file
are deliberately left in place and unmoved: the JSON store calls back into
them at call time, so the existing tests that monkeypatch _get_hallway_file
keep working untouched. All 110 pre-existing hallway tests pass unchanged.

Tests: tests/test_hallways_store_routing.py, 9 cases — the default config
still reads the same JSON file, the monkeypatchable helpers still work, the
wing filter and pagination reach the store, delete delegates, the recompute
reads one wing's dynamics and never lists the whole store, writes only its
own wing, preserves accumulated dynamics across a reversed-pair recompute,
and clears a wing whose pairs all fell below min_count.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(hallways): streaming, resumable hallways.json -> postgres migration

python -m mempalace.migrate_hallways [source] [--dry-run] [--restart]
[--state PATH] [--batch-size N]

Streaming is the load-bearing requirement, not a nicety. Measured here on a
synthetic 201 MB / 400K-record file, which is the same shape as production's
1.04 GB / ~797K:

    json.load (what _load_hallways does)   1,072 MB peak RSS   2.28 s
    this streaming reader                     31 MB peak RSS   1.92 s

34x less memory and slightly faster. A migration that has to allocate 4 GB to
move data whose entire problem is that it allocates 4 GB is not a migration.
Extrapolated to the real file, json.load is ~5.4 GB while the reader stays
buffer-bounded and flat in the file size.

The reader uses json.JSONDecoder.raw_decode over a sliding text buffer rather
than a third-party streaming parser, so this adds no dependency and the
local-first install is unchanged. Text mode is deliberate: Python's text IO
will not split a multi-byte character across two reads, so entity names in
any script survive chunking. The buffer is compacted only once the consumed
prefix exceeds a chunk — slicing after every record made it quadratic in the
chunk size and was measured 6x slower before the fix.

Also:

- idempotent: the upsert is keyed on the record id, so re-running converges;
- resumable, and it REFUSES to resume when the source file's size changed.
  The saved position is an ordinal into this file's record sequence, so
  applying it to different content would skip the wrong records and silently
  under-import. --restart is the documented escape hatch, safe because of
  the upsert;
- --dry-run reads and reports without opening a connection or touching a
  row, and deliberately does NOT require a configured postgres store.
  Requiring one would make the safe rehearsal harder to run than the real
  thing, which is backwards for the step whose job is to de-risk the other
  one. (Found by an end-to-end CLI run; every unit test injected a store and
  so could not see it.)
- reports a count of records carrying NUL or lone-surrogate bytes. The scrub
  itself lives at the storage boundary so every writer gets it; what the
  migration owes the operator is visibility, so a surprising number is
  noticed rather than absorbed.

This imports only. Flipping hallway_backend to postgres is a separate,
deliberate step taken after the import is verified against real data.

Tests: tests/test_migrate_hallways.py, 20 cases — both persisted file shapes,
empty stores, records spanning chunk boundaries, multibyte and astral
characters under an 8-byte chunk, malformed JSON raising rather than
importing nothing, missing files, dry run touching nothing, full import,
idempotence, resume, resume refusal on a changed file, --restart, state
recording, bounded batches, schema ensured once, unstorable bytes counted
without aborting, and a dry run with no store configured.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(hallways): report entity classes in the migration dry run, filter nothing

Measured read-only against the live 1.14 GB store on the palace host: of
154,692 distinct entities only 12,484 -- 8% -- are word-shaped. Identifier,
path, url and template shapes are 47% of distinct entities and appear in the
large majority of records. The first record in the file links the entity
${this.baseUrl}/health. These are code tokens harvested from mined source,
not names.

Whether they belong in the palace is a judgement about the corpus, not about
storage, so the dry run prints the class mix and the migration filters
NOTHING. A silent filter would be an irreversible edit to the user's data
made by the tool that was only asked to move it, and "we never summarize, we
never paraphrase" covers deciding which of someone's entities were worth
keeping. The table also makes those rows cheap to hold and cheap to query,
so the filtering decision stays open after the import rather than being
forced at it.

`other` is a catch-all bucket, not a junk verdict -- it will include
legitimate names carrying digits or punctuation -- so 47% is the floor for
code-shaped, not the total. Ordering is most-specific-first, so a template
containing a slash classifies as a template rather than a path.

Also date-stamps the scale figures rather than quoting one as current. The
file moved 479 MB -> 1,041,537,215 B / ~797K records / 21 wings ->
1,142,562,893 B / 1,903,306 records / 50 wings inside one evening. A
duplication hypothesis was tested and refuted: record count, distinct ids
and distinct (wing, sorted-pair) tuples all agree exactly at 1,903,306, so
the growth is new wings plus N(N-1)/2 pair combinatorics, not a writer bug.

The same streaming reader parsed that live 1.14 GB file end to end in 52 s
at 366 MB peak RSS (most of it the analysis's dedup hash sets, not the
reader) -- a positive control for the migration against real data, with no
write to the host.

Tests: 3 more in tests/test_migrate_hallways.py -- the dry run reports every
class, the classifier's buckets are pinned individually, and reporting does
not filter (every record still imports).

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(hallways): pin that the store does not normalize wing names

hallways.py never calls normalize_wing_name — zero references in the module
— so wing matching is exact string equality, and a wing spelled differently
from the drawers' metadata matches nothing. The #474 lane measured what that
produces: silence, not duplicates (the drawer filter at hallways.py:264 is
also exact, so a mismatch returns [] before the store is ever opened).

Normalizing in the postgres read path would be both an improvement and a
behaviour change — queries that used to return nothing would start returning
rows. A storage move is not the place to make it silently, so the store
passes the caller's wing through verbatim and this test says so out loud
rather than leaving it to be discovered by whoever changes it next.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hallways): a truncated hallways.json must raise, not read as complete

Review catch (#479). The reader returned silently when input ran out
without a closing "]" -- a file cut exactly at a record boundary (killed
scp, full disk, interrupted write) yielded N records and no error.
Mid-record truncation already raised; this boundary case did not.

The compounding half is what makes it serious: migrate() would then write a
resume state recording that count as the finished total, so a later run
would skip straight past the missing tail. Silent tail loss on a 1 GB
import, invisible until someone noticed hallways missing.

Now the only clean exit from the loop is having seen "]". Running out of
input raises with a message that says the file is truncated and that a
partial read will not be reported as complete. A failed read therefore
leaves no resume point behind, which is asserted directly.

Also strengthens the resume fingerprint from size-only to size + mtime_ns.
Size alone misses a same-length rewrite. Deliberately NOT a content hash:
hashing 1 GB costs a full read, which is most of what resuming exists to
avoid; both fields come free from one stat. A state file written before
mtime was tracked still resumes on size alone rather than being refused.

Positive control, because a guard that fires on good input would be worse
than the bug: the shipped reader parsed the live 1,142,562,893-byte
production hallways.json end to end -- 1,903,306 records, 27.2 s, 42 MB
peak RSS, no exception. The record count matches an independent analysis
run of the same file exactly, which cross-validates both instruments.

Tests: 5 more -- unterminated array at a record boundary, unterminated
after a trailing comma, no resume state written after a failed read, a
properly terminated array still reading clean (the control), and resume
refused when mtime moves at an unchanged size.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hallways): replace_wing is one transaction; reads name a missing table

Review catch (#479). replace_wing issued DELETE + N INSERTs on an
autocommit connection -- N+1 separate transactions. A crash after the
DELETE leaves the wing EMPTY; a crash midway leaves it partial; and the
recompute that would repair it only runs on the next mine of that wing.

The JSON path made that unreachable by writing a temp file and os.replace
-ing it. My docstring said "as the JSON path did", which was true of SCOPE
(other wings preserved) and false of ATOMICITY -- exactly the half that
matters here. The storage move must not trade one for the other silently,
so the whole swap now commits or rolls back together, and the docstring
says both things explicitly.

_run grows an `atomic` flag: it flips autocommit off, commits once at the
end, and rolls back on any exception. Single-statement reads keep the
cheaper autocommit path and a test pins that they do. When the caller
supplies a connection the caller's transaction governs and `atomic` is
ignored -- committing someone else's open work would be worse than not
grouping ours.

Also, the low item from the same review: reads on a table that does not
exist yet returned an opaque UndefinedTable, and would have been
indistinguishable from an empty store had they returned []. list, count and
dynamics_for_wing now return empty and warn ONCE per store naming
`python -m mempalace.migrate_hallways`. Same reasoning as the daemon's
fast-intercept note (palace-daemon#255): a bare [] sends whoever is
debugging it somewhere else entirely, and raising would take down a mine
over an ordering mistake. Writes still fail loudly -- "run the migration
first" should be enforced, not worked around.

Tests: 5 more -- one transaction for the swap, rollback when an insert
fails mid-batch, rollback when the delete fails, reads staying on
autocommit, and the missing-table warning firing once across three reads.
The transaction fake starts autocommit=True to match what _connect()
actually returns, so the autocommit assertion is not vacuous.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(hallways): assert the wing's rows survive a failed swap, not just that rollback was called

The #479 review asked for "a test where a failing insert mid-batch leaves
the wing's previous rows intact". My existing tests asserted rollback() was
called and commit() was not — which proves the code *asked* for a rollback,
not that the data survived. Those are different claims, and only the second
one is the property the JSON path's temp-file + os.replace actually gave.

Adds a fake that models transaction VISIBILITY: committed rows kept
separate from pending ones, DELETE/INSERT applied to pending, commit()
publishing and rollback() reverting. The test can then assert the wing's
previous rows are still committed after a mid-batch failure, and that no
partial write is visible — which is the real acceptance criterion.

Plus a control that a successful swap does replace the wing and does spare
the other wings, so the new test cannot pass by the swap silently doing
nothing.

Verified both directions: with atomic=True removed, the new test fails
along with the three existing ones; restored, all 24 pass.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): hallways to a postgres table (#442)

One entry file under docs/fork-changes/ per #480's layout, seq 140,
commit: HEAD + fork_pr: 479 so the merge step resolves the squash sha.
Replaces the five fork-changes.yaml commits this branch carried before
the rebase — that file no longer exists, and per-entry files mean this
branch no longer conflicts with any other lane's docs.

No test-count literal: #480 removed that check, and with it the
per-rebase arithmetic that broke three times on this branch alone.

Part of #442

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ per #480's per-entry format, `seq` 140
from `scripts/fork_changes.py --next-seq`, `commit: HEAD` and `fork_pr: 490`
so the merge step resolves the squash sha from the right PR.

Part of #451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ per #480's per-entry format, `seq` 141
from scripts/fork_changes.py --next-seq, `commit: HEAD` and `fork_pr: 481`
so the merge step resolves the squash sha from the right PR.

The old single-file docs commit was dropped rather than replayed, and the
docs changes that had been amended into the cmd_mined commit were stripped
with it, so nothing in this branch touches docs/fork-changes.yaml.

Part of #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein pushed a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ in the post-#480 format: `commit: HEAD`
(the squash sha does not exist yet; maintain-fork-changes resolves it from
fork_pr after merge) and `fork_pr: 484`. seq 141 from --next-seq.

All three renderers re-run; README/CLAUDE test count re-derived on the
rebased base (now 7218 collected). check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…dir precheck (#459) (#481)

* fix(cli): resolve the backend before the local-dir precheck in the shared seam

#418 removed this precheck from `purge` and `sync`; it survived in two more
places, so `prune`, `status --json` and `compress` still refused a Postgres
palace. A Postgres palace directory holds only sidecars (dsn.env,
hallways.json, tunnels.json, locks/, wal/) and `PostgresBackend.detect()`
returns False unconditionally, because the drawers live in the service.

Measured on a sidecar-only palace with MEMPALACE_BACKEND=postgres and a DSN
pointing at a closed port, where a working command must fail with
"connection refused":

  prune --stale-days 99999   before: "No palace found at <dir>"       exit 0
                             after:  "connection refused"            exit 1
  compress --dry-run         before: "has no backend database yet"    exit 1
                             after:  "connection refused"            exit 1
  --json status              before: hint "has no backend database"   exit 2
                             after:  hint "connection refused"        exit 2

`status --json` still exits 2 — the palace genuinely is unavailable — but
the hint now names the real reason instead of asserting a missing database
that was never supposed to be there.

- `palace._open_collection_or_explain` resolves the backend first and runs
  States A and B only for a local-mode store. State B is kept rather than
  dropped: its purpose is that some local backends create their DB file on
  first open, so probing one would mutate the filesystem during a read-only
  inspection. Neither hazard exists for a service-backed store. Fixing the
  shared seam covers `cli._emit_local_status_json`, `cli.cmd_compress`,
  miner status and searcher's local fallback in one place.
- `cmd_prune` carried its own copy plus a hardcoded `ChromaBackend`; it now
  resolves the backend, gates the precheck on it, and opens through
  `palace.get_collection`. It also stops rejecting a local sqlite_exact
  palace for not being chroma.
- Every prune outcome names its target: `Target: <palace> (<backend>)` in
  the text path, `palace` + `backend` keys in every `--json` payload
  including the error ones. An unreachable palace, a failed query and a
  failed delete now exit non-zero instead of 0.

Fixes #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): route cmd_mined through the same backend resolution

`mined` was the last cmd_* carrying its own copy of the local-directory
precheck plus a hardcoded ChromaBackend. On a daemon-strict host its daemon
route hides the local path, so the refusal only surfaced with --palace or
daemon-strict off — and it printed "No palace found at <dir>" and returned
0, the "a refusal looks like a success" shape #418 removed.

Measured on a sidecar-only palace with MEMPALACE_BACKEND=postgres and a DSN
pointing at a closed port:

  before: "No palace found at <dir>"                          exit 0
  after:  "Error reading palace: ... Connection refused"       exit 2

The resolve-gate-open sequence is extracted to
`_mined_open_drawers_or_exit`, which also drops cmd_mined back under ruff's
complexity ceiling. Named for `mined` on purpose: `cmd_purge` and
`cmd_prune` hold near-identical sequences with different message shapes,
and unifying all three is worth a deliberate PR rather than a drive-by in
one under review. A general-looking helper with a single caller is how a
fourth copy gets written.

Both output paths now exit 2 on a refusal. The --json path always did; the
text path returned 0, and one condition must not hand a text caller and a
JSON caller different exit codes.

An earlier draft of the extracted helper chose the text renderer by whether
the backend was known, so a connection error — which knows its backend —
printed "No palace found at <dir>" and hid the real cause: a refusal naming
the wrong reason, the same defect family. Found by running the CLI against
the closed port, not by a unit test, because the JSON payload carries the
hint either way. `palace_state` is now an explicit argument and
`test_an_open_failure_says_why_instead_of_no_palace_found` pins it.

Also fixes the review nit: `opened.assert_not_called(), "msg"` is a tuple
expression whose message is unreachable — rewritten as a real assert.

Test updates, both contract changes rather than repairs: the two
`TestCmdMinedJson` cases patched `mempalace.backends.chroma.ChromaBackend`,
which this path no longer constructs, so they now patch
`mempalace.palace.get_collection`; `test_cmd_mined_no_palace` expects exit
2. `test_cmd_mined_groups_by_wing_and_source` runs against a real chroma
palace and passed unchanged throughout, which is what establishes the
routing change is sound rather than just re-pointed mocks.

Part of #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): entry for the local-dir precheck fix

One new file under docs/fork-changes/ per #480's per-entry format, `seq` 141
from scripts/fork_changes.py --next-seq, `commit: HEAD` and `fork_pr: 481`
so the merge step resolves the squash sha from the right PR.

The old single-file docs commit was dropped rather than replayed, and the
docs changes that had been amended into the cmd_mined commit were stripped
with it, so nothing in this branch touches docs/fork-changes.yaml.

Part of #459

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein pushed a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ in the post-#480 format: `commit: HEAD`
(the squash sha does not exist yet; maintain-fork-changes resolves it from
fork_pr after merge) and `fork_pr: 484`. seq 141 from --next-seq.

All three renderers re-run; README/CLAUDE test count re-derived on the
rebased base (now 7218 collected). check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
)

* perf(graph): batch tunnel persistence — 2000 tunnels 37.9s -> 0.08s

create_tunnel did a full _load_tunnels AND a full atomic _save_tunnels on
every call, and it is called from inside two loops: the per-entity loop in
entity_tunnels_for_wing and the per-wing loop in compute_topic_tunnels. N
tunnels therefore cost N loads and N rewrites, with per-tunnel cost rising
linearly with what was already on disk — O(n^2).

Measured on a throwaway palace, before:

    100 tunnels   0.61s       1000 tunnels  11.54s
    500 tunnels   3.97s       2000 tunnels  37.87s

extrapolating to ~16 min at 10K tunnels, which is the 29-minute mine on #474.
The file was 837 KB at 2000 tunnels — this was never a big-file problem, it
was a per-call-persist problem.

After, same benchmark:

    2000 tunnels  0.08s       (454x, and the curve is linear)

create_tunnel is public API with callers outside those loops, so its per-call
semantics are deliberately unchanged: it is now a one-element batch. Measured
on the same run, 500 one-at-a-time calls still cost 3.77s (was 3.97s).

create_tunnels also fixes two smaller per-call costs the loops paid: the
dedupe was a linear rescan of the whole list per tunnel (now an O(1) id
index, or a batch would just trade one quadratic term for another), and
kind="explicit" room validation opened a collection per call (now once per
batch, and before the lock, so a rejected batch cannot half-write).

Ten tests, including an equivalence test asserting the batch and sequential
routes produce identical files from identical specs, and coverage for
undirected identity within a batch, created_at/updated_at, and L7 dynamics
preservation across a rebuild.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): batched tunnel persistence

One new file under docs/fork-changes/ in the post-#480 format: `commit: HEAD`
(the squash sha does not exist yet; maintain-fork-changes resolves it from
fork_pr after merge) and `fork_pr: 484`. seq 141 from --next-seq.

All three renderers re-run; README/CLAUDE test count re-derived on the
rebased base (now 7218 collected). check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: jp <claude2@techempower.org>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein pushed a commit that referenced this pull request Sep 11, 2026
One new file under docs/fork-changes/ in the post-#480 format: seq 143 from
--next-seq, `commit: HEAD` (the squash sha does not exist yet;
maintain-fork-changes resolves it from fork_pr after merge), `fork_pr: 486`.
No test-count step — that phrase is gone from README and CLAUDE.md, and
check-docs check 1 now skips rather than comparing.

All three renderers re-run; check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…purpose (#486)

* feat(cli): tunnels --rebuild --wing W — refresh the derived graph on purpose

After #478, palace-daemon#262 and palace-daemon#264, the derived graph
(topic tunnels, hallways, entity tunnels) refreshes ONLY as a side effect of
a full-directory mine that actually files drawers. That is the intended
trade, but it left no way to say "refresh the graph" deliberately. This is
that way: the same three steps, against a named wing, mining nothing.

It runs locally and does not require the daemon — only the listing path does
— so the branch sits ahead of that check.

The three functions are called through the `miner` module rather than
imported by name, so the post-mine block stays the single definition of what
"the derived graph" means; this verb must not become a second, drifting copy
of that list. Each step keeps the post-mine block's per-step fault tolerance:
a derived analytic must never take down the whole operation, and a partial
refresh beats none.

No --all, by measurement rather than preference. A whole-palace sweep pays
compute_hallways_for_wing's full load-and-rewrite of hallways.json once per
wing. Extrapolated from a measured 396 MB point (load 3.1s, dump 8.4s) to the
current 1.2 GB file: ~44s per wing, ~37 min across 50 wings — which would
recreate the exact problem this verb exists to fix. `--rebuild` without
--wing exits 2 and says so, naming #442. --all becomes cheap once the
hallway store stops being a monolithic JSON.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): tunnels --rebuild — refuse when it would rebuild the wrong palace

Review fixes on #486.

F1 (red): under daemon-strict with no --palace the verb rebuilt the LOCAL
palace and printed +0/+0/+0 exit 0. An operator runs this BECAUSE the graph
is stale, so a confident "fresh" is worse than a refusal. Now exits 2 naming
the daemon URL and the two ways out, matching cmd_purge's shape. An explicit
--palace still rebuilds: it says which palace is meant.

F2: no palace write lock. Hallways are a whole-file load / modify / save that
carries other wings forward from its OWN load, so a rebuild of wing A racing
a drain mine of wing B silently drops one side's new records — whichever
os.replace lands second wins, with no error. Now wrapped in
mine_palace_lock; MineAlreadyRunning surfaces and exits 1, as cmd_sync does.

F3: get_collection defaults create=True, so a typo'd --palace MATERIALIZED an
empty palace and then reported success against the thing it had just made.
create=False.

F4: the wing was passed raw while the miner normalizes (My-Project ->
my_project), so a mismatched spelling matched no drawers and printed a
confident +0. Normalized at the boundary.

F5: --all is now a real parser flag that refuses with the measured reason and
names #442, instead of a bare argparse usage error that teaches nothing.

F6: the LIST of three steps was written twice, and my comment claimed it was
not. Extracted miner.recompute_derived_graph — the post-mine block and this
verb now call the same function, so a fourth derived step cannot land in one
and be forgotten in the other. Per-step fault tolerance moves into it,
failures come back in `errors` keyed by step, and each caller still formats
its own output (the mine prints only non-zero counts; the verb prints all
three, because a rebuild that prints nothing is indistinguishable from one
that did nothing). The post-mine block's exact wording is preserved.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): tunnels --rebuild verb

One new file under docs/fork-changes/ in the post-#480 format: seq 143 from
--next-seq, `commit: HEAD` (the squash sha does not exist yet;
maintain-fork-changes resolves it from fork_pr after merge), `fork_pr: 486`.
No test-count step — that phrase is gone from README and CLAUDE.md, and
check-docs check 1 now skips rather than comparing.

All three renderers re-run; check-docs clean on all seven.

Part of #474

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: jp <claude2@techempower.org>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…ntry file (#476)

`fork_pr` could not carry this on its own: a lane cannot know its own PR
number while writing the entry, which is the same chicken-and-egg as the
sha. #480's own entry is the proof — I wrote `fork_pr: 0` as a
placeholder, deleted it rather than ship a wrong number, and left an
entry nothing could resolve.

The commit that ADDED an entry's file is the squash commit by
construction, since the file arrives with the pull request:

    git log --follow --diff-filter=A --format=%H -- docs/fork-changes/<file>

Deterministic, offline, needs nothing from the author, and immune to a
squash subject reworded at merge time — which defeated 4 of the 27 cases
in the #472 sweep. `--follow` so a renamed entry file still resolves to
its original add, and the LAST add is taken, since a rename history can
list several.

`fork_pr` becomes a cross-check rather than the key: when present, the
API answer is compared and a DISAGREEMENT refuses to resolve, because
two independent mechanisms disagreeing is the worst case in which to
pick one. It also still works as the fallback when file-add cannot
answer.

This runs ONLY on an entry whose `commit` is literally `HEAD`.

Every one of the 137 entry files that predates the one-file-per-entry
split was created by the SPLIT's own commit. So asking "what added this
file" about an already-resolved entry returns the migration commit.
MEASURED on this branch, before the guard existed:

  entry `scripts-maintain-fork-changes`  field says 9060e09  (correct)
  git log --diff-filter=A on its file →  6da8775            (the split)

That would have rewritten 137 correct historical shas to one wrong
value — and 6da8775 IS an ancestor of main, so it would have passed the
new ancestry check forever and read as correct. Exactly the
wrong-but-plausible failure the reviewer named on #480. A test pins the
boundary in both directions, and asserts the resolver never even asks
about an entry that already has a sha.

Ran against main: resolved exactly ONE entry — #480's own,
`commit: HEAD → 6da8775` — and left the other 137 untouched, which is
the boundary holding in practice rather than only in the unit test.
6da8775 verified an ancestor of main.

The 2026-05-28 `scripts-maintain-fork-changes` entry needed no work: its
`commit` is already 9060e09 and already an ancestor. It looked like an
unresolved HEAD entry only because the entry is ABOUT HEAD resolution,
so the literal string `commit:HEAD` appears twice in its own prose — a
field-shaped grep matches the changelog entry that documents the field.

docs/fork-changes/README.md updated: `fork_pr` is documentation and a
cross-check, not load-bearing, and the boundary hazard is written down
so nobody widens it.

5 tests (23 in the file). Full suite 7065 passed. Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…ering it as a link (#476)

lucid's finding: seven entries merged since #480 still read
`commit: HEAD`, and step 2b could not see any of them. Verified, and the
mechanism is worth stating precisely because the obvious fix would not
have worked:

  fork_changes.iter_commit_refs SKIPS HEAD, so those entries never reach
  the ancestry predicate at all. Hardening the predicate alone changes
  NOTHING. (`git merge-base --is-ancestor HEAD HEAD` also exits 0, so
  both halves had to learn about the literal.)

⇒ 2b caught a WRONG sha and was blind to a MISSING one.

`--strict-resolved` / `STRICT_RESOLVED=1` enumerates HEAD entries
(`fork_changes.py --commit-refs --include-head`) and fails on them,
naming the entry and telling you to run the sweep. Wired in CI to
`push` + `refs/heads/main` only: a pull request legitimately carries
HEAD, because the squash commit it will point at does not exist yet.
Loud, never auto-fixing — resolving is the sweep's job.

POSITIVE CONTROL, both directions, on the same tree (1 HEAD entry):
  permissive  -> exit 0
  --strict-resolved -> exit 1, "entry 'init-test-cwd-relative-syspath'
                       still has `commit: HEAD` on main"
  STRICT_RESOLVED=1 -> exit 1

`https://github.com/techempower-org/mempalace/commit/HEAD` is a VALID
GitHub URL that resolves to whatever is at main's tip. So the 8 links in
FORK_CHANGELOG.md and 7 in the README table pointed at an unrelated
commit while looking exactly like real references — a reader cannot tell
them apart, which makes it worse than an obviously missing value.
`commit_link()` now renders "`HEAD` — pending resolution" as plain text.
After this: 0 such links in either file.

Worth noting an old entry's body already described "broken
``[HEAD](commit/HEAD)`` links", so this had been seen before and came
back. A rendering rule is a better place for it than a habit.

More HEAD entries land after this (#477, #490, stage A, #485), so a
resolution commit would be stale before it merged. The sweep is one
dedicated PR at the very end of the cascade, and it is the first PR the
strict check has to pass. docs/fork-changes/README.md now says the sweep
is the lead's last step of every wave, and why per-lane resolution
cannot work.

Caught while wiring CI: my first edit added a SECOND `env:` key to the
check-docs step, which YAML resolves by keeping the last one — silently
dropping `GH_TOKEN`, which step 4 needs for upstream PR state. Merged
into the existing block and asserted both keys survive by parsing the
workflow.

9 tests (65 in the three docs-pipeline files). Full suite 7076 passed.
Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
…k_pr case (#476)

The existing tests stub `adding_commit`, so they prove the wiring but
not the git invocation. These drive real `git log` in a temp repo.

The case that matters is #480's own entry: `commit: HEAD` and NO
`fork_pr`, because the entry was written before the PR existed and a
guessed number would have been worse than none. It is the reason
file-add is the primary mechanism rather than a fallback. VERIFIED live
on this repo as well — the entry's file was added by 6da8775, which is
#480's squash commit.

Measured on main (7df8dee): 8 entries carry `commit: HEAD`, and exactly
ONE of them lacks `fork_pr` — that one.

Four tests:
- no `fork_pr` resolves to the ADDING commit, not the tip, with two
  unrelated commits on either side of it, and emits no advisory (there
  is no number to cross-check or warn about)
- a LATER EDIT of the entry does not move the answer (`--diff-filter=A`
  means the add, so a typo fix or reworded body must not re-point a
  resolved sha)
- a RENAMED entry file still resolves to its original add. Entry
  filenames embed the date, so correcting a date renames the file;
  `--follow` is what keeps that from silently re-pointing the sha at the
  rename commit
- an untracked path returns None and is reported, never guessed

Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jphein added a commit that referenced this pull request Sep 11, 2026
… for unresolved placeholders (#476) (#492)

* fix(scripts): resolve `commit: HEAD` from the commit that added the entry file (#476)

`fork_pr` could not carry this on its own: a lane cannot know its own PR
number while writing the entry, which is the same chicken-and-egg as the
sha. #480's own entry is the proof — I wrote `fork_pr: 0` as a
placeholder, deleted it rather than ship a wrong number, and left an
entry nothing could resolve.

The commit that ADDED an entry's file is the squash commit by
construction, since the file arrives with the pull request:

    git log --follow --diff-filter=A --format=%H -- docs/fork-changes/<file>

Deterministic, offline, needs nothing from the author, and immune to a
squash subject reworded at merge time — which defeated 4 of the 27 cases
in the #472 sweep. `--follow` so a renamed entry file still resolves to
its original add, and the LAST add is taken, since a rename history can
list several.

`fork_pr` becomes a cross-check rather than the key: when present, the
API answer is compared and a DISAGREEMENT refuses to resolve, because
two independent mechanisms disagreeing is the worst case in which to
pick one. It also still works as the fallback when file-add cannot
answer.

This runs ONLY on an entry whose `commit` is literally `HEAD`.

Every one of the 137 entry files that predates the one-file-per-entry
split was created by the SPLIT's own commit. So asking "what added this
file" about an already-resolved entry returns the migration commit.
MEASURED on this branch, before the guard existed:

  entry `scripts-maintain-fork-changes`  field says 9060e09  (correct)
  git log --diff-filter=A on its file →  6da8775            (the split)

That would have rewritten 137 correct historical shas to one wrong
value — and 6da8775 IS an ancestor of main, so it would have passed the
new ancestry check forever and read as correct. Exactly the
wrong-but-plausible failure the reviewer named on #480. A test pins the
boundary in both directions, and asserts the resolver never even asks
about an entry that already has a sha.

Ran against main: resolved exactly ONE entry — #480's own,
`commit: HEAD → 6da8775` — and left the other 137 untouched, which is
the boundary holding in practice rather than only in the unit test.
6da8775 verified an ancestor of main.

The 2026-05-28 `scripts-maintain-fork-changes` entry needed no work: its
`commit` is already 9060e09 and already an ancestor. It looked like an
unresolved HEAD entry only because the entry is ABOUT HEAD resolution,
so the literal string `commit:HEAD` appears twice in its own prose — a
field-shaped grep matches the changelog entry that documents the field.

docs/fork-changes/README.md updated: `fork_pr` is documentation and a
cross-check, not load-bearing, and the boundary hazard is written down
so nobody widens it.

5 tests (23 in the file). Full suite 7065 passed. Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs+fix: fill fork_pr after `gh pr create`; surface an unverifiable one (#476)

GitHub's next PR number is not predictable: a lane wrote `fork_pr: 483`
before opening and the PR came back 490. A guessed number is the #472
failure in a new coat — a confidently wrong field instead of a visibly
unresolved one — so the directory README now says to fill `fork_pr` in
AFTER `gh pr create` returns it, and that leaving it absent is the safe
default since file-add is primary.

Worth stating what a wrong number can and cannot do, because it decides
how much this matters. It cannot corrupt the sha:

  - points at another MERGED PR  -> its different answer trips the
                                    existing cross-check refusal
  - unverifiable (404/unmerged)  -> file-add stands alone and is
                                    already right

MEASURED: PR 483 does not exist (404), so lucid's guess falls in the
second bucket and resolves correctly anyway.

But "resolves correctly anyway" is how a wrong field survives, so the
second case is now printed as an ADVISORY — "resolved from file-add;
fork_pr #483 is unverifiable — check it" — rather than silently ignored.
Deliberately not folded into the blocking list: an advisory that fails
`--check` is an advisory the next person deletes.

The function returns `(changes, unresolved, notes)` so the advisory is
actually reported. I had first appended to a `notes` list that nothing
returned — dead code that LOOKED like it reported something, which is
the same class of defect as a check that enumerates nothing.

1 test (24 in the file), full suite 7071 passed. Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(check-docs): reject the literal `commit: HEAD` on main; stop rendering it as a link (#476)

lucid's finding: seven entries merged since #480 still read
`commit: HEAD`, and step 2b could not see any of them. Verified, and the
mechanism is worth stating precisely because the obvious fix would not
have worked:

  fork_changes.iter_commit_refs SKIPS HEAD, so those entries never reach
  the ancestry predicate at all. Hardening the predicate alone changes
  NOTHING. (`git merge-base --is-ancestor HEAD HEAD` also exits 0, so
  both halves had to learn about the literal.)

⇒ 2b caught a WRONG sha and was blind to a MISSING one.

`--strict-resolved` / `STRICT_RESOLVED=1` enumerates HEAD entries
(`fork_changes.py --commit-refs --include-head`) and fails on them,
naming the entry and telling you to run the sweep. Wired in CI to
`push` + `refs/heads/main` only: a pull request legitimately carries
HEAD, because the squash commit it will point at does not exist yet.
Loud, never auto-fixing — resolving is the sweep's job.

POSITIVE CONTROL, both directions, on the same tree (1 HEAD entry):
  permissive  -> exit 0
  --strict-resolved -> exit 1, "entry 'init-test-cwd-relative-syspath'
                       still has `commit: HEAD` on main"
  STRICT_RESOLVED=1 -> exit 1

`https://github.com/techempower-org/mempalace/commit/HEAD` is a VALID
GitHub URL that resolves to whatever is at main's tip. So the 8 links in
FORK_CHANGELOG.md and 7 in the README table pointed at an unrelated
commit while looking exactly like real references — a reader cannot tell
them apart, which makes it worse than an obviously missing value.
`commit_link()` now renders "`HEAD` — pending resolution" as plain text.
After this: 0 such links in either file.

Worth noting an old entry's body already described "broken
``[HEAD](commit/HEAD)`` links", so this had been seen before and came
back. A rendering rule is a better place for it than a habit.

More HEAD entries land after this (#477, #490, stage A, #485), so a
resolution commit would be stale before it merged. The sweep is one
dedicated PR at the very end of the cascade, and it is the first PR the
strict check has to pass. docs/fork-changes/README.md now says the sweep
is the lead's last step of every wave, and why per-lane resolution
cannot work.

Caught while wiring CI: my first edit added a SECOND `env:` key to the
check-docs step, which YAML resolves by keeping the last one — silently
dropping `GH_TOKEN`, which step 4 needs for upstream PR state. Merged
into the existing block and asserted both keys survive by parsing the
workflow.

9 tests (65 in the three docs-pipeline files). Full suite 7076 passed.
Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(scripts): file-add resolution against real git, incl. the no-fork_pr case (#476)

The existing tests stub `adding_commit`, so they prove the wiring but
not the git invocation. These drive real `git log` in a temp repo.

The case that matters is #480's own entry: `commit: HEAD` and NO
`fork_pr`, because the entry was written before the PR existed and a
guessed number would have been worse than none. It is the reason
file-add is the primary mechanism rather than a fallback. VERIFIED live
on this repo as well — the entry's file was added by 6da8775, which is
#480's squash commit.

Measured on main (7df8dee): 8 entries carry `commit: HEAD`, and exactly
ONE of them lacks `fork_pr` — that one.

Four tests:
- no `fork_pr` resolves to the ADDING commit, not the tip, with two
  unrelated commits on either side of it, and emits no advisory (there
  is no number to cross-check or warn about)
- a LATER EDIT of the entry does not move the answer (`--diff-filter=A`
  means the add, so a typo fix or reworded body must not re-point a
  resolved sha)
- a RENAMED entry file still resolves to its original add. Entry
  filenames embed the date, so correcting a date renames the file;
  `--follow` is what keeps that from silently re-pointing the sha at the
  rename commit
- an untracked path returns None and is reported, never guessed

Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(fork-changes): entry for the sha-resolution hardening (#476)

`commit: HEAD` and no `fork_pr` yet — deliberately. The PR number does
not exist until `gh pr create` returns it, and a guessed number is a
confidently wrong field (a lane guessed 483 and got 490). It is filled
in immediately after the PR is opened, in the following commit.

Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): never resolve an entry against a feature branch (#476)

Filling in `fork_pr: 492` — the number `gh pr create` actually returned,
not a guess — surfaced a way file-add could recreate #472 through a new
door, so this closes it before the mechanism ships.

Asked about a FEATURE BRANCH, "what added this file" truthfully answers
*the branch commit*. Written into the entry, the squash would orphan it
moments later — which is precisely the failure this mechanism replaced.
MEASURED on this branch: `git_file_add_commit(<my entry>)` with the
default branch returns f87f9c1, the branch commit.

The default `--branch=origin/main` already prevents it, and that is the
property rather than an accident: against `origin/main` an unmerged
entry finds NO add, so it stays `HEAD` instead of acquiring a sha that
is about to become unreachable. Verified end to end — running the sweep
command on this branch resolved the 8 landed entries from main's history
and left THIS PR's own entry untouched at `HEAD`, which is exactly the
behaviour the sweep needs.

A test now pins both halves in one place: asked about the branch it
returns the branch commit, asked about main it returns None, and
`resolve_head_by_file_add` reports rather than resolves. The README says
resolve against `origin/main`, never a branch, and why.

(The 8 entries that run resolved in my working tree were reverted — the
sweep is its own PR, per the lead.)

1 test (29 in the file). Full suite 7304 passed. Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): refuse to write a sha that is not an ancestor of origin/main (#476)

Oracle's rider, and it is a better fix than what I had. Before any
resolved sha is written, `git_is_ancestor(sha, origin/main)` must hold;
otherwise the entry is reported and `--check` exits non-zero.

The `--branch=origin/main` default already made a branch commit hard to
write. But a default is one argument away from being wrong, and the
question the caller actually needs answered is not "which ref did you
ask about" — it is "is this commit on main". Asserting that directly
makes the hazard IMPOSSIBLE rather than unlikely, and it holds whatever
`--branch` is passed.

Correcting my own framing while I am here, because the lead caught it
and the record should be right: the previous commit
("never resolve an entry against a feature branch") changed NO code —
`maintain-fork-changes.py` is byte-identical across it. It added a test
and documentation for a property the `origin/main` default already had.
Calling that "closing the hazard" was an over-claim: I documented a
safeguard and described it as building one. THIS commit is the code fix.

3 tests: a branch-only sha is refused and never written; the verify ref
stays `origin/main` even when `--branch` is a feature branch; an
ancestor sha still resolves. The real-git fixtures pass `verify_ref`
explicitly, since a temp repo has no `origin/main` — the real
`git_is_ancestor` still runs, against that repo's own ref.

Verified live: with the gate in place the sweep still resolves all 8
landed entries against origin/main (reverted here — the sweep is its own
PR). Full suite 7307 passed.

Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): bind via_api unconditionally so term order cannot break it (#476)

Oracle's note: the unverifiable-`fork_pr` advisory reads `via_api`, which
was assigned only inside `if pr and fetch is not None:` — so it was safe
purely by SHORT-CIRCUIT ORDER in the later condition. Reordering those
terms, a harmless-looking edit, would have turned it into a NameError on
every entry that carries no `fork_pr`, which is exactly the entry the
file-add mechanism exists for.

Asked for a comment; binding it unconditionally instead, because that is
Oracle's own principle applied one level down — the ancestry gate was
preferred over a default plus a README for the same reason. Make the
edit safe rather than forbid it. The comment stays too, saying why.

PROVEN rather than argued: a copy of the module with the terms reordered
(`if not via_api and pr and fetch is not None:`) resolves an entry with
NO `fork_pr` and raises nothing. Before this, that reorder was a latent
crash.

Part of #476

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
jphein pushed a commit that referenced this pull request Sep 18, 2026
check-docs verified render parity, sha resolution, sha ancestry and upstream
PR states — but never that a merged fork PR documented itself. #517 was green
on every check with no entry at all, and nothing would have surfaced it later:
--next-seq and the renderers are happy with any subset. The checker answered a
narrower question than its name, which is #516's twin and #505's class.

Step 8 lists squash-merge commits since a baseline by their trailing (#NNN)
and requires either a `fork_pr: NNN` entry or a reasoned allowlist line.

Baseline is 6da8775, the commit that introduced docs/fork-changes/ AND the
fork_pr field (#480). Before it the field did not exist, so "missing" would be
meaningless for the ~137 older entries; a baseline is what keeps this about
drift rather than about history.

git log only, never the GitHub API — a docs check that needs the network is
one that gets skipped.

Warn-only by default, --strict fails, so the residue can be worked without
blocking. A bare number in the allowlist is refused: an allowlist records WHY
or it is a mute button, and the next person cannot tell a deliberate omission
from an abandoned one.

Pre-registered before writing the step, by hand from git log: 20 squash
commits since the baseline, 19 with fork_pr, missing exactly {495}. The step
reports exactly that. #495 is the docs-tooling sweep
(scripts/maintain-fork-changes.py) that rewrites landed `commit: HEAD` values
across EXISTING entries and adds no change of its own — the producer this
allowlist exists for, and the pair this check consumes.

Note: #517, the PR the issue cites, now HAS an entry; it was backfilled after
the issue was filed. The backlog is one PR, not several.

Tests drive the REAL script over throwaway git repos built under the project's
tmp/ (never /tmp — a 16 GB tmpfs here). Mutation-tested: a reasonless
allowlist line exits 1; allowlisting a PR that DOES have an entry fails the
producer/consumer test as a dead line; disabling the fork_pr scan reports all
19 as missing, proving the scan is load-bearing.

Closes #519
jphein pushed a commit that referenced this pull request Sep 18, 2026
check-docs verified render parity, sha resolution, sha ancestry and upstream
PR states — but never that a merged fork PR documented itself. #517 was green
on every check with no entry at all, and nothing would have surfaced it later:
--next-seq and the renderers are happy with any subset. The checker answered a
narrower question than its name, which is #516's twin and #505's class.

Step 8 lists squash-merge commits since a baseline by their trailing (#NNN)
and requires either a `fork_pr: NNN` entry or a reasoned allowlist line.

Baseline is 6da8775, the commit that introduced docs/fork-changes/ AND the
fork_pr field (#480). Before it the field did not exist, so "missing" would be
meaningless for the ~137 older entries; a baseline is what keeps this about
drift rather than about history.

git log only, never the GitHub API — a docs check that needs the network is
one that gets skipped.

Warn-only by default, --strict fails, so the residue can be worked without
blocking. A bare number in the allowlist is refused: an allowlist records WHY
or it is a mute button, and the next person cannot tell a deliberate omission
from an abandoned one.

Pre-registered before writing the step, by hand from git log: 20 squash
commits since the baseline, 19 with fork_pr, missing exactly {495}. The step
reports exactly that. #495 is the docs-tooling sweep
(scripts/maintain-fork-changes.py) that rewrites landed `commit: HEAD` values
across EXISTING entries and adds no change of its own — the producer this
allowlist exists for, and the pair this check consumes.

Note: #517, the PR the issue cites, now HAS an entry; it was backfilled after
the issue was filed. The backlog is one PR, not several.

Tests drive the REAL script over throwaway git repos built under the project's
tmp/ (never /tmp — a 16 GB tmpfs here). Mutation-tested: a reasonless
allowlist line exits 1; allowlisting a PR that DOES have an entry fails the
producer/consumer test as a dead line; disabling the fork_pr scan reports all
19 as missing, proving the scan is load-bearing.

Closes #519
jphein added a commit that referenced this pull request Sep 18, 2026
…PR (#530)

Closes #519.

## What

`check-docs.sh` gains **step 8**: every squash-merge commit on `main` since a baseline must have a `fork_pr: NNN` entry under `docs/fork-changes/`, or a reasoned line in the new `docs/fork-changes-no-entry.txt`.

The logic lives in a standalone `scripts/check-entry-coverage.sh` so it can run on its own (a pre-push hook, the sweep) and so its tests can drive the **real** script.

- **Baseline `6da87755`** — the commit that introduced `docs/fork-changes/` *and* the `fork_pr` field (#480). Before it the field did not exist, so "missing" would be meaningless for the ~137 older entries. A baseline is what keeps this about drift rather than about history.
- **`git log` only, never the GitHub API.** A docs check that needs the network is one that gets skipped.
- **Warn-only by default; `--strict` fails.** Missing PRs are printed with their titles.
- **A bare number in the allowlist is refused.** An allowlist records *why*, or it is a mute button — and the next person cannot tell a deliberate omission from an abandoned one.

## Pre-registered before the step was written

Enumerated by hand from `git log --oneline 6da8775..HEAD`, stated in my report before any code:

```
squash PRs since 6da8775 (20): 477 479 481 482 484 486 487 490 491 492 493 495
                                505 509 510 511 512 517 520 522
fork_pr values in entries (19): the same, minus 495
MISSING (1):                    495
```

The step reports exactly `{495}` on this main:

```
examined 20 squash-merge commit(s) since 6da8775
documented: 19 fork_pr value(s) · allowlisted: 0
! #495 has no docs/fork-changes entry — docs(fork-changes): resolve every landed
  `commit: HEAD` to its squash sha (#476) (#495)
```

Invariant, not a total: *every squash-merge commit since the baseline has an entry or an allowlist line*. The count moves with every merge; the invariant does not.

## The allowlist: 1 PR

| PR | title | why no entry |
|---|---|---|
| #495 | `docs(fork-changes): resolve every landed commit: HEAD to its squash sha` | Docs-tooling sweep. It rewrites `commit:` values across **existing** entries and introduces no change of its own, so an entry for it would describe nothing a reader of `FORK_CHANGELOG.md` wants. |

**One**, not several. Worth stating plainly because the issue implies a backlog: **#517, the PR the issue cites as the trigger, now HAS an entry** — it was backfilled after the issue was filed. I checked rather than assuming the issue's framing still held.

## The producer/consumer pair

`scripts/maintain-fork-changes.py` is the **producer**: the sweep that resolves landed `commit: HEAD` values from `fork_pr`. Its own PRs add no entry by design — that is precisely why the allowlist exists. This check is the **consumer**.

They are executed together in `test_every_allowlisted_pr_is_genuinely_missing_an_entry`, which runs against the real repo and requires every allowlisted PR to be (a) genuinely a squash commit in range and (b) genuinely without an entry. An allowlist that drifts from what the sweep produces would silence a real miss, so it is verified rather than trusted.

## Composition with the docs sweep — a rule, not a coincidence

`scripts/maintain-fork-changes.py` **step 1** resolves `commit: HEAD` from an entry's `fork_pr:`. Two consequences follow, and both are load-bearing here:

1. **The sweep consumes entries and produces none of its own**, so every sweep PR that ever lands belongs in the allowlist. That is a standing rule, not a judgement call to re-make each time, and the allowlist file says so.
2. **The sweep runs LAST in a wave**, after every other PR. So this check will *always* land while unresolved placeholders exist.

⇒ **This step checks that an entry EXISTS (by `fork_pr:`), never that its `commit:` is resolved.** A `commit: HEAD` entry counts as present. Whether a sha is real and is an ancestor is the strict ancestry check's job — conflating the two would make a correct entry look missing for the entire window between a PR merging and the sweep running, which is exactly when a wave is busiest.

Pinned two ways: a behavioural test with a fixture entry carrying an unresolved placeholder (with a control asserting the fixture really is unresolved), and a structural test that the script contains no `commit:` logic at all.

> The brief asked me to cite the sweep's "step 5b". The script documents **steps 1 and 2** only — there is no 5b. The relevant step is 1, and that is what the allowlist cites.

## Tests

**10 new**, driving the real `check-entry-coverage.sh` over throwaway git repos built under the project's own `tmp/` — never `/tmp`, which is a 16 GB tmpfs on this workstation.

A `commit: HEAD` entry counts as present · the script contains no `commit:` logic · missing PR is named with its title · fully-covered repo is clean (positive control) · **an empty range reports the count it examined** rather than silently passing · warn-only vs `--strict` · allowlisted PR is not reported · comments and blanks ignored · a reasonless line is refused · the producer/consumer allowlist check.

Suite: **7514 passed**, 82 skipped. `ruff check` + `ruff format --check` clean. `bash -n` on both scripts. `check-docs.sh` clean on all 8.

Mutation-tested, each verified to apply:

| mutant | result |
|---|---|
| allowlist line loses its reason | `--strict` exits 1 ✓ |
| allowlist a PR that DOES have an entry (dead line) | producer/consumer test fails ✓ |
| disable the `fork_pr` scan | all 19 reported missing ✓ (the scan is load-bearing) |
| skip entries whose `commit:` is still `HEAD` | behavioural + structural tests red ✓ |

> One earlier mutant only moved the text without changing behaviour; the structural test caught it and the behavioural one correctly did not. Recorded as inert, not as evidence.

## Note

This entry is the first whose own check would have caught its absence.
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.

2 participants