Skip to content

ADR-0039: Cross-Harness Merge Gate — Findings-as-Rows, Derived Gate State, Check-Run Teeth

Accepted Town-crier Cross-Harness Contract

Date: Ratified + built 2026-07-03; merged to development 2026-07-05; released to prod (Fly v44) 2026-07-06. ADR crystallizes the shipped end-state 2026-07-10. Tracks: kendo TC-0036TC-0040TC-0041 (script instance, project 13, contract-decision); TC-0054 amendment pending. Reasoning: campaigns/town-crier/2026-07-03-tc0041-merge-gate-design-and-build.md (design → ratification → build) + campaigns/town-crier/2026-07-03-tc0041-stack-review-fix-cycle.md (review arc, residual-drain).

Status note. This is a cross-harness coordination-contract change, not a town-crier-internal refactor — the finding schema and gate semantics are the surface every harness's submit leg depends on, so it earns its own ADR (the same Q1 rationale that gave ADR-0036 one). Both owners ratified TC-0041 same-day (2026-07-03): Jasper folded all six of his answers into the issue description himself — the binding record — and accepted the mid-thread-lifecycle rider. Built as three stacked PRs (#133 schema → #134 gate → #135 teeth), merged to development by 2026-07-05, released to prod 2026-07-06. Per ADR-0015 (decision → ADR) this crystallizes the ratified + shipped decision; it does not reopen the contract. The one live refinement — per-repo gate_mode (TC-0054) — is an explicit pending amendment (§8), not a blocker on recording the built gate.

Context

The town-crier bus coordinates cross-harness PR reviews as a serialized thread (announce → claim the turn-lock → submit a stance → resolve). Before this ADR, a Review carried only stance / verdict_url / note, and the only influence a review had on a merge was ADR-0036 GitHub-side consensus (the App auto-resolves a thread at ≥2 distinct current-head approvals with zero outstanding objections). Severity itself lived entirely in prose — the TC-0036 ladder (🛑 Blocker / 🔴 Major / 🟡 Minor / ⚪ Nit + confidence/provenance tags) that humans and harnesses read, parsed with a copied BADGE_RE regex, but which no machine enforced. A reviewing harness could call something a Blocker and a third party could merge over it thirty seconds later.

Three ratifications built toward closing that gap:

  • TC-0036 — the severity vocabulary (the ladder + confidence/provenance, as-if-true, riders R1–R4).
  • TC-0040 — the schema: optional structured findings[] on the submit leg, enum-validated, retiring the prose BADGE_RE.
  • TC-0041 (this ADR) — the gate: findings become persisted rows, a merge-gate state derives from them, and the bus-hosted App emits a GitHub check run so the gate has teeth a branch-protection rule can require.

A Review before TC-0041 had no structured severity; consensus was head-gated approval-counting with a deliberate standing-objection bias (objections persist across heads). The gate had to inherit that bias — a real Blocker must not vanish on a cosmetic push — while never overriding a harness's own judgment (R1). That tension is the shape of the decision below.

Decision

1. Findings are first-class rows, not prose

A review_findings table (migration 2026_07_03_000001) persists each finding: tier, confidence, provenance, title, file?, line?, ref? (the GitHub thread/comment URL — the R4 disposition join key), plus the disposition lifecycle columns (§6). A finding row is owned by a Review via FK without cascade (ADR-0002 — FK-as-guard). Findings are declared through the submit leg's typed findings[] array, enum-validated (tier ∈ blocker/major/minor/nit; confidence ∈ confirmed/unconfirmed; provenance ∈ introduced/adjacent/pre_existing) — a malformed row fails 422, it is not silently dropped.

2. Present-empty ≠ absent (classified-clean vs unclassified)

reviews.findings_declared (bool, migration …000002) distinguishes a classified-clean review (findings key present, empty array — "I classified and found nothing gating") from an unclassified prose review (key absent). Neither carries gate weight in either direction; the console flags the unclassified. This keeps prose reviews first-class and legitimate while making classification an opt-in, auditable posture (via a stored contract_version) rather than a forced migration.

3. Gate state is DERIVED, never stored

ReviewRequest::gateState(): GateStateEnum computes the gate over the request's currently-open findings:

  • BLOCKED — any open Confirmed Blocker.
  • HELD — else any open Confirmed Major or Unconfirmed Blocker.
  • CLEAR — else. Open Minors, Nits, and Unconfirmed Majors never gate.

GateStateEnum::gates(): bool { return $this !== self::Clear; }. The derivation lives on the model (the deptrac Resources-wall forces it there; it mirrors reviewerRoster()). There is deliberately no stored gate-state column — a stored state can drift from the findings that justify it; the findings are the single source of truth, so the gate is recomputed on read.

The tier asymmetry is a deliberate false-negative bias toward not merging over uncertainty about the worst case: an Unconfirmed Blocker HOLDS (an unverified 🛑 is worth pausing for until a verification turn confirms or refutes it), while an Unconfirmed Major does not. It is the same bias the consensus tally already carries by letting objections persist across heads.

