Skip to content

issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned)#95

Open
hanwencheng wants to merge 16 commits into
mainfrom
claude/gallant-ride-cec4d7
Open

issue #82: ERC-7730 clear-signing + EIP-712 typed-data sign (v2-aligned)#95
hanwencheng wants to merge 16 commits into
mainfrom
claude/gallant-ride-cec4d7

Conversation

@hanwencheng
Copy link
Copy Markdown
Member

Refresh of #82 against v2 architecture (#87 / #92). The original issue targeted v1 (mock-server-as-signer, daemon-side metadata, broker SQLite audit); plan was rewritten to v2 surfaces (signer typed RPC, worker audit rows with intent commitments, ERC-7730 catalog as a §22 pluggable surface).

Full v2-aligned plan: docs/spec/plans/issue-82-erc7730-v2-aligned.md.

What ships

Phase 1 — EIP-712 typed-data signing at the signer

  • New endpoint POST /dev/sign-typed-data on the mock-server signer. Accepts canonical EIP-712 v4 JSON (matches MetaMask eth_signTypedData_v4), parses + hashes internally (never trusts a caller-supplied prehash), returns 65-byte signature + every intermediate digest (primary_type_hash, domain_separator, digest).
  • DevKeyService::sign_eip712 + Eip712SignResult envelope.
  • New SignerError::InvalidTypedData (400) propagated through SignerClientError.
  • SignerClient::sign_eip712 trait method + HttpSignerClient impl.
  • Wired into both signer-only and full mock-server routers.

Phase 2 — clear_signing module in agentkeys-core

crates/agentkeys-core/src/clear_signing/:

File Responsibility
eip712.rs EIP-712 v4 encoder (no external dep). string/bytes/bool/address, uint{8..256}, int{8..256}, bytes{1..32}, static/dynamic arrays, nested structs. Cycle detection. EIP-712 spec reference vector matches exactly.
parser.rs ERC-7730 v2 JSON parser (v0 subset)
format.rs Per-field formatters (tokenAmount with decimals+ticker, address with truncation, integer, date as ISO-8601 UTC, bool, raw) + {name} intent interpolator
binding.rs domain → 7730-file lookup; case-insensitive on address; refuses wildcard matches
catalog.rs Bundled USDC permit fixture + filesystem dir loading via $AGENTKEYS_7730_DIR
mod.rs::build_preview "Render this typed-data against this catalog" returning intent_text + intent_commitment = keccak256(intent_text || 0x7c || digest)

Phase 3 — CLI preview surfaces

Two new subcommands under agentkeys signer:

  • sign-typed-data --signer-url ... --omni-account ... --typed-data-file ./permit.json [--preview-7730] — calls /dev/sign-typed-data. With --preview-7730, renders operator intent + per-field review before signing.
  • preview-7730 --typed-data-file ./permit.json [--7730-file ./erc20-permit-usdc.json] — render WITHOUT signing. Dry-run for new 7730 files.

Both pick up $AGENTKEYS_7730_DIR for operator-custom 7730 files. Both support --json for machine output.

Phase 4 — audit-row intent-commitment schema (arch.md only)

arch.md §15.3 extended with two optional audit-row fields (signed_intent_text, signed_intent_hash). Schema is backwards-compatible — pre-#82 rows have the fields absent; worker reads/writes land in a follow-up PR.

Docs

