Home / Projects / Orchestrata Ultimate / Decisions / ADR index / ADR-0032
ADR-0032: The gate adopts the admission-control contract
| field | value |
|---|---|
| Status | Accepted |
| Date | 2026-07-24 |
| Register | factory (docs/adr, 4-digit ids) |
| Source | docs/adr/0032-gate-adopts-the-admission-control-contract.md — permalink at bdafa201a (unchanged on master since that commit) |
| Wiki register entry | ADR-0032 (wiki status: Accepted) |
The record
- Status: Accepted
- Date: 2026-07-24
- Spec: SPEC-033 §1, §7
- Amends: ADR-0002
Context
ADR-0002 requires factory gates to fail closed. The shipped hook does the opposite, and the code says
so out loud. In scripts/claude_governance_gate.py:
except (json.JSONDecodeError, ValueError)→return 0— malformed input allows.except Exception as exc: # fail-open: plumbing errors must never wedge Claude→return 0— any plumbing error allows.- The decision branch handles only
InterceptorDecision.BLOCK.MODIFY— whichcore/adapters/claude/tool_interceptor.pydefines, and for whichInterceptorResultcarries a populatedmodified_input— has no branch at all. A mutation the interceptor computed is silently discarded and the original, unmutated call proceeds.
The fail-open choice is not indefensible on its own terms: a gate that wedges the tool it governs gets disabled, and a disabled gate governs nothing. But it is currently an undocumented, uniform choice — every failure mode gets the same permissive treatment, with no distinction between "the policy engine says allow" and "we could not determine what the policy engine thinks."
Kubernetes admission control has already worked out this contract in production, and every element of it addresses a gap we currently have:
- Mutate-then-validate ordering. Mutating webhooks run first, validating webhooks see the mutated
object. Our interceptor computes
MODIFYand then nothing consumes it — we have the mutating stage with no wiring. - Mutators must be idempotent, because they can be re-invoked; a non-idempotent mutator produces different results on retry.
- Explicit
failurePolicyper gate —FailorIgnore, declared, not implicit. This is the crucial one: it makes fail-open a per-gate, stated decision rather than a global default hidden in an exception handler. - Self-exclusion. The admission controller must not gate the traffic that keeps it alive, or a failure deadlocks the cluster. Our gate governs the tool that would be used to repair the gate.
- Timeouts are part of the contract, since a hung webhook is a failure mode distinct from a rejecting one.
Decision
Adopt the admission-control contract explicitly:
- Ordered stages: mutate, then validate.
MODIFYis wired — the mutated input is what the validating stage sees and what executes. AMODIFYresult may never be silently dropped; failing to apply a computed mutation is itself a gate failure. - Mutators are idempotent. Applying a mutation twice yields the same result as applying it once.
- Every gate declares an explicit failure policy —
fail-closedorfail-open— as data, not as an exception handler. The default is fail-closed (ADR-0002). A gate that declaresfail-openmust state why, and that declaration is reviewable. - Undetermined ≠ allowed. Malformed input, engine error and timeout are distinct outcomes from
ALLOW, are recorded as such, and are resolved by the declared failure policy — never by falling through toreturn 0. - Self-exclusion is explicit. The set of operations required to diagnose or repair the governance path is declared and excluded, so a gate failure cannot make itself unrecoverable. This is what makes fail-closed survivable, and it is the missing precondition that made fail-open look necessary.
- Every gate declares a timeout, and timeout resolves through the failure policy.
Alternatives rejected
- Keep the uniform fail-open — contradicts ADR-0002, and makes every plumbing bug a silent authorisation. An attacker who can crash the gate is authorised by construction (§7).
- Flip to uniform fail-closed with no self-exclusion — satisfies ADR-0002 on paper and is operationally unsurvivable: the first gate bug wedges the tool needed to fix it. Self-exclusion is the precondition, not an optional extra.
- Handle
MODIFYby converting it toBLOCK— loses the repair the interceptor already computed and converts a fixable call into an escalation. - Design our own failure taxonomy — the admission-control contract is battle-tested against exactly these failure modes; there is no argument for re-deriving it.
Consequences
claude_governance_gate.pyandtool_interceptor.pyboth need real work: wiringMODIFY, replacing blanketreturn 0with policy-resolved outcomes, and adding the declaration surface. This is a build-backlog item, not a spec weakening — ADR-0002 was always right and the code diverged.- The self-exclusion set is a security-sensitive allowlist: too broad and it is a bypass, too narrow and fail-closed becomes unrecoverable. It requires human ratification and belongs in the constitution tier.
- Idempotent mutators constrain what a mutation may do — no appending, no counters, no timestamps in mutated content.
- Distinguishing undetermined from allow means the audit record gains a third outcome class, which ADR-0031's proof trees should carry (a proof tree for "could not determine" is the engine error, not a derivation).
Provenance
2026-07-24 prior-art research (Kubernetes admission control: mutate-then-validate, idempotent mutators,
failurePolicy, self-exclusion, timeouts), matched against a read of scripts/claude_governance_gate.py
and core/adapters/claude/tool_interceptor.py, which showed the uniform fail-open and the dropped
MODIFY branch.
Related ADRs
Rulings cited
None.