Skip to content

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

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.

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:

  1. Constraints are preconditions. Every constraints[] entry in spec/verbs/*.schema.json is 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.

  2. The idempotency key is a hash of the input. _common.schema.json defines it as SHA256(<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.

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.

  • errors.json ships 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.json describes the code space as “CONSTRAINT_, SCHEMA_INVALID, AUTH_DENIED, REPO_NOT_IN_REGISTRY, etc.”, but cli/src/verbs/_helpers.ts emits the bare name (NO_PRIVILEGED_PATHS, not CONSTRAINT_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.
  • 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.
  • spec/verbs/_common.schema.jsonIdempotencyKey, Status, Error defs
  • cli/src/verbs/_helpers.ts — the emitter whose code shape is published
  • codes/src/pages/errors.json.ts, codes/src/lib/verb-facts.ts
  • docs/decisions/2026-08-10-machine-surface-bound-to-sources.md
  • External audit §11.2 (2026-08-10)
  • 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.