Test plan

  • cargo test --workspace — 600+ tests, 0 failures, 1 ignored.
  • 30 new unit tests under agentkeys-core::clear_signing (EIP-712 spec reference vector, cyclic type detection, integer range checks, array length validation, U256 dec/hex roundtrip, two's-complement negation, parser, formatter, binding, catalog).
  • 2 sign_eip712 unit tests in dev_key_service.rs (recovers-to-derived-address, malformed-typed-data rejection).
  • 6 route tests in dev_key_service_routes.rs (200 / 400-unknown-primary / 400-out-of-range-uint / 503-signer-disabled / address-matches-derive / full-sig-recovery roundtrip).
  • cargo clippy — clean on all new code; pre-existing warnings unchanged.
  • Harness (/agentkeys-harness) — to be run by the operator after merge against Heima mainnet to confirm no regression on v2 stage-1/2/3 demos.

What did NOT land (follow-ups, tracked in the plan doc)

  • Broker cap-mint policy gate — broker cap-mint doesn't yet require an intent_commitment for typed-data signs. Daemon currently goes direct to signer via signer_client. When broker mediation lands, the cap-token will carry the commitment.
  • Worker audit-row wiringagentkeys-worker-audit doesn't read the new schema fields yet (forward-compatible; unknown fields silently ignored). Schema is documented so the follow-up PR has a fixed target.
  • On-chain CredentialAudit event extension — needs a contract revision + redeploy; out of scope for a signer + worker change.
  • Registry fetch (v1 source)github.com/ethereum/clear-signing-erc7730-registry integration is v1 catalog source per arch.md §22; v0 bundled set ships in this PR.
  • EIP-4337 UserOp clear signing — out of scope per original Enhance signing process with ERC-7730 clear-signing support (typed-data + display metadata + intent audit) #82.
  • K11 binding on high-value signs — Phase 5 in the plan; needs ScopeContract extension to express per-(operator, agent) signing policy.

🤖 Generated with Claude Code

Refresh of issue #82 against v2 architecture (#87/#92). Original issue
targeted v1 (mock-server-as-signer, daemon-side metadata, broker SQLite
audit); plan was rewritten to the v2 surfaces (signer typed RPC, worker
audit rows with intent commitments, ERC-7730 catalog as a §22 pluggable
surface). Plan: docs/spec/plans/issue-82-erc7730-v2-aligned.md.

## What ships in this PR

### Phase 1 — EIP-712 typed-data signing at the signer

* New endpoint `POST /dev/sign-typed-data` on the mock-server signer:
  accepts canonical EIP-712 v4 JSON (matches MetaMask `eth_signTypedData_v4`),
  parses + hashes internally (never trusts a caller-supplied prehash),
  returns the 65-byte canonical signature + every intermediate digest
  (`primary_type_hash`, `domain_separator`, final `digest`).
* `DevKeyService::sign_eip712` + `Eip712SignResult` envelope.
* New `SignerError::InvalidTypedData` (400) + propagation through
  `SignerClientError`.
* `SignerClient::sign_eip712` trait method + `HttpSignerClient` impl.
* Wire signer-only + full routers in agentkeys-mock-server.

### Phase 2 — clear_signing module in agentkeys-core

New crate module at `crates/agentkeys-core/src/clear_signing/`:

* `eip712.rs` — EIP-712 v4 encoder (no external dep). Supports
  string/bytes/bool/address, uint{8..256}, int{8..256}, bytes{1..32},
  static/dynamic arrays, nested struct types. Cycle detection on type
  graph. Spec reference vector (`Mail` example) matches exactly.
* `parser.rs` — ERC-7730 v2 JSON parser (subset for v0).
* `format.rs` — per-field formatters (tokenAmount with
  decimals+ticker, address with truncation, integer, date as ISO-8601
  UTC, bool, raw) + `{name}` intent interpolator.
* `binding.rs` — domain-{name,version,chainId,verifyingContract} →
  7730-file lookup; case-insensitive on address; refuses wildcard
  matches.
* `catalog.rs` — bundled set (USDC permit fixture) + filesystem dir
  loading via `extend_from_dir` (operators ship custom files via
  `$AGENTKEYS_7730_DIR`).
* `mod.rs::build_preview` — top-level "render this typed-data against
  this catalog" returning `intent_text` + `intent_commitment` =
  `keccak256(intent_text || 0x7c || digest)`.

### Phase 3 — CLI preview surfaces

Two new subcommands under `agentkeys signer`:

* `sign-typed-data` — call `/dev/sign-typed-data`. With
  `--preview-7730`, renders + prints operator intent + per-field review
  before signing.
* `preview-7730` — render WITHOUT signing. Dry-run for new 7730 files
  before plumbing them into automated agent signing.

Both pick up `$AGENTKEYS_7730_DIR` for operator-custom 7730 files; both
support `--json` for machine-readable output.

### Phase 4 — audit-row intent-commitment schema (arch.md only)

`arch.md §15.3` extended with two optional audit-row fields
(`signed_intent_text`, `signed_intent_hash`). Schema is backwards-
compatible — pre-#82 rows have the fields absent; worker reads/writes
land in a follow-up PR (broker cap-mint propagation + on-chain
`CredentialAudit` event extension also follow-up).

### Docs

* `docs/spec/signer-protocol.md` — full `/dev/sign-typed-data` wire
  contract documented (request, response, supported type-string
  subset, errors).
* `docs/spec/architecture.md` §14.2 + §15.3 + §22 — typed-data RPC in
  the signer surface, audit-row intent-commitment fields, clear-signing
  metadata as a pluggable surface (bundled → registry → on-chain
  progression).
* `docs/spec/plans/issue-82-erc7730-v2-aligned.md` — full refreshed plan,
  including the K11-binding-on-high-value-signs follow-up (Phase 5 — out
  of scope here, tracked as separate issue since it needs a
  ScopeContract extension).

## Test plan

* `cargo test --workspace` — 600+ tests across the workspace, all pass.
* New tests added in this PR:
  - 30 unit tests under `agentkeys-core::clear_signing` (EIP-712 spec
    reference vector, cyclic type detection, integer range checks,
    array length validation, U256 dec/hex roundtrip, two's-complement
    negation, parser, formatter, binding, catalog).
  - 2 sign_eip712 unit tests in `dev_key_service.rs`
    (recovers-to-derived-address, malformed-typed-data rejection).
  - 6 route tests in `dev_key_service_routes.rs` (200 / 400-unknown-
    primary / 400-out-of-range-uint / 503-signer-disabled / address-
    matches-derive / full-sig-recovery-roundtrip).
* `cargo clippy` — clean on all new code; pre-existing warnings
  unchanged.
* Signature roundtrip verified: HKDF-derived secp256k1 key signs the
  EIP-712 digest, `ecrecover` returns the same address that
  `derive_address` produces for the same `omni_account`.

## What did NOT land in this PR

Tracked as follow-ups so this PR stays scoped:

* **Broker cap-mint policy gate** — the broker cap-mint endpoint
  doesn't yet require an `intent_commitment` for typed-data signs.
  Today the daemon goes direct to the signer via `signer_client`. When
  broker mediation lands, the cap-token carries the commitment.
* **Worker audit-row wiring** — `agentkeys-worker-audit` doesn't read
  the new schema fields yet (forward-compatible; unknown fields are
  silently ignored). Schema is documented in arch.md §15.3 so the
  follow-up PR has a fixed target.
* **On-chain `CredentialAudit` event extension** — needs a contract
  revision + redeploy; out of scope for a signer + worker change.
* **Registry fetch (v1 source)** — `github.com/ethereum/clear-signing-
  erc7730-registry` integration is the v1 catalog source per arch.md
  §22 (the bundled set is the v0 default that ships in this PR).
* **EIP-4337 UserOp clear signing** — out of scope per original #82.
* **K11 binding on high-value signs** — Phase 5 in the plan; needs a
  ScopeContract extension to express "agent A may sign EIP-712 binding
  to chainId=1 verifyingContract=$X with tokenAmount ≤ Y".

Plan-completion summary:

* **What landed**: Plan refresh, signer-protocol.md update, arch.md
  §14.2/§15.3/§22 updates, `/dev/sign-typed-data` endpoint, signer-side
  EIP-712 hashing (no external dep), `clear_signing` module (parser +
  formatter + binding + catalog + EIP-712), bundled USDC permit fixture,
  CLI `sign-typed-data` + `preview-7730` subcommands, audit-row intent-
  commitment schema doc, full sig-recovery roundtrip test.
* **What did NOT land**: Broker cap-mint policy gate, worker audit-row
  wiring, on-chain `CredentialAudit` event extension, registry-fetch
  catalog source, K11-on-high-value-signs (Phase 5). All tracked
  explicitly in the plan doc as follow-ups.
Defines the unified abstract audit message format that every audit-producing
surface (creds, memory, signer, broker, payment-service, email-service,
SidecarRegistry, K3EpochCounter) MUST emit going forward, and that the
chain + explorer + indexer consume.

## What this section adds

* **Envelope schema** — version, ts_unix, actor_omni, operator_omni,
  op_kind (u8), op_body (CBOR), result, intent_text + intent_commitment
  (PR #95). Canonical CBOR per RFC 8949 §4.2.1.
* **Wire shape** — `POST /v1/audit/append` accepts the envelope;
  `GET /v1/audit/envelope/<hash>` returns the full envelope on demand
  (used by explorers).
* **On-chain shape** — `CredentialAudit.appendV2(operatorOmni, actorOmni,
  opKind, envelopeHash)` + `appendRootV2(... opKindBitmap)` lands
  additively alongside the v1 `append`/`appendRoot`. New events
  `AuditAppendedV2` + `AuditRootAppendedV2` with `indexed opKind` topic
  so explorers can filter via `eth_getLogs`.
* **Canonical op_kind table** — 17 op_kinds across 8 families
  (creds=0..2, memory=10..12, signs=20..21, payments=30..31,
  scope=40..41, device=50..52, email=60..61, K3=70). Grouped by 10s
  leaves room for related ops. PRs adding new op_kinds MUST append a
  row; numbers never reused, never reordered.
* **Eight non-break invariants** — the cost of adding a new op_kind is
  "uglier UI temporarily for old explorers" — never "broken explorer /
  dropped event." Open enum, stable envelope-level fields, version
  gating, fallback renderer, opaque body pass-through, op-kind-agnostic
  contract, canonical table, 3-test contract per new op_kind.
* **5-phase migration** — A (this doc) → B (worker + core migration)
  → C (contract revision) → D (subscan-essentials decoder) → E
  (subscan-essentials-ui-react renderer) → F (extend op_kind coverage).
  Phases B / C / F tracked at agentkeys#97; phases D / E tracked at
  subscan-essentials#12.

## Why this matters

Today's audit surface only has 3 op_kinds (STORE / READ / TEARDOWN) and
those are credential-CRUD-only. A typed-data sign event, a scope
mutation, a device add, a payment, a memory put, an email send, a K3
epoch advance — none of these have a row to render in the explorer.
With this section in place, the explorer can render a uniform timeline
across all of them, and adding a new op_kind doesn't require the
explorer to ship a release before AgentKeys can ship the feature.

## What does NOT land in this PR

This is the schema lock-in (Phase A). The implementation phases (worker
migration, contract redeploy, explorer decoder, UI renderer) ship as
follow-ups in their respective repos. agentkeys#97 + subscan-essentials#12
are the tracking issues.
Lands the canonical AuditEnvelope shape as live code, not just a doc.
Documented in arch.md §15.3a; this commit ships the worker side. Contract
revision (Phase C) + emit-site migration across signer/scope/device/payment/
memory/email/K3 (Phase F) remain follow-ups in #97.

## What ships

### `agentkeys-core::audit` — canonical envelope (new module)

* `AuditEnvelope` struct — version + ts_unix + actor_omni + operator_omni
  + op_kind (u8 open enum) + op_body (ciborium::Value) + result +
  intent_text + intent_commitment. Envelope-level fields are stable
  across all op_kinds.
* `AuditOpKind` repr-u8 enum — 18 variants matching arch.md §15.3a
  canonical table (creds=0..2, memory=10..12, signs=20..21,
  payments=30..31, scope=40..41, device=50..52, email=60..61, K3=70).
  Open enum: `from_u8` returns Option, never panics.
* `AuditResult` repr-u8 enum (Success=0, Failure=1, NotPermitted=2).
* Per-op_kind typed body schemas in `audit::bodies` — 18 structs with
  serde derives matching the canonical table field-for-field.
* Canonical CBOR codec in `audit::cbor` — deterministic per RFC 8949
  §4.2.1. Encoder builds the envelope as an ordered CBOR map with keys
  sorted by canonical CBOR ordering. Decoder ignores unknown
  envelope-level keys (forward-compat) and rejects unsupported
  envelope versions.
* `envelope_hash()` = keccak256(canonical_cbor). The 32-byte
  commitment that lands on chain as the second arg to the future
  `CredentialAudit.appendV2(operatorOmni, actorOmni, opKind, hash)`.
* `commit_intent()` helper — same scheme as
  `clear_signing::commit_intent` (PR #95); verified by a test that
  asserts byte-for-byte equality between the two.

### `agentkeys-worker-audit` — V2 endpoints

* `POST /v1/audit/append/v2` — accept envelope (as JSON), convert
  op_body to CBOR, compute envelope_hash, store CBOR by hash. Returns
  `{envelope_hash}`.
* `GET /v1/audit/envelope/:hash` — return canonical CBOR bytes for the
  envelope (200 application/cbor) or 404 envelope_not_found. Explorers
  fetch via this endpoint after seeing the on-chain hash.
* V1 endpoints (`/v1/audit/append`, `/v1/audit/flush/:op`, etc.)
  retained so existing callers keep working through the migration
  cycle.
* `state.rs` extended with `envelopes: Mutex<HashMap<String, Vec<u8>>>`
  — in-memory v0; persistent S3 storage is a separate concern tracked
  alongside Phase C.

### Non-break invariants enforced by code

Per arch.md §15.3a:

1. ✅ `op_kind` is `u8`, never a sealed enum (open enum design;
   `AuditOpKind::from_u8` returns Option).
2. ✅ Envelope-level fields decode for ANY op_kind, even op_kind=250
   (test: `unknown_op_kind_still_decodes_envelope_level_fields`).
3. ✅ `version` bumped only on envelope-level breakage; new op_kinds
   stay at v1.
4. ✅ Worker accepts unknown op_kinds + stores the opaque body for
   explorers to fetch (test: `append_v2_accepts_unknown_op_kind`).
5. ✅ Decoder ignores unknown envelope-level keys (forward-compat for
   future versions; test: `decoder_ignores_unknown_envelope_keys`).
6. ✅ No contract-side decode of op_body — only `(opKind, envelopeHash)`
   would land on chain (Phase C scope; out of this PR).
7. ✅ Canonical op_kind table in arch.md §15.3a — `op_kind.rs::tests`
   asserts no byte collisions + all variants roundtrip.

## Tests

* 17 unit tests in `agentkeys-core::audit` — envelope encode/decode,
  envelope hash determinism, unknown-op_kind tolerance, version
  refusal, typed body decode, op_kind byte uniqueness, commit_intent
  parity with `clear_signing::commit_intent`.
* 7 integration tests in `agentkeys-worker-audit::tests::envelope_v2`:
  - append → 200 + envelope_hash with correct shape
  - GET → 200 application/cbor with canonical bytes
  - GET unknown hash → 404 envelope_not_found
  - reject envelope version 99
  - reject malformed actor_omni
  - accept unknown op_kind (non-break invariant #1 + #4)
  - envelope_hash deterministic across appends
  - ts_unix=0 gets server-assigned

* `cargo test --workspace` — 600+ tests, **0 failures, 1 ignored**
  (network-dependent test; pre-existing).
* `cargo clippy` — clean on all new code.

## What does NOT land in this PR

Tracked in #97 as Phases C + F:

* On-chain `CredentialAudit.appendV2` + `appendRootV2` + new events
  with indexed opKind topic — needs contract revision + Heima Mainnet
  redeploy.
* Migration of credentials-service + memory-service + signer + broker
  emit sites from legacy `AuditEvent` to `AuditEnvelope`. Each new
  op_kind PR will append a row to the arch.md §15.3a table + add the
  worker emit-site call.
* Persistent storage for envelopes (S3 `audit/envelopes/<hash>.cbor`).
  In-memory v0 is sufficient for the worker's lifecycle; if the
  worker restarts before chain commitment lands, callers re-emit.
* Subscan-essentials indexer decoder + UI renderer
  (subscan-essentials#12).
…ndpoints

Future emit sites (credentials-service, memory-service, signer, broker,
payment-service, email-service, SidecarRegistry, K3EpochCounter) all need
the same `POST /v1/audit/append/v2` + `GET /v1/audit/envelope/<hash>` wire
shape. Putting the client in agentkeys-core means each emitter consumes the
contract from one place — and the wire-level test surface is centralized.

## What ships

* `agentkeys_core::audit::AuditClient`:
  - `new(base_url)` / `from_env()` (reads `$AGENTKEYS_AUDIT_WORKER_URL`,
    defaults to `https://audit.litentry.org`).
  - `append(envelope)` → returns `{ok, envelope_hash}` from the worker.
  - `get_envelope(hash)` → `Option<Vec<u8>>` (None on 404).
* `envelope_for(actor, operator, op_kind, op_body, result, intent_text,
  intent_commitment)` convenience builder — constructs an envelope from
  a typed body (any `serde::Serialize`), wires the canonical CBOR.

## Emit-and-forget semantics

Per arch.md §15.3a, chain commitment is the durability mechanism — the
worker's in-memory envelope map is best-effort cache. Emitters that need
guaranteed delivery either retry on transient failure or fall back to
direct on-chain `CredentialAudit.append`.

## Tests

Two unit tests added in `audit::client::tests`:

* `envelope_for_builds_typed_body` — round-trip through the typed body
  decoder: `SignEip712Body` → envelope → `typed_body()` returns the same
  body.
* `envelope_for_emits_canonical_cbor` — same inputs produce same
  `envelope_hash` regardless of build path (cross-encoder stability).

Total audit-module tests now 19. Full workspace `cargo test --workspace`
clean (600+ tests, 0 failures).
…code only)

Adds the V2 surface to the CredentialAudit contract per arch.md §15.3a.
V1 (`append` + `appendRoot`) is retained unchanged so existing indexers +
the live tier-A worker keep working through the migration cycle.

## What ships

* `appendV2(operatorOmni, actorOmni, opKind, envelopeHash)` — emits
  `AuditAppendedV2(operatorOmni indexed, actorOmni indexed, opKind
  indexed, envelopeHash)`. **Event-only — no on-chain storage.** The
  full envelope lives off-chain at the audit-service worker, addressed
  by `envelopeHash = keccak256(canonical_cbor(AuditEnvelope))`. The
  `opKind` indexed topic lets explorers filter `eth_getLogs` by op_kind
  without scanning every row.
* `appendRootV2(operatorOmni, merkleRoot, opKindBitmap, batchEntryCount)`
  — emits `AuditRootAppendedV2`. `opKindBitmap` is `bytes32` where bit N
  = op_kind N is present in the batch. Lets explorers filter batches by
  op_kind without fetching every leaf from the worker. Gated to the
  operator's master wallet (same as V1 `appendRoot`, codex M1).
* No on-chain decode of `op_body` — the contract stays op-kind-agnostic
  (non-break invariant #6 per arch.md §15.3a). New op_kinds need ZERO
  contract redeploys.

## Forge tests

5 new tests in `AgentKeysV1.t.sol` (alongside 4 existing CredentialAudit
tests):

* `test_CredentialAudit_AppendV2_EmitsEvent` — confirms the event topics
  carry operator + actor + opKind for `eth_getLogs` filtering.
* `test_CredentialAudit_AppendV2_AcceptsAnyOpKind` — invariant #1 +
  invariant #6: op_kind=250 (reserved future byte) accepted without
  revert.
* `test_CredentialAudit_AppendV2_OpenToAnyCaller` — `appendV2` is open
  to any caller (chain ordering + gas is the safety; indexer filters
  out attacker-emitted noise via canonical envelope hashes).
* `test_CredentialAudit_AppendRootV2_EmitsEvent` — Merkle-batch path
  with multi-op_kind bitmap (bits 0 + 21 + 40 = CredStore + SignEip712
  + ScopeGrant set).
* `test_CredentialAudit_AppendRootV2_RejectsNonMaster` — gated to
  operator's master wallet per codex M1.
* `test_CredentialAudit_V1_And_V2_Coexist` — V1 `append` + V2
  `appendV2` write to disjoint paths; V2 emits don't touch V1's
  `entries` storage.

Forge: 9/9 CredentialAudit tests pass; full forge suite 39/39 tests
pass. Workspace cargo test still clean.

## Redeploy: operator action

This commit ships the contract code + tests. The actual Heima Mainnet
redeploy via `scripts/heima-bring-up.sh --upgrade` is operator action
gated on PR review — left for a follow-up operator step. Until
redeployed, the live `CredentialAudit` on Heima still has only V1
methods, so callers of `agentkeys-worker-audit::handlers::append_v2`
can store envelopes off-chain but can't commit `envelopeHash` to chain
until redeploy lands.

Migration sequence per arch.md §15.3a Phase C:

1. Operator reviews this PR.
2. Operator runs `bash scripts/heima-bring-up.sh --upgrade` (idempotent
   — redeploys CredentialAudit if address bytecode hash changed).
3. Operator captures new address into `scripts/operator-workstation.env`
   + `docs/spec/deployed-contracts.md`.
4. Run `AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh`.
5. Run harness/v2-stage1-demo.sh through 3 to confirm no regression
   (V1 path still works on the redeployed contract).
Address two architect-review findings against earlier commits in this PR
(reviewer: oh-my-claudecode:architect on PR #95).

## Fix 1 — recursive op_body canonicalization (cross-language hash determinism)

Architect finding (section 4): the canonical CBOR encoder sorted only
envelope-level keys, not `op_body` map keys recursively. The Rust
ecosystem happened to produce stable hashes because `serde_json::Value::
Object` is `BTreeMap`-backed, but a Go or TypeScript encoder building
`op_body` with unsorted keys would have produced different CBOR bytes
and a different `envelope_hash` — silently breaking the chain-commitment
property for cross-language clients.

`audit::cbor::canonicalize()` now walks `op_body` recursively: every
nested map's keys are sorted by their canonical CBOR-encoded bytes
(RFC 8949 §4.2.3). Arrays preserve order (semantic ordering). Two new
tests prove the property:

* `op_body_key_order_does_not_affect_hash` — flat map, alphabetical vs
  reverse-alphabetical insertion order → identical envelope_hash.
* `op_body_nested_map_key_order_does_not_affect_hash` — nested map
  recursion check.

Total audit-module tests now 21. Workspace cargo test clean.

## Fix 2 — arch.md event signatures match the actual contract

Architect finding (section 3): arch.md §15.3a `AuditAppendedV2` /
`AuditRootAppendedV2` declarations included `entryIndex` /
`rootIndex` fields that the actual `CredentialAudit.sol` events do
NOT emit. Explorer implementers reading arch.md would have expected
fields that aren't there.

Doc updated to match the live contract surface. Added a sentence
explaining V2's event-only design: position within the operator's
stream is derivable from `(block_number, log_index)` so the contract
doesn't need to carry `entryIndex` explicitly.

## What this PR ships (cumulative across all commits)

Phase A — arch.md §15.3a (canonical schema + table + non-break invariants + migration phases) ✅
Phase B — agentkeys-core::audit module + worker V2 endpoints + AuditClient ✅
Phase C — CredentialAudit.appendV2 + appendRootV2 (code + 5 forge tests; redeploy is operator action) ✅

Phase D / E (subscan-essentials decoder + UI) tracked at subscan-essentials#12.
Phase F (extend emit coverage to sign/scope/device/payment/email/K3) tracked at agentkeys#97.
PR #96 retired legacy CLI commands (cmd_link, cmd_recover, cmd_usage) and
the bulk broker endpoints. This PR (#95) independently added two new
signer subcommands (cmd_signer_sign_typed_data, cmd_signer_preview_7730).
The conflict in main.rs was the import list — kept the new additions,
dropped the retired ones.

Workspace build clean; full cargo test suite passes; no behaviour change
from the merge resolution beyond combining the two PRs' independent
additions/removals.
… rule

Three related changes addressing user request after the #97 op-kind work:

## 1. How-to-add-a-new-op-kind documentation

### arch.md §15.3b — the 5-step ritual
Brief operator-facing ritual: (1) pick the byte from the appropriate
family range, (2) append a row to §15.3a canonical table, (3) add the
Rust variant in `audit::{op_kind,bodies,mod}`, (4) wire the emit site
via `envelope_for` + `AuditClient::append`, (5) ship 3 tests (CBOR
roundtrip + explorer Unknown(byte) fallback + arch.md row uniqueness).

Critical invariant called out: never bump ENVELOPE_VERSION for a new
op_kind. The version is reserved for envelope-level breakage; open-enum
op_kinds are the whole point.

### wiki/audit-envelope-add-op-kind.md — detailed worked example
Walks through adding `PaymentRefund` (byte 32) end-to-end:
- Step-by-step code for op_kind.rs / bodies.rs / mod.rs.
- Sample emit-site wiring in a worker handler.
- Complete PR checklist + the explicit "what you DON'T need to do" list
  (no contract redeploy, no version bump, no migration, no synchronous
  rollout).

Lives under `./wiki/` per CLAUDE.md "Wiki-location policy" — auto-
publishes to the GitHub wiki on every push to main.

## 2. scripts/setup-heima.sh — single idempotent entry point

Mirrors the `scripts/setup-broker-host.sh` pattern: one operator-facing
orchestrator that runs the entire Heima chain bring-up + binding flow
end-to-end in 15 idempotent steps. Delegates to the existing per-action
helpers (`heima-bring-up.sh`, `heima-device-register.sh`,
`heima-agent-create.sh`, `heima-scope-set.sh`,
`heima-credential-audit.sh`, `heima-worker-smoke.sh`,
`verify-heima-contracts.sh`) so:

- Each helper's existing idempotency check (`cast call <view-fn>`,
  `cast code <addr>`, `cast balance ≥ amount`, file-exists guards)
  is preserved.
- Per-action helpers stay callable directly for surgical re-runs
  (e.g. `bash scripts/heima-scope-set.sh ...` for just the scope work).
- The orchestrator is THE entry point operators run — same posture
  as setup-broker-host.sh.

Flag surface mirrors the harness orchestrators: `--chain`, `--session-id`,
`--agent-label`, `--service`, `--webauthn`, `--yes`, `--from-step N`,
`--to-step N`, `--only-step N`, `--help`.

Two append-only steps (13 audit append + 14 tier-A relay) are explicitly
called out in the header per the CLAUDE.md rule: "If a remote-setup
script you're writing CAN'T be made idempotent (...append-only audit
event), explicitly call it out."

`bash -n` clean; `--help` renders correctly.

## 3. CLAUDE.md — idempotent remote-setup rule

New section "Idempotent remote-setup rule (CLOUD / BLOCKCHAIN / CI / VM)"
makes the existing implicit pattern an explicit project policy:

- Every remote-mutation script (AWS / Heima / CI / VM / Cloudflare /
  Tencent / IAM / DNS) MUST be idempotent. Re-runs MUST exit 0
  without re-applying.
- Three reasons: operators retry, CI re-runs, the harness re-runs as
  a regression gate.
- Concrete pre-check / short-circuit table for 9 mutation types
  (contract deploy, chain tx, fund EVM account, AWS resource, systemd
  unit, env file, nginx vhost, DNS A record, key gen).
- Output convention: `ok proceeding` / `skip <reason>` / `fail <reason>`
  so the harness can read state per step.
- Exception clause: if truly non-idempotent (one-shot CAS-burn cap,
  append-only audit event), explicitly call it out in script header
  AND runbook.

Also adds "Heima chain (single entry point)" section pointing at the
new `setup-heima.sh`.
The previous version of this guide stopped at the agentKeys-side ritual
and left explorer work as a one-line bullet ('explorer-side PR'). Per
follow-up request — flesh out what 'update the explorer' actually means
across the two separate repos (subscan-essentials + subscan-essentials-
ui-react) so an operator working through the guide doesn't have to
reverse-engineer the seam.

## New section structure

The page now has three parallel tracks:

1. **agentKeys-side PR** — the original 5-step ritual (unchanged).
2. **Indexer-side PR** ([litentry/subscan-essentials](https://github.com/litentry/subscan-essentials)): Go
   decoder registration, typed XxxDecoder impl, REST shape, three
   tests (canonical-fixture decode + unknown-byte non-break +
   cross-language hash match).
3. **UI-side PR** ([litentry/subscan-essentials-ui-react](https://github.com/litentry/subscan-essentials-ui-react)):
   React renderer component, registration in OP_KIND_RENDERERS map,
   Storybook story + fallback story.

## What the new explorer section adds

- **§A1-A4**: Concrete Go code samples for the new PaymentRefund (byte
  32) example — decoder table entry, typed body struct with CBOR tags,
  REST shape function, generic event-handler dispatch that stays
  op-kind-agnostic, and the three required tests.
- **§B1-B3**: React renderer component with Field/Card layout, registry
  entry, Storybook expectation.
- **§C**: Shared cross-language test vectors as the load-bearing
  cross-encoder determinism guard. Tracked as a follow-up alongside
  the next new op_kind.
- **Phasing table**: Visual confirmation of the non-break trade-off at
  each column (operator emit-site → chain event → worker → indexer →
  UI), showing that at every step the system is functional and the
  only visible degradation between phases is 'uglier UI temporarily
  for old explorers.'

## PR checklist split

The checklist is now three sub-checklists — one per repo — so a PR
author can see exactly what lands in each of the three independent
PRs. The agentKeys-side PR is fully self-contained; the other two land
on their own cadence per the non-break design.
## The gap (what the user asked)

Before this commit, the K11 WebAuthn ceremony's localhost confirmation
page showed the operator ONLY:

  Operator        0xb3224706…
  RP ID           localhost
  Challenge       0xdead…beef    ← 32 bytes — what's actually signed

The operator had no way to tell WHAT they were authorizing — just the
opaque 32-byte challenge hex. WebAuthn's OS-level Touch ID prompt is
fixed by the platform; it can't show application text either. So the
operator was blind-signing — exact same failure mode arch.md §15.3a
called out for typed-data signs, but at the K11 binding site.

## What this commit changes

`crates/agentkeys-cli/src/k11_webauthn.rs`:

* **New public type** — `K11IntentContext { text: Option<String>,
  fields: Vec<(String, String)> }`. Display-only operator-readable
  intent description + per-field rows.

* **New public entry points**:
  - `assert_webauthn_with_intent(operator_omni, message, rp_id, intent)`
    — assert with operator intent rendered.
  - `assert_webauthn_for_chain_with_intent(operator_omni,
    expected_challenge, rp_id, intent)` — chain-ready variant.

* **Legacy entry points unchanged**: `assert_webauthn`,
  `assert_webauthn_with_rp`, `assert_webauthn_for_chain` still work —
  they pass `K11IntentContext::empty()` internally, so existing call
  sites + existing tests are bit-identical to before.

* **Confirmation page HTML** now renders a bordered intent block above
  the raw challenge dump when intent is supplied:

    YOU ARE ABOUT TO AUTHORIZE:
    Grant agent demo-agent access to openrouter

      Agent omni       0xb3224706…cc999E02
      Service          openrouter
      Max calls / hour 100
      K3 epoch         1
      Expires          2026-06-20T22:13:20Z

    Review the above BEFORE pressing Sign. The Touch ID prompt itself
    cannot show this text — your eyes are the last line of defense
    between the daemon's claim and the signature.

* **New `html_escape` helper** + 3 tests proving malicious daemon-supplied
  intent strings cannot inject `<script>` into the page. The daemon
  controls the intent payload but the page's safety properties
  (operator sees real intent, localhost-only origin, OS prompt fires)
  hold regardless.

* **Challenge label updated** to `Challenge (raw)` + meta-text
  `"32-byte commitment — what WebAuthn actually signs"` so the
  operator understands the relationship between the intent text + the
  challenge bytes.

## Cryptographic binding (unchanged)

The intent parameter is DISPLAY-ONLY. The signed payload is still:

  challenge_bytes = sha256(message)   # or pre-computed for chain submission
  clientDataJSON  = {"type":"webauthn.get","challenge":b64url(challenge_bytes),"origin":"..."}
  authData        = rpIdHash || flags || signCount
  signature       = ECDSA-P256(sha256(authData || sha256(clientDataJSON)))

Adding the intent does NOT change any existing signature consumer
(broker / on-chain K11Verifier / audit-row verifier).

## Audit binding — intent_commitment

The same intent string fed to the WebAuthn page SHOULD populate
`AuditEnvelope.intent_text` + `AuditEnvelope.intent_commitment`. The
audit commitment is `keccak256(intent_text || 0x7c || op_payload_digest)`
— so auditors later can verify the operator saw text T AND the audit
row commits to T. Closes the "what did the operator actually see?"
forensics gap end-to-end (page-render → operator-eyes → audit-row →
chain-commitment).

## Documentation

* `wiki/k11-webauthn-intent-rendering.md` (NEW, 200+ lines):
  - The OS-level constraint (why custom Touch ID prompts are
    impossible).
  - Where AgentKeys closes the gap (localhost confirmation page).
  - The intent block design (header / headline / fields / caveat).
  - Public API + worked example for scope-grant.
  - Cryptographic-binding-unchanged guarantee.
  - Audit-binding mapping to AuditEnvelope.intent_text +
    intent_commitment.
  - When-to-provide-an-intent table per call site.
  - Tests reference.

* `wiki/audit-envelope-add-op-kind.md`: cross-link added — every new
  master-mutation op_kind PR also wires `assert_webauthn_*_with_intent`.

* `docs/spec/architecture.md` §10.1: cross-link added pointing at the
  new wiki page; explains the page is where intent rendering happens
  and binds to the audit row.

## Tests

`cargo test -p agentkeys-cli --lib k11_webauthn`: 9 tests pass (5 new):

* html_escape_neutralizes_script_injection — load-bearing safety check.
* html_escape_handles_quote_chars.
* html_escape_passes_safe_text_through.
* k11_intent_context_empty_is_default.
* k11_intent_context_with_text_is_not_empty.

Full workspace `cargo test --workspace` clean.

End-to-end visual verification (manual): open the confirmation page
during `harness/v2-stage1-demo.sh --webauthn` — intent block renders
above the challenge hex.
## Symptom (the user's report)

\`bash harness/v2-stage2-demo.sh --webauthn\` step 6 failed with:

  fail cast send failed: Error: Failed to estimate gas: server returned
       an error response: error code -32603: VM Exception while processing
       transaction: revert, data: \"0xa98bbce05f0fa99105175d11f8a6f7e5f60…\"

## Diagnosis

Selector \`0xa98bbce0\` decodes to
\`SidecarRegistry.DeviceAlreadyRegistered(bytes32)\`. The 32-byte arg
\`0x5f0fa991…\` is the companion's device_key_hash — the device was
ALREADY registered on chain (from a prior \`--webauthn\` run that ran
through). The script blindly re-submitted the registerAdditionalMaster
tx instead of pre-checking + skipping. Idempotency hole.

## Fix

\`harness/scripts/heima-device-add.sh\` Step 1 now pre-reads
\`SidecarRegistry.getDevice(deviceKeyHash)\` and short-circuits when
\`registeredAt > 0\` (the canonical pre-check shape from CLAUDE.md
\"Idempotent remote-setup rule\" — \"Chain tx → cast call <view-fn>
returning canonical state → skip already-registered\").

Three paths:
* \`registeredAt = 0\` (not on chain yet) → log \"proceeding\" + continue
  the existing flow (K11 ceremony + cast send).
* \`registeredAt > 0\` + \`revoked = false\` → log \`skip already-registered\`
  with JSON output \`{\"ok\":true,\"skipped\":\"already-registered\",
  \"device_key_hash\":\"…\",\"registered_at\":<ts>}\` and exit 0 — no
  K11 ceremony, no tx, the harness step records green.
* \`registeredAt > 0\` + \`revoked = true\` → die with clear operator
  message: \"re-registering a revoked device requires a new device
  hash; generate a fresh companion device + re-enroll.\" (the contract
  would revert anyway; failing loud + clear here saves the operator
  one round-trip + one Touch ID tap.)

Sibling scripts (\`heima-register-first-master.sh\`,
\`heima-register-spare-master.sh\`, \`heima-agent-create.sh\`,
\`heima-device-register.sh\`) already had this check — verified via
\`grep -c\`. \`heima-device-add.sh\` was the only outlier.

## Why this is the CLAUDE.md \"runbook-fix-fold-back\" pattern

This is the second iteration of CLAUDE.md \"Idempotent remote-setup
rule\" enforcement. The rule listed \"Chain tx (register / scope /
audit append) → cast call <view-fn> returning canonical state\" as
the canonical pre-check shape. Every script that mutates chain state
needs that check; the one without it broke the harness on re-run.
The fix lives where the bug is (the device-add helper); no runbook
revision needed because \`v2-stage2-demo.sh\` already calls the helper
by name + would now skip cleanly on re-runs.

## Test

\`bash -n harness/scripts/heima-device-add.sh\` clean.

Live: operator re-runs \`bash harness/v2-stage2-demo.sh --webauthn\` —
step 6 should now log \`skip device 0x…5f0fa991… already registered\`
and advance to step 7 instead of reverting.
Independent diff review via \`codex review --base main\`. Six findings, all
real; all six fixed in this commit with regression tests for the
testable ones (5 tests added). Workspace cargo test clean (47 suites,
0 failures).

## P1 (blocking) findings

### P1-1: Canonical CBOR top-level map order was lexicographic-by-text, not RFC 8949 §4.2.3

\`crates/agentkeys-core/src/audit/cbor.rs\` — the encoder hard-coded the
top-level map in alphabetical-by-text order, but canonical CBOR sorts by
the encoded BYTES (length-prefix first, then bytes). For our 9 envelope-
level keys this means shorter keys like \`result\` (6 chars) MUST sort
before longer keys like \`actor_omni\` (10 chars).

The bug would have silently desynchronized \`envelope_hash\` between the
Rust encoder and any RFC-8949-correct Go or TypeScript encoder — exactly
the cross-language determinism property the doc + the tests claim. The
existing recursive \`canonicalize()\` helper already had the correct
sort logic for \`op_body\` inner maps; the top-level map was simply
bypassing it.

**Fix:** route the top-level map through the same
\`canonicalize()\` helper. Single source of truth for byte ordering —
top-level + nested can never drift again.

**Regression test:**
\`top_level_map_keys_emitted_in_canonical_cbor_order\` decodes the
output bytes and asserts the key order is the exact canonical sequence:
\`result, op_body, op_kind, ts_unix, version, actor_omni, intent_text,
operator_omni, intent_commitment\`.

### P1-2 + P1-3: setup-heima.sh called non-existent flags on helper scripts

\`scripts/setup-heima.sh\` step 4 called \`heima-bring-up.sh --only-step
gen-key\` and step 5 called \`heima-fund-account.sh --target deployer\`.
Neither flag exists. \`heima-bring-up.sh\` has no \`--only-step\` parser
so extra args were silently ignored and the FULL bring-up ran from
step 1 (funding + deploying contracts when the operator only wanted
key generation). \`heima-fund-account.sh\` rejects unknown flags so
step 5 would hard-fail with \"--to is required\".

**Fix:** delegate the entire \"make-chain-ready\" flow (key gen → fund
→ deploy → persist addresses) to a SINGLE call to \`heima-bring-up.sh\`
in step 4 — that script is the canonical idempotent owner of the
flow and pre-checks every mutation itself. Step 5 now derives the
deployer address from the persisted key (\`cast wallet address\`) and
calls \`heima-fund-account.sh --to <addr>\` with the flag the helper
actually accepts. Steps 6 + 7 become explicit no-ops with comments
pointing at step 4.

\`bash -n scripts/setup-heima.sh\` clean.

## P2 (quality) findings

### P2-4: U256::shl returned ZERO at 64-bit boundaries

\`crates/agentkeys-core/src/clear_signing/eip712.rs\` —
\`U256::ONE.shl(64)\` produced \`0\` because the prior off-by-one impl
copied \`self.limbs[3 - src]\` where \`src = i + limb_shift\`. When
\`bit_shift == 0\` (i.e. \`bits\` is a multiple of 64), \`hi\` reduced
to a plain limb copy from the wrong slot — for \`Self::ONE.shl(64)\`
this copied \`self.limbs[2]\` (zero) into \`out[3]\` instead of
\`self.limbs[3]\` (the value 1) into \`out[2]\`.

Practical effect: every \`uint64: N\`, \`uint128: N\`, \`uint192: N\` (and
the matching int sizes) in a typed-data field hit the range check
\`big >= U256::ONE.shl(bits)\` with the right side spuriously zero, so
the EIP-712 signer rejected valid values like \`uint64: 1\` as
out-of-range — making the new typed-data sign path unusable for
common fixed-width integer fields outside the existing
\`uint8\`/\`uint256\` test coverage.

**Fix:** re-implement \`shl\` to iterate INPUT limbs LSB-first; each
non-zero limb's bits land in its primary output slot (shifted up by
\`bit_shift\`) plus a secondary slot when \`bit_shift > 0\`. No
off-by-one possible.

**Regression tests:**
- \`u256_shl_at_64_bit_boundary_does_not_drop_to_zero\`: asserts
  \`U256::ONE.shl(64) == 2^64\`, same for 128 + 192.
- \`uint64_accepts_value_one\`: end-to-end at the encoder layer.
- \`uint128_accepts_mid_range_value\`: confirms 2^127 round-trips.

### P2-5: int256 range check was skipped entirely

\`encode_int\` guarded the range check behind \`if bits < 256\` so for
\`int256\` fields no check ran. Values >= 2^255 (which should be
rejected — they wrap into negative two's-complement under signed-256)
were accepted silently. An attacker could craft a typed-data payload
whose declared int256 value lies outside the signed range and get a
signature anyway.

**Fix:** drop the \`if bits < 256\` guard. The boundary
\`pos_max = U256::ONE.shl(bits - 1)\` fits in U256 for every supported
N from 8 to 256 (for N=256, pos_max = 2^255 — exactly representable).

**Regression tests:**
- \`int256_rejects_value_at_or_above_2_pow_255\`: 2^255 → rejected.
- \`int256_accepts_max_positive\`: 2^255 - 1 → accepted.
- \`int256_accepts_min_negative\`: -2^255 → accepted.

### P2-6: clap-derived flag name was --seven-thirty-file, docs said --7730-file

\`crates/agentkeys-cli/src/main.rs\` — clap derives the long-flag name
from the Rust field ident. \`seven_thirty_file\` becomes
\`--seven-thirty-file\`. But the command's \`long_about\` text + every
example advertised \`--7730-file\`. Users following the doc would hit
\"unrecognized argument: --7730-file\".

**Fix:** explicit \`#[arg(long = \"7730-file\", ...)]\` override.

\`agentkeys signer preview-7730 --help\` now shows the
\`--7730-file <SEVEN_THIRTY_FILE>\` flag matching the docs.

## Test summary

- \`cargo test -p agentkeys-core --lib audit\`: 22 tests pass.
- \`cargo test -p agentkeys-core --lib clear_signing\`: 37 tests pass.
- \`cargo test --workspace\`: 47 test suites, 0 failures.
- \`bash -n scripts/setup-heima.sh\`: clean.
- \`target/debug/agentkeys signer preview-7730 --help\`: shows \`--7730-file\`.
## Answer to the user's question

> in local webauthn signing process with touchID, I see challenge is a
> encoded raw data, is there a readable original text?

YES — the library API for it shipped in PR #95 (\`assert_webauthn_with_intent\`,
\`assert_webauthn_for_chain_with_intent\`, the \`K11IntentContext\` type, the
HTML intent block above the raw challenge dump on the confirmation page).

But the CLI subcommand \`agentkeys k11 assert --webauthn\` and the harness
helper scripts still used the LEGACY non-intent entry points — so when
the user ran the harness with \`--webauthn\`, the confirmation page rendered
only the 32-byte challenge hex. The plumbing was incomplete at the seam
between the harness scripts and the library.

This commit completes the plumbing end-to-end.

## What changed

### CLI: \`agentkeys k11 assert --webauthn\` accepts intent flags

\`crates/agentkeys-cli/src/main.rs\` — \`K11Action::Assert\` gains two new
flags:

- \`--intent-text <STRING>\` — the headline rendered prominently on the
  WebAuthn confirmation page. Example:
  \`--intent-text \"Grant agent demo-agent access to openrouter\"\`.
- \`--intent-field <Label=Value>\` (repeatable) — per-field detail rows
  below the headline. Example:
  \`--intent-field \"Service=openrouter\" --intent-field \"K3 epoch=1\"\`.

Both flags are ignored in stub mode (\`--webauthn\` not passed). The
dispatch builds a \`K11IntentContext\` and calls the corresponding
\`*_with_intent\` library entry point.

\`Label=Value\` parsing splits on the FIRST \`=\` (so values may contain
\`=\` themselves); empty labels + rows without \`=\` are rejected with a
clear operator-facing error.

### Harness scripts: 5 call sites now pass op-specific intents

| Script | Op | Intent text |
|---|---|---|
| \`harness/scripts/heima-device-add.sh\` | \`registerAdditionalMasterDevice\` | \"Register companion device as 2nd master\" + new device hash, role bitfield, companion RP ID, chain ID, nonce |
| \`harness/scripts/heima-recovery.sh\` | \`revokeMasterDevice\` (M-of-N) | \"Revoke master device via M-of-N recovery quorum\" + target hash, threshold, asserting role, chain ID |
| \`scripts/heima-device-revoke.sh\` | \`revokeDevice\` (master) | \"⚠ REVOKE MASTER device — this disables the operator's master entirely\" + master hash, wallet, recovery note |
| \`scripts/heima-scope-set.sh\` | \`setScopeWithWebauthn\` | \"Grant agent '<label>' access to: <services>\" + agent omni, services list, read-only flag, max-per-call, max-per-period, max-total, period, chain ID, scope nonce |
| \`scripts/heima-scope-revoke.sh\` | \`revokeScope\` | \"Revoke all scope grants for agent '<label>'\" + agent omni, effect note, chain ID, scope nonce |

Each intent is hand-tailored to the op's actual semantics — the
\`device-revoke\` master path gets a ⚠-prefixed warning because the
operator is one Touch ID tap away from disabling their own master
entirely; the others get straightforward descriptive text.

## What the operator sees now

Before:
\`\`\`
🔑 PRIMARY MASTER
K11 assertion

Operator        0xb3224706…
RP ID           localhost
Challenge       0xdead…beef        ← 32 bytes — only what they saw
\`\`\`

After (scope-set example):
\`\`\`
🔑 PRIMARY MASTER
K11 assertion

YOU ARE ABOUT TO AUTHORIZE:
Grant agent 'demo-agent' access to: openrouter,brave-search

  Agent label            demo-agent
  Agent omni             0xb3224706…
  Services               openrouter,brave-search
  Read-only              false
  Max amount per call    1000000000000000000 (0 = unlimited)
  Max amount per period  10000000000000000000 over 86400s (0 = unlimited)
  Max total amount       0 (0 = unlimited)
  Chain ID               212013
  Scope nonce            5

Review the above BEFORE pressing Sign. The Touch ID prompt itself
cannot show this text — your eyes are the last line of defense between
the daemon's claim and the signature.

Operator        0xb3224706…
RP ID           localhost
Challenge (raw) 0xdead…beef        ← 32-byte commitment — what WebAuthn actually signs

[ Sign as PRIMARY MASTER ]
\`\`\`

The intent rendering is display-only (cryptographic binding is still
\`challenge = sha256(message)\`, unchanged). It exists because WebAuthn's
OS-level Touch ID prompt is fixed by the platform — no application can
inject custom text. The localhost confirmation page is the only surface
where AgentKeys can render what's being authorized.

## Tests

- \`cargo build -p agentkeys-cli\` clean.
- \`cargo test -p agentkeys-cli --lib k11_webauthn\` — 9 tests pass
  (including the html_escape regression tests proving malicious daemon-
  supplied intent strings cannot inject \`<script>\` into the page).
- \`bash -n\` clean on all 5 updated scripts.

End-to-end visual verification (manual): re-run
\`harness/v2-stage2-demo.sh --webauthn\` — the Touch ID confirmation page
for each master mutation now shows the headline + per-field rows above
the challenge hex.
## Symptom (operator-reported)

\`bash harness/v2-stage1-demo.sh\` step 8 (Smoke-test S3 envelope) fails:

  read failed: internal error:
  assume_role_with_web_identity(arn:aws:iam::…:role/agentkeys-vault-role):
  dispatch failure

\"dispatch failure\" alone is unactionable — could be DNS, TCP, TLS, proxy,
or 'no connector available' (a config bug). The operator can't tell which
without re-running the SDK with debug logs.

## Root cause

\`aws_sdk_sts::Error\`'s \`Display\` impl renders ONLY the top-level
\`SdkError\` variant. For \`DispatchFailure\` that's the literal string
\"dispatch failure\" with no causal info. The real reason lives in the
\`source()\` chain — which both AgentKeys call sites swallowed:

* [crates/agentkeys-provisioner/src/aws_creds.rs](crates/agentkeys-provisioner/src/aws_creds.rs) — operator-side STS for cred reads
* [crates/agentkeys-broker-server/src/sts.rs](crates/agentkeys-broker-server/src/sts.rs)        — broker-side \`/v1/mint-aws-creds\`

Both did \`format!(\"…: {}\", e)\` which loses the chain.

## Fix

Walk \`std::error::Error::source()\` recursively at the catch site, flatten
into a one-line message:

  msg = \"assume_role_with_web_identity(…): dispatch failure | caused by:
       dns error: failed to lookup address information: nodename nor
       servname provided, or not known\"

(...or whichever layer actually failed.) After this lands, the operator's
next retry surfaces the actual error: DNS, TCP, TLS, proxy, or
no-connector-configured. From there the fix is one-line (\"export
HTTPS_PROXY=…\" / \"check corporate VPN\" / \"update CA bundle\") or, if it
turns out to be no-connector, a separate in-repo fix (add hyper-rustls
feature).

## Why both call sites

Symmetry: the same diagnostic gap exists on broker-side (when the broker
mints creds via \`/v1/mint-aws-creds\`). Fixing only the operator side
would leave the broker emitting the same useless message later.

## Test plan

- \`cargo build -p agentkeys-provisioner -p agentkeys-broker-server --release\`
  clean.
- Operator retries:
  \`bash harness/v2-stage1-demo.sh --only-step 8\`
  Expect: \"dispatch failure | caused by: <real reason>\" replacing the
  bare \"dispatch failure\".
## Symptom (operator)

\`bash harness/v2-stage1-demo.sh\` step 13 fails with the unactionable:

  fail primary K11 ceremony failed
  fail  heima-scope-set.sh failed

No hint why — Touch ID was cancelled? challenge mismatch? signature
parse error? WebAuthn ceremony timeout? Operator has to manually
re-run \`agentkeys k11 assert\` outside the harness to see the real
error, reconstructing every CLI flag by hand.

## Root cause

Four helper scripts redirected \`agentkeys k11 assert\`'s stderr to
\`/dev/null\`:

  ASSERTION_JSON=\$("\$AGENTKEYS_BIN" k11 assert ... 2>/dev/null) \\
    || die "primary K11 ceremony failed"

Same diagnostic-swallow pattern that hid the STS \`dispatch failure\`
root cause two commits ago (\`238d8ff\`). The shipped error message
was the lowest-information form possible: a generic phrase with zero
indication of which layer (browser / Touch ID / k11 binary / CLI flag
parser) actually failed.

## Fix

All four call sites now capture stderr to a tmpfile, print it on
failure, clean up on success:

  K11_ERR=\$(mktemp -t heima-<name>-k11.XXXXXX) || die "mktemp failed"
  ASSERTION_JSON=\$("\$AGENTKEYS_BIN" k11 assert ... 2>"\$K11_ERR") \\
    || {
      echo "==> K11 assert stderr ↓ ↓ ↓" >&2
      cat "\$K11_ERR" >&2
      echo "==> K11 assert stderr ↑ ↑ ↑" >&2
      rm -f "\$K11_ERR"
      die "primary K11 ceremony failed (see stderr above for root cause)"
    }
  rm -f "\$K11_ERR"

Sites fixed:

* \`scripts/heima-scope-set.sh\`                    (line 197) → step 13
* \`scripts/heima-scope-revoke.sh\`                 (line 122)
* \`harness/scripts/heima-register-spare-master.sh\` (line 144) → stage 2 step 8
* \`harness/scripts/heima-device-add.sh\`            (line 181) → stage 2 step 6

\`grep -rn "k11 assert.*2>/dev/null"\` returns empty after this commit
— no remaining swallows in the harness or scripts/ dirs.

## Why land everywhere at once (per CLAUDE.md Land-the-fix policy)

The bug is structural: every heima-*.sh that drives k11 has the same
shape. Fixing only \`heima-scope-set.sh\` would leave the operator
guessing again when they hit step 6 or step 8 of stage 2. \`grep\` proves
the four sites above are the complete set; fixing all four in one
commit closes the diagnostic gap for the whole harness.

## Test

- \`bash -n\` clean on all 4 scripts.
- Operator retries:
  \`bash harness/v2-stage1-demo.sh --only-step 13\`
  Expect: instead of just "primary K11 ceremony failed", the new
  output includes the K11 binary's full stderr — Touch ID error
  code, CLI parse error, challenge-mismatch detail, etc. From there
  the next fix is one-line (operator-side action, or in-repo edit
  per the diagnosis).

Same diagnostic-pattern as commit 238d8ff (STS dispatch failure
source-chain unrolling). Both close the same class of bug: catch
sites that throw away the real reason their dependency failed.

Follow-up: heima-register-spare-master.sh also doesn't yet pass
\`--intent-text\` to the k11 ceremony so the operator can't see what
they're authorizing on the Touch ID confirmation page. Tracked as
inline TODO comment; per-script intent wiring lands separately.
## Symptom (operator)

In stage-2 demo with --webauthn:

  step 7 (set recovery threshold):    K11 prompt had NO signing info
  step 8 (register synthetic spare):  K11 prompt had NO signing info
  step 9 PRIMARY  (revoke quorum):    K11 prompt HAD signing info
  step 9 COMPANION (revoke quorum):   K11 prompt had NO signing info

Inconsistent across prompts. Operators learn to ignore the page when
some ceremonies show intent + others don't — exactly the failure mode
the K11 binding is supposed to prevent (tap-to-approve).

## Root cause

Three sites still called \`agentkeys k11 assert\` (or its daemon
equivalent) WITHOUT the \`--intent-text\` + \`--intent-field\` flags
shipped in commit 69540f2:

* \`harness/scripts/heima-set-recovery-threshold.sh\`  → step 7 prompt
* \`harness/scripts/heima-register-spare-master.sh\`   → step 8 prompt
* \`crates/agentkeys-daemon/src/companion.rs::approve\` → step 9
  COMPANION prompt (rendering side; the API endpoint had no field
  for the caller to pass intent through)

Step 9 PRIMARY worked because heima-recovery.sh had already wired
intent on the PRIMARY side. The asymmetry inside one ceremony was
the worst case — the operator saw intent on one tap + nothing on
the next tap of the same operation.

## Fix

Four sites updated to the uniform K11-intent shape (documented in
wiki/k11-intent-conventions.md):

### 1. heima-set-recovery-threshold.sh
Adds the full intent envelope:
  --intent-text \"Set recovery threshold to ${THRESHOLD} (M-of-N master
                  quorum)\"
  --intent-field \"Operator omni=0x${OPERATOR_OMNI}\"
  --intent-field \"Asserting role=PRIMARY (key hash ${PRIMARY_DEVICE_KEY_HASH})\"
  --intent-field \"New recovery threshold=${THRESHOLD}\"
  --intent-field \"Effect=future master-device revokes will require this
                   many active master signatures\"
  --intent-field \"Chain ID=${LIVE_CHAIN_ID}\"
  --intent-field \"Operator nonce=${NONCE}\"

### 2. heima-register-spare-master.sh
Same envelope, operation-specific headline:
  --intent-text \"Register synthetic 3rd master (spare) device\"
  + standard rows + per-op rows (new device hash, role bitfield, effect)

### 3. crates/agentkeys-daemon/src/companion.rs
\`ApproveRequest\` extended:
  pub intent_text: Option<String>
  pub intent_fields: Vec<String>  // each \"Label=Value\"

Handler:
  - Builds K11IntentContext from request fields (splits each
    \"Label=Value\" on the first \`=\`)
  - Calls \`assert_webauthn_for_chain_with_intent\` instead of the
    no-intent variant
  - Logs intent_text + field count for diagnostics

This is the ONLY API change in this commit — the field is
optional + serde-defaulted to None/empty so existing callers that
don't pass it stay bit-compatible.

### 4. heima-recovery.sh
- Both PRIMARY + COMPANION K11 ceremonies now render the SAME
  headline + same per-op rows + same Effect; only \`Asserting role\`
  differs per master.
- Builds the COMPANION POST body via \`jq -n\` so multi-word labels,
  equals signs in values, and special characters round-trip safely
  to the daemon (no shell-quoting traps).
- Same uniform envelope: Operator omni / Asserting role / Target
  device hash / Recovery threshold / Effect / Chain ID / Operator
  nonce.
- stderr capture (per d58aab1 diagnostic pattern) also applied to
  the PRIMARY k11 assert call so future failures surface the real
  error.

## Documentation

New wiki page \`wiki/k11-intent-conventions.md\`:
- Why uniform (load-bearing operator safety property).
- The required envelope shape (Operator omni + Asserting role +
  Chain ID + Nonce + operation rows + Effect).
- Canonical headline + Effect text table for every operation
  (one row per op_kind that needs K11).
- Multi-party ceremony rule — both prompts MUST be uniform; only
  Asserting role differs.
- Conformant K11 emit sites table (all 7 sites listed) — checked
  in by this commit.
- \"What doesn't count\" anti-pattern list — caught on every PR
  review.
- Warning-prefix convention (\`⚠ \`) for catastrophic operations
  (master-device revoke) — used sparingly.

\`wiki/k11-webauthn-intent-rendering.md\` (the rendering-mechanism
page) cross-links to the new conventions page.

## Test

- \`cargo build --release -p agentkeys-daemon\` clean.
- \`bash -n\` clean on all 3 modified scripts.
- Operator retries:
    bash harness/v2-stage2-demo.sh --webauthn
  Expect: every K11 Touch ID prompt across steps 6-9 renders the
  uniform intent envelope. Step 9 PRIMARY + COMPANION look
  identical apart from the \`Asserting role\` row.

## Why all four in one commit (per CLAUDE.md Land-the-fix policy)

The bug is the asymmetry. Fixing only step 7 + step 8 would still
leave step 9 with PRIMARY-shows-intent + COMPANION-doesn't, which
is the WORST case the user actually reported. Same root cause + same
fix shape across all 4 sites — land together so the convention is
enforceable from this commit forward.

Follow-up: integration test that asserts every K11 confirmation
page contains the required rows, so the convention is mechanically
enforced not convention-only. Stub for the test in
\`wiki/k11-intent-conventions.md\` § Verification.
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.

2 participants