A refusal is a precondition failure, so retry semantics are derived and not ruled per-code
Date: 2026-08-10
Status: open
Supersedes: none
Implements: docs/decisions/2026-08-10-machine-surface-bound-to-sources.md Wave 1 — errors.json
Superseded-by: none — current
Context
Section titled “Context”The external audit of the machine surface asked for errors.json: a
machine-readable table of refusal codes carrying, per code, “whether retry with
the same key is safe”. Its reasoning is right — an agent recovering from a
failure was reasoning from narrative prose, and the retry question is exactly
the kind that must not be reasoned about at runtime.
Wave 0’s decision doc treated that as blocked on a per-code ruling: sixty-odd constraint codes, each needing a human call on retry safety. On building it, that framing turned out to be wrong.
Decision
Section titled “Decision”Retry safety is a property of where constraints are evaluated, not of any
individual code. It is therefore stated once in errors.json, derived from
two facts the schemas already fix, and no per-code ruling is needed.
The two facts:
-
Constraints are preconditions. Every
constraints[]entry inspec/verbs/*.schema.jsonis evaluated before the verb branches, commits, or opens a PR. A refused call has no side effects, so retrying it is never destructive. There is no partial-write state for an agent to reason about. -
The idempotency key is a hash of the input.
_common.schema.jsondefines it asSHA256(<verb>|<target_repo>|<canonical_input_json>). This settles the audit’s question by construction rather than by ruling:- If the refusal’s cause was outside your input — the repo was not yet in
registry.yaml, the contract was missing, the playbook was not installed — fix it and resend the identical request. Same input, same key. Safe. - If the fix requires changing the input, the key necessarily changes. “Retry with the same key” is not unsafe in that case; it is impossible, and there is no reason to want it.
A per-code column would have had to encode which of those two applies, which is a property of the operator’s fix rather than of the code — the same code can arrive either way.
- If the refusal’s cause was outside your input — the repo was not yet in
Third, already true and worth publishing beside them: a verb that already
succeeded returns skipped_idempotent on resend rather than acting twice. So
resending is safe even when an agent does not know whether the first call
landed — the case where it most needs to be told.
The code table itself is generated, from the constraints[] each schema
already declares, collapsed across verbs so a code appears once with the list of
verbs it can arrive from. Nothing in errors.json is authored. A hand-written
error table carries the same drift risk as a hand-written verb count, and could
come to answer the retry question differently from the code that enforces it.
Where the same code carries differently-worded descriptions across schemas, the longest is published — it states the condition most completely, and picking whichever sorted first would have been arbitrary.
Consequences
Section titled “Consequences”errors.jsonships now rather than waiting on a ruling that was never actually needed. The Wave 0 doc’s “blocked on a per-code retry-safety ruling” is superseded by this analysis.- The retry guidance is only as true as fact (1). If a verb is ever written that evaluates a constraint after it has begun writing, the blanket statement becomes false for that verb, silently. That is the standing risk this decision accepts. It is mitigated by convention, not by a check — a test asserting “constraints are evaluated before any write” would have to model every verb’s control flow. Flagged here so the next author of a write verb knows the published contract they are bound by.
- Refusal codes are published as the bare constraint name.
_common.schema.jsondescribes the code space as “CONSTRAINT_, SCHEMA_INVALID, AUTH_DENIED, REPO_NOT_IN_REGISTRY, etc.”, but cli/src/verbs/_helpers.tsemits the bare name (NO_PRIVILEGED_PATHS, notCONSTRAINT_NO_PRIVILEGED_PATHS). The published table follows the emitter, since an agent matching on a prefix we do not emit would never match. The schema’s description is the thing that is wrong here, and correcting it is left as follow-up rather than done inline — it is a contract surface and deserves its own change.
Alternatives considered
Section titled “Alternatives considered”- Rule each code individually, as the audit implied. Sixty-odd judgements, each of which would restate the same two facts, and each a place for the table to drift from the enforcer. Rejected once the general property was clear.
- Publish no retry guidance until the per-code work is done. Leaves the precise gap the audit identified open indefinitely, when the sound answer was available immediately.
- Derive a per-code
fix_locus(input vs environment) by keyword-matching the constraint descriptions. Tried and abandoned: it produces a confident-looking field that is a guess, and a wrong guess here sends a recovering agent down the wrong path with more authority than prose would have carried.
References
Section titled “References”spec/verbs/_common.schema.json—IdempotencyKey,Status,Errordefscli/src/verbs/_helpers.ts— the emitter whose code shape is publishedcodes/src/pages/errors.json.ts,codes/src/lib/verb-facts.tsdocs/decisions/2026-08-10-machine-surface-bound-to-sources.md- External audit §11.2 (2026-08-10)
Sign-off
Section titled “Sign-off”- Subagent: Claude Opus 5 (this session, 2026-08-10)
- Human: pending
- Human countersign — that retry safety is a derived property stated once rather than a per-code ruling, that publishing the bare constraint name as the refusal code is correct and the schema’s description is what needs amending, and that the standing risk in Consequences (a future verb evaluating a constraint mid-write) is accepted as convention-enforced.