Skip to content

docs(decisions): 0250 — a fabrika hook whose verb cannot run fails open, loudly (#5079) - #5250

Merged
usirin merged 1 commit into
mainfrom
usirin/umut/5079-fabrika-hook-cannot-run-fails-open-E0A63C18
Aug 10, 2026
Merged

docs(decisions): 0250 — a fabrika hook whose verb cannot run fails open, loudly (#5079)#5250
usirin merged 1 commit into
mainfrom
usirin/umut/5079-fabrika-hook-cannot-run-fails-open-E0A63C18

Conversation

@usirin

@usirin usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member

Fixes #5079

Records the founder ruling on the one founder-seat question inside epic #4927, and flips the single reversible policy point it owns.

What the ruling is

A fabrika hook whose verb cannot run fails OPEN — and the silence is what is banned. A verb that never started (a bare fabrika at exit 127, or the cross-checkout refusal at exit 2) produced no evidence, so it may never deny; but the cannot-run state owes a visible degraded notice. Fail-open-and-loud, never fail-open-and-forgotten.

The boundary holds: a verb that runs and returns a deny still fails closed exactly as designed. fabrika hook spawn denying an off-allowlist model is untouched.

Why that polarity — the two families, stated explicitly

"Could not execute" resolves in opposite directions depending on which family the check belongs to, and this ADR names which one applies:

The fabrika cannot-run state is the second family, and the discriminator is which thing failed. A verification guard that fails closed has run and is holding a decision it owns. A hook whose verb never started produced no evidence at all — a deny there asserts a violation nobody observed. So it may not deny; what does not follow is that it may pass, hence the third outcome: UNKNOWN, announced.

Two things checked first-hand, because the epic body carried a stale reading

  1. Decide: should the worktree-isolation guard fail closed? (fail-open invariant vs. confirmed edit-bleed) #3743 was never ruled. Read live from the API this run: state closed, state_reason not_planned, closed 2026-08-01 — swept in the v1 backlog kill batch as fabrika-superseded. claude-plugins/kampus-pipeline/hooks/guard.sh L32 still calls it "an open founder ruling"; that header is stale, and this is a fresh ruling against fabrika, not a revival. (guard.sh is v1 and frozen per ADR 0238, so this PR does not edit it.)
  2. The shared .git/hooks blast radius does not reach fabrika — Horn A loses its strongest support. The Harness worktree-agent spawns fail: post-checkout lefthook can't resolve sh in stripped PATH (blocks both lanes) #787/fix(lefthook): rc-restore PATH so post-checkout survives stripped-PATH worktree spawns (#787) #788/bootstrap-deps still aborts worktree creation on missing pnpm in stripped harness env (follow-up to #787) #789 all-lanes-down radius is a property of a git hook (.git/hooks shared across linked worktrees, git worktree add exec'ing with a stripped PATH — create-worktree.sh L52–55). fabrika ships no git hook: zero .git/hooks hits under claude-plugins/fabrika/, and everything it declares in hooks.json is a per-session Claude Code harness hook. v1's own guard.sh cited that radius for what is likewise a harness hook, so the v1 rationale conflated the two surfaces. Horn A survives on different grounds instead — the bootstrap deadlock (failing closed at exit 127 bricks the session you would need to install fabrika) and the fact that rule 5 admits no wrapper, so there is nowhere to fail closed from.

Both horns are preserved intact in the ADR, with their incidents (#1050 + #787/#788/#789 for A; #3742's stale-build and the masking case for B).

What this PR does not claim

The ruling's teeth are only half-implementable today, and that is recorded rather than papered over: on exit 2 fabrika speaks for itself (resolve.ts's foreign-checkout refusal), but on exit 127 fabrika cannot speak — it is what failed to resolve — and the session-start seam the ruling points at (#5078) is closed with a RETIRE verdict. So the notice for the 127 state has no owner; its only structural cure is the publish-plus-install at #4791. Also recorded as UNKNOWN: what Claude Code itself does when a declared hook's command is unresolvable, which could not be checked first-hand.

Implementation of the chosen horn is gated on an adversarial review / threat-model in either direction. This ADR is the ruling, not an implementation licence.

Diff

  • .decisions/0250-fabrika-hook-cannot-run-fails-open.md — new. The ruling.
  • claude-plugins/fabrika/docs/hook-surface.md — the dispatch-failure policy point flips from INTERIM, owned by Decide: does a fabrika hook fail open or fail closed when its verb cannot run? (re-seats #1050 / #3743) #5079 to RULED, plus the notice wording; four downstream record cross-references that described the question as open are corrected, including the pin.sh record's dangling Owner (this decision child ruled the cannot-run polarity only, so the lost version-gated refusal is still unowned).

Acceptance criteria

  • An ADR records the ruling — fail open, with the notice owed.
  • Both horns preserved with their incidents, neither collapsed.
  • Proven-unsafe vs UNKNOWN stated as a table; UNKNOWN never drives a refusal.
  • First-hand record that Decide: should the worktree-isolation guard fail closed? (fail-open invariant vs. confirmed edit-bleed) #3743 was closed not-planned and never ruled.
  • The shared .git/hooks blast radius established as not applying to fabrika's hook surface, and Horn A re-weighed accordingly.
  • Adversarial review / threat-model recorded as required before implementing, in either direction.
  • Names the single reversible policy point — the #the-dispatch-failure-policy-point section of claude-plugins/fabrika/docs/hook-surface.md.

Scope

Not control-plane: neither changed path matches a live CODEOWNERS row, and claude-plugins/fabrika/** is founder-ruled non-§CP (#5036). No file under claude-plugins/kampus-pipeline/ is touched. Docs-only — no code, no user-facing surface (the issue's containment is exempt).

…en, loudly (#5079)

Records the founder ruling on #5079 and flips the one reversible policy point in the fabrika hook surface from INTERIM to RULED.

The polarity: a hook whose verb never ran produced no evidence, so it may never deny. The teeth: silence is the banned state — the cannot-run state owes a visible degraded notice.

Two things checked first-hand because the epic body carried a stale reading: #3743 is closed not_planned and was never ruled (guard.sh L32's header is stale), and the shared .git/hooks all-lanes blast radius does not reach fabrika's harness hooks, so Horn A loses its strongest support and survives on the bootstrap deadlock instead.

Also records what the ruling does not buy: the degraded notice has no fabrika-side owner for the exit-127 state, because fabrika is what failed to resolve and the session-start seam is graded RETIRE.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

No preview deploy

  • No preview deploy for this PR — its diff touches no deploy-relevant path, so no preview stack was minted and e2e is not applicable. (2cbfce9)
  • web — Stage pr-5250 torn down.

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

ADR number collision resolved (engine note, 2026-08-10).

This PR and #5252 both authored themselves as ADR 0250 — a concurrent-author race on the next number, caught by live enumeration before CI.

Resolution: #5250 keeps 0250. It opened first and was already at the gate. #5252 renumbered to 0251 (head 82f3707abe59016d84b6fdca4a4d1f878336b0e3), and #5249 was amended so its citation points at 0251.

No action needed here. Enumerate .decisions/ across origin/main and every open PR immediately before committing an ADR number — main alone does not see an in-flight one.

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-doc: advisory — blocking-set PR (§CP — approval-gated)

This verdict is advisory only and authorizes no merge. §CP membership here is by CONTENT, not path (ADR 0164): guard-content-probe classifies .decisions/0250-fabrika-hook-cannot-run-fails-open.md as a guard-touching ADR. A @kamp-us/control-plane approval at head is what unblocks the enqueue (ADR 0135 approve-then-enqueue; ADR 0048 single merge authority).

Reviewed-head: @ 2cbfce9

Linked issue: #5079. Graded against the founder ruling (comment 5234582663), which rescoped the body ask ("decide the fork") to a settled ruling — fail open, with a mandatory visible degraded notice.

§CP re-derivation — both axes

Axis Source read live Result
Path CONTROL_PLANE_RE @ origin/main §CP + .github/CODEOWNERS not §CP — only claude-plugins/kampus-pipeline/{skills,lib,agents,hooks} is owned; claude-plugins/fabrika/** matches no row
Content (ADR 0164) shared/scripts/cp-guard-adr.sh (2 files scanned, read OK) BLOCKING.decisions/0250-…md is guard-touching

The not-control-plane report from the coder was right on the path axis and incomplete: adding a .decisions/ ADR gives the content probe input, and it returns guard-touching. Same routing as PR #5238.

Acceptance criteria — 7/7

The substance — is the fail-open reasoning sound?

  • [PASS] The discriminator is sound and cut on the right axis. The ADR splits on which thing failed / what evidence exists, not on consequences, and says so outright: a verification guard that fails closed "has run and is holding a decision it is the authority for"; a hook whose verb never started "has produced no evidence at all", so a deny "would be asserting a violation nobody observed". That is exactly the ratified three-outcome rule in PROBES.md — verified live: outcome 2 ("the probe actually ran and observed the target") is "the only outcome that may gate/hold dispatches."
  • [PASS] It does NOT license fail-open for a guard that DID run. The boundary is fenced four times: the opening scope line ("Nothing here weakens a gate that executed"), decision item 3 ("A verb that ran and denied still denies"), the table row (ran, returned a denyyes — fails closed), and the closing rule ("A hook may only deny on a decision its verb actually computed from input it actually read"). The doc flip repeats the fence. A later reader cannot get from this text to "guards may fail open".
  • [PASS] The checks read contrast is accurate. packages/pipeline-cli/src/tools/checks/command.ts L19–22 verified verbatim: exit 2 is UNREADABLE, "deliberately not exit 1", and "neither may be mistaken for green" (ship-it Step 3 reads gh pr checks, which reports completed CI as in-progress #3999). The ADR uses it as the counter-example — the family that fails closed — which is the right way round.

The UNKNOWN-announced requirement — required, not merely permitted

  • [PASS] The flipped section reads "Ruled behaviour: fail open, and say so. … What is banned is the silence: the cannot-run state owes a visible degraded notice on stderr naming that the hook did not run and which defence is therefore absent." The words owes and banned are mandatory language, not permissive. Decision item 2 in the ADR matches ("Silence is the banned state"). The teeth of the ruling survive the flip.

The two candid claims from the coder — both verified

  • [PASS] Zero git hooks under fabrika — CONFIRMED. A grep for .git/hooks scoped to claude-plugins/fabrika/ on origin/main returns zero hits. claude-plugins/fabrika/hooks.json at head declares exactly two Claude Code harness hooks: PreToolUse (fabrika hook spawn) and SessionStart (fabrika hook check). The blast radius genuinely does not transfer, and Horn A correctly re-seats on the bootstrap deadlock plus "nowhere to fail closed from" (the no-wrapper literal of rule 5 — verified at cli-interface-convention.md: "The literal is fabrika").
    • Observation, not a defect: the ADR cites create-worktree.sh L52–55 as the live authority for the git-hook radius, but that script is itself declared under WorktreeCreate in claude-plugins/kampus-pipeline/hooks.json — a harness event. Its header describes the earlier lefthook post-checkout era (ADR 0109/0178). That makes the conflation broader than the ADR claims, so the conclusion is unaffected and arguably understated; the supporting citation is a comment about a prior surface rather than proof of a live git hook. Worth a future tightening, not a FAIL.
  • [PASS] Teeth half-absent and fabrika: grade freshness against the shim own signalling — build the session-start degraded signal or retire it #5078 CLOSED — CONFIRMED, and the ADR is honest about it. fabrika: grade freshness against the shim own signalling — build the session-start degraded signal or retire it #5078 read live: state=closed, state_reason=completed, and its grading verdict is ### spawn-guard freshness — RETIRE (present at hook-surface.md line 185 at head), so "closed with a RETIRE verdict" is precise. The ADR does not assert a mechanism that does not exist: ### The teeth have no home today — recorded, not papered over states flatly that "the notice for the 127 state has no owner today", splits the two exits (exit 2 works via the stderr refusal in resolve.ts — verified; exit 127 cannot, because fabrika is what failed to resolve), points at Convention-driven versioning + npm publishing missing; commit scopes route nothing #4791 as the structural cure, and declares an explicit UNKNOWN about whether Claude Code itself surfaces a hook-failure line, adding "This ADR does not claim either." Follow-up fabrika: the exit-127 degraded notice the #5079 ruling owes has no owner #5251 is filed and open (status:needs-triage). This is the strongest hygiene property in the PR.

Closing-keyword audit — the closing set is exactly {#5079}

Case-insensitive (fix|close|resolve)[a-z]* +#[0-9]+ re-run across all four surfaces:

Surface Hits Classification
PR body 1 — line 1, Fixes #5079 plain prose, intended, the linked issue
.decisions/0250-…md 0
claude-plugins/fabrika/docs/hook-surface.md 0
Commit messages (2cbfce9e) 0

Every other issue reference in the ADR is either a bare markdown link or carries the keyword after the ref ("which #4791 closes"), which does not trigger. #5251, #5078, #4927 and #3411 are NOT in the closing set. Clean.

Doc-hygiene checklist

  • [PASS] House-format — frontmatter id/title/status/date/tags present; ## Context / ## The decision / ## Consequences present.
  • [PASS] Index row — no .decisions/index.md committed (ADR 0126, ambient discovery); frontmatter status: accepted is the row.
  • [PASS] Links resolve — all 12 relative targets the ADR adds exist at head; the new link from hook-surface.md to the ADR resolves; the #the-dispatch-failure-policy-point anchor is present.
  • [PASS] No leaked local/home paths — the shared matcher over added lines exited 0 (clean).
  • [PASS] Supersession — supersedes nothing and amends no prior decision text. It re-characterizes the stale guard.sh header but explicitly declines to edit it (v1 frozen, ADR 0238), so no forward cross-link is owed.
  • [PASS] Status sanity — accepted, coherent with a landed founder ruling.
  • [PASS] Single Diátaxis mode — single-mode: explanation. No how-to/reference drift.
  • [PASS] Clear, concise prose — active voice, concrete, no AI-tell density. Em-dash and bold-lead bullets are established ADR house idiom here.

Step 4a — ADR contradiction sweep

  • [PASS] adr-sweep over the head: 247 ADRs scanned, 207 live-accepted and uncited in scope, 10 decision terms. The 8-entry shortlist (0248, 0249, 0241, 0247, 0246, 0188, 0176, 0226) was opened — all lexically adjacent on fabrika/silence/state, none rules on the cannot-run polarity question. Semantic half done by hand across the whole fail-open/fail-closed/guard/probe/UNKNOWN ADR set: the nearest ruling is 0092 (gates fail closed on zero scope), which the ADR cites and correctly distinguishes — 0092 governs a gate that ran over zero scope, 0250 governs a verb that never ran. No uncited conflict.

CI at head

All check-runs at 2cbfce9e are success — zero failing, zero pending. Notably validate ADR files — no duplicate/mismatched ADR number is green.


Merge-order hazard — a live ADR-0250 number collision (not a defect in this PR)

Re-enumerated at review time across origin/main and every open PR:

Per ADR 0074, /adr claims its number by detect-and-serialize against open ADR PRs; that did not happen for one of these two. This is not a corruption risk — the validate ADR files — no duplicate/mismatched ADR number CI job will red on whichever PR lands second. It is a sequencing note for the merge actor: land #5250 first and let #5252 renumber to 0251, or have #5252 renumber now. Recorded outside the checkbox table on purpose — a cross-PR coordination finding, not a failing criterion (ADR 0226).


Verified read-only against the PR head through a per-run ref (§RO/§HEAD); no working tree was switched. The review-code namespace is posted as a separate comment.

@usirin

usirin commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

review-code: advisory — blocking-set PR (§CP — approval-gated)

This verdict is advisory only and authorizes no merge. §CP by CONTENT (ADR 0164) — guard-content-probe classifies .decisions/0250-fabrika-hook-cannot-run-fails-open.md as a guard-touching ADR, so the merge-authority hold covers the whole diff. A @kamp-us/control-plane approval at head unblocks the enqueue (ADR 0135; ADR 0048 single merge authority).

Reviewed-head: @ 2cbfce9

Linked issue: #5079 (graded against founder ruling 5234582663).

Why this namespace is required at all

pipeline-cli class-probe classify --namespaces over the 2 changed files returns review-code + review-doc (exit 0, 2 namespaces). The review-code requirement comes entirely from the no-class fail-closed rule (§CLASS, #2765), not from any source file:

Changed file HAS_CODE_RE HAS_SKILLS_RE has-docs (carve-then-test) Class
.decisions/0250-fabrika-hook-cannot-run-fails-open.md no no yes has-docs
claude-plugins/fabrika/docs/hook-surface.md no no (not skills/ or agents/) no (excluded by ^claude-plugins/) no class → rides has-code

Regexes re-resolved live from gh-issue-intake-formats.md §CLASS on origin/main, not from any injected snapshot. review-design is not required — nothing under apps/web/src/.

Verification

Notes carried from the doc-class pass

Full acceptance-criteria verification (7/7), the substance judgment on the fail-open reasoning, the two verified coder claims, the doc-hygiene checklist, and the ADR contradiction sweep are in the review-doc comment on this PR. One cross-PR hazard is repeated here because it affects merge order: open PR #5252 also mints ADR 0250. This PR was created about 2 minutes earlier and holds the earlier claim; the duplicate-ADR-number CI job will red on whichever lands second, so land #5250 first or have #5252 renumber to 0251.


Verified read-only against the PR head through a per-run ref (§RO/§HEAD); no working tree was switched.

@usirin usirin added the status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval label Aug 10, 2026
@usirin
usirin requested a review from notusirin August 10, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:cp-banked Control-plane PR banked on the board, awaiting a control-plane approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide: does a fabrika hook fail open or fail closed when its verb cannot run? (re-seats #1050 / #3743)

2 participants