Skip to content

US-45.4: merge gate and stage machine speak checkpoints (v3, mode bound at placement) - #908

Merged
mkreyman merged 4 commits into
masterfrom
feature/us-45.4-checkpoint-gate-v3
Sep 27, 2026
Merged

mkreyman merged 4 commits into
masterfrom
feature/us-45.4-checkpoint-gate-v3

Conversation

@mkreyman

@mkreyman mkreyman commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

US-45.4: the merge gate and stage machine speak checkpoints (v3)

This replaces #906, which was closed after its round-3 review still found material defects. Most of them came from one design choice: the gate read the intake source's CURRENT mode. So a mode change made while a story was in flight changed which route that story was judged on. #906 tried to prevent that with a stories_in_flight 409 on update and enrol. That refusal had race and settled-stage holes of its own.

The decision behind v3: the mode is bound per implement dispatch at placement.

  • DispatchLedger.record_sent/3 copies the source's mode onto the new runner_dispatches.mode column (migration 20260926150000, nullable; a NULL row reads as pr, the only route legacy rows had).
  • The gate reads the mode from the implement row of the story's CURRENT claim: same claim_epoch, status accepted or superseded, and the shared implement-kind filter. That read is DispatchPayload.dispatch_route/3.
  • The source's mode now decides only what FUTURE placements get. A change is always allowed, so the stories_in_flight 409, the in-flight set, previous_mode and max_blocking_named are deleted, along with their docs and tests.
  • AC-45.4.1 is rewritten to match. OpenAPI, both MCP intake tool descriptions, the README and docs/agent-delivery-loop.md all say it.

Also in v3

  • Base freshness (B). A thread-mode allow requires the compare's merge_base_sha to equal the base head (base_sha). Otherwise the gate answers base_moved_since_checkpoint, routed like the other moved-head reasons: with a live claim it goes back to implementing to rebase; without one it is refused with claim_not_live. Both routes are tested.
  • Round-3 finding 2. On the merged path, a contains? error that is not transient (a 404 included) falls through to judging the checkpoint as open. Only a transient error surfaces as unevaluated.
  • Round-3 finding 3. A replay at the same head whose allow payload changed (a different base_sha or checkpoint_id) records a new effect_recorded event. The latest event therefore always matches the returned verdict. Stages.put_effect refreshes the event only when the payload differs.
  • Round-3 finding 6. The branch resolves in one documented order inside dispatch_route/3: the stage's recorded branch effect, then the ledger row, then branch_for. Each step has a test.
  • Round-3 finding 7. Threads.claim_checkpoints is one query (a story left-joined to its checkpoints), selects only the fields it needs, and does no gate_evidence load and no exists? query.
  • Round-3 finding 8. The route read has its own busy telemetry event, [:loopctl, :delivery, :dispatch_route_busy].
  • Round-3 finding 9. Doc errors fixed: commit/2 returns the tree only; the function is dispatch_route/3; GitHubPullRequestSource's reads are described without counting them.
  • Findings 4, 5 and 10 no longer exist, because the mode binding (A) removed the code they were about.
  • MCP package bumped to 2.106.0.

Not covered by a test

  • The busy path of dispatch_route/3: no test fires the dispatch_route_busy telemetry. It goes through the same Stages.answering_busy/4 seam that other reads already exercise.

Gate

mix precommit ran through the commit hook: 11607 tests, 0 failures (88 excluded); credo and dialyzer clean. MCP node --test is green.

Review

The review gate is still to run.

Mutation table

Every mutation below was run with ~/workspace/claude-config/bin/mutate.sh <file> --old ... --new ... -- mix test <check> (or node --test for the W-series). Exit 0 means the check went red under the mutation. Wiring mutations are included: V3-02, 04, 08, 13, 16, 22, 28, 35, 37, 46, 49, 52, 60, 64 and 66.