4. HELD binds bus machinery only — it never forces a verdict (R1)

BLOCKED and HELD suppress consensus auto-resolve (a gating thread cannot auto-close) and drive the check run red (§7). They do not force any harness's stance. Cumulative judgment stays with the reviewer: Jasper's R1 clarification — his auto-approve gate withholds while a 🛑/🔴 thread is open — is a choice on the APPROVE side, not a machine-forced REQUEST_CHANGES. The gate shapes the bus's behavior (auto-resolve, the check run), never a reviewer's verdict.

5. Findings persist across heads until dispositioned (R4)

A new commit does not clear a finding — the mirror of the CHANGES_REQUESTED standing-objection bias. A finding clears only by an explicit disposition (§6). A real Blocker survives a cosmetic push; that permanence is the point of the gate.

6. Disposition lifecycle — the residual drain (R4), one chokepoint

Every finding carries a disposition ∈ fixed | follow_up | declined | refuted. It is written through exactly three entry points, all funneling to one chokepoint:

  • Submit leg assessments[] ({finding_id, verdict, note?, url?}) — the mid-thread valve (Jasper's accepted rider). confirmed promotes confidence (an Unconfirmed → Confirmed release for the Q2 hold); the others write a disposition. Assessments apply before the turn's own findings persist, so a turn cannot self-target its own new rows.
  • Resolve leg dispositions[] + remainder: declined — a manual by-id resolve REFUSES (OUTCOME_OPEN_FINDINGS, resolved: false) while any finding is undispositioned and remainder ≠ declined"silence is not a decision" (Q3). System/consensus resolvers are exempt; the residual queue is no-void.
  • PATCH /review-requests/residual-findings/{id} — the dispose verb for a Done request's residuals (the R4 "path back to disposition" once a thread is closed).

All three route through App\Support\FindingDisposer::stamp() with two guards: (a) already-settled → reject; (b) no-self-verificationconfirmed/refuted are rejected when the assessor authored the finding's review (fixed/follow_up/declined are identity-neutral). The resolve leg and the dispose verb share this code byte-identically, so guard parity cannot drift between them.

7. Teeth — the App emits town-crier/gate as a GitHub check run

(Commander overrule, pre-signature: "I want teeth now" — the draft had parked GitHub-side emission as Phase-2b.)

The bus-hosted App (ADR-0036) writes a town-crier/gate check run per head (EmitGateCheckRunJob): gateState()->gates()failure (the open-finding table rendered in the check output, tier-badged), CLEARsuccess, un-cried / no-thread → neutral (or, strict-mode, action_required). Re-emitted on pull_request webhooks, on bus gate transitions, and on a dispose (the fourth emission trigger). This is the bus's first outbound WRITE to GitHub (+ the checks: write App permission).

Requiredness is per-repo owner opt-in — the bus emits state and never touches branch protection. Blast radius stays owner-controlled: a repo owner chooses to make town-crier/gate a required check; the bus only ever reports. The emission is fail-open (a GitHub write failure degrades to "no check this round," never a thrown 500) and atomic: a gate_check_runs row (unique [repo_full_name, head_sha, check_name]) is claimed as a null-check_run_id sentinel before the outbound POST and backfilled after, closing the SELECT-then-POST-then-INSERT race on concurrent same-head emissions.

Options Considered

OptionVerdictReason
Store gate state as a column on the requestRejectedDrifts from the findings that justify it. The findings are the source of truth; derive on read (§3).
Severity stays prose only (TC-0036 ladder, no schema)RejectedNo machine enforcement; the copied BADGE_RE prose-parsing stays load-bearing. Schema registers the vocabulary once (§1).
Findings clear on a new headRejectedA real Blocker would vanish on a cosmetic push. Persist-until-dispositioned (§5) is the whole point of the gate.
Gate forces a verdict (BLOCKED → auto REQUEST_CHANGES)RejectedViolates R1. The gate binds machinery, not judgment — a harness may still weigh a Blocker as acceptable and say so (§4).
Teeth as a branch-protection mutation by the busRejectedThe bus must never touch protection config. Per-repo opt-in check-run requiredness keeps the blast radius with the repo owner (§7).
Teeth deferred to Phase 2b (state only, no GitHub write)Overruled (Commander)"Teeth now." Cost was shaped in-scope instead of traded away: opt-in requiredness + fail-open + checks: write per-org re-approval.
Findings-as-rows + derived gate + opt-in check-run teethAcceptedMachine-enforces severity, keeps findings as queryable data, closes the residual-void race (R4), preserves R1 judgment and prose reviews, and rides the already-built App for the teeth.

Consequences

Positive

  • Severity is machine-enforced — a Confirmed Blocker gates a merge on any repo that opts the check in, not just a prose warning.
  • Findings are data — console rendering, tier chips, thread analytics, and the residual queue all read structured rows instead of parsing prose.
  • The APPROVE-with-unpicked-residuals race is closed (R4) — a manual resolve can't void surviving findings; every row is fixed/follow_up/declined/refuted or the resolve refuses.
  • Verification stays decorrelated — the no-self-verification guard means a harness cannot confirm/refute its own findings; a second harness's turn is load-bearing.
  • Prose reviews remain first-class — present-empty ≠ absent (§2) means classification is opt-in, not a forced migration.

Negative

  • First bus WRITE to GitHub + the checks: write permission → per-org App re-approval on every install (a detour lesson: a permission save once bumped pull_requests read→write instead — always API-verify the accepted permission set after an App-permission change).
  • A new schema surface harnesses must adopt. A mis-shaped findings[] 422s (and historically, for us specifically, a malformed array was accepted-stance-but-dropped — now enum-validated). Harnesses that haven't adopted the payload shape fall back to prose-in-review + a clean stance.
  • Schema forces vocabulary, not calibration. A wrong tier on a real finding still ships — calibration stays the confirm/deny turn, now disputing tiers on specific rows rather than prose.

Risks

  • Unclassified-review invisibility — a prose review carries no gate weight. Mitigation: the console flags unclassified reviews; classification is auditable via contract_version.
  • neutral satisfying a required check — untested until a repo actually requires the gate. Mitigation: verify neutral-satisfies-required before the first requiredness flip; TOWN_CRIER_GATE_NA_CONCLUSION=success is the fallback.
  • Strict-as-built requiredness bricks bots — gate emission today dispatches only on the announce path, so an un-cried PR gets no check-run and a required gate dead-ends a Dependabot PR at "Expected — waiting." Mitigation: the gate_mode hybrid (§8, TC-0054), pending.

Enforcement

WhatMechanismLevel
findings[] / assessments[] enum-validated on submitSubmitReviewRequestRequest rules + FormRequestsTest arch test (must define rules()/toDto)1 / 3
Gate state derived on the model, no stored column, no DB in the HTTP layerdeptrac Resources-wall + ModelsTest / HttpTest arch tests1
Single disposition chokepoint (FindingDisposer), already-settled + no-self-verification guardsFeature tests (ResolveFindingsGateTest, dispose-verb guard-parity + EmptyIdentityRejectTest)1
Manual resolve refuses on open findings (remainder required)ResolveFindingsGateTest1
resolve refuses under a fresh in_review lock (TC-0037 inheritance)ResolveInReviewGuardTest1
Atomic check-run emit (placeholder-row lock-anchor)Unit-covered; true fork-race concurrency test parked WR-0320 (n=2)1 (partial)
Check-run requiredness is per-repo owner opt-in; bus never touches protectionOperational gate (teeth-rollout mission) + doctrine3 / 4
neutral satisfies a required check, verified before first flipOperational gate3

Implementation

TerritoryStateNotes
town-crierBUILT + PROD-LIVE#133 (schema) + #134 (gate + folded residual-dispose verb) + #135 (teeth) merged to development by 2026-07-05; released to prod Fly v44 2026-07-06. Arch (87) / deptrac (0) / phpstan max / unit --min=100 / Feature (→391) all green. checks: write on town-crier-announce approved on script-development / Back-to-code / emmie / Commander-personal; Jasper's org approval pending (WR-0324). Per-repo requiredness wave HELD on TC-0054 clearance.

Pending amendment — TC-0054 (per-repo gate_mode)

The gate as-built dispatches emission only on the announce path, so an un-cried PR gets no check-run and a required gate makes the review label de-facto mandatory per PR (a Dependabot PR bricks at "Expected — waiting"). Commander ruling (2026-07-06) → hybrid, filed TC-0054 (assigned Jasper, we author, his CODEOWNERS gates): always emit on pull_request events; per-repo gate_modelenient default (neutral N/A) / strict opt-in (action_required, instructive output) / a gate_exempt_authors bot valve. Labeled semantics untouched. This ADR is amended when TC-0054 ratifies + ships; it is not ratified yet.

References

  • Originating tickets: kendo TC-0036 (severity ladder + R1–R4), TC-0040 (schema enforcement), TC-0041 (this gate design — Done = ratified record), TC-0054 (gate_mode hybrid, pending) — script instance, project 13, contract-decision label.
  • Reasoning / build arc: campaigns/town-crier/2026-07-03-tc0041-merge-gate-design-and-build.md, campaigns/town-crier/2026-07-03-tc0041-stack-review-fix-cycle.md.
  • Build orders: orders/town-crier/tc0041-merge-gate-build-engineer-deployment.md (+ residual-dispose + emit-TOCTOU follow-on orders).
  • Related ADRs: Cross-Harness Producer Ingress (ADR-0036 — the App that emits the check run + its outbound-HTTP surface), Cascade Deletion & Selective Soft Deletes (ADR-0002 — FK-as-guard on review_findings), ADR Governance (ADR-0015 — decision → ADR).
  • Related doctrine: the TC-0036 severity ladder (/registry/general-review-voice.md + /pr-review-sweep + /bus-review), the cross-harness coordination contract (town-crier CLAUDE.md), the contract-decision log (project_town_crier_contract_decision_log memory).

Architecture documentation for contributors and collaborators.