Skip to content

workflow: stop parallel content cycles from colliding - #175

Merged
dbwls99706 merged 2 commits into
mainfrom
claude/pr-merge-issues-z6asfi
Aug 18, 2026
Merged

workflow: stop parallel content cycles from colliding#175
dbwls99706 merged 2 commits into
mainfrom
claude/pr-merge-issues-z6asfi

Conversation

@dbwls99706

@dbwls99706 dbwls99706 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Why

Four PRs sat unmergeable at the same time — #165, #166, #168, #172. None of them had a failing check; validate-and-build was green on all four. They were blocked by the content workflow itself, in three separate ways, all of which would recur on the next batch.

1. Everyone re-verified the same three files

Every content PR refreshes a few aging canons alongside its new pages, and every cycle picked "the canons with the oldest last_confirmed". That rule is deterministic, so parallel cycles selected the same files. Whichever PR merged first left the rest conflicting on precisely the date fields they came to refresh:

-    "last_confirmed": "2026-08-16"   ← main, from the PR that merged first
+    "last_confirmed": "2026-08-14"   ← this PR, same file, same line

No disagreement about anything — just two cycles doing identical work.

Fix: generator/reverify.py assigns each cycle a bucket. Every canon belongs to a bucket determined solely by hashing its ID; a seed (the target country code) hashes to one bucket, and the cycle takes the oldest canons in it.

git fetch origin --prune
python -m generator.reverify --seed nz

Bucketing by ID rather than by position in the aging list is the whole point. Two cycles never see the same list — each branches from a different main, and any merge that ages canons in or refreshes them shifts every later boundary — so positional blocks would let two cycles land on overlapping-but-unequal slices. That is exactly the shape that conflicts on merge. A canon's ID does not move.

So two seeds either own the same bucket (identical picks, obvious immediately) or share nothing at all. A partial overlap cannot occur. DEFAULT_BUCKETS must stay constant for that agreement to hold; that constraint is documented at the constant and in both docs.

The command additionally excludes every canon another pushed branch already touches, on by default — a guarantee nobody has to remember beats one that needs the right flag. It always reports which state the scan is in: excluded N, clean, or incomplete. A scan that could not read every branch never reads as a clean bill of health.

2. Nothing said to check open PRs before picking a target

Three sessions independently chose New Zealand because each looked only at main, where the other two branches were invisible. Step 2 now claims a target against the open-PR list first.

3. The duplicate check only caught identical slugs

The check was rg -l "your-slug" data/canons/. Every duplicate that actually shipped arrived under a different slug describing the same dead end — banking/no-ird-number-45-percent-withholding/nz vs banking/rwt-non-declaration-rate/nz, legal/undeclared-risk-goods-instant-fine/nz vs food-safety/undeclared-biosecurity-goods/nz. A slug grep cannot see those, and near-duplicate pages are what the workflow's own hard rules say hurt indexing.

The check is now by topic: read the country's existing signatures and summaries, then for each planned canon name its nearest neighbour and say why it differs. Partial overlap gets narrowed to the new angle and cross-linked, or folded into the existing canon — not shipped as a competing page.

Also: the re-verification contract

Re-verification now means re-reading the sources, in order — open every sources[] URL, fix moved URLs and broken claims, and only then bump last_confirmed / verdict.last_updated / metadata.last_verification. A refreshed date on a canon nobody re-read tells every downstream agent the entry was confirmed when it was not, so that is a hard rule rather than an implication.

Known limits, stated rather than papered over

  • The claim scan only sees pushed branches, and a cycle picks before it pushes. Two cycles starting simultaneously can still both pick. Step 6 tells you to re-run the command just before committing.
  • It does not try to tell a merged branch from a live one. The repo squash-merges, which severs ancestry, so git merge-base --is-ancestor reports long-merged branches as unmerged and there is no reliable local signal to replace it. Over-excluding costs one canon in your own bucket; under-excluding brings the conflicts back.
  • A fork PR is invisible to it. Step 2 says to note those IDs and pass them with --exclude.

Changes

  • generator/reverify.py — new module + CLI. Reuses the validator's AGING_THRESHOLD_DAYS and age calculation rather than reimplementing them. Uses hashlib.sha256 rather than hash(), which is salted per process and would make two sessions disagree about bucket ownership.
  • tests/test_reverify.py — 32 tests. Notably: no two seeds partially overlap across two different corpus states (the property positional blocks could not provide); an unreadable branch marks the scan incomplete rather than clean; origin/HEAD is not counted as a branch; exclusion applies even on a corpus smaller than one bucket.
  • .claude/commands/add-canon.md — new step 2 (claim against open PRs), topic-based duplicate check in step 3, new step 6 (assigned bucket), two new hard rules, steps renumbered.
  • CLAUDE.md — module listing, key commands, a "Re-verifying Aging Canons" section, and the new test file.

