Decisions

ADR-176: Fixer fire-event verification principle

Every gyrum-fixup fire emits **structured evidence at fire time** — not just "did the gate stop complaining?" — so fixer correctness is queryable, auditable, and self-policing at scale. Five verification layers…

#176

ADR-176: Fixer fire-event verification principle

Status: Proposed Date: 2026-05-08

Related: warp#2027 (parent EPIC: gyrum-fixup verification at scale), warp#2028 (this ADR — Phase 1), warp#2021 (gyrum-fixup CLI — the consumer of this ADR's evidence model), warp#2025 (Move 1: fixup fire-event JSONL ledger), warp#1974 (sister structural EPIC: trust-boundary), warp#1976 (ADR-175 — sister-shape Phase 1 ADR), ADR-083 (defense-in-depth-rule-promotion), ADR-115 (principle-aware reviewers), ADR-118 (per-block validation), ADR-173 (queue-on-warp — Phase 2's fixer_fires table substrate)

TL;DR

Every gyrum-fixup fire emits structured evidence at fire time — not just "did the gate stop complaining?" — so fixer correctness is queryable, auditable, and self-policing at scale. Five verification layers (re-review pass / build+tests green / persona approval / merge clean / no-revert in 7 days) ride independently per ADR-083's defense-in-depth shape. Brand-new fixers fire experimentally with --include-experimental and earn promotion to stable after 5 successful fires through all 5 layers, mirroring ADR-115's principle-aware reviewer trust-earning model. The cheap symptom check ("gate stopped complaining") suppresses symptoms but doesn't prove correctness; this ADR codifies the structural evidence model that does.

Context

The fleet's gyrum-fixup CLI shipped 2026-05-08 (warp#2021). It dispatches per-gate fixers that auto-apply the canonical 4-gate fixups: doc-truth markers, shellcheck transforms (SC2015 / SC2016 / SC2164 / SC1091), viewer-validator link prefixes and demotions, and index regeneration via scripts/update-docs.sh. The CLI's verification today is one cheap signal: re-run gyrum-review-pr after --apply and confirm the gate no longer fails.

That signal is suppressing the symptom, not proving correctness. Five concrete failure modes the cheap signal cannot catch:

  • doc-truth marker above the wrong line — the marker silences the check; the prose underneath is still wrong, but the gate is now blind to it.
  • SC2164 cd-or-exit masking intentional cd-tolerant patterns — some scripts deliberately continue past a failed cd (e.g. cd "$dir" || true); the fixer's cd "$dir" || exit 1 transform changes runtime behaviour while the gate goes green.
  • Viewer-validator demoting load-bearing hyperlinks — a link to a script that is intentionally runnable-but-undocumented becomes a plain code span; the docs render fine, the gate passes, the audit trail breaks silently.
  • SC2015 if/then/else rewrite that flips control flowcmd1 && cmd2 || cmd3 is not equivalent to if cmd1; then cmd2; else cmd3; fi when cmd2 itself can fail; the fixer's "cleaner" rewrite is wrong in those cases.
  • SC2016 quote-flip changing variable expansion at the wrong layer — when the variable is sourced from the outer shell (e.g. an envsubst template), flipping single→double quotes silently expands what was meant to be literal.

Each of these has shipped, or could ship, with gyrum-fixup --apply clean and gyrum-review-pr green. The fleet's economy depends on fixers being trustworthy: at 30+ fixers × 60+ PRs/day projected throughput, the operator cannot manually inspect every fix. Verification has to be structural — the same pattern this fleet already uses for inventory rules (ADR-175), pipeline blocks (ADR-118), and rule promotion (ADR-083).

The companion ticket warp#2025 is shipping the local JSONL fire-event ledger today (Move 1 of the parent EPIC); this ADR codifies that ledger as structural infrastructure rather than a debugging convenience, and specifies the five-layer verification the ledger feeds.

Decision