The first V3-11 was a mutation that could not change behaviour: it added a clause guarded by when false, which never matches. It exited 1 for that reason, not because the test was weak. It was replaced with the real regression (the gate following the source's CURRENT mode), which exited 0. V3-41's first run was refused (exit 2) because its target text did not match. It was retargeted and exited 0.

id mutation check exit
V3-01 placement records the source mode (mechanism) test/loopctl/runners/dispatch_ledger_test.exs:172 0
V3-02 placed_mode wired into the row test/loopctl/runners/dispatch_ledger_test.exs:172 0
V3-03 only implement dispatches bind a mode test/loopctl/runners/dispatch_ledger_test.exs:190 0
V3-04 gate reads the placed mode (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:490 0
V3-05 route mode from the row test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:55 0
V3-06 route reads the current claim only test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:77 0
V3-07 route reads ran rows only test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:77 0
V3-08 route shares the implement-kind filter (wiring) test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:77 0
V3-09 implement-kind filter mechanism test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:77 0
V3-10 route newest first test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:77 0
V3-11 gate follows the source's CURRENT mode instead of the placed one test/loopctl/delivery/merge_precondition_integration_test.exs:622 0
V3-12 stage branch wins test/loopctl/delivery/dispatch_payload_dispatch_route_test.exs:68 0
V3-13 stage branch wired into the gate test/loopctl/delivery/merge_precondition_integration_test.exs:606 0
V3-14 dispatch branch before the derived name test/loopctl/delivery/merge_precondition_integration_test.exs:596 0
V3-15 base_moved_since_checkpoint (mechanism) test/loopctl/delivery/merge_precondition_judge_test.exs:1141 0
V3-16 base freshness wired into moved test/loopctl/delivery/merge_precondition_integration_test.exs:633 0
V3-17 base freshness thread only test/loopctl/delivery/merge_precondition_judge_test.exs:1148 0
V3-18 not-live base move escalates test/loopctl/delivery/merge_precondition_integration_test.exs:642 0
V3-19 live: back to implementing test/loopctl/delivery/merge_precondition_judge_test.exs:1099 0
V3-20 live predicate reads the fence's fact test/loopctl/delivery/merge_precondition_judge_test.exs:1099 0
V3-21 pr mode always goes back test/loopctl/delivery/merge_precondition_judge_test.exs:1116 0
V3-22 claim_live? from Claimant (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:539 0
V3-23 not live refused claim_not_live (judge) test/loopctl/delivery/merge_precondition_judge_test.exs:1082 0
V3-24 branch_head_unrecorded test/loopctl/delivery/merge_precondition_judge_test.exs:1007 0
V3-25 branch_missing test/loopctl/delivery/merge_precondition_judge_test.exs:1056 0
V3-26 branch_head_regressed test/loopctl/delivery/merge_precondition_judge_test.exs:1073 0
V3-27 branch fact first (order) test/loopctl/delivery/merge_precondition_judge_test.exs:1063 0
V3-28 branch reasons wired into moved test/loopctl/delivery/merge_precondition_integration_test.exs:517 0
V3-29 pr mode reads no branch fact test/loopctl/delivery/merge_precondition_judge_test.exs:1154 0
V3-30 earlier checkpoints carried to the judge test/loopctl/delivery/merge_precondition_integration_test.exs:551 0
V3-31 thread input facts test/loopctl/delivery/merge_precondition_judge_test.exs:1037 0
V3-32 empty_change (tree) test/loopctl/delivery/merge_precondition_judge_test.exs:1015 0
V3-33 empty_change (no files) test/loopctl/delivery/merge_precondition_judge_test.exs:1022 0
V3-34 checkpoint_tree_mismatch test/loopctl/delivery/merge_precondition_judge_test.exs:1029 0
V3-35 thread reasons wired into gated test/loopctl/delivery/merge_precondition_integration_test.exs:531 0
V3-36 claim_ended reason test/loopctl/delivery/merge_precondition_judge_test.exs:1122 0
V3-37 claim_ended from the ledger (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:725 0
V3-38 busy read is transient test/loopctl/delivery/merge_precondition_judge_test.exs:1134 0
V3-39 checkpoint consumed on every path test/loopctl/delivery/merge_precondition_judge_test.exs:1134 0
V3-40 current claim only (threads) test/loopctl/threads_test.exs:105 0
V3-41 claimant kind only test/loopctl/threads_test.exs:105 0
V3-42 earlier checkpoints read test/loopctl/threads_test.exs:88 0
V3-43 earlier claim detected test/loopctl/threads_test.exs:105 0
V3-44 latest is the newest test/loopctl/threads_test.exs:88 0
V3-45 branch read FIRST test/loopctl/delivery/merge_precondition_integration_test.exs:746 0
V3-46 branch 404 examined (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:576 0
V3-47 readable repo: branch_missing test/loopctl/delivery/merge_precondition_integration_test.exs:576 0
V3-48 unreadable repo escalates test/loopctl/delivery/merge_precondition_integration_test.exs:694 0
V3-49 repository read consulted (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:694 0
V3-50 commit 404 escalates test/loopctl/delivery/merge_precondition_integration_test.exs:712 0
V3-51 merged only when contained test/loopctl/delivery/merge_precondition_integration_test.exs:758 0
V3-52 contains? consulted (wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:758 0
V3-53 contained merge adopted test/loopctl/delivery/merge_precondition_integration_test.exs:768 0
V3-54 contains? 404 judged open (F2) test/loopctl/delivery/merge_precondition_integration_test.exs:652 0
V3-55 contains? transient stays an error (F2) test/loopctl/delivery/merge_precondition_integration_test.exs:664 0
V3-56 base_sha carried from compare test/loopctl/delivery/merge_precondition_integration_test.exs:490 0
V3-57 allow names the checkpoint test/loopctl/delivery/merge_precondition_integration_test.exs:490 0
V3-58 base_sha in the allow event test/loopctl/delivery/merge_precondition_integration_test.exs:490 0
V3-59 record_effect writes the payload test/loopctl/delivery/merge_precondition_integration_test.exs:490 0
V3-60 replay refreshes a changed payload (F3 wiring) test/loopctl/delivery/merge_precondition_integration_test.exs:676 0
V3-61 replay writes nothing when unchanged (F3) test/loopctl/delivery/merge_precondition_integration_test.exs:676 0
V3-62 moved skips the file reads test/loopctl/delivery/merge_precondition_integration_test.exs:780 0
V3-63 repo_for_story through the source test/loopctl/delivery/post_deploy_verification_test.exs 0
V3-64 PATCH forwards mode (wiring) test/loopctl_web/controllers/intake_source_controller_test.exs:463 0
V3-65 mode change audited test/loopctl_web/controllers/intake_source_controller_test.exs:463 0
V3-66 enrolment forwards mode (wiring) test/loopctl_web/controllers/intake_source_controller_test.exs:439 0
V3-67 null mode refused test/loopctl_web/controllers/intake_source_controller_test.exs:495 0
V3-68 compare 300-file cap test/loopctl/delivery/github_pull_request_source_test.exs:432 0
V3-69 compare counts required test/loopctl/delivery/github_pull_request_source_test.exs:420 0
V3-70 exact git/ref endpoint test/loopctl/delivery/github_pull_request_source_test.exs:339 0
V3-71 adapter reads the repository itself test/loopctl/delivery/github_pull_request_source_test.exs:360 0
V3-72 adapter reads base_commit.sha test/loopctl/delivery/github_pull_request_source_test.exs:385 0
V3-W1 merge_precondition description names base_moved_since_checkpoint mcp-server/test/delivery_loop_tools.test.js 0
V3-W2 README row names base_moved_since_checkpoint mcp-server/test/delivery_loop_tools.test.js 0
V3-W3 intake_source_enroll says a change is always allowed mcp-server/test/intake_source_tools.test.js 0
V3-W4 intake_source_update says always allowed (mutated to name stories_in_flight) mcp-server/test/intake_source_tools.test.js 0
V3-W5 README update row says always allowed mcp-server/test/intake_source_tools.test.js 0
V3-W6 README enrol row says always allowed mcp-server/test/intake_source_tools.test.js 0
V3-73 migration 20260926150000 up body to a no-op (run LAST; test DB dropped and re-migrated after) ecto.rollback --step 1 then test/loopctl/runners/dispatch_ledger_test.exs:172 0

Review round 3 (the ceiling), fixed in place

Round 3 found 8 findings. Four shared one cause, placement-bound facts read partially from the claim's ledger row, so they were fixed in place rather than rewritten, following #902's round 3. No round 4.

  1. Stage row branch outlives a release: the current claim's dispatched branch is now judged first.
  2. A claim with no accepted row followed the source's current mode: it is now always pr, so flipping a source never re-routes a session's own PR.
  3. A resume re-derived base_branch from the source: it now re-sends the recorded one; a different one is 422 base_branch_conflict.
  4. Stale checkpoint_id on replay: disproved, since checkpoints are unique per (commit_sha, claim_epoch) and a release clears the allowed sha.
  5. base_sha duplicated merge_base_sha: collapsed into one verdict field.
  6. Ledger rules in DispatchPayload: moved to DispatchLedger.claim_route_query/2.
  7. Doc drift: fixed.
  8. Extra reads on pr evaluations: Claimant.live? is pure, and the ledger read decides pr versus thread, so it stays, with the reason stated at the call.
id mutation check exit
R3-1 drop pin_recorded_base_branch from the resume path placement_test.exs 0
R3-2 base_branch conflict silently substitutes placement_test.exs 0
R3-3 stage branch before dispatched branch dispatch_route + integration tests 0
R3-4 no-row mode falls back to source mode merge_precondition_integration_test.exs 0
R3-5 allow records head_sha as base_sha merge_precondition_integration_test.exs 0
R3-6 controller sends base_sha for pr verdicts merge_precondition_controller_test.exs 0
R3-7 drop the implement-kind filter from claim_route_query dispatch_payload_dispatch_route_test.exs 0
R3-8 drop the claim-epoch filter from claim_route_query dispatch_payload_dispatch_route_test.exs 0

… bound at placement)

Thread mode: the merge gate judges a story's latest recorded checkpoint under the current
claim instead of a pull request, reading the branch, compare and tree through the new
CheckpointSource.

The merge route is BOUND per implement dispatch at placement. DispatchLedger.record_sent/3
copies the intake source's mode onto the new runner_dispatches.mode column (NULL read as pr),
and the gate reads it from the current claim's implement row via DispatchPayload.dispatch_route/3,
which also resolves the branch in one documented order: the stage's recorded branch effect,
then the ledger row, then branch_for. A source's mode now decides only what future placements
get, so a mode change is always allowed and the stories_in_flight refusal is deleted.

A thread-mode allow also requires the compare's merge base to equal the base head; otherwise
base_moved_since_checkpoint, routed like the other moved-head reasons (live claim: back to
implementing; not live: refuse with claim_not_live).

Also: a contains? error on the merged path falls through to judging the checkpoint as open
unless it is transient; a same-head replay with changed allow data records a fresh effect
event; claim_checkpoints reads in one query; the route read has its own busy telemetry event.

Replaces #906.
…ase branch bound at placement

The gate no longer refuses a base that moved since the checkpoint (base_moved_since_checkpoint
is removed): at ci a claim is usually not live, so a busy repository escalated nearly every
thread-mode story. The gate judges the three-dot diff and records base_sha as the compare's
merge base; the merge executor (US-45.5, new AC-45.5.8) merges only while the base head still
equals it and otherwise takes the base-update path. With base_sha the merge base, a same-head
replay records the same data, so the replay-refresh event machinery is removed.

A checkpoint the base already contains (merge base equals the checkpoint) is merged, with or
without a recorded merge commit, and judged on its recorded allow instead of reading as an
empty change.

The implement ledger row now pins base_branch beside mode (same unmerged migration, renamed
add_runner_dispatches_route), and the gate reads both through dispatch_route; a NULL base
branch falls back to the source's current one. The mode is carried from the placement's own
source resolution (DispatchPayload.fill) into record_sent, or read with a project-filtered
query when the caller named the refs, instead of loading every live source of the tenant.

An unreadable route reports mode nil rather than pr, and the route read's lock wait is
bounded. dispatch_route reads accepted rows only. Docs, OpenAPI and the MCP descriptions say
what the code now does.
…nly branch resolution

A checkpoint the base already contains is already_merged only when a recorded allow names it,
and otherwise refused checkpoint_on_base_without_allow (a checkpoint that is the base it was
cut from, or an ungated fast-forward), never the ungated-merge alarm. The same contained check
now runs on a missing thread branch before it is answered branch_missing, so a checkpoint
fast-forwarded and then deleted is not read as never pushed.

The branch is resolved for thread mode only (DispatchPayload.thread_branch/3): dispatch_route/2
returns recorded facts and derives nothing, so the pr path neither reads nor fails on a thread
branch. A claim with no accepted implement dispatch now takes the source's current mode, as it
already took the base branch, so a thread-source story with no route is judged as a thread.

A non-contention failure to read the thread is thread_unreadable, refused like
pull_request_unavailable, instead of no_checkpoint_recorded. The compare call pages commits with
per_page=1. source_for_project/2 filters the project in SQL and is the one derivation of the
exactly-one-source rule; project_source_mode/2 is removed.

Docs no longer claim the executor's base-update path comes back through this gate: it judges
claimant checkpoints only, and US-45.5 gains AC-45.5.9 (with a test case) requiring
claim_checkpoints to judge the latest base_update reaching the allowed checkpoint.
…im's own ledger row

- The gate judges the current claim's dispatched branch before the stage row's, which
  survives a release and can name an earlier claim's branch (finding 1).
- A claim with no accepted implement row is judged as a pull request whatever the source's
  mode now, so flipping a source never re-routes a session's own PR (finding 2). A checkpoint
  can only be recorded under an accepted row, so such a claim has no thread to judge.
- A resumed dispatch re-sends the base branch recorded at the first push; a retry naming a
  different one is 422 base_branch_conflict (finding 3).
- base_sha is no longer a second verdict field: an allow records merge_base_sha under that
  name, and the API derives it for thread verdicts only (finding 5).
- The claim-route query moved into DispatchLedger.claim_route_query/2 with the ledger's other
  readers; where_implement_kind/1 is gone (finding 6).
- Doc drift fixed: dispatch_route/2 arity, and the base branch pull_request/4 receives (7).
- Finding 4 disproved: checkpoints are unique per (commit_sha, claim_epoch) and a release
  clears the allowed sha, so one sha cannot be allowed under two checkpoint rows.
- Finding 8: Claimant.live? is a pure read of the story struct; the ledger read is what
  decides pr versus thread and cannot be skipped, now said at the call.

Mutations, all exit 0 (caught): R3-1 pin wiring, R3-2 base conflict, R3-3 branch order,
R3-4 no-row mode fallback, R3-5 recorded base_sha, R3-6 controller pr base_sha,
R3-7 implement-kind filter, R3-8 claim-epoch filter.
@mkreyman
mkreyman enabled auto-merge (squash) September 27, 2026 03:37
@mkreyman
mkreyman merged commit d0ab3b6 into master Sep 27, 2026
16 checks passed
@mkreyman
mkreyman deleted the feature/us-45.4-checkpoint-gate-v3 branch September 27, 2026 03:45
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.

1 participant