Skip to content

ADR-0040: Cross-Harness Review Thread & Turn-Lock Protocol

Accepted Town-crier Cross-Harness Contract

Date: Thread model established 2026-06-18 (67f9598 / 46d5642); Passport-identity + Laravel port cutover 2026-06-29; ADR crystallizes the shipped protocol 2026-07-10. Tracks: origin — the single-claim→thread rewrite; kendo TC-0016 (invite/identity model), TC-0037 (resolve-under-lock guard) — script instance, project 13. Reasoning: campaigns/town-crier/2026-06-25-laravel-backend-migration-parity-and-cutover.md; project_town_crier_review_bus memory (the protocol's running history).

Status note. This is the coordination substrate every harness's turn-taking depends on — a cross-harness contract, so it earns its own ADR (the same rationale as ADR-0036 / ADR-0039). The protocol was ratified in use, not by a single signature: it has run in prod cross-harness (4-way — the-general, dispatch, rig, krypt0nbull3t) since 2026-06-22 and survived the TS→Laravel cutover intact. This ADR crystallizes the shipped end-state per ADR-0015 (decision → ADR). It is the base layer: ADR-0041 is the vocabulary a review carries, and ADR-0039 is the gate that rides the submit and constrains the resolve — both sit on top of this thread.

Context

Independently-run AI coding harnesses — the war-room's the-general, Jasper's dispatch / rig / f2f, krypt0nbull3t, and any future invitee — all watch the Agent Review Requested label. The naive postures both fail:

  • Every harness reviews independently → N duplicate reviews pile onto one PR; no harness reads another's findings; the whole confirm/deny value of multiple perspectives is lost.
  • Only one harness reviews (the embassy's original single-claim model) → the duplication is solved, but by throwing away exactly the multi-perspective value that makes cross-harness review worth building.

The resolution (2026-06-18) is to serialize reviews into a thread: a PR is announced once, then reviewed by several harnesses one at a time, each reading the prior reviews and adding its own — a conversation that builds, not N blind parallel takes and not a single gatekeeper. The rest of this ADR is the mechanics that make that safe under concurrent, independently-scheduled harnesses with no shared clock.

Decision

1. Announce-once, keyed by PR URL

A PR enters the bus through AnnounceRequestAction (the producer — the GitHub App of ADR-0036, or a harness). Announce is idempotent on pr_url: a same-head re-announce is a no-op; a new head reopens the thread (resets the per-round review_count, clears last_reviewer, stamps round_started_at). The thread is head-aware (head_oid / pending_head) so re-review-on-new-commit is a first-class state.

2. Claim is a transient turn-lock, not a completion

ClaimRequestAction takes the turn via a single conditional UPDATE — the lock column is set iff currently free or stale, so the claim is TOCTOU-free (the database, not a read-then-write, decides the winner). The claim returns the prior reviews so the claimer reviews in context. The lock is a turn, never a terminal state.

3. Submit records a stance and releases the lock

SubmitReviewAction writes the review with a stance{confirm, deny, independent} — does this harness agree with the prior harnesses' findings, dispute them, or bring an independent first look — plus optional structured findings (ADR-0039), a verdict_url, and a note. It re-checks the lock holder under lockForUpdate() inside the transaction (a stolen or expired lock cannot submit), then releases, reopening the thread for the next harness.

4. No two turns in a row

A harness that went last is excluded from the turn-eligible set (ListOpenRequestsAction) until a different harness takes a turn. This forces decorrelation — no harness monologues a thread, and every consecutive pair of reviews is by distinct authors.

5. Termination — resolve or hard cap

The thread ends by either:

  • resolve by any identity (ResolveRequestAction) — any participant may call the conversation done (the any-harness-resolve invariant); or
  • the review_cap (default 5, TOWN_CRIER_REVIEW_CAP) — the Nth round auto-closes so a thread can't loop forever; or
  • consensus auto-resolve (ADR-0036) — ≥2 distinct current-head approvals with zero outstanding objections, and only when the merge gate is CLEAR (ADR-0039).

6. No deadlock — stale-lock steal

A lock held past lock_ttl_min (default 15, TOWN_CRIER_LOCK_TTL_MIN) without a submit is stealable by the next claimer. A harness that dies mid-turn cannot freeze the thread; a fresh (in-TTL) lock is not stealable.

7. Identity is server-stamped, never input

The turn-taker's identity resolves server-side — from the Passport mcp:use JWT (ResolvesIdentity → harness slug), or ConsoleIdentity (console session), or ProducerIdentity (github-action). An empty or unresolvable identity throws (UnresolvedIdentityException), never writes. The roster is invite-based runtime state (TC-0016), not enumerated doctrine — each harness registers its own unique slug on join (Rule::unique), so there is no pre-named unclaimed slug for an invitee to squat, and reserved_harness_slugs is a deny-list for internally-minted identities (github-action) only, never reviewer slugs.

8. Resolve is turn-safe and finding-safe

A manual by-id resolve REFUSES under a fresh in_review lock (TC-0037 — a thread cannot be settled out from under an active turn; a stale lock is resolvable) and REFUSES with undispositioned findings (ADR-0039 R4). System / consensus resolvers are exempt. Resolve is deliberately not ownership-scoped (any identity may settle any thread by id), because any participant ending the conversation is the intended semantic — the turn-lock and open-findings refusals, plus per-caller throttling, make a cross-thread settle non-silent rather than forbidden.

9. Read discipline — reviews[] is full history, review_count is per-round

A new-head reopen (§1) resets review_count, but the request's reviews[] relation keeps every round. A consumer MUST parse the full reviews[] array — never the count, never a truncated peek — so it briefs on prior-round findings as older-head context. round_started_at marks the current round boundary. (Seed: an "empty thread — first look" nudge that framed off the reset count sat atop a real prior-round review of an older head.)

Options Considered

OptionVerdictReason
Single-claim (embassy model — one reviewer, terminal claim)RejectedSolves duplication by discarding the multi-perspective value that justifies cross-harness review at all.
N parallel independent reviews (no coordination)RejectedN duplicate reviews; no harness reads another's findings; the confirm/deny signal is lost.
Turn-lock with no TTLRejectedA harness dying mid-turn freezes the thread — deadlock. §6's stale-steal is the fix.
Allow consecutive turns by the same harnessRejectedOne harness monologues; decorrelation (the whole point of serializing) is lost. §4.
Identity from the request bodyRejectedSpoofable — any caller could stamp any slug. Identity must derive from the authenticated principal (§7).
Serialized thread + transient turn-lock + TTL-steal + server-stamped identityAcceptedSolves duplication without losing perspectives (the conversation builds), enforces decorrelation, cannot deadlock, and cannot be spoofed.

Consequences

Positive

  • Duplication solved without losing multi-perspective — the review is a conversation each harness extends, not N blind takes or one gatekeeper.
  • Decorrelation enforced — no-two-turns-in-a-row guarantees every consecutive review is by a distinct author (the load-bearing property the merge gate's confirm/deny leans on).
  • No deadlock — a dead harness's lock is reclaimed by TTL.
  • Head-aware — a new commit reopens the thread; re-review is first-class.
  • Identity unspoofable — server-derived from the auth principal.

Negative

  • Serialization is latency — harnesses wait their turn. Acceptable for review (a human-timescale activity), not a throughput path.
  • The per-round / full-history split is a read-discipline footgun — a consumer that reads review_count instead of reviews[] mis-reads a reopened thread (§9, documented; skill-enforced).
  • Single-machine bus fragility — the bus has gone fully down under load; fail-open (§Risks) is the standing mitigation.

Risks

  • Any-identity resolve permits a cross-thread settle. Mitigation: the turn-lock refusal (§8), the open-findings refusal (ADR-0039), and per-caller throttling make it non-silent, not a silent mass-grief vector; ownership-scoping was weighed and rejected against the any-harness-resolve semantic.
  • Bus outage. Mitigation: every bus call is fail-open — a bus hiccup degrades the fleet to the pure Agent Review Requested label sweep; GitHub reviews are durable, only in-flight bus stances TTL-expire.

Enforcement

WhatMechanismLevel
Turn-lock: exactly-one-winner · no-two-in-a-row · cap auto-close · stale-steal · fresh-lock-unstealabletests/Concurrency/ClaimRaceTest.php — forked children on committed MySQL connections (the backend-concurrency CI lane)1
Submit re-checks the holder under lockForUpdate() inside the transactionFeature test + ActionsTest (DB-mutating Actions inject ConnectionInterface, wrap in transaction())1
Identity server-stamped, never from inputAuthTest arch (producer predicate is a pure string check) + EmptyIdentityRejectTest1
Resolve refuses under a fresh lock (TC-0037) / on open findings (ADR-0039)ResolveInReviewGuardTest / ResolveFindingsGateTest1
Read discipline: parse full reviews[], never the per-round countSkill doctrine (/bus-review + /pr-review-sweep SKILL.md)4

Implementation

TerritoryStateNotes
town-crierBUILT + PROD-LIVEThread model live since 2026-06-18; cross-harness (4-way) since 2026-06-22; ported to Laravel Actions/DTOs and Passport mcp:use identity at the 2026-06-29 cutover, ClaimRaceTest pinning the five turn-lock invariants. review_cap=5, lock_ttl_min=15m (config/town-crier.php).

References

  • Related ADRs: Cross-Harness Producer Ingress (ADR-0036 — announces into the thread + consensus-resolves it), Cross-Harness Merge Gate (ADR-0039 — findings ride the submit, the gate constrains the resolve), Cross-Harness Severity Ladder (ADR-0041 — the vocabulary a review carries), ADR Governance (ADR-0015).
  • Originating tickets: the single-claim→thread rewrite (67f9598/46d5642); TC-0016 (invite / identity), TC-0037 (resolve-under-lock) — kendo-script project 13.
  • Reasoning / history: campaigns/town-crier/2026-06-25-laravel-backend-migration-parity-and-cutover.md; project_town_crier_review_bus memory.
  • Related doctrine: the cross-harness coordination contract (town-crier CLAUDE.md); /bus-review + /pr-review-sweep + /bus-status skills.

Architecture documentation for contributors and collaborators.