Every gyrum-fixup fire emits a structured evidence record at fire time. A nightly cron back-fills lifecycle fields. A PR-time gate enforces ledger-provenance for fixer-shaped diffs. Drift detection auto-files tickets when a fixer's track-record degrades. Five verification layers ride the evidence record, each catching a class the others cannot.

Four sub-rules expand this:

1. Fire-time evidence record

Each fire writes one JSONL row to ~/.gyrum/fixup/fires.jsonl (local; promoted to the warp-hosted fixer_fires table per ADR-173 in Phase 2):

fire_id:                 ulid                  # primary key
fired_at:                rfc3339
fixer_id:                shellcheck-sc2164     # canonical fixer name
fixer_version:           v0.4.2                # devtools version that shipped this fixer
gate:                    shell-static-analysis # parent gate
target_path:             scripts/setup.sh      # file mutated
diff_hash:               sha256:…              # hash of the applied diff
target_repo:             gyrum-labs/dark-factory
target_pr:               731                   # if invoked inside a PR worktree
review_findings_before:  sha256:…              # hash of gyrum-review-findings.json pre-fire
review_findings_after:   sha256:…              # hash post-fire
status:                  experimental | stable # provenance of the fixer at fire time

The schema is append-only; later layers add fields, never mutate existing ones. The fields a fire knows immediately go on the row at fire time; lifecycle fields (merged_at, reverted_within_24h, reverted_within_7d, persona_approval_count) are filled by the nightly cron once the upstream PR's state is observable.

2. The five verification layers

Each layer catches a class of failure the others cannot. The layers are independent — a regression in any one trips, even if the others still pass.

Layer What it checks Cost Catches
L1 — re-review gyrum-review-pr re-runs clean post-fire cheap (~30s) gate-suppression bugs, today's bar
L2 — build & tests structural CI gates remain green cheap (covered by gyrum-review-pr) tests-broken-by-fix (e.g. SC2015 flip changing control flow)
L3 — persona approval persona reviewers (Priya/Marcus/Lin) clear the diff medium (~1-3 min) subtle judgement-shift the gates can't see (semantics-changing rewrites)
L4 — merge clean the upstream PR merges without operator override or --admin cheap (post-merge cron sweep) the operator-flag layer; --admin overrides on fixer diffs are signal
L5 — no revert in 7 days no commit reverting the fixer's diff lands within 7 days medium (7-day cron sweep) "looked right, was wrong" — the truest failure mode

The gate stack rides ADR-083's defense-in-depth shape: each layer is structurally enforced; no single layer trusted alone; failure of any layer surfaces as evidence, not noise. L1+L2 are the cheap pre-merge gates the existing CLI already runs. L3+L4+L5 are the post-merge structural extensions this ADR codifies.

