Skip to content

Wire-contract evolution: Hello capability discovery + the five evolution rules #31

Description

@hellno

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)

  1. Capability discovery, not version negotiationHello as above; old peers answer with the existing decode error.
  2. 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.
  3. 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.
  4. Two distinguishable failures — "unsupported message/capability" (frame-decode error) vs "supported but failed" (Decision::Deny from the frozen vocabulary).
  5. In-repo home — rules + registry live in this repo; publication artifacts are deferred (below).

In scope

  • Hello request + HelloInfo response in deckard-contract; answered in every daemon state including Locked; reveals capability names only — no state, no policy contents, no key material.
  • Baseline capability registry table in the new build doc: core (the shipped socket API), mcp.v0.1 (the 6-tool profile) — columns: name · status · since · defining doc.
  • The five rules above written into the build doc.
  • Tests: E1 Hello shape (spec_version matches ^\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; no deny_unknown_fields on wire types).
  • No code path branches on impl_name (feature detection = capabilities only).

Not in scope (deferred until a second implementer appears)

Definition of done

  • All in-scope checkboxes; existing deckard-contract round-trip tests untouched and green.
  • cargo fmt --all --check clean; just check green (both feature configs); cargo test --workspace green.
  • No new or changed dependencies.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions