Skip to content

feat(contract): freeze the Deny-reason vocabulary (#28) - #39

Merged
hellno merged 1 commit into
mainfrom
hellno/deny-vocabulary
Jun 12, 2026
Merged

feat(contract): freeze the Deny-reason vocabulary (#28)#39
hellno merged 1 commit into
mainfrom
hellno/deny-vocabulary

Conversation

@hellno

@hellno hellno commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Closes #28.

What

Keep Decision::Deny { reason: String } on the wire unchanged (the byte-stable serde/CBOR/JSON round-trip stays frozen — no enum migration, no new fields) and freeze the vocabulary in code instead. The intended tags existed only informally; nothing stopped a new Deny site inventing "overCap" or a typo, and an agent retrying against an unstable vocabulary flails.

How

  • deny_reasons module (crates/deckard-contract/src/deny_reasons.rs) — the single source of truth: 30 documented static tag consts + 4 typed dynamic-prefix builders (railgun_keys/signer_error/sign_failed/broadcast_failed"prefix: detail"). There is deliberately no open with_detail(prefix, …), so an arbitrary string can't become a reason prefix.
  • Every Decision::Deny / ExecuteResult::Denied / SignOrderResult::Denied / reply_error() construction in deckard-contract, deckard-signerd, and deckard-mcp now references a const. The mcp failure-catalog match arms + sidecar guards use const patterns, so a tag rename is a compile error.
  • Enforcement test (crates/deckard-contract/tests/deny_vocabulary.rs, runs under cargo test --workspace, no new deps): a structure-aware source scan that masks string/comment interiors, blanks #[cfg(test)] blocks, then brace-aware requires every Deny reason to route through deny_reasons:: — raw literals, foreign vars/consts, and forged prefixes all fail. Plus a reflective module↔snapshot cross-check so a new const/pub fn can't slip past, and self-tests proving each bypass class is caught. ReadStatus/ShieldStatus reasons (a different, free-form vocabulary) are never touched.
  • Docs: the per-tag remediation table in docs/build/31-agent-quickstart.md is extended to every tag (16 new rows); from_deny_reason gains matching arms for the prefix tags + malformed_request so runtime remediation matches the docs.

The full Deny surface turned out to be 34 tags (incl. swap #24's, already merged), not the issue's informal list of 7 — all enumerated from code.

Open question (resolved)

unsupported_v1 is kept — it's reachable via Unshield / non-shaped ContractCall (daemon_e2e asserts it).

Verification

  • cargo fmt --all --check clean
  • just check green (both feature configs)
  • cargo test --workspace → 211 passed, 2 ignored
  • Existing JSON/CBOR round-trip tests untouched and green (wire provably unchanged)
  • No new or changed dependencies
  • Adversarially reviewed by codex (GPT-5.4, xhigh): first pass found a defeatable scan + a docs/runtime gap; both hardened; re-review = ACHIEVED.

Not in scope

No wire-shape change, no SetPolicy/policy mutation, no Allow/NeedsApproval semantics change.

Keep `Decision::Deny { reason: String }` on the wire unchanged (the
byte-stable serde/CBOR/JSON round-trip stays frozen) and freeze the
*vocabulary* in code instead.

- deny_reasons module in deckard-contract: 30 documented static tag
  consts + 4 typed dynamic-prefix builders (railgun_keys/signer_error/
  sign_failed/broadcast_failed -> "prefix: detail"). No open
  with_detail(prefix, ..) — an arbitrary string can't become a reason.
- Migrate every Decision::Deny / *::Denied / reply_error construction in
  deckard-contract, deckard-signerd, deckard-mcp to the consts; the mcp
  failure-catalog match arms + sidecar guards use const patterns so a
  rename is a compile error.
- tests/deny_vocabulary.rs: a dependency-free, structure-aware source
  scan (mask string/comment interiors, blank #[cfg(test)] blocks, then
  brace-aware require every Deny reason to route through deny_reasons::)
  + a reflective module<->snapshot cross-check so a new const/pub fn
  can't slip past. Fails under `cargo test --workspace` if a Deny is
  built from a string outside the frozen set.
- Per-tag remediation table extended in docs/build/31-agent-quickstart.md;
  from_deny_reason gains matching arms for the prefix tags + malformed_request.

unsupported_v1 is kept (reachable via Unshield/ContractCall). No wire-shape
change, no new dependencies.
@hellno
hellno merged commit 4ef0e33 into main Jun 12, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Freeze the Deny-reason vocabulary in deckard-contract

1 participant