A fixer's track record is the join of its fires and the five layers' verdicts. The query "is this fixer healthy?" answers in a single SQL aggregate (per Phase 2's fixer_fires table per ADR-173):

SELECT
  fixer_id,
  COUNT(*)                                                AS fires,
  AVG(CASE WHEN reverted_within_7d THEN 0 ELSE 1 END)     AS l5_pass_rate,
  COUNT(*) FILTER (WHERE persona_approval_count >= 2)
    * 1.0 / COUNT(*)                                      AS l3_pass_rate
FROM fixer_fires
WHERE fired_at > now() - interval '30 days'
GROUP BY fixer_id;

The aggregate is the operator-readable answer the symptom-check cannot give.

3. Bootstrap problem — experimental → stable promotion

A brand-new fixer has no track record. Today's cheap symptom check would let it fire freely; this ADR refuses that path. New fixers ship with status: experimental and require an explicit --include-experimental flag to fire:

gyrum-fixup --apply --include-experimental

Without the flag, only status: stable fixers fire. Promotion from experimentalstable is structural, not editorial:

Requirement Why
≥ 5 fires recorded in fires.jsonl rules out single-incident luck
All 5 fires green on L1, L2 the cheap gates clear
All 5 fires green on L3 (persona approval ≥ 2 of 3) the medium gate clears
All 5 fires green on L4 (merged without --admin) no operator override masking a bad fire
All 5 fires green on L5 (no revert within 7 days) the truest "looked right, was actually right" signal
Fixer's bootstrap-corpus pair lands in devtools/fixup/fixtures/<fixer-id>/ sister to ADR-118's per-block validation principle

The promotion gate is enforced in code: a Phase 3 cron (per warp#2027 EPIC) lifts the status field from experimental to stable automatically when the six conditions are met, and demotes back to experimental on a single L5 revert (the demotion is the structural escape hatch, sister to ADR-083's gyrum-rule-demote). The trust-earning model mirrors ADR-115's principle-aware reviewer pattern: a fixer earns its license to fire by surviving the same kind of evidence the reviewers themselves have to pass.

4. PR-time ledger-provenance gate

Any PR whose diff matches a known fixer's transform shape (e.g. an inserted <!-- doc-truth: ignore-next --> marker, an SC2164 cd-or-exit rewrite) must carry a fire-event row in fires.jsonl referencing that PR's number. Without the row, gyrum-review-pr refuses to clear with:

fixer-shape diff at <path>:<line> matches <fixer_id>; no
fire-event record for PR #<n> in fires.jsonl. Either run
gyrum-fixup --apply (which records the fire) or, if the
diff is genuinely hand-authored, add an audited override
via gyrum-fixup --hand-authored "<reason>".

This catches fixer-shaped hand edits — the failure mode where an operator (or an agent acting as one) manually applies what looks like the canonical fix without going through the CLI. Hand-authored fixer-shaped diffs lose the structural verification model entirely; the gate refuses them by default and forces either CLI usage or an explicit audited override (per feedback_admin_override_with_audit.md).

Alternatives considered

The 2026-05-08 design session walked four alternatives. Each was rejected for the fleet's specific context (30+ fixers projected, 60+ PRs/day, no manual inspection budget, structural-over-prose default per feedback_structural_over_prose.md):

Alternative Why rejected
Just-trust-the-gate (today's state — re-review passes is enough) Fails at scale. Five named failure modes (§Context) ship green-gate clean today; symptom suppression masks real damage. Every additional fixer multiplies the surface; the cheap signal cannot scale.
Pure-Grafana-no-DB (Prometheus time-series only) Insufficient. Prometheus aggregates fire counts and pass rates beautifully, but doesn't capture per-fire structured evidence (which file, which diff, which PR, which persona verdicts). The "is this fixer healthy?" query has to land on row-level data; time-series is a complement, not a substitute.
LLM-validates-each-fire (a Claude observer per fire judges correctness) Wrong shape per ADR-115 and the broker tradeoffs in ADR-111. Introduces non-determinism into the gate stack; fixers must be auditable transforms (sister to ADR-083 §1 condition 3 — "mechanical verifiability"). An LLM-judged fire is a prose check wearing a different costume; gives up the "cannot drift" property that motivates the fleet's whole structural-rule program. Operator-rejection candidate per feedback_structural_over_prose.md.
Per-fixer manual review queue (every fire surfaces in /operate for human approval) Defeats the productivity premise. The fleet built gyrum-fixup precisely to remove the 30-90 min hand-hoist per fix; routing every fire through human review re-introduces the cost the CLI exists to eliminate. Human attention belongs at exception-time (a degraded fixer surfaces a ticket), not at every-fire-time.

The shipped design (structural evidence + 5 layers + experimental→stable + PR gate) is the only path that scales to fleet throughput while preserving auditability.

Consequences

What becomes easier.

  • Every fixer's track record is queryable. The operator can answer "is this fixer healthy?" with a SQL aggregate, not a hunch. "Which fixer caused the most reverts last quarter?" has a one-line answer.
  • Wrong-fixes surface structurally instead of bleeding into production. L5 (7-day no-revert) catches the "looked right, was actually wrong" class that today's symptom check is blind to; the auto-filed warp ticket lands the operator at the failure with full provenance.
  • New fixer onboarding is paved. The experimental→stable promotion is automatic once the six conditions are met; new-fixer authors don't need to lobby for promotion or argue confidence — the data argues for them.
  • Fleet review converges. Persona reviewers (per ADR-115) cite this ADR on every PR touching devtools/fixup/** or any new fixer fixture; the question "should this fixer fire?" has a documented answer instead of recurring debate.
  • Audit story is one query. Compliance question "show me every automated mutation to this repo's code in the last 30 days, who approved each, and which were reverted" answers from fixer_fires directly.

What becomes harder.

  • Every new fixer ships with a fixture pair (sister to ADR-118 per-block validation). The fixture is the bootstrap corpus the cron uses to evaluate new fires; without it, the fixer cannot graduate to stable. Mitigated: the fixture template lives in devtools/fixup/fixtures/_template/ and gyrum-fixup-init (Phase 4 of warp#2027) scaffolds it.
  • Fixer maintenance becomes part of the fleet's ongoing structural-care surface. A fixer that degrades (L5 reverts cluster) auto-demotes to experimental and surfaces in /operate for repair. Mitigated: the demotion is automatic; the operator only sees the ones that actually broke.
  • The fires.jsonlfixer_fires table migration (Phase 2 per ADR-173) is non-trivial. Running both substrates in parallel during the transition adds operational complexity until the cutover is complete. Mitigated: the JSONL is append-only and replayable; the warp table re-derives from the journal.
  • Phase 3 cron's promotion logic adds a structural moving part. A bug in the promotion criteria could promote a degraded fixer or refuse a healthy one. Mitigated: the criteria are encoded in devtools/fixup/promotion-criteria.yml with a fixture suite (sister to ADR-083 §4 Gate 2's category-filter fixtures); a regression fails CI before the cron runs against live data.

What stays out of scope.

  • Cross-fleet fixer sharing. Fixers are devtools-global today; multi-fleet sharing (e.g. open-sourcing the fixup CLI for non-gyrum fleets to consume) is a separate ADR when the time comes.
  • LLM-driven fixer generation. Authoring a new fixer is a human concern; the experimental→stable promotion is the bound on automation. Auto-generating fixer transforms from review findings is recursion-adjacent and out of scope for this ADR.
  • Per-EPIC versioned fixers. All fixers ship in devtools at the devtools version. EPIC-scoped fixer overrides (e.g. "this EPIC needs a tighter SC2164 transform") are a future need, not this ADR's.
  • Real-time dashboards. The query model (§2) is SQL-aggregate. A live Grafana panel reading from fixer_fires is a Phase 5 deliverable per warp#2027, not codified here.

Enforcement

This ADR is documentary by itself. The structural enforcement that makes the principle self-policing rides five sister tickets, each promoting the rule one tier up the documentary→structural axis (per ADR-083):

Phase Ticket Promotion
2 warp#2027 child (TBD) fixer_fires Postgres table on warp (per ADR-173) + JSONL → DB migration script
3 warp#2027 child (TBD) Nightly cron back-fills lifecycle fields (merged_at, reverted_within_24h/_within_7d, persona_approval_count)
4 warp#2027 child (TBD) gyrum-review-pr PR-time gate enforces ledger-provenance for fixer-shaped diffs
5 warp#2027 child (TBD) Drift-detection cron auto-files warp ticket when a fixer's L5-pass rate degrades below threshold
6 warp#2027 child (TBD) gyrum-fixup-init scaffolds new-fixer fixture pairs; structural gate refuses fixer PRs without paired fixtures

Phases 4-5-6 each emit findings to gyrum-review-findings.json per the warp#1420 contract so gyrum-recover surfaces gaps with canonical rule body + fix-hint.

Persona reviewers (per ADR-115) fetch this ADR before reviewing any PR that:

  • Touches devtools/fixup/** (CLI, fixers, fixtures, promotion criteria)
  • Adds a new fixer under devtools/fixup/fixers/<name>/
  • Modifies devtools/fixup/promotion-criteria.yml or the fire-event schema
  • Reverts a commit whose hash matches a diff_hash in fires.jsonl (the L5 trigger)

On a PR introducing a new fixer, reviewers cite this ADR by number and quote the rule directly: "ADR-176: every new fixer ships as status: experimental with paired fixtures; promotion is automatic on 5 successful fires across all 5 layers."

Exceptions

Two classes:

  1. --hand-authored "<reason>" overrides — operator may explicitly mark a fixer-shaped diff as hand-authored when the deterministic transform genuinely doesn't apply (e.g. a doc-truth marker placed for a reason the fixer can't infer). The override lands in ~/.gyrum/admin-overrides.log per feedback_admin_override_with_audit.md; clusters of overrides on the same fixer trigger a Phase 5 drift ticket signalling either a fixer bug or a missing fixture case.
  2. Pre-fleet-throughput migration window — until Phase 3 cron has been running ≥ 30 days, L5 (7-day no-revert) is advisory rather than promotion-blocking. Brand-new fleet means there is no 7-day-old fire to evaluate; the gate trips itself on cold start. The window ends when the cron has 30 days of green operation; the schema validator (Phase 2) refuses the long-lived state.

Any case outside these two is a re-evaluation of this ADR, not an exception. New exception classes ride the ADR amendment process.

Open questions

  • Demotion threshold for L5 cluster. Default: a fixer auto-demotes to experimental on a single L5 revert within a 30-day window. Tune after Phase 3 data lands — likely a 2-of-N revert rate is the operationally-correct threshold once enough fires accumulate.
  • Multi-PR fires. A single gyrum-fixup --apply on a stacked PR may emit fires referencing multiple PR numbers. The schema accommodates this with one row per file; the open question is whether the lifecycle-back-fill cron should fold sibling rows when one revert lands on the merge commit.
  • Fixer co-fires. When two fixers (e.g. SC2164 and SC1091) fire on the same file in one --apply invocation, the diff hash is shared but the rows are separate. Track-record attribution: does an L5 revert blame both fixers, or only the one whose transform was reverted? Default: blame both; refine once Phase 5's drift detector has signal.
  • Fixture corpus rotation. New fleet patterns will surface new fixer-shapes that the bootstrap fixtures don't cover. Cadence: review fixture coverage quarterly; auto-file a meta-ticket when the L3 persona-approval rate on a stable fixer drops below 90% (signal that the fixer is firing in shapes its fixtures don't represent).
  • Cross-repo replay. Phase 2's fixer_fires table is fleet-global; a fixer proven on dark-factory is trusted on distill-gyrum-ai without a fresh corpus. Acceptable (devtools is shared by contract, sister to ADR-083 §9 Q7) or per-repo opt-in? Default: fleet-global; revisit if a cross-project misfire surfaces.

References

  • Originating instance: warp#2021 (gyrum-fixup CLI shipped 2026-05-08 — the first time the verification gap was visible at scale)
  • Move 1 substrate: warp#2025 (fixup fire-event JSONL ledger — the local evidence store this ADR codifies as structural)
  • First implementation template: ~/.gyrum/devtools/fixup.sh (the CLI consuming this ADR's evidence model; v0.1.x today, gains the --include-experimental and --hand-authored flags in Phase 4 of warp#2027)
  • Sister structural EPIC: warp#1974 (trust-boundary; same 6-phase shape) and ADR-175 (sister Phase 1 ADR)
  • Cross-cutting: ADR-083 (defense-in-depth-rule-promotion — the documentary→structural axis this ADR rides), ADR-115 (principle-aware reviewers — the trust-earning model bootstrap-experimental fixers ride), ADR-118 (per-block validation — sister structural-validation principle), ADR-173 (queue-on-warp — where Phase 2's fixer_fires table lives)
  • Conversation that surfaced the principle: 2026-05-08 design session (operator + agent), captured in the parent EPIC warp#2027