Why
The wire contract (crates/deckard-contract: Intent / Decision / Policy / SignerRequest, CBOR over UDS) is frozen by convention with no version field, no capability discovery, and no written rule for what an addition may look like. Upcoming features (7702 session keys, x402) both add request kinds — without evolution rules each one is an ad-hoc breaking change to a contract we call frozen.
Grounded in verified prior art: RFC 9987 (ssh-agent — 20 years unversioned, formalized late with named extensions + a generic-failure valve; https://datatracker.ietf.org/doc/rfc9987/), MCP date-versioning, protobuf never-reuse discipline (https://protobuf.dev/programming-guides/proto3/), EIP-5792 capability maps (https://eips.ethereum.org/EIPS/eip-5792), varlink GetInfo.
Decision (and tradeoff)
Capability discovery instead of a version handshake. One additive request:
SignerRequest::Hello // -> HelloInfo { spec_version: "YYYY-MM-DD", capabilities: Vec<String>, impl_name: String }
An old daemon receiving Hello fails to decode the unknown enum variant and returns the existing frame-decode error — that failure is the backward-compat valve, and it already ships. No existing frame changes; the round-trip tests prove it. Tradeoff: no negotiated handshake (à la MCP ≤2025-11) — accepted because MCP itself is moving away from the handshake, and the valve needs zero migration.
The five evolution rules (normative; documenting them in docs/build/ is part of this issue)
- Capability discovery, not version negotiation —
Hello as above; old peers answer with the existing decode error.
- Date-version (
YYYY-MM-DD), bump only on breaking change — additions ship under a new capability name, never a version bump. Capability names are never reused.
- Protobuf discipline on the CBOR — map-key names are forever (never reuse, rename, or retype; removed keys go to a
reserved list); decoders MUST ignore unknown struct keys and MUST reject unknown enum variants.
- Two distinguishable failures — "unsupported message/capability" (frame-decode error) vs "supported but failed" (
Decision::Deny from the frozen vocabulary).
- In-repo home — rules + registry live in this repo; publication artifacts are deferred (below).
In scope
Not in scope (deferred until a second implementer appears)
Definition of done
Open question
Should HelloInfo also carry the daemon's active chain id, so a wrong-chain client fails at first contact instead of at the first Propose (Deny{chain_mismatch} today fires late)? Leaning yes.
Why
The wire contract (
crates/deckard-contract: Intent / Decision / Policy / SignerRequest, CBOR over UDS) is frozen by convention with no version field, no capability discovery, and no written rule for what an addition may look like. Upcoming features (7702 session keys, x402) both add request kinds — without evolution rules each one is an ad-hoc breaking change to a contract we call frozen.Grounded in verified prior art: RFC 9987 (ssh-agent — 20 years unversioned, formalized late with named extensions + a generic-failure valve; https://datatracker.ietf.org/doc/rfc9987/), MCP date-versioning, protobuf never-reuse discipline (https://protobuf.dev/programming-guides/proto3/), EIP-5792 capability maps (https://eips.ethereum.org/EIPS/eip-5792), varlink
GetInfo.Decision (and tradeoff)
Capability discovery instead of a version handshake. One additive request:
An old daemon receiving
Hellofails to decode the unknown enum variant and returns the existing frame-decode error — that failure is the backward-compat valve, and it already ships. No existing frame changes; the round-trip tests prove it. Tradeoff: no negotiated handshake (à la MCP ≤2025-11) — accepted because MCP itself is moving away from the handshake, and the valve needs zero migration.The five evolution rules (normative; documenting them in
docs/build/is part of this issue)Helloas above; old peers answer with the existing decode error.YYYY-MM-DD), bump only on breaking change — additions ship under a new capability name, never a version bump. Capability names are never reused.reservedlist); decoders MUST ignore unknown struct keys and MUST reject unknown enum variants.Decision::Denyfrom the frozen vocabulary).In scope
Hellorequest +HelloInforesponse indeckard-contract; answered in every daemon state includingLocked; reveals capability names only — no state, no policy contents, no key material.core(the shipped socket API),mcp.v0.1(the 6-tool profile) — columns: name · status · since · defining doc.Helloshape (spec_versionmatches^\d{4}-\d{2}-\d{2}$, capabilities ⊇ {core, mcp.v0.1}); E2 old-peer replay via the existing round-trip fixtures (byte-identical — freeze holds); E3 unknown-variant frame → decode error, connection survives, no panic, nothing signed; E4 extra unknown map key on a struct → decodes, key ignored (pins serde behavior; nodeny_unknown_fieldson wire types).impl_name(feature detection = capabilities only).Not in scope (deferred until a second implementer appears)
Definition of done
deckard-contractround-trip tests untouched and green.cargo fmt --all --checkclean;just checkgreen (both feature configs);cargo test --workspacegreen.Open question
Should
HelloInfoalso carry the daemon's active chain id, so a wrong-chain client fails at first contact instead of at the firstPropose(Deny{chain_mismatch}today fires late)? Leaning yes.