No canon data changes; canon count is unchanged.

Two review passes ran over this; the second commit is what they turned up — the positional-vs-ID bucketing flaw above, plus four claim-scan defects (a swallowed per-branch git diff failure that produced a false "clean", a /HEAD guard that never fired because the short refname format renders it as bare origin, an "already claimed" message on a bucket that was merely empty, and --count 0 dying on an uncaught ValueError after running the whole scan).

Checks

  • ruff check generator/ tests/ — clean
  • python -m pytest tests/ -q — 369 passed (337 existing + 32 new)
  • python -m generator.validate --data-only — PASSED (2486 canons, 0 stale)
  • python -m generator.build_site + python -m generator.validate --site-only — PASSED

Four PRs (#165, #166, #168, #172) sat unmergeable at once. None of them
had failing CI; they were blocked by the workflow itself, in three ways.

1. Every cycle re-verified 'the canons with the oldest last_confirmed'.
   That rule is deterministic, so parallel cycles selected the same files
   and the first PR to merge left the rest conflicting on precisely the
   date fields they came to refresh.

   generator/reverify.py hands out disjoint blocks instead: aging canons
   sorted oldest-first, cut into fixed blocks, each seed hashing to one.
   Two seeds get the same block or share nothing - never the partial
   overlap that conflicts on merge. --exclude takes the IDs open PRs
   already touch, turning 'unlikely to collide' into 'cannot collide'.

2. Nothing told a cycle to look at open PRs before choosing a target, so
   three sessions independently picked New Zealand off the same view of
   main. Step 2 of the workflow now claims a target against the open PR
   list first.

3. The duplicate check was 'rg your-slug data/canons/', which only
   catches an identical slug. The actual duplicates arrived under
   different slugs describing the same dead end. The check is now by
   topic - read the country's existing signatures and summaries, and for
   each planned canon name its nearest neighbour and why it differs.
   Partial overlap gets narrowed and cross-linked, or folded into the
   existing canon, rather than shipped as a competing page.

Also documents the re-verification contract: re-read every source before
bumping any date. A refreshed date on an unchecked canon tells every
downstream agent the entry was confirmed when it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ksw62KukVY7Ft7e8zZGhrp
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
deadends-dev Ready Ready Preview Aug 18, 2026 6:57am

…lean

Follow-up to the previous commit, from reviewing it.

Selection cut the aging list into positional blocks. That made the
central promise - two cycles never partially overlap - true only against
a frozen corpus. Two cycles never see the same corpus: each branches from
a different main, and any merge that ages canons in or refreshes them
shifts every later block boundary, so two cycles could land on
overlapping-but-unequal slices. Exactly the shape that conflicts on
merge, and exactly what the module exists to prevent.

Buckets are now a pure function of the canon ID, so a canon stays in its
bucket no matter what else changed. Two seeds own the same bucket
(identical picks, obvious at once) or share nothing. DEFAULT_BUCKETS has
to stay constant for that agreement to hold, which is now documented at
the constant and in both docs. Replaces the --pool knob with --buckets.

Claim-scan fixes, all in the half the docs tell operators to trust:

- A per-branch `git diff` failure was swallowed, so unrelated histories
  or a shallow CI clone produced ok=True with an empty set - the exact
  false clean bill of health the ClaimScan docstring forbids. Those
  branches are now collected and reported as INCOMPLETE, keeping the
  claims that were readable.
- The short refname format renders refs/remotes/origin/HEAD as bare
  "origin", so the /HEAD guard never fired: HEAD was scanned as a branch,
  inflating the count and importing its canons as claims. Uses full
  refnames now.
- An empty bucket was reported as "already claimed" whenever the scan had
  found anything, which with auto-exclude on is almost always. The two
  cases are now told apart by asking the corpus.
- --count 0 / --buckets 0 ran the whole scan before dying on an uncaught
  ValueError; argparse rejects them up front.
- The DEFAULT_BUCKETS comment claimed collisions among ~30 country seeds
  were unlikely. Across all seeds a collision is near-certain; the number
  that justifies the constant is per-concurrency (~2.3% for 3 at once).

Tests: 32 for this module, including that no two seeds partially overlap
across two *different* corpus states, that an unreadable branch marks the
scan incomplete, and that origin/HEAD is not counted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ksw62KukVY7Ft7e8zZGhrp
@dbwls99706
dbwls99706 merged commit 0ba03dd into main Aug 18, 2026
4 checks passed
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