Skip to main content

polyc_crypto/
approval.rs

1//! HITL approval payload encoders/decoders and signer.
2//!
3//! The approval flow persists two event payloads in the conversation eventlog:
4//!
5//! * `approval_request` — minted when `polyc_agent::run_turn` surfaces a
6//!   pending tool call that needs human consent.
7//! * `approval_response` — minted by `ApprovalService::Respond` (a human
8//!   decision) or, non-interactively, by the `POLYCHROME_APPROVAL_MODE`
9//!   machine-approval path (the reviewer-agent auto-review or the blanket
10//!   approve-all mode) carrying a signed decision, distinguishable from a human
11//!   one only by its signed `reason` (see [`auto_review_reason`] /
12//!   [`AUTO_REVIEW_REASON_PREFIX`] / [`APPROVE_ALL_DANGEROUS_REASON`]).
13//!
14//! The encoders / decoders live in `polyc-crypto` (this crate) rather
15//! than the control-plane binary so the harness pod can verify inbound
16//! `approval_response` payloads on its own — the harness sits in a sandbox
17//! and shouldn't trust the wire blindly.
18//!
19//! [`verify_signed_receipt`] keeps one frozen canonical per receipt schema
20//! version, v1 included, and every version it has ever frozen stays. That is
21//! not the legacy shim this repo forbids: a signature covers the exact bytes it
22//! was minted over, and those bytes are already in an append-only log. Deleting
23//! the v1 reader would migrate nothing — it would leave the deployment unable
24//! to verify its own settled payment history. Read the per-version builders as
25//! cryptographic verification of history, not as a compatibility fallback, and
26//! only ever add to them.
27
28use serde::Serialize;
29use serde_json::Value;
30use std::collections::HashSet;
31
32use crate::signed::{Envelope, canonical_bytes};
33use crate::verify;
34
35pub use crate::signing_role::ApprovalSigner;
36
37/// JSON payload for an `approval_request` event.
38///
39/// `request_id` is the model's tool-call id — stable for the lifetime of the
40/// turn and used by `ApprovalService::Respond` to address the matching
41/// response.
42/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
43/// harness was running under when it paused this call. The control plane reads it
44/// back at respond time and signs it into the response, so a remembered approval
45/// is bound to the mode it was granted under.
46/// `reason` is the OVERRIDE explanation for why the call is gated — empty for an
47/// ordinary gated call, non-empty only for the lethal-trifecta / Rule-of-Two
48/// containment override. It is presentation + audit (NOT covered by any
49/// signature — the `approval_request` event itself is unsigned; the *response*
50/// is what gets signed), so the durable log records WHY a trifecta-gated call
51/// was paused, and the edge can render it on the approval card.
52/// `missing_capabilities` records the capability shortfall the gate computed
53/// when it paused the call (`#595`): the stable kebab-case names of the
54/// capabilities the call required but was not granted. Read back at respond
55/// time and signed into the response as `covered_capabilities`, so a
56/// remembered grant is scoped to exactly what the approver saw it cover.
57/// Empty for an ordinary policy/sandbox gate.
58/// `preview_json` (`#1496`) is the pre-serialized computed-preview
59/// enrichment — opaque at this layer, exactly like `args_json` above — or
60/// empty for every call the enrichment doesn't apply to (anything but
61/// `routine_create`). Recorded durably here (not just on the live wire
62/// response) so the `ListPending` recovery path re-renders the SAME preview a
63/// lost-stream card showed, never a re-derivation that could drift.
64///
65/// This is the one payload in this module still built as a
66/// [`serde_json::Value`], so its key order — and its `preview`'s — follows the
67/// map's, which `serde_json/preserve_order` decides per build (`#1842`). That
68/// is deliberate and safe: an `approval_request` carries no signature, so no
69/// verification depends on its bytes, and every reader looks its fields up by
70/// key. `preview` is caller-supplied JSON whose own key order belongs to the
71/// caller in any case. Nothing here can be frozen, and nothing needs to be —
72/// the signed side is the response.
73#[must_use]
74pub fn request_payload(
75    request_id: &str,
76    tool_name: &str,
77    args_json: &str,
78    sandbox_mode: &str,
79    reason: &str,
80    missing_capabilities: &[String],
81    preview_json: &str,
82) -> Vec<u8> {
83    // Null when there is no preview (the overwhelming common case — every
84    // call but `routine_create`), rather than omitting the key: an absent
85    // key and an explicit null both decode to "no preview" at
86    // `decode_request_preview_json`, so this is a style choice, made for
87    // symmetry with the other always-present fields on this payload.
88    let preview: Value = if preview_json.is_empty() {
89        Value::Null
90    } else {
91        serde_json::from_str(preview_json).unwrap_or(Value::Null)
92    };
93    // canonical-allow: an `approval_request` carries no signature (the *response*
94    // is what gets signed), so no verification depends on these bytes, and every
95    // reader looks its fields up by key. `preview` is caller-supplied JSON whose
96    // own key order belongs to the caller in any case — nothing here can be
97    // frozen, and nothing needs to be. See the doc comment above.
98    serde_json::json!({
99        "tool_name": tool_name,
100        "args_json": args_json,
101        "request_id": request_id,
102        "sandbox_mode": sandbox_mode,
103        "reason": reason,
104        "missing_capabilities": missing_capabilities,
105        "preview": preview,
106    })
107    .to_string()
108    .into_bytes()
109}
110
111/// The single source for the signed `approval_response` canonical JSON. Both the
112/// signing path ([`response_payload`]) and the verifying paths
113/// ([`verify_signed_response`], [`verify_wire_response`]) route through this so
114/// the covered field set/order cannot drift. Adding/renaming/reordering here is a
115/// signed-contract change.
116///
117/// The signature binds the approval to the exact call identity (`request_id`,
118/// `tool_name`, `args_json`) so a re-emitted same-id call with different
119/// args/tool cannot inherit it, AND — for a "don't ask again" decision — to
120/// `approved_for_session` plus the `caller` the memory is scoped to, so a
121/// remembered approval is per-caller and unforgeable. A one-shot approval simply
122/// signs `approved_for_session: false`.
123///
124/// `approver` (`#1025`, RFC 8693's `act`/actor claim, distinct from `caller`'s
125/// `sub`/subject) is the identity that actually resolved this decision, when
126/// the edge supplied one — NOT necessarily the same persona as `caller` (the
127/// paused turn's own beneficiary): an admin approving on someone else's
128/// behalf signs a DIFFERENT `approver` than `caller`. Omitted from the
129/// canonical entirely when empty (rather than signed as `""`) so every
130/// `approval_response` persisted before this field existed — and every one
131/// an edge that doesn't yet supply an approver identity signs today — stays
132/// byte-identical and continues to verify unchanged; only a genuinely
133/// non-empty `approver` changes the signed shape, and that decision is
134/// always signed by code that already knows about this field.
135///
136/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
137/// PAUSED turn ran under, resolved server-side from the request. Binding it means
138/// a remembered approval granted under one mode cannot be replayed to auto-
139/// approve a later call running under a different (e.g. more-privileged) mode —
140/// the harness re-prompts. Empty when no mode was recorded.
141///
142/// `conversation_id` and `nonce` make the response a SINGLE-USE, conversation-
143/// bound capability token (`#370`, closes `#77` bug 3B). `conversation_id` binds
144/// the approval to the one conversation it was granted in, so a signed response
145/// copied into a different conversation's log fails to verify against that
146/// conversation. `nonce` is a per-approval unique value the consumer records on
147/// use, so a captured token cannot be re-presented after it has been spent. Both
148/// are covered by the signature, so neither can be re-targeted or replayed
149/// without invalidating it.
150/// `covered_capabilities` (`#595`) records the capability shortfall this
151/// approval covered — the missing set the gate computed when it paused the
152/// call. Covered by the signature, so the effective session-grant key is
153/// (caller, tool, covered capabilities): if the tool's required set later
154/// grows, the old grant does not cover the new capability and the gate asks
155/// again. Empty for an approval of an ordinary policy/sandbox gate.
156#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
157fn response_canonical(
158    request_id: &str,
159    tool_name: &str,
160    args_json: &str,
161    modified_args_json: &str,
162    approved: bool,
163    approved_for_session: bool,
164    covered_capabilities: &[String],
165    caller: &str,
166    approver_id: &str,
167    sandbox_mode: &str,
168    reason: &str,
169    injected_context: &str,
170    conversation_id: &str,
171    nonce: &str,
172) -> Vec<u8> {
173    canonical_bytes(&ResponseCanonical {
174        body: ResponseBody {
175            request_id,
176            tool_name,
177            args_json,
178            modified_args_json,
179            approved,
180            approved_for_session,
181            covered_capabilities,
182            caller,
183            sandbox_mode,
184            reason,
185            injected_context,
186            conversation_id,
187            nonce,
188        },
189        approver: approver_id,
190    })
191}
192
193/// The thirteen `approval_response` fields every decision signs, in their
194/// frozen order.
195///
196/// Split out from [`ResponseCanonical`] so the canonical and the persisted
197/// payload ([`ResponseFull`]) share one declaration of the body — they place
198/// the optional `approver` differently (last in both, but the payload has the
199/// two provenance fields in between), and that is the only difference between
200/// them.
201#[derive(Serialize)]
202struct ResponseBody<'a> {
203    request_id: &'a str,
204    tool_name: &'a str,
205    args_json: &'a str,
206    modified_args_json: &'a str,
207    approved: bool,
208    approved_for_session: bool,
209    covered_capabilities: &'a [String],
210    caller: &'a str,
211    sandbox_mode: &'a str,
212    reason: &'a str,
213    injected_context: &'a str,
214    conversation_id: &'a str,
215    nonce: &'a str,
216}
217
218/// The signed `approval_response` canonical: the body plus the optional
219/// `approver`.
220///
221/// `approver` is skipped entirely when empty rather than signed as `""` (see
222/// [`response_canonical`], `#1025`), which keeps every `approval_response`
223/// persisted before the field existed byte-identical and still verifiable.
224#[derive(Serialize)]
225struct ResponseCanonical<'a> {
226    #[serde(flatten)]
227    body: ResponseBody<'a>,
228    #[serde(skip_serializing_if = "str::is_empty")]
229    approver: &'a str,
230}
231
232/// The persisted `approval_response` payload: the body, the two provenance
233/// fields, then the optional `approver`.
234///
235/// The `approver` trails the signature fields because that is where appending
236/// it to the built object always put it, and the order is now the source's
237/// rather than a map's (`#1842`).
238#[derive(Serialize)]
239struct ResponseFull<'a> {
240    #[serde(flatten)]
241    body: ResponseBody<'a>,
242    signed_by: String,
243    signature_hex: String,
244    #[serde(skip_serializing_if = "str::is_empty")]
245    approver: &'a str,
246}
247
248/// JSON payload for an `approval_response` event.
249///
250/// The signature commits to the canonical (unsigned) JSON form: the call
251/// identity (`request_id`, `tool_name`, `args_json`), the decision
252/// (`approved`), the session scope
253/// (`approved_for_session`) and the `caller` it is bound to. `signed_by` and
254/// `signature_hex` are populated *after* the signer runs and are NOT covered by
255/// the signature.
256///
257/// Each parameter is a distinct signed field, so they're passed individually
258/// rather than wrapped in a struct (the canonical form is the contract).
259///
260/// `conversation_id` binds the token to the conversation it was granted in and
261/// `nonce` is a per-approval unique value (the control plane mints a fresh one
262/// per response); together they make the approval a single-use, conversation-
263/// bound capability (`#370`). Returns
264/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
265#[must_use]
266#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
267pub fn response_payload(
268    request_id: &str,
269    tool_name: &str,
270    args_json: &str,
271    modified_args_json: &str,
272    approved: bool,
273    approved_for_session: bool,
274    covered_capabilities: &[String],
275    caller: &str,
276    approver_id: &str,
277    sandbox_mode: &str,
278    reason: &str,
279    injected_context: &str,
280    conversation_id: &str,
281    nonce: &str,
282    signer: &ApprovalSigner,
283) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
284    let canonical = response_canonical(
285        request_id,
286        tool_name,
287        args_json,
288        modified_args_json,
289        approved,
290        approved_for_session,
291        covered_capabilities,
292        caller,
293        approver_id,
294        sandbox_mode,
295        reason,
296        injected_context,
297        conversation_id,
298        nonce,
299    );
300    let signature = signer.sign(&canonical);
301    let pk = signer.public_key_bytes();
302    // Same omit-when-empty rule as `response_canonical` (#1025) — keeps the
303    // persisted payload byte-identical to before this field existed.
304    let full = ResponseFull {
305        body: ResponseBody {
306            request_id,
307            tool_name,
308            args_json,
309            modified_args_json,
310            approved,
311            approved_for_session,
312            covered_capabilities,
313            caller,
314            sandbox_mode,
315            reason,
316            injected_context,
317            conversation_id,
318            nonce,
319        },
320        signed_by: crate::hex::lower(&pk),
321        signature_hex: crate::hex::lower(&signature),
322        approver: approver_id,
323    };
324    (canonical_bytes(&full), signature, pk)
325}
326
327/// Scope names for a signed taint-excision marker (`#590`).
328///
329/// `cascade` is the sound default: the named positions are excised AND so is
330/// every model-authored content event after the earliest of them — the
331/// recovery literature shows a model re-derives an injected instruction from
332/// its own retained reasoning if only the source is removed. `source-only`
333/// excises exactly the named positions: an explicit, human-vouched override
334/// for content the person read and judged benign, named in the signed
335/// payload so the audit trail shows which posture the human chose.
336pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
337/// See [`EXCISION_SCOPE_CASCADE`].
338pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
339
340/// The canonical (signature-covered) form of a `taint_excision` marker.
341///
342/// Covers the conversation (a marker signed for one conversation cannot be
343/// replayed into another), the scope, the named journal positions, and who
344/// requested the excision — so a marker can be neither forged, re-targeted,
345/// nor widened. `signed_by`/`signature_hex` are appended after signing and
346/// are not covered.
347fn excision_canonical(
348    conversation_id: &str,
349    scope: &str,
350    positions: &[u64],
351    requested_by: &str,
352    reason: &str,
353) -> Vec<u8> {
354    canonical_bytes(&ExcisionCanonical {
355        conversation_id,
356        scope,
357        positions,
358        requested_by,
359        reason,
360    })
361}
362
363/// The signed `taint_excision` field set, in its frozen order.
364#[derive(Serialize)]
365struct ExcisionCanonical<'a> {
366    conversation_id: &'a str,
367    scope: &'a str,
368    positions: &'a [u64],
369    requested_by: &'a str,
370    reason: &'a str,
371}
372
373/// JSON payload for a signed `taint_excision` event (`#590`).
374///
375/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
376#[must_use]
377pub fn excision_payload(
378    conversation_id: &str,
379    scope: &str,
380    positions: &[u64],
381    requested_by: &str,
382    reason: &str,
383    signer: &ApprovalSigner,
384) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
385    Envelope::seal(
386        ExcisionCanonical {
387            conversation_id,
388            scope,
389            positions,
390            requested_by,
391            reason,
392        },
393        signer.as_signer(),
394    )
395}
396
397/// The canonical (signature-covered) form of a `grant_replay` audit record
398/// (`#594`).
399///
400/// Binds the conversation and turn the replay happened in, the tool the grant
401/// cleared, the [`crate::grant::grant_ref`] of the grant that cleared it, the
402/// capability names the grant kept against taint, and the template-coverage hash
403/// (#618) the grant matched — so the durable record commits to exactly which
404/// grant kept which capabilities on which turn. `signed_by`/`signature_hex` are
405/// appended after signing and are not covered.
406fn grant_replay_canonical(
407    conversation_id: &str,
408    turn_id: &str,
409    tool: &str,
410    grant_ref: &str,
411    covered_capabilities: &[String],
412    coverage_hash: &str,
413) -> Vec<u8> {
414    canonical_bytes(&GrantReplayCanonical {
415        conversation_id,
416        turn_id,
417        tool,
418        grant_ref,
419        covered_capabilities,
420        coverage_hash,
421    })
422}
423
424/// The signed `grant_replay` field set, in its frozen order.
425#[derive(Serialize)]
426struct GrantReplayCanonical<'a> {
427    conversation_id: &'a str,
428    turn_id: &'a str,
429    tool: &'a str,
430    grant_ref: &'a str,
431    covered_capabilities: &'a [String],
432    coverage_hash: &'a str,
433}
434
435/// JSON payload for a signed `grant_replay` audit event (`#594`).
436///
437/// The durable, trust-tagged record PRD §12 requires that a replayed grant kept
438/// a capability taint would have removed — appended by the control plane once per
439/// grant-cleared gate (never a `tracing` line). Platform-signed (ed25519, the
440/// [`ApprovalSigner`]) so the record is tamper-evident and attributable. The
441/// field names match `polychrome.events.v1.GrantReplayEvent`, so the forensics
442/// decoder renders it. Returns `(full_payload_bytes, signature_bytes,
443/// public_key_bytes)`.
444#[must_use]
445#[allow(clippy::too_many_arguments)] // each arg is a distinct field of the audit record
446pub fn grant_replay_payload(
447    conversation_id: &str,
448    turn_id: &str,
449    tool: &str,
450    grant_ref: &str,
451    covered_capabilities: &[String],
452    coverage_hash: &str,
453    signer: &ApprovalSigner,
454) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
455    Envelope::seal(
456        GrantReplayCanonical {
457            conversation_id,
458            turn_id,
459            tool,
460            grant_ref,
461            covered_capabilities,
462            coverage_hash,
463        },
464        signer.as_signer(),
465    )
466}
467
468/// Verify a persisted `grant_replay` audit payload against its embedded signer
469/// (`#594`).
470///
471/// Rebuilds the canonical from the payload's own fields and checks the embedded
472/// ed25519 signature. Returns `true` only when the signature covers the exact
473/// record — a tamper to any bound field flips it to `false`. Fail-closed on any
474/// malformed field or bad hex.
475#[must_use]
476pub fn verify_grant_replay(payload: &[u8]) -> bool {
477    let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
478        return false;
479    };
480    let (
481        Some(conversation_id),
482        Some(turn_id),
483        Some(tool),
484        Some(grant_ref),
485        Some(covered),
486        Some(coverage_hash),
487        Some(signed_by),
488        Some(signature_hex),
489    ) = (
490        v.get("conversation_id").and_then(Value::as_str),
491        v.get("turn_id").and_then(Value::as_str),
492        v.get("tool").and_then(Value::as_str),
493        v.get("grant_ref").and_then(Value::as_str),
494        v.get("covered_capabilities").and_then(Value::as_array),
495        v.get("coverage_hash").and_then(Value::as_str),
496        v.get("signed_by").and_then(Value::as_str),
497        v.get("signature_hex").and_then(Value::as_str),
498    )
499    else {
500        return false;
501    };
502    let Some(covered_capabilities) = covered
503        .iter()
504        .map(|c| c.as_str().map(str::to_owned))
505        .collect::<Option<Vec<_>>>()
506    else {
507        return false;
508    };
509    let (Some(pk), Some(sig)) = (
510        crate::hex::decode(signed_by),
511        crate::hex::decode(signature_hex),
512    ) else {
513        return false;
514    };
515    let canonical = grant_replay_canonical(
516        conversation_id,
517        turn_id,
518        tool,
519        grant_ref,
520        &covered_capabilities,
521        coverage_hash,
522    );
523    crate::verify(&pk, &canonical, &sig)
524}
525
526/// Whether `signer_pk` is a member of the deployment's pinned approval
527/// allow-list (`#845`).
528///
529/// An internally-consistent ed25519 signature proves only that a payload was
530/// not altered after it was signed; it says nothing about whether the signer is
531/// one the deployment trusts, since anyone can mint a keypair, embed its own
532/// public key, and self-sign an arbitrary payload. Every trust-gated verifier
533/// therefore checks membership here before honoring a payload. An empty
534/// allow-list trusts no one (fail closed). Shared by the grant-replay and
535/// approval-response pinned verifiers so the allow-list check cannot drift
536/// between them or from [`verify_signed_receipt`]'s.
537fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
538    trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
539}
540
541/// Verify a persisted `grant_replay` audit payload against a **trusted-signer
542/// allow-list** (`#845`).
543///
544/// Like [`verify_grant_replay`] but additionally rejects any payload whose
545/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
546/// pinned approval public key(s), typically the running
547/// [`ApprovalSigner::public_key_bytes`], since every `grant_replay` record this
548/// control plane persists is self-signed with it. Without this gate an attacker
549/// could sign a well-formed record with their own key, embed it, and have the
550/// audit trail render it as `valid`. Mirrors [`verify_signed_receipt`]'s
551/// allow-list gate. Returns `true` only when the signer is trusted AND the
552/// signature covers the exact record; `false` (fail closed) on a malformed
553/// payload, an untrusted signer, or a bad signature.
554#[must_use]
555pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
556    // Gate on the allow-list before the (single-sourced) signature check: an
557    // untrusted signer is rejected no matter how internally consistent its
558    // signature is.
559    let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
560        v.get("signed_by")
561            .and_then(Value::as_str)
562            .and_then(crate::hex::decode)
563    }) else {
564        return false;
565    };
566    if !signer_is_trusted(&pk, trusted_signers) {
567        return false;
568    }
569    verify_grant_replay(payload)
570}
571
572/// A verified `taint_excision` marker.
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct VerifiedExcision {
575    /// The conversation the marker is bound to.
576    pub conversation_id: String,
577    /// [`EXCISION_SCOPE_CASCADE`] or [`EXCISION_SCOPE_SOURCE_ONLY`].
578    pub scope: String,
579    /// The named journal positions.
580    pub positions: Vec<u64>,
581    /// Who requested the excision (persona id or operator identity).
582    pub requested_by: String,
583    /// Free-text audit reason.
584    pub reason: String,
585    /// The verified signer's public key (encoded).
586    pub signer_public_key: Vec<u8>,
587}
588
589impl VerifiedExcision {
590    /// Whether this marker's scope is the cascading (sound-default) one.
591    #[must_use]
592    pub fn is_cascade(&self) -> bool {
593        self.scope == EXCISION_SCOPE_CASCADE
594    }
595}
596
597/// Verify a persisted `taint_excision` payload.
598///
599/// `None` for a malformed payload, an unknown scope, or a signature that
600/// does not verify — the caller ignores the marker and taint stays (fail
601/// closed).
602#[must_use]
603pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
604    let v: Value = serde_json::from_slice(payload).ok()?;
605    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
606    let scope = v.get("scope")?.as_str()?.to_owned();
607    if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
608        return None;
609    }
610    let positions: Vec<u64> = v
611        .get("positions")?
612        .as_array()?
613        .iter()
614        .map(serde_json::Value::as_u64)
615        .collect::<Option<Vec<_>>>()?;
616    let requested_by = v.get("requested_by")?.as_str()?.to_owned();
617    let reason = v.get("reason")?.as_str()?.to_owned();
618    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
619    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
620    let canonical =
621        excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
622    if verify(&pk, &canonical, &sig) {
623        Some(VerifiedExcision {
624            conversation_id,
625            scope,
626            positions,
627            requested_by,
628            reason,
629            signer_public_key: pk,
630        })
631    } else {
632        None
633    }
634}
635
636/// JSON payload for an `approval_deferred` event (`#67` "send back").
637///
638/// A defer records that the approver bounced the call back without approving or
639/// denying it — the audit trail shows the intent, but the pending
640/// `approval_request` is NOT resolved (no `approval_response`), so the call stays
641/// open. The signature commits to the call identity (`request_id`), the
642/// `conversation_id` it was deferred in, and the free-form `reason`; `signed_by`
643/// and `signature_hex` are appended after signing and are not covered.
644///
645/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
646#[must_use]
647pub fn deferred_payload(
648    request_id: &str,
649    conversation_id: &str,
650    reason: &str,
651    signer: &ApprovalSigner,
652) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
653    Envelope::seal(
654        DeferredCanonical {
655            request_id,
656            conversation_id,
657            reason,
658        },
659        signer.as_signer(),
660    )
661}
662
663/// The signed `approval_deferred` field set, in its frozen order.
664#[derive(Serialize)]
665struct DeferredCanonical<'a> {
666    request_id: &'a str,
667    conversation_id: &'a str,
668    reason: &'a str,
669}
670
671/// Verify a persisted `approval_deferred` payload (`#67`).
672///
673/// Returns `Some((request_id, conversation_id, reason))` when the signature
674/// checks out against the embedded key, else `None`.
675#[must_use]
676pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
677    let v: Value = serde_json::from_slice(payload).ok()?;
678    let request_id = v.get("request_id")?.as_str()?.to_owned();
679    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
680    let reason = v.get("reason")?.as_str()?.to_owned();
681    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
682    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
683    let canonical = canonical_bytes(&DeferredCanonical {
684        request_id: &request_id,
685        conversation_id: &conversation_id,
686        reason: &reason,
687    });
688    verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
689}
690
691/// JSON payload for a dispatch-mutation event (`#67`, #539/#540).
692///
693/// A signed record that a policy rewrote a call's args (`tool_input_rewrite`),
694/// injected context (`tool_context_injection`), or redacted a result
695/// (`tool_result_redaction`).
696///
697/// The signature commits to the event `kind` (so a record can't be re-filed under
698/// another mutation kind), the call identity (`tool_call_id`, `tool_name`), the
699/// conversation, and the mutation's `before`/`after` (proposed→executed args,
700/// or empty→context, or original→redacted result). `signed_by` / `signature_hex`
701/// are appended after signing and not covered. Returns
702/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
703#[must_use]
704#[allow(clippy::too_many_arguments)] // each is a distinct signed field of the canonical contract
705pub fn mutation_payload(
706    kind: &str,
707    tool_call_id: &str,
708    tool_name: &str,
709    conversation_id: &str,
710    before: &str,
711    after: &str,
712    signer: &ApprovalSigner,
713) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
714    Envelope::seal(
715        MutationCanonical {
716            kind,
717            tool_call_id,
718            tool_name,
719            conversation_id,
720            before,
721            after,
722        },
723        signer.as_signer(),
724    )
725}
726
727/// The signed dispatch-mutation field set, in its frozen order.
728#[derive(Serialize)]
729struct MutationCanonical<'a> {
730    kind: &'a str,
731    tool_call_id: &'a str,
732    tool_name: &'a str,
733    conversation_id: &'a str,
734    before: &'a str,
735    after: &'a str,
736}
737
738/// Verify a persisted dispatch-mutation payload (`#67`).
739///
740/// Returns the signed `(kind, tool_call_id, tool_name, conversation_id, before,
741/// after)` when the signature checks out against the embedded key, else `None`.
742#[must_use]
743pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
744    let v: Value = serde_json::from_slice(payload).ok()?;
745    let kind = v.get("kind")?.as_str()?.to_owned();
746    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
747    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
748    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
749    let before = v.get("before")?.as_str()?.to_owned();
750    let after = v.get("after")?.as_str()?.to_owned();
751    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
752    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
753    let canonical = canonical_bytes(&MutationCanonical {
754        kind: &kind,
755        tool_call_id: &tool_call_id,
756        tool_name: &tool_name,
757        conversation_id: &conversation_id,
758        before: &before,
759        after: &after,
760    });
761    verify(&pk, &canonical, &sig).then_some((
762        kind,
763        tool_call_id,
764        tool_name,
765        conversation_id,
766        before,
767        after,
768    ))
769}
770
771/// Reason-string prefix marking an `approval_response` as a reviewer-agent
772/// auto-approval (`#377`), as opposed to a human decision.
773///
774/// The reviewer signs the EXACT same canonical `approval_response` a human
775/// would — same [`response_payload`], same signer, same bound identity
776/// (`request_id` + `tool_name` + `args_json` + `caller` + `sandbox_mode`),
777/// `approved == true`, `approved_for_session == false` — so the wire/signature
778/// contract is byte-for-byte identical and every existing verify path accepts
779/// it unchanged. The ONLY field distinguishing an auto-approval from a human
780/// one is the signed `reason`, which carries this prefix. Because `reason` is
781/// covered by the signature (`response_canonical`), the distinction is
782/// unforgeable: a compromised forwarder can neither launder an auto-approval as
783/// human nor a human decision as auto without invalidating the signature. The
784/// event log is therefore auditable for machine-vs-human consent off this one
785/// signed field — the guardrail `#377` requires.
786pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
787
788/// Build the signed `reason` for a reviewer auto-approval at risk `tier`.
789///
790/// Carries [`AUTO_REVIEW_REASON_PREFIX`] so the audit log can tell it from a
791/// human decision; `tier` (e.g. `"low"`) records WHY the classifier deemed the
792/// call auto-eligible. The control plane passes the result as the `reason`
793/// argument to the SAME [`response_payload`] the human path uses, so no
794/// separate signing surface exists.
795#[must_use]
796pub fn auto_review_reason(tier: &str) -> String {
797    format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
798}
799
800/// Whether a signed `reason` marks its `approval_response` as a reviewer
801/// auto-approval (`#377`) rather than a human decision.
802///
803/// The audit distinguisher; it reads the signed `reason` field, so it cannot be
804/// spoofed without breaking the signature.
805#[must_use]
806pub fn is_auto_review_reason(reason: &str) -> bool {
807    reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
808}
809
810/// Signed `reason` recorded for the blanket `approve-all-dangerous` mode.
811///
812/// Set by `POLYCHROME_APPROVAL_MODE=approve-all-dangerous` — the legible single
813/// approve-all surface that replaced the legacy `POLYCHROME_APPROVE_ALL` flag.
814/// Unlike [`auto_review_reason`] this is NOT a risk-classified verdict: it marks
815/// an unconditional machine approval, so the audit log can tell a blanket
816/// test-rig approval apart from both a human decision and a reviewer
817/// auto-approval. Like every other reason it is covered by the signature, so the
818/// distinction is unforgeable.
819pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
820
821/// A decoded `approval_response` payload after signature verification.
822#[derive(Debug, Clone)]
823pub struct VerifiedResponse {
824    /// The tool-call id this response answers.
825    pub request_id: String,
826    /// The bound tool name — the approval applies only to this exact call.
827    pub tool_name: String,
828    /// The bound `args_json` — the model's PROPOSED args, the identity the
829    /// approval is bound to. [`Self::authorizes_call`] matches this byte-for-byte,
830    /// so a re-emitted call with different args cannot inherit the approval. This
831    /// is the args the approver saw, NOT necessarily the args that execute.
832    pub args_json: String,
833    /// The approver's EDIT to the proposed args — the args to actually execute,
834    /// or empty when the approver did not edit (execute `args_json` unchanged).
835    /// Signed, so the edit is unforgeable and auditable; the delta from
836    /// `args_json` is the recorded mutation. Resolve the effective execution args
837    /// with the pure `polyc_agent::resolve_approved_call`.
838    pub modified_args_json: String,
839    /// Whether the request was approved.
840    pub approved: bool,
841    /// Whether the approval is remembered for the rest of the session ("don't
842    /// ask again"); `false` for a one-shot approval.
843    pub approved_for_session: bool,
844    /// The capability shortfall this approval covered (`#595`): the stable
845    /// kebab-case capability names the gate reported missing when it paused
846    /// the call. Covered by the signature, so the effective session-grant key
847    /// is (caller, tool, covered capabilities) — a grant recorded against one
848    /// covered set never satisfies the same tool after its required set grows.
849    /// Empty for an approval of an ordinary policy/sandbox gate.
850    pub covered_capabilities: Vec<String>,
851    /// The caller identity the (session) approval is scoped to — the paused
852    /// turn's own beneficiary (RFC 8693 `sub`/subject), NOT necessarily who
853    /// clicked. Set by the trusted control plane and covered by the
854    /// signature, so a session grant cannot be re-scoped to a different user.
855    pub caller: String,
856    /// The identity that actually resolved this decision (`#1025`, RFC 8693
857    /// `act`/actor), when the edge supplied one — empty otherwise (no edge
858    /// integration yet, or a payload signed before this field existed).
859    /// Distinct from `caller`: an admin approving on someone else's behalf
860    /// signs a different `approver` than `caller`. For approval-policy
861    /// checks and the audit trail ONLY — never fed into `principal_ref` (see
862    /// `caller`'s own resume-attribution use in the control plane).
863    pub approver: String,
864    /// The sandbox/permission mode the paused turn ran under, covered by the
865    /// signature so a grant cannot be replayed under a different mode.
866    pub sandbox_mode: String,
867    /// Free-form human-supplied reason.
868    pub reason: String,
869    /// Context the approver attached to inject before the tool runs — prepended
870    /// as an `internal_only` message ahead of execution, or empty when none.
871    /// Signed, so an injected instruction is unforgeable and recorded.
872    pub injected_context: String,
873    /// The conversation the approval was granted in, covered by the signature so
874    /// a token signed for one conversation cannot be replayed into another
875    /// (`#370`, closes `#77` bug 3B).
876    pub conversation_id: String,
877    /// Per-approval unique value, covered by the signature. A consumer records it
878    /// on use so the token cannot be re-presented once spent (single-use, `#370`).
879    pub nonce: String,
880    /// The verified signer's public key (encoded).
881    pub signer_public_key: Vec<u8>,
882}
883
884impl VerifiedResponse {
885    /// Whether this verified, approved token authorizes the EXACT call
886    /// `(request_id, tool_name, args_json)` — the args-binding (`#370` item 3).
887    ///
888    /// The signed `args_json` is matched byte-for-byte, so a re-emitted same-id
889    /// call with different arguments (or a different tool) is NOT authorized: a
890    /// captured approval can never be reused to run a different action. A denial
891    /// (`approved == false`) authorizes nothing.
892    #[must_use]
893    pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
894        self.approved
895            && self.request_id == request_id
896            && self.tool_name == tool_name
897            && self.args_json == args_json
898    }
899}
900
901/// Verify a persisted `approval_response` payload.
902///
903/// Returns `Some(record)` if the signature checks out against the embedded
904/// public key (the caller is responsible for trusting that public key — a key
905/// allow-list lives alongside this in production). Returns `None` if the payload
906/// is malformed, the hex fields don't decode, or the signature doesn't verify.
907#[must_use]
908pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
909    let v: Value = serde_json::from_slice(payload).ok()?;
910    let request_id = v.get("request_id")?.as_str()?.to_owned();
911    let tool_name = v.get("tool_name")?.as_str()?.to_owned();
912    let args_json = v.get("args_json")?.as_str()?.to_owned();
913    let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
914    let approved = v.get("approved")?.as_bool()?;
915    let approved_for_session = v.get("approved_for_session")?.as_bool()?;
916    let covered_capabilities: Vec<String> = v
917        .get("covered_capabilities")?
918        .as_array()?
919        .iter()
920        .map(|c| c.as_str().map(str::to_owned))
921        .collect::<Option<Vec<_>>>()?;
922    let caller = v.get("caller")?.as_str()?.to_owned();
923    // #1025: absent on every payload signed before this field existed (and
924    // on any signed today with an empty approver — omitted, not `""`, at
925    // sign time) — defaults to empty, NOT a decode failure.
926    let approver_id = v
927        .get("approver")
928        .and_then(|x| x.as_str())
929        .unwrap_or_default()
930        .to_owned();
931    let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
932    let reason = v.get("reason")?.as_str()?.to_owned();
933    let injected_context = v.get("injected_context")?.as_str()?.to_owned();
934    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
935    let nonce = v.get("nonce")?.as_str()?.to_owned();
936    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
937    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
938
939    let canonical = response_canonical(
940        &request_id,
941        &tool_name,
942        &args_json,
943        &modified_args_json,
944        approved,
945        approved_for_session,
946        &covered_capabilities,
947        &caller,
948        &approver_id,
949        &sandbox_mode,
950        &reason,
951        &injected_context,
952        &conversation_id,
953        &nonce,
954    );
955    if verify(&pk, &canonical, &sig) {
956        Some(VerifiedResponse {
957            request_id,
958            tool_name,
959            args_json,
960            modified_args_json,
961            approved,
962            approved_for_session,
963            covered_capabilities,
964            caller,
965            approver: approver_id,
966            sandbox_mode,
967            reason,
968            injected_context,
969            conversation_id,
970            nonce,
971            signer_public_key: pk,
972        })
973    } else {
974        None
975    }
976}
977
978/// Verify a persisted `approval_response` payload against a **trusted-signer
979/// allow-list** (`#845`).
980///
981/// Like [`verify_signed_response`] but additionally rejects any payload whose
982/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
983/// pinned approval public key(s), typically the running
984/// [`ApprovalSigner::public_key_bytes`], since every `approval_response` this
985/// control plane persists is self-signed with it. Without this gate an attacker
986/// could sign a well-formed decision with their own key, embed it, and have it
987/// honored as an approval. Mirrors [`verify_signed_receipt`]'s allow-list gate.
988/// Returns `None` (fail closed) on a malformed payload, an untrusted signer, or
989/// a bad signature.
990#[must_use]
991pub fn verify_signed_response_pinned(
992    payload: &[u8],
993    trusted_signers: &[Vec<u8>],
994) -> Option<VerifiedResponse> {
995    let verified = verify_signed_response(payload)?;
996    // Reject an untrusted signer even though its signature is self-consistent:
997    // anyone can mint a keypair, embed its public key, and self-sign.
998    if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
999        return None;
1000    }
1001    Some(verified)
1002}
1003
1004/// Verify a persisted `approval_response` as a SINGLE-USE, conversation-bound
1005/// capability token (`#370`), gated on a **trusted-signer allow-list** (`#845`).
1006///
1007/// Returns the verified response only when ALL hold:
1008/// * the embedded `signed_by` key is a member of `trusted_signers` — the
1009///   deployment's pinned approval public key(s) (see
1010///   [`verify_signed_response_pinned`]); an internally-consistent signature over
1011///   an untrusted key authorizes nothing;
1012/// * the signature verifies against that key (provenance);
1013/// * the signed `conversation_id` equals `conversation_id` — a token signed for
1014///   one conversation is rejected when presented for another (closes `#77`
1015///   bug 3B);
1016/// * the signed `nonce` is non-empty AND not already in `consumed` — a token
1017///   that has been spent (its nonce recorded on a prior use) is rejected.
1018///
1019/// The caller binds the token to a specific call by matching the returned
1020/// [`VerifiedResponse::authorizes_call`], and MUST record the returned
1021/// [`VerifiedResponse::nonce`] into its `consumed` set before honoring it, so a
1022/// second presentation of the same token is rejected. An empty nonce is treated
1023/// as malformed and fails closed (every minted token carries one).
1024#[must_use]
1025pub fn verify_capability<S: std::hash::BuildHasher>(
1026    payload: &[u8],
1027    conversation_id: &str,
1028    consumed: &HashSet<String, S>,
1029    trusted_signers: &[Vec<u8>],
1030) -> Option<VerifiedResponse> {
1031    let verified = verify_signed_response_pinned(payload, trusted_signers)?;
1032    if verified.conversation_id != conversation_id {
1033        return None;
1034    }
1035    if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
1036        return None;
1037    }
1038    Some(verified)
1039}
1040
1041/// Verify a wire-form `approval_response`, binding the approval to its full
1042/// signed identity `(request_id, tool_name, args_json, approved,
1043/// approved_for_session, caller, reason)`.
1044///
1045/// Used by the harness when it receives `HarnessMessage.approval_responses` over
1046/// the wire and must confirm provenance AND identity before executing the paused
1047/// tool or honoring a "don't ask again" grant. The `caller` is covered by the
1048/// signature, so a compromised control plane cannot re-scope a remembered
1049/// approval onto a different user. Returns `true` only if `signer_pk_hex +
1050/// signature_hex` validates against the canonical.
1051#[must_use]
1052#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
1053pub fn verify_wire_response(
1054    request_id: &str,
1055    tool_name: &str,
1056    args_json: &str,
1057    modified_args_json: &str,
1058    approved: bool,
1059    approved_for_session: bool,
1060    covered_capabilities: &[String],
1061    caller: &str,
1062    approver_id: &str,
1063    sandbox_mode: &str,
1064    reason: &str,
1065    injected_context: &str,
1066    conversation_id: &str,
1067    nonce: &str,
1068    signer_pk_hex: &str,
1069    signature_hex: &str,
1070) -> bool {
1071    let Some(pk) = crate::hex::decode(signer_pk_hex) else {
1072        return false;
1073    };
1074    let Some(sig) = crate::hex::decode(signature_hex) else {
1075        return false;
1076    };
1077    let canonical = response_canonical(
1078        request_id,
1079        tool_name,
1080        args_json,
1081        modified_args_json,
1082        approved,
1083        approved_for_session,
1084        covered_capabilities,
1085        caller,
1086        approver_id,
1087        sandbox_mode,
1088        reason,
1089        injected_context,
1090        conversation_id,
1091        nonce,
1092    );
1093    verify(&pk, &canonical, &sig)
1094}
1095
1096/// Whether an approval response is a session ("don't ask again") grant for
1097/// `current_caller`.
1098///
1099/// True when it is approved, flagged for the session, and bound to a non-empty
1100/// `caller` equal to the current turn's caller.
1101///
1102/// This is the per-USER isolation invariant — user A's remembered approval must
1103/// never auto-approve user B in a shared conversation. It lives here, in one
1104/// place, so the harness (which re-verifies signed wire responses) and the
1105/// control plane (the in-process path) cannot drift on *who* a remembered
1106/// approval applies to. Callers still gate the TOOL on its idempotency
1107/// separately ([`crate`] does not know tool policy).
1108#[must_use]
1109pub fn is_session_grant_for(
1110    approved: bool,
1111    approved_for_session: bool,
1112    caller: &str,
1113    current_caller: &str,
1114) -> bool {
1115    approved && approved_for_session && !caller.is_empty() && caller == current_caller
1116}
1117
1118/// Extract `(request_id, approved)` from an `approval_response` payload.
1119///
1120/// Used by replay to find which pending requests have been answered. Skips
1121/// signature verification on the assumption the caller has already accepted
1122/// the entry — pair with [`verify_signed_response`] when trust matters.
1123#[must_use]
1124pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
1125    let v: Value = serde_json::from_slice(payload).ok()?;
1126    let request_id = v.get("request_id")?.as_str()?.to_owned();
1127    let approved = v.get("approved")?.as_bool()?;
1128    Some((request_id, approved))
1129}
1130
1131/// Every decoded field of an `approval_response` payload (unverified).
1132#[derive(Debug, Clone)]
1133pub struct DecodedResponse {
1134    /// Tool-call id this response answers.
1135    pub request_id: String,
1136    /// Bound tool name.
1137    pub tool_name: String,
1138    /// Bound `args_json` — the model's proposed args (identity binding).
1139    pub args_json: String,
1140    /// The approver's edit to the proposed args (empty = unedited).
1141    pub modified_args_json: String,
1142    /// Approve / deny decision.
1143    pub approved: bool,
1144    /// Whether the approval is remembered for the session ("don't ask again").
1145    pub approved_for_session: bool,
1146    /// The capability shortfall this approval covered (`#595`).
1147    pub covered_capabilities: Vec<String>,
1148    /// The caller identity the (session) approval is scoped to — the paused
1149    /// turn's own beneficiary, NOT necessarily who clicked. See `approver`.
1150    pub caller: String,
1151    /// The identity that actually resolved this decision (`#1025`), when the
1152    /// edge supplied one — empty otherwise (no edge integration yet, or a
1153    /// payload signed before this field existed). Distinct from `caller`.
1154    pub approver: String,
1155    /// The sandbox/permission mode the grant was made under.
1156    pub sandbox_mode: String,
1157    /// Human-supplied reason.
1158    pub reason: String,
1159    /// Context the approver attached to inject before execution (empty = none).
1160    pub injected_context: String,
1161    /// The conversation the approval was granted in (`#370` binding).
1162    pub conversation_id: String,
1163    /// Per-approval single-use nonce (`#370` binding).
1164    pub nonce: String,
1165    /// Signer public key, hex.
1166    pub signer_pk_hex: String,
1167    /// Signature, hex.
1168    pub signature_hex: String,
1169}
1170
1171/// Decode every field of an `approval_response` payload without verifying.
1172///
1173/// Used by the control plane to forward signed responses onto the harness wire;
1174/// the harness re-verifies on receipt.
1175#[must_use]
1176pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
1177    let v: Value = serde_json::from_slice(payload).ok()?;
1178    Some(DecodedResponse {
1179        request_id: v.get("request_id")?.as_str()?.to_owned(),
1180        tool_name: v.get("tool_name")?.as_str()?.to_owned(),
1181        args_json: v.get("args_json")?.as_str()?.to_owned(),
1182        modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
1183        approved: v.get("approved")?.as_bool()?,
1184        approved_for_session: v.get("approved_for_session")?.as_bool()?,
1185        covered_capabilities: v
1186            .get("covered_capabilities")
1187            .and_then(Value::as_array)
1188            .map(|a| {
1189                a.iter()
1190                    .filter_map(|c| c.as_str().map(str::to_owned))
1191                    .collect()
1192            })
1193            .unwrap_or_default(),
1194        caller: v.get("caller")?.as_str()?.to_owned(),
1195        // #1025: absent on every payload signed before this field existed —
1196        // defaults to empty, NOT a decode failure (see `response_canonical`).
1197        approver: v
1198            .get("approver")
1199            .and_then(|x| x.as_str())
1200            .unwrap_or_default()
1201            .to_owned(),
1202        sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
1203        reason: v.get("reason")?.as_str()?.to_owned(),
1204        injected_context: v.get("injected_context")?.as_str()?.to_owned(),
1205        conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
1206        nonce: v.get("nonce")?.as_str()?.to_owned(),
1207        signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
1208        signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
1209    })
1210}
1211
1212/// Current signed `payment_receipt` schema version.
1213///
1214/// **v2** makes a settled receipt self-describing: alongside the original
1215/// settlement facts it covers the event `kind` (direction — without it an
1216/// inbound payload could be re-filed under the outbound kind, or vice versa,
1217/// since the stored `Event.kind` is not itself signed), the binding fields
1218/// (`tool_call_id`, `approval_pos`, `approved_args_hash`), and an opaque
1219/// `subject` — so an auditor can bind the receipt to the exact approved tool
1220/// call it answered without walking the log to the separate
1221/// `outbound_payment_attempt` event. **v3** adds payer attribution
1222/// (`payer_kind`, `paying_account`) — which account actually paid: a linked
1223/// wallet or the deployment's own signer — computed at settlement and now
1224/// carried on the durable receipt instead of only reaching the tool result.
1225/// **v1** (legacy, no `version` field) signed only the six settlement facts;
1226/// it still *verifies* for forensics but carries no kind, binding tuple, or
1227/// payer attribution.
1228pub const RECEIPT_VERSION: u64 = 3;
1229
1230// Compile-time tripwire for a `RECEIPT_VERSION` bump. `receipt_payload` signs
1231// the frozen `ReceiptPayload::canonical_json_v3` *by name*, and `ReceiptSchema`
1232// resolves each persisted version to its own frozen builder. Raising the
1233// constant without doing the rest would leave new receipts signing v3 bytes
1234// while claiming the new version, so make that mismatch fail the build instead
1235// of shipping. The message lists every site a bump touches: the enum's
1236// exhaustive matches force (1) and (2), `dead_code` on an unreachable variant
1237// forces (3), this assert itself catches (4), and (5) is on the bumper.
1238const _: () = assert!(
1239    RECEIPT_VERSION == 3,
1240    "RECEIPT_VERSION changed. A bump is five edits, not one: (1) add \
1241     ReceiptPayload::canonical_json_v<N> beside the frozen builders; (2) add \
1242     ReceiptSchema::V<N> and answer number(), covers_binding() — say which \
1243     fields the new canonical actually covers — and canonical(); (3) give \
1244     ReceiptSchema::resolve an arm mapping the claimed number to it; (4) \
1245     repoint receipt_payload at the new builder; (5) in the tests, add a \
1246     golden v<N> fixture beside GOLDEN_V2_RECEIPT and re-freeze the pinned \
1247     writer shape, leaving every already-frozen v2 literal untouched"
1248);
1249
1250/// The receipt body fields [`receipt_payload`] signs, named at the call site.
1251///
1252/// Replaces positional `&str` arguments: with the positional form, two
1253/// same-typed fields (e.g. `recipient` and `method`) could be swapped at a call
1254/// site and still compile, silently signing a corrupt receipt. Naming the
1255/// fields here makes such a swap a compile error.
1256///
1257/// The field set, names, and the order they are serialized in
1258/// [`receipt_payload`] are the signed-payload contract: they must NOT change
1259/// without a version bump, or previously-persisted receipts stop verifying.
1260///
1261/// `kind` and the trailing four fields are **v2** additions: the event kind
1262/// (direction) plus the binding tuple and an opaque `subject`. Inbound (the
1263/// server was *paid*) receipts have no approved tool call, so they pass empty
1264/// strings for the binding tuple and subject; outbound (the control plane
1265/// *paid* a 402 service) receipts populate them. Both directions sign their
1266/// `kind`.
1267#[derive(Debug, Clone, Copy)]
1268pub struct ReceiptPayload<'a> {
1269    /// The event kind the payload is stored under (`payment_receipt` for
1270    /// inbound, `outbound_payment_receipt` for outbound). Signed so a payload
1271    /// cannot be re-filed under the other direction's kind (v2; empty for
1272    /// legacy v1).
1273    pub kind: &'a str,
1274    /// Chain/transaction reference (e.g. tx id) the receipt settles.
1275    pub reference: &'a str,
1276    /// Settled amount as a string, never a float (no drift, no rounding).
1277    ///
1278    /// The UNIT is the direction's, not this type's, and the two directions do
1279    /// not agree: an inbound receipt (`payment_receipt`) carries a decimal
1280    /// figure in the settlement currency (`"0.01"`), an outbound one
1281    /// (`outbound_payment_receipt`) carries the settlement token's base units
1282    /// (`"10000"`, or `""` when the payment proxy could not capture the
1283    /// charge). Read it through the matching `polyc_payments::amount` reader,
1284    /// dispatched on [`Self::kind`] — never with a bare parse, and never by
1285    /// guessing from the string's shape.
1286    pub amount: &'a str,
1287    /// Currency / asset label. Follows the same per-direction split as
1288    /// [`Self::amount`]: the settlement currency symbol inbound, the
1289    /// settlement token's contract address outbound.
1290    pub currency: &'a str,
1291    /// Recipient address.
1292    pub recipient: &'a str,
1293    /// Settlement method (e.g. `tempo`).
1294    pub method: &'a str,
1295    /// RFC3339 settlement timestamp.
1296    pub timestamp: &'a str,
1297    /// The `paid_fetch` tool-call id this payment answered (v2; empty for
1298    /// inbound and legacy v1).
1299    pub tool_call_id: &'a str,
1300    /// Decimal string of the `approval_request` log position the payment
1301    /// answered (v2; empty for inbound and legacy v1).
1302    pub approval_pos: &'a str,
1303    /// sha256 hex of the approved `args_json` — the same idempotency-key
1304    /// component the `outbound_payment_attempt` marker carries, so a verifier
1305    /// can cross-check the receipt against the attempt (v2; empty otherwise).
1306    pub approved_args_hash: &'a str,
1307    /// Opaque principal the spend is attributed to. Currently the conversation
1308    /// id; the structured agent/tenant identity is supplied later by the
1309    /// declarative-catalog identity model. Treat as opaque (v2; empty otherwise).
1310    pub subject: &'a str,
1311    /// Which account actually paid: `"linked_wallet"` or `"deployment"` — the
1312    /// stable vocabulary `PayerKind::as_str` mints (in the payments client
1313    /// resolver). Explicit unknown (empty string) for v1/v2 receipts and for
1314    /// inbound receipts, which have no payer concept — never inferred (v3).
1315    pub payer_kind: &'a str,
1316    /// The paying account's own address, when known. Empty when the payer is
1317    /// the deployment's own signer (there is no separate "paying account" to
1318    /// name beyond the signer itself) or when this build cannot observe it
1319    /// (v3; empty for v1/v2 and inbound receipts).
1320    pub paying_account: &'a str,
1321}
1322
1323impl ReceiptPayload<'_> {
1324    /// Frozen **v3** canonical: the v2 field set plus payer attribution
1325    /// (`payer_kind`, `paying_account`).
1326    ///
1327    /// This is the SINGLE source for the signed v3 receipt field set and order:
1328    /// both the signing path ([`receipt_payload`]) and the verifying path
1329    /// ([`verify_signed_receipt`]) route their v3 canonical bytes through here,
1330    /// so the two paths cannot drift. The key set/order is the on-wire signed
1331    /// contract and must NOT change — a new covered field means a new version
1332    /// with its own builder beside this one.
1333    ///
1334    /// Frozen exactly like [`Self::canonical_json_v2`], and retained for the
1335    /// same reason: once [`RECEIPT_VERSION`] moves past 3, every receipt
1336    /// already signed under v3 still *verifies* through this builder. That is
1337    /// why the `version` key below is the literal `3` rather than
1338    /// [`RECEIPT_VERSION`] — pinning it to the constant would re-canonicalize
1339    /// every persisted v3 receipt the day the constant changed, and none of
1340    /// them would verify again.
1341    #[must_use]
1342    const fn canonical_json_v3(&self) -> ReceiptCanonicalV3<'_> {
1343        ReceiptCanonicalV3 {
1344            version: 3,
1345            kind: self.kind,
1346            reference: self.reference,
1347            amount: self.amount,
1348            currency: self.currency,
1349            recipient: self.recipient,
1350            method: self.method,
1351            timestamp: self.timestamp,
1352            tool_call_id: self.tool_call_id,
1353            approval_pos: self.approval_pos,
1354            approved_args_hash: self.approved_args_hash,
1355            subject: self.subject,
1356            payer_kind: self.payer_kind,
1357            paying_account: self.paying_account,
1358        }
1359    }
1360
1361    /// Frozen **v2** canonical: the six settlement facts plus the event
1362    /// `kind`, the binding tuple, and the opaque `subject`.
1363    ///
1364    /// This is the SINGLE source for the signed v2 receipt field set and order:
1365    /// both the signing path ([`receipt_payload`]) and the verifying path
1366    /// ([`verify_signed_receipt`]) route their v2 canonical bytes through here,
1367    /// so the two paths cannot drift. The key set/order is the on-wire signed
1368    /// contract and must NOT change — a new covered field means a new version
1369    /// with its own builder beside this one.
1370    ///
1371    /// Frozen exactly like [`Self::canonical_json_v1`], and retained for the
1372    /// same reason: once [`RECEIPT_VERSION`] moves past 2, every receipt
1373    /// already signed under v2 still *verifies* through this builder. That is
1374    /// why the `version` key below is the literal `2` rather than
1375    /// [`RECEIPT_VERSION`] — pinning it to the constant would re-canonicalize
1376    /// every persisted v2 receipt the day the constant changed, and none of
1377    /// them would verify again.
1378    #[must_use]
1379    const fn canonical_json_v2(&self) -> ReceiptCanonicalV2<'_> {
1380        ReceiptCanonicalV2 {
1381            version: 2,
1382            kind: self.kind,
1383            reference: self.reference,
1384            amount: self.amount,
1385            currency: self.currency,
1386            recipient: self.recipient,
1387            method: self.method,
1388            timestamp: self.timestamp,
1389            tool_call_id: self.tool_call_id,
1390            approval_pos: self.approval_pos,
1391            approved_args_hash: self.approved_args_hash,
1392            subject: self.subject,
1393        }
1394    }
1395
1396    /// Legacy **v1** canonical (the original six settlement fields, no
1397    /// `version`). Retained only so receipts persisted before the v2 binding
1398    /// still *verify* for forensics; new receipts always sign v3.
1399    #[must_use]
1400    const fn canonical_json_v1(&self) -> ReceiptCanonicalV1<'_> {
1401        ReceiptCanonicalV1 {
1402            reference: self.reference,
1403            amount: self.amount,
1404            currency: self.currency,
1405            recipient: self.recipient,
1406            method: self.method,
1407            timestamp: self.timestamp,
1408        }
1409    }
1410}
1411
1412/// The frozen **v3** receipt field set, in its frozen order.
1413///
1414/// `version` is the literal `3`, never [`RECEIPT_VERSION`], for the reason
1415/// [`ReceiptPayload::canonical_json_v3`] gives: a receipt already in the log was
1416/// signed over these exact bytes and must keep verifying after the constant
1417/// moves on.
1418#[derive(Serialize)]
1419struct ReceiptCanonicalV3<'a> {
1420    version: u8,
1421    kind: &'a str,
1422    reference: &'a str,
1423    amount: &'a str,
1424    currency: &'a str,
1425    recipient: &'a str,
1426    method: &'a str,
1427    timestamp: &'a str,
1428    tool_call_id: &'a str,
1429    approval_pos: &'a str,
1430    approved_args_hash: &'a str,
1431    subject: &'a str,
1432    payer_kind: &'a str,
1433    paying_account: &'a str,
1434}
1435
1436/// The frozen **v2** receipt field set, in its frozen order.
1437///
1438/// `version` is the literal `2`, never [`RECEIPT_VERSION`], for the reason
1439/// [`ReceiptPayload::canonical_json_v2`] gives: a receipt already in the log was
1440/// signed over these exact bytes and must keep verifying after the constant
1441/// moves on.
1442#[derive(Serialize)]
1443struct ReceiptCanonicalV2<'a> {
1444    version: u8,
1445    kind: &'a str,
1446    reference: &'a str,
1447    amount: &'a str,
1448    currency: &'a str,
1449    recipient: &'a str,
1450    method: &'a str,
1451    timestamp: &'a str,
1452    tool_call_id: &'a str,
1453    approval_pos: &'a str,
1454    approved_args_hash: &'a str,
1455    subject: &'a str,
1456}
1457
1458/// The frozen **v1** receipt field set (the six settlement facts, no
1459/// `version`), in its frozen order.
1460#[derive(Serialize)]
1461struct ReceiptCanonicalV1<'a> {
1462    reference: &'a str,
1463    amount: &'a str,
1464    currency: &'a str,
1465    recipient: &'a str,
1466    method: &'a str,
1467    timestamp: &'a str,
1468}
1469
1470/// JSON payload for a `payment_receipt` event.
1471///
1472/// Mirrors [`response_payload`] exactly: the signature commits to the
1473/// canonical (unsigned) JSON form of the receipt body
1474/// (`reference`, `amount`, `currency`, `recipient`, `method`, `timestamp`).
1475/// `signed_by` and `signature_hex` are populated *after* the signer runs and
1476/// are NOT covered by the signature itself. Tampering with any body field
1477/// invalidates the signature.
1478///
1479/// The fields arrive as a single named [`ReceiptPayload`] (rather than six
1480/// positional strings) so a call site cannot silently swap two same-typed
1481/// fields; the serialized key set/order is unchanged and remains the signed
1482/// contract.
1483///
1484/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
1485#[must_use]
1486pub fn receipt_payload(
1487    fields: &ReceiptPayload<'_>,
1488    signer: &ApprovalSigner,
1489) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1490    // New receipts always sign the current version's frozen canonical, named
1491    // here rather than looked up, so a `RECEIPT_VERSION` bump has to repoint
1492    // this line deliberately (the tripwire beside the constant enforces it).
1493    //
1494    // The full payload is the canonical body plus the two signature fields,
1495    // which are NOT covered by the signature. `Envelope` flattens the same
1496    // single-source canonical struct, so the body field set still lives only in
1497    // `ReceiptPayload::canonical_json_v3`.
1498    Envelope::seal(fields.canonical_json_v3(), signer.as_signer())
1499}
1500
1501/// A decoded `payment_receipt` payload after signature verification.
1502#[derive(Debug, Clone)]
1503pub struct VerifiedReceipt {
1504    /// Chain/transaction reference (e.g. tx id) the receipt settles.
1505    pub reference: String,
1506    /// Settled amount as a string, never a float (no drift, no rounding).
1507    ///
1508    /// Carries whichever unit its direction stores — a decimal figure in the
1509    /// settlement currency for an inbound `payment_receipt`, the settlement
1510    /// token's base units for an outbound `outbound_payment_receipt` (empty
1511    /// when the proxy could not capture the charge). Every reader of this
1512    /// field dispatches on the direction it read the receipt under and uses
1513    /// the matching `polyc_payments::amount` reader; a bare `parse` here is
1514    /// the #1739 bug class, which silently dropped every inbound charge.
1515    pub amount: String,
1516    /// Currency / asset label — the settlement currency symbol inbound, the
1517    /// settlement token's contract address outbound.
1518    pub currency: String,
1519    /// Recipient address.
1520    pub recipient: String,
1521    /// Settlement method (e.g. `tempo`).
1522    pub method: String,
1523    /// RFC3339 settlement timestamp.
1524    pub timestamp: String,
1525    /// Schema version (`1` = legacy settlement-only, `2` = kind + binding
1526    /// tuple present).
1527    pub version: u64,
1528    /// The signed event kind (`payment_receipt` or `outbound_payment_receipt`).
1529    /// Callers should check it matches the kind the event was stored under —
1530    /// the stored kind itself is not signed (v2; empty for v1).
1531    pub kind: String,
1532    /// The `paid_fetch` tool-call id this payment answered (v2; empty for v1).
1533    pub tool_call_id: String,
1534    /// Decimal string of the `approval_request` log position (v2; empty for v1).
1535    pub approval_pos: String,
1536    /// sha256 hex of the approved `args_json` (v2; empty for v1).
1537    pub approved_args_hash: String,
1538    /// Opaque principal the spend is attributed to (v2; empty for v1).
1539    pub subject: String,
1540    /// Which account actually paid: `"linked_wallet"` or `"deployment"`,
1541    /// explicit unknown (empty) when this build cannot say so — never
1542    /// inferred (v3; empty for v1/v2).
1543    pub payer_kind: String,
1544    /// The paying account's own address, when known (v3; empty for v1/v2, for
1545    /// a deployment-signer payer, or when unobservable).
1546    pub paying_account: String,
1547    /// The verified signer's public key (encoded).
1548    pub signer_public_key: Vec<u8>,
1549}
1550
1551/// A receipt schema version this build has frozen a canonical for.
1552///
1553/// A version's canonical freezes the instant the first receipt is signed under
1554/// it: those bytes are already in an append-only log and no later edit can
1555/// reach them. So this enum only ever *gains* variants — bumping
1556/// [`RECEIPT_VERSION`] adds one and never replaces one, and nothing on the read
1557/// path consults the current version to decide what a persisted receipt means.
1558///
1559/// An enum rather than a table of function pointers keyed on `u64` because
1560/// every question a version has to answer is answered by an exhaustive
1561/// `match self`: [`Self::number`], [`Self::covers_binding`], and
1562/// [`Self::canonical`] each fail to compile until a new variant says what
1563/// number it writes, which fields its canonical covers, and how it builds
1564/// them. A variant no [`Self::resolve`] arm can produce is dead code. The
1565/// closed variant set is also what makes the fail-closed rule structural: there
1566/// is no `V1` an explicit `version: 1` can resolve to (see [`Self::resolve`]),
1567/// so no future edit to a numeric guard can hand a claimed version to the v1
1568/// canonical.
1569#[derive(Debug, Clone, Copy)]
1570enum ReceiptSchema {
1571    /// Legacy: the six settlement facts, signed with no `version` key at all.
1572    V1,
1573    /// Adds the event `kind`, the binding tuple, and the opaque `subject`, and
1574    /// covers its own `version` key.
1575    V2,
1576    /// Adds payer attribution (`payer_kind`, `paying_account`) on top of V2.
1577    V3,
1578}
1579
1580impl ReceiptSchema {
1581    /// The frozen schema a persisted receipt's `version` field names, or `None`
1582    /// when this build has frozen no canonical for it.
1583    ///
1584    /// A legacy receipt carries no `version` key; absence — and ONLY absence —
1585    /// means [`Self::V1`]. A *present* key must name a version whose canonical
1586    /// actually covers that key, which is why an explicit `1` resolves to
1587    /// nothing: the v1 canonical does not cover `version`, so admitting one
1588    /// would let a valid v1 signature verify while the output echoed an
1589    /// unsigned, writer-chosen version. There is no arm to relax here — a
1590    /// claimed `1` is unreachable by construction, not by a numeric guard.
1591    ///
1592    /// Keyed on literal version numbers, never on [`RECEIPT_VERSION`]: a
1593    /// receipt signed under any frozen version keeps verifying after the
1594    /// current version moves on.
1595    #[must_use]
1596    fn resolve(claimed: Option<&Value>) -> Option<Self> {
1597        let Some(claimed) = claimed else {
1598            return Some(Self::V1);
1599        };
1600        // Every non-integer (a string, a float, `null`, a negative number, an
1601        // array, an object) and every version this build has not frozen is
1602        // refused outright — never verified against a guessed canonical.
1603        match claimed.as_u64() {
1604            Some(2) => Some(Self::V2),
1605            Some(3) => Some(Self::V3),
1606            _ => None,
1607        }
1608    }
1609
1610    /// The version number a receipt signed under this schema reports.
1611    #[must_use]
1612    const fn number(self) -> u64 {
1613        match self {
1614            Self::V1 => 1,
1615            Self::V2 => 2,
1616            Self::V3 => 3,
1617        }
1618    }
1619
1620    /// Whether this schema's canonical covers the event `kind`, the binding
1621    /// tuple, and the `subject`.
1622    ///
1623    /// Field extraction is per-version for the same reason the canonical is:
1624    /// reading a field the signature does not cover would echo an unsigned
1625    /// value out of [`verify_signed_receipt`]. A schema that covers a
1626    /// *different* subset than v2 does not belong behind this boolean — give it
1627    /// its own accessor and its own extraction branch (see
1628    /// [`Self::covers_payer`] for v3's addition).
1629    #[must_use]
1630    const fn covers_binding(self) -> bool {
1631        match self {
1632            Self::V1 => false,
1633            Self::V2 | Self::V3 => true,
1634        }
1635    }
1636
1637    /// Whether this schema's canonical covers payer attribution (`payer_kind`,
1638    /// `paying_account`).
1639    ///
1640    /// A separate accessor from [`Self::covers_binding`] rather than folded
1641    /// into it: v3 covers a strictly larger field set than v2, but the two
1642    /// booleans answer different questions, and a future schema could cover
1643    /// binding without payer (or vice versa) — collapsing them into one flag
1644    /// would stop being able to say which.
1645    #[must_use]
1646    const fn covers_payer(self) -> bool {
1647        match self {
1648            Self::V1 | Self::V2 => false,
1649            Self::V3 => true,
1650        }
1651    }
1652
1653    /// The frozen canonical (unsigned) JSON bytes a receipt under this schema
1654    /// is signed over and verified against.
1655    #[must_use]
1656    fn canonical(self, fields: &ReceiptPayload<'_>) -> Vec<u8> {
1657        match self {
1658            Self::V1 => canonical_bytes(&fields.canonical_json_v1()),
1659            Self::V2 => canonical_bytes(&fields.canonical_json_v2()),
1660            Self::V3 => canonical_bytes(&fields.canonical_json_v3()),
1661        }
1662    }
1663}
1664
1665/// Verify a persisted `payment_receipt`/`outbound_payment_receipt` payload
1666/// against a **trusted-signer allow-list**.
1667///
1668/// Returns `Some(record)` only if the signature checks out against the
1669/// embedded public key AND that key is a member of `trusted_signers`. An
1670/// internally-consistent signature over an *unknown* key proves the payload
1671/// was not tampered with after signing — it proves nothing about whether the
1672/// signer should be trusted; anyone can mint a fresh keypair, embed its own
1673/// public key, and sign an arbitrary settlement, so a caller MUST supply the
1674/// deployment's own set of trusted signers here (typically the deployment's
1675/// [`ApprovalSigner::public_key_bytes`], since receipts are self-signed by
1676/// this same control plane) rather than treating "verifies" as "trustworthy".
1677///
1678/// Every receipt is checked against the frozen canonical of the version it was
1679/// signed under, never against the current [`RECEIPT_VERSION`]: a v1 receipt
1680/// (no `version` key) and a v2 one both keep verifying, whatever the constant
1681/// reads today. A claimed version this build has frozen no canonical for —
1682/// including an explicit `1`, which the v1 canonical does not cover — is
1683/// refused outright rather than checked against a guessed canonical.
1684///
1685/// Returns `None` if the payload is malformed, the hex fields don't decode,
1686/// the embedded key is not in `trusted_signers`, the claimed version resolves
1687/// to no frozen canonical, or the signature doesn't verify.
1688#[must_use]
1689pub fn verify_signed_receipt(
1690    payload: &[u8],
1691    trusted_signers: &[Vec<u8>],
1692) -> Option<VerifiedReceipt> {
1693    let v: Value = serde_json::from_slice(payload).ok()?;
1694    let reference = v.get("reference")?.as_str()?.to_owned();
1695    let amount = v.get("amount")?.as_str()?.to_owned();
1696    let currency = v.get("currency")?.as_str()?.to_owned();
1697    let recipient = v.get("recipient")?.as_str()?.to_owned();
1698    let method = v.get("method")?.as_str()?.to_owned();
1699    let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1700    let signed_by_hex = v.get("signed_by")?.as_str()?;
1701    let signature_hex = v.get("signature_hex")?.as_str()?;
1702    let pk = crate::hex::decode(signed_by_hex)?;
1703    let sig = crate::hex::decode(signature_hex)?;
1704    // Reject an unknown signer before doing any further work (including the
1705    // signature check below): a key that is not on the allow-list is not
1706    // trusted no matter how internally consistent its signature is — anyone
1707    // can mint a keypair and self-sign an arbitrary receipt.
1708    if !signer_is_trusted(&pk, trusted_signers) {
1709        return None;
1710    }
1711    // Resolve the claimed version to a schema this build has frozen — before
1712    // any versioned field is read, and never against `RECEIPT_VERSION`. A
1713    // version with no frozen canonical is refused outright rather than verified
1714    // against a guessed one; `ReceiptSchema::resolve` documents which claims
1715    // resolve and why an explicit `1` is not one of them.
1716    let schema = ReceiptSchema::resolve(v.get("version"))?;
1717    let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = if schema.covers_binding()
1718    {
1719        (
1720            v.get("kind")?.as_str()?.to_owned(),
1721            v.get("tool_call_id")?.as_str()?.to_owned(),
1722            v.get("approval_pos")?.as_str()?.to_owned(),
1723            v.get("approved_args_hash")?.as_str()?.to_owned(),
1724            v.get("subject")?.as_str()?.to_owned(),
1725        )
1726    } else {
1727        // v1 signed the six settlement facts and nothing else, so it reports
1728        // nothing else: an uncovered field read off the payload would leave
1729        // `verify_signed_receipt` echoing a value no signature vouches for.
1730        (
1731            String::new(),
1732            String::new(),
1733            String::new(),
1734            String::new(),
1735            String::new(),
1736        )
1737    };
1738    let (payer_kind, paying_account) = if schema.covers_payer() {
1739        (
1740            v.get("payer_kind")?.as_str()?.to_owned(),
1741            v.get("paying_account")?.as_str()?.to_owned(),
1742        )
1743    } else {
1744        // v1/v2 signed no payer attribution at all, so a v1/v2 receipt reports
1745        // payer as explicit unknown — never inferred from other fields — for
1746        // the same reason the binding tuple falls back above.
1747        (String::new(), String::new())
1748    };
1749    // Rebuild the canonical bytes via the SAME single source the signer used,
1750    // so the verify path can never check a different field set/order.
1751    let fields = ReceiptPayload {
1752        kind: &kind,
1753        reference: &reference,
1754        amount: &amount,
1755        currency: &currency,
1756        recipient: &recipient,
1757        method: &method,
1758        timestamp: &timestamp,
1759        tool_call_id: &tool_call_id,
1760        approval_pos: &approval_pos,
1761        approved_args_hash: &approved_args_hash,
1762        subject: &subject,
1763        payer_kind: &payer_kind,
1764        paying_account: &paying_account,
1765    };
1766    let canonical = schema.canonical(&fields);
1767    if verify(&pk, &canonical, &sig) {
1768        Some(VerifiedReceipt {
1769            reference,
1770            amount,
1771            currency,
1772            recipient,
1773            method,
1774            timestamp,
1775            version: schema.number(),
1776            kind,
1777            tool_call_id,
1778            approval_pos,
1779            approved_args_hash,
1780            subject,
1781            payer_kind,
1782            paying_account,
1783            signer_public_key: pk,
1784        })
1785    } else {
1786        None
1787    }
1788}
1789
1790/// The `payment_refusal` field set [`refusal_payload`] signs, named at the call site.
1791///
1792/// Mirrors [`ReceiptPayload`]'s reasoning (`#2090`, INV-W5): a positional
1793/// argument list of same-typed `&str`s invites a silent field swap at the
1794/// call site, so every field is named here instead.
1795///
1796/// Unlike a receipt, a refusal has exactly one schema version — this event
1797/// kind ships new, with no prior persisted history to keep verifying, so
1798/// there is no v1/v2 split to carry.
1799#[derive(Debug, Clone, Copy)]
1800pub struct RefusalPayload<'a> {
1801    /// The event kind the payload is stored under (always
1802    /// `polyc_proto::kinds::PAYMENT_REFUSAL`). Signed so a payload cannot be
1803    /// re-filed under a different kind, mirroring [`ReceiptPayload::kind`].
1804    pub kind: &'a str,
1805    /// The stable, machine-readable reason tag (e.g. `"over_spend_cap"`) —
1806    /// one of the tags [`crate`]'s callers mint via an exhaustive match over
1807    /// `polyc_payments::proxy::RejectReason`, or `"unknown"` for a reason
1808    /// this build has no tag for. Never the free-text `Display` rendering.
1809    pub reason: &'a str,
1810    /// A non-secret diagnostic detail for the reason — the wrapped error's
1811    /// own `Display` text (e.g. the SSRF-guard host, the mandate failure).
1812    /// Never signer-key or credential material.
1813    pub reason_detail: &'a str,
1814    /// The destination host the fetch would have paid, when the reject site
1815    /// had a host to name (empty otherwise).
1816    pub merchant_host: &'a str,
1817    /// The base-unit amount the call requested, as a decimal string, for a
1818    /// reason that carries one (`over_spend_cap`, `over_budget`); empty for
1819    /// every other reason.
1820    pub requested_base_units: &'a str,
1821    /// The base-unit amount the cap/budget actually permitted, as a decimal
1822    /// string, for a reason that carries one; empty for every other reason.
1823    pub permitted_base_units: &'a str,
1824    /// The `paid_fetch` tool-call id the refused attempt answered.
1825    pub tool_call_id: &'a str,
1826    /// Opaque principal the refused attempt is attributed to — the same
1827    /// status [`ReceiptPayload::subject`] carries.
1828    pub subject: &'a str,
1829    /// Decimal string of the unix-seconds clock value at the moment the
1830    /// reject site recorded the refusal — the `payments` sibling field to
1831    /// [`ReceiptPayload::timestamp`], but unix-seconds rather than RFC3339:
1832    /// the recording seam already carries the turn's dispatch-time
1833    /// `now_unix` (no fresh wall-clock read needed), so this reuses that
1834    /// value verbatim rather than reformatting it. Without this, a blocked
1835    /// row has no time field at all and cannot be placed on a ledger
1836    /// alongside settled receipts.
1837    pub timestamp: &'a str,
1838}
1839
1840impl RefusalPayload<'_> {
1841    /// The frozen `payment_refusal` canonical: every field, in this struct's
1842    /// declaration order — the single source both [`refusal_payload`] (the
1843    /// signing path) and [`verify_signed_refusal`] (the verifying path)
1844    /// route their canonical bytes through, so the two paths cannot drift.
1845    #[must_use]
1846    const fn canonical(&self) -> RefusalCanonical<'_> {
1847        RefusalCanonical {
1848            kind: self.kind,
1849            reason: self.reason,
1850            reason_detail: self.reason_detail,
1851            merchant_host: self.merchant_host,
1852            requested_base_units: self.requested_base_units,
1853            permitted_base_units: self.permitted_base_units,
1854            tool_call_id: self.tool_call_id,
1855            subject: self.subject,
1856            timestamp: self.timestamp,
1857        }
1858    }
1859}
1860
1861/// The frozen `payment_refusal` field set, in its frozen order — see
1862/// [`RefusalPayload::canonical`].
1863#[derive(Serialize)]
1864struct RefusalCanonical<'a> {
1865    kind: &'a str,
1866    reason: &'a str,
1867    reason_detail: &'a str,
1868    merchant_host: &'a str,
1869    requested_base_units: &'a str,
1870    permitted_base_units: &'a str,
1871    tool_call_id: &'a str,
1872    subject: &'a str,
1873    timestamp: &'a str,
1874}
1875
1876/// JSON payload for a `payment_refusal` event (`#2090`, INV-W5).
1877///
1878/// Mirrors [`receipt_payload`]: the signature commits to the canonical
1879/// (unsigned) JSON form of `RefusalPayload::canonical`; `signed_by` and
1880/// `signature_hex` are appended after signing and are not covered by the
1881/// signature. Tampering with any field invalidates the signature.
1882///
1883/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
1884#[must_use]
1885pub fn refusal_payload(
1886    fields: &RefusalPayload<'_>,
1887    signer: &ApprovalSigner,
1888) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1889    Envelope::seal(fields.canonical(), signer.as_signer())
1890}
1891
1892/// A decoded `payment_refusal` payload after signature verification.
1893#[derive(Debug, Clone)]
1894pub struct VerifiedRefusal {
1895    /// The signed event kind (always `payment_refusal`). Callers should
1896    /// check it matches the kind the event was stored under, mirroring
1897    /// [`VerifiedReceipt::kind`].
1898    pub kind: String,
1899    /// The stable, machine-readable reason tag.
1900    pub reason: String,
1901    /// Non-secret diagnostic detail for the reason.
1902    pub reason_detail: String,
1903    /// The destination host the fetch would have paid, or empty.
1904    pub merchant_host: String,
1905    /// Requested base-unit amount as a decimal string, or empty.
1906    pub requested_base_units: String,
1907    /// Permitted base-unit amount as a decimal string, or empty.
1908    pub permitted_base_units: String,
1909    /// The `paid_fetch` tool-call id the refused attempt answered.
1910    pub tool_call_id: String,
1911    /// Opaque principal the refused attempt is attributed to.
1912    pub subject: String,
1913    /// Decimal string of the unix-seconds clock value at the moment the
1914    /// reject site recorded the refusal.
1915    pub timestamp: String,
1916    /// The verified signer's public key (encoded).
1917    pub signer_public_key: Vec<u8>,
1918}
1919
1920/// Verify a persisted `payment_refusal` payload against a **trusted-signer allow-list**.
1921///
1922/// The exact same trust posture [`verify_signed_receipt`] documents (an
1923/// internally-consistent signature over an unknown key proves no tampering,
1924/// and nothing about trust, so `trusted_signers` must be supplied).
1925///
1926/// Returns `None` if the payload is malformed, a field is missing, the hex
1927/// fields don't decode, the embedded key is not in `trusted_signers`, or the
1928/// signature doesn't verify. Never panics — a structurally malformed payload
1929/// (INV-W5b) is exactly the `None` case, not a decode error a caller must
1930/// handle separately.
1931#[must_use]
1932pub fn verify_signed_refusal(
1933    payload: &[u8],
1934    trusted_signers: &[Vec<u8>],
1935) -> Option<VerifiedRefusal> {
1936    let v: Value = serde_json::from_slice(payload).ok()?;
1937    let kind = v.get("kind")?.as_str()?.to_owned();
1938    let reason = v.get("reason")?.as_str()?.to_owned();
1939    let reason_detail = v.get("reason_detail")?.as_str()?.to_owned();
1940    let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
1941    let requested_base_units = v.get("requested_base_units")?.as_str()?.to_owned();
1942    let permitted_base_units = v.get("permitted_base_units")?.as_str()?.to_owned();
1943    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
1944    let subject = v.get("subject")?.as_str()?.to_owned();
1945    let timestamp = v.get("timestamp")?.as_str()?.to_owned();
1946    let signed_by_hex = v.get("signed_by")?.as_str()?;
1947    let signature_hex = v.get("signature_hex")?.as_str()?;
1948    let pk = crate::hex::decode(signed_by_hex)?;
1949    let sig = crate::hex::decode(signature_hex)?;
1950    if !signer_is_trusted(&pk, trusted_signers) {
1951        return None;
1952    }
1953    let fields = RefusalPayload {
1954        kind: &kind,
1955        reason: &reason,
1956        reason_detail: &reason_detail,
1957        merchant_host: &merchant_host,
1958        requested_base_units: &requested_base_units,
1959        permitted_base_units: &permitted_base_units,
1960        tool_call_id: &tool_call_id,
1961        subject: &subject,
1962        timestamp: &timestamp,
1963    };
1964    let canonical = canonical_bytes(&fields.canonical());
1965    if verify(&pk, &canonical, &sig) {
1966        Some(VerifiedRefusal {
1967            kind,
1968            reason,
1969            reason_detail,
1970            merchant_host,
1971            requested_base_units,
1972            permitted_base_units,
1973            tool_call_id,
1974            subject,
1975            timestamp,
1976            signer_public_key: pk,
1977        })
1978    } else {
1979        None
1980    }
1981}
1982
1983/// The `wallet_link_lifecycle` field set [`wallet_link_lifecycle_payload`]
1984/// signs, named at the call site (`#2123`).
1985///
1986/// Mirrors [`RefusalPayload`]'s reasoning: a positional argument list of
1987/// same-typed `&str`s invites a silent field swap, so every field is named
1988/// here instead. One schema version, like [`RefusalPayload`] — this event
1989/// kind ships new, with no prior persisted history to keep verifying.
1990#[derive(Debug, Clone, Copy)]
1991pub struct WalletLinkLifecyclePayload<'a> {
1992    /// The event kind the payload is stored under (always
1993    /// `polyc_proto::kinds::WALLET_LINK_LIFECYCLE`).
1994    pub kind: &'a str,
1995    /// Which ceremony transition this event records: `"linked"`,
1996    /// `"renewed"` (limit/expiry updated by `complete_update`), or
1997    /// `"revoked"` — each minted by its own success-arm call site in
1998    /// `crates/control-plane/src/wallet_link.rs`'s `complete`/
1999    /// `complete_revoke`/`complete_update`, never a caller-chosen string.
2000    pub transition: &'a str,
2001    /// Opaque principal the ceremony is attributed to — the same status
2002    /// [`RefusalPayload::subject`] carries.
2003    pub subject: &'a str,
2004    /// The linked wallet's on-chain address.
2005    pub wallet_address: &'a str,
2006    /// Settlement-token contract (hex) the ceremony's spend cap applies to.
2007    pub currency: &'a str,
2008    /// Decimal string of the chain id the ceremony ran on.
2009    pub chain_id: &'a str,
2010    /// The spend cap in the settlement token's base units, as a decimal
2011    /// string; empty for `"revoked"` (no cap to report on a dead link).
2012    pub limit_base_units: &'a str,
2013    /// Human-readable spend cap; empty for `"revoked"`.
2014    pub limit_human: &'a str,
2015    /// Decimal string of the spend cap's reset period in seconds; empty for
2016    /// `"renewed"` (TIP-1011's `updateSpendingLimit` cannot change the
2017    /// period — see `crate::wallet_link::PERIOD_UNCHANGED_NOTICE` in the
2018    /// control plane) and for `"revoked"`.
2019    pub period_secs: &'a str,
2020    /// Decimal string of the unix-seconds authorization expiry; empty for
2021    /// `"renewed"` (the expiry is unchanged, not re-attested — TIP-1011's
2022    /// `updateSpendingLimit` never touches it) and for `"revoked"` (no
2023    /// expiry survives a dead link).
2024    pub expiry_unix: &'a str,
2025    /// Comma-joined recipient allowlist (TIP-1011); empty means any
2026    /// recipient.
2027    pub recipients: &'a str,
2028    /// The conversation the ceremony was minted and redeemed in — also the
2029    /// journal partition (`conv-{conversation_id}`) this event is appended
2030    /// to.
2031    pub conversation_id: &'a str,
2032    /// Decimal string of the unix-**seconds** clock value at the moment the
2033    /// ceremony handler recorded the transition — the
2034    /// `payment_refusal.timestamp` sibling, divided from a millisecond clock
2035    /// exactly once at the recording seam.
2036    pub timestamp: &'a str,
2037}
2038
2039impl WalletLinkLifecyclePayload<'_> {
2040    /// The frozen `wallet_link_lifecycle` canonical: every field, in this
2041    /// struct's declaration order — the single source both
2042    /// [`wallet_link_lifecycle_payload`] (the signing path) and
2043    /// [`verify_signed_wallet_link_lifecycle`] (the verifying path) route
2044    /// their canonical bytes through, so the two paths cannot drift.
2045    #[must_use]
2046    const fn canonical(&self) -> WalletLinkLifecycleCanonical<'_> {
2047        WalletLinkLifecycleCanonical {
2048            kind: self.kind,
2049            transition: self.transition,
2050            subject: self.subject,
2051            wallet_address: self.wallet_address,
2052            currency: self.currency,
2053            chain_id: self.chain_id,
2054            limit_base_units: self.limit_base_units,
2055            limit_human: self.limit_human,
2056            period_secs: self.period_secs,
2057            expiry_unix: self.expiry_unix,
2058            recipients: self.recipients,
2059            conversation_id: self.conversation_id,
2060            timestamp: self.timestamp,
2061        }
2062    }
2063}
2064
2065/// The frozen `wallet_link_lifecycle` field set, in its frozen order — see
2066/// [`WalletLinkLifecyclePayload::canonical`].
2067#[derive(Serialize)]
2068struct WalletLinkLifecycleCanonical<'a> {
2069    kind: &'a str,
2070    transition: &'a str,
2071    subject: &'a str,
2072    wallet_address: &'a str,
2073    currency: &'a str,
2074    chain_id: &'a str,
2075    limit_base_units: &'a str,
2076    limit_human: &'a str,
2077    period_secs: &'a str,
2078    expiry_unix: &'a str,
2079    recipients: &'a str,
2080    conversation_id: &'a str,
2081    timestamp: &'a str,
2082}
2083
2084/// JSON payload for a `wallet_link_lifecycle` event (`#2123`).
2085///
2086/// Mirrors [`refusal_payload`]: the signature commits to the canonical
2087/// (unsigned) JSON form of `WalletLinkLifecyclePayload::canonical`;
2088/// `signed_by` and `signature_hex` are appended after signing and are not
2089/// covered by the signature. Tampering with any field invalidates the
2090/// signature.
2091///
2092/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
2093#[must_use]
2094pub fn wallet_link_lifecycle_payload(
2095    fields: &WalletLinkLifecyclePayload<'_>,
2096    signer: &ApprovalSigner,
2097) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2098    Envelope::seal(fields.canonical(), signer.as_signer())
2099}
2100
2101/// A decoded `wallet_link_lifecycle` payload after signature verification.
2102#[derive(Debug, Clone)]
2103pub struct VerifiedWalletLinkLifecycle {
2104    /// The signed event kind (always `wallet_link_lifecycle`).
2105    pub kind: String,
2106    /// Which transition this event records: `"linked"`, `"renewed"`, or
2107    /// `"revoked"` — carried through verbatim, with no known-tag allowlist
2108    /// gating verification (mirrors INV-W5b: an unrecognized transition tag
2109    /// still verifies; classifying it is a read-side projection concern).
2110    pub transition: String,
2111    /// Opaque principal the ceremony is attributed to.
2112    pub subject: String,
2113    /// The linked wallet's on-chain address.
2114    pub wallet_address: String,
2115    /// Settlement-token contract (hex).
2116    pub currency: String,
2117    /// Decimal string of the chain id.
2118    pub chain_id: String,
2119    /// Decimal string of the spend cap in base units, or empty.
2120    pub limit_base_units: String,
2121    /// Human-readable spend cap, or empty.
2122    pub limit_human: String,
2123    /// Decimal string of the reset period in seconds, or empty.
2124    pub period_secs: String,
2125    /// Decimal string of the unix-seconds authorization expiry, or empty.
2126    pub expiry_unix: String,
2127    /// Comma-joined recipient allowlist, or empty (any recipient).
2128    pub recipients: String,
2129    /// The minting conversation id.
2130    pub conversation_id: String,
2131    /// Decimal string of the unix-seconds clock value the ceremony handler
2132    /// recorded the transition at.
2133    pub timestamp: String,
2134    /// The verified signer's public key (encoded).
2135    pub signer_public_key: Vec<u8>,
2136}
2137
2138/// Verify a persisted `wallet_link_lifecycle` payload against a
2139/// **trusted-signer allow-list** (`#2123`).
2140///
2141/// The exact same trust posture [`verify_signed_refusal`] documents: an
2142/// internally-consistent signature over an unknown key proves no tampering,
2143/// and nothing about trust, so `trusted_signers` must be supplied.
2144///
2145/// Returns `None` if the payload is malformed, a field is missing, the hex
2146/// fields don't decode, the embedded key is not in `trusted_signers`, or the
2147/// signature doesn't verify. Never panics.
2148#[must_use]
2149pub fn verify_signed_wallet_link_lifecycle(
2150    payload: &[u8],
2151    trusted_signers: &[Vec<u8>],
2152) -> Option<VerifiedWalletLinkLifecycle> {
2153    let v: Value = serde_json::from_slice(payload).ok()?;
2154    let kind = v.get("kind")?.as_str()?.to_owned();
2155    let transition = v.get("transition")?.as_str()?.to_owned();
2156    let subject = v.get("subject")?.as_str()?.to_owned();
2157    let wallet_address = v.get("wallet_address")?.as_str()?.to_owned();
2158    let currency = v.get("currency")?.as_str()?.to_owned();
2159    let chain_id = v.get("chain_id")?.as_str()?.to_owned();
2160    let limit_base_units = v.get("limit_base_units")?.as_str()?.to_owned();
2161    let limit_human = v.get("limit_human")?.as_str()?.to_owned();
2162    let period_secs = v.get("period_secs")?.as_str()?.to_owned();
2163    let expiry_unix = v.get("expiry_unix")?.as_str()?.to_owned();
2164    let recipients = v.get("recipients")?.as_str()?.to_owned();
2165    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2166    let timestamp = v.get("timestamp")?.as_str()?.to_owned();
2167    let signed_by_hex = v.get("signed_by")?.as_str()?;
2168    let signature_hex = v.get("signature_hex")?.as_str()?;
2169    let pk = crate::hex::decode(signed_by_hex)?;
2170    let sig = crate::hex::decode(signature_hex)?;
2171    if !signer_is_trusted(&pk, trusted_signers) {
2172        return None;
2173    }
2174    let fields = WalletLinkLifecyclePayload {
2175        kind: &kind,
2176        transition: &transition,
2177        subject: &subject,
2178        wallet_address: &wallet_address,
2179        currency: &currency,
2180        chain_id: &chain_id,
2181        limit_base_units: &limit_base_units,
2182        limit_human: &limit_human,
2183        period_secs: &period_secs,
2184        expiry_unix: &expiry_unix,
2185        recipients: &recipients,
2186        conversation_id: &conversation_id,
2187        timestamp: &timestamp,
2188    };
2189    let canonical = canonical_bytes(&fields.canonical());
2190    if verify(&pk, &canonical, &sig) {
2191        Some(VerifiedWalletLinkLifecycle {
2192            kind,
2193            transition,
2194            subject,
2195            wallet_address,
2196            currency,
2197            chain_id,
2198            limit_base_units,
2199            limit_human,
2200            period_secs,
2201            expiry_unix,
2202            recipients,
2203            conversation_id,
2204            timestamp,
2205            signer_public_key: pk,
2206        })
2207    } else {
2208        None
2209    }
2210}
2211
2212/// TTL for a minted `resolve_token` (`#787`).
2213///
2214/// Generous enough that a human has time to see and act on the approval card
2215/// (which can sit in a Slack/Telegram thread for hours), short enough that a
2216/// token captured off a stale card cannot resolve the request indefinitely.
2217/// Independent of the underlying approval's own lifetime — a request that
2218/// outlives the TTL simply needs the control plane to re-mint (a fresh
2219/// `ListPending` call re-renders the card with a fresh token).
2220pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
2221
2222/// The canonical (signature-covered) form of a `resolve_token` (`#787`).
2223///
2224/// Binds the token to the exact `(request_id, conversation_id)` pair it was
2225/// minted for and the time it was minted, so it cannot be replayed against a
2226/// different request, a different conversation, or presented once its TTL has
2227/// elapsed.
2228fn resolve_token_canonical(request_id: &str, conversation_id: &str, minted_at_ms: u64) -> Vec<u8> {
2229    canonical_bytes(&ResolveTokenCanonical {
2230        request_id,
2231        conversation_id,
2232        minted_at_ms,
2233    })
2234}
2235
2236/// The signed `resolve_token` field set, in its frozen order.
2237#[derive(Serialize)]
2238struct ResolveTokenCanonical<'a> {
2239    request_id: &'a str,
2240    conversation_id: &'a str,
2241    minted_at_ms: u64,
2242}
2243
2244/// A minted `resolve_token`: the canonical body plus its signature.
2245///
2246/// The only signed payload in this module with no `signed_by`: the token is
2247/// verified against the control plane's own key, supplied by the caller of
2248/// [`verify_resolve_token`], never one carried on the token itself.
2249#[derive(Serialize)]
2250struct ResolveToken<'a> {
2251    #[serde(flatten)]
2252    body: ResolveTokenCanonical<'a>,
2253    signature_hex: String,
2254}
2255
2256/// Mint a short-lived, signed `resolve_token` (`#787`) scoped to one
2257/// `(request_id, conversation_id)` pair.
2258///
2259/// This is the capability `ApprovalService.Respond` requires alongside the
2260/// plain `request_id`/`conversation_id` it already checks: minted by the
2261/// control plane at the moment it hands a pending approval to an edge for
2262/// rendering (`AgentEnd.pending_approvals`, `ListPendingReply.pending`), it is
2263/// opaque to — and unforgeable by — anything downstream of that mint point,
2264/// including a compromised harness pod (the harness never holds the signing
2265/// key and never sees the minted token; it only proposes the tool call the
2266/// token later authorizes resolving). `minted_at_ms` is the caller's wall
2267/// clock at mint time; pass a real timestamp in production, an injected one in
2268/// tests.
2269///
2270/// Returns the token as a lowercase-hex opaque string, safe to carry on any
2271/// wire surface (a button value, a proto field) alongside `request_id`.
2272#[must_use]
2273pub fn mint_resolve_token(
2274    request_id: &str,
2275    conversation_id: &str,
2276    minted_at_ms: u64,
2277    signer: &ApprovalSigner,
2278) -> String {
2279    let canonical = resolve_token_canonical(request_id, conversation_id, minted_at_ms);
2280    let signature = signer.sign(&canonical);
2281    let full = ResolveToken {
2282        body: ResolveTokenCanonical {
2283            request_id,
2284            conversation_id,
2285            minted_at_ms,
2286        },
2287        signature_hex: crate::hex::lower(&signature),
2288    };
2289    crate::hex::lower(&canonical_bytes(&full))
2290}
2291
2292/// Verify a `resolve_token` minted by [`mint_resolve_token`] against the
2293/// `(request_id, conversation_id)` a `Respond` call presents it for.
2294///
2295/// Returns `true` only when ALL hold: the token decodes, its embedded
2296/// signature verifies against `signer`'s public key, its bound `request_id`
2297/// and `conversation_id` equal the ones supplied, and `now_ms - minted_at_ms`
2298/// is within [`RESOLVE_TOKEN_TTL_MS`] (a token minted in the future, by clock
2299/// skew beyond the TTL, also fails — fail closed rather than trust an
2300/// out-of-bounds clock). Fails closed on any malformed field.
2301#[must_use]
2302pub fn verify_resolve_token(
2303    token: &str,
2304    request_id: &str,
2305    conversation_id: &str,
2306    now_ms: u64,
2307    signer: &ApprovalSigner,
2308) -> bool {
2309    let Some(bytes) = crate::hex::decode(token) else {
2310        return false;
2311    };
2312    let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
2313        return false;
2314    };
2315    let (
2316        Some(bound_request_id),
2317        Some(bound_conversation_id),
2318        Some(minted_at_ms),
2319        Some(signature_hex),
2320    ) = (
2321        v.get("request_id").and_then(Value::as_str),
2322        v.get("conversation_id").and_then(Value::as_str),
2323        v.get("minted_at_ms").and_then(Value::as_u64),
2324        v.get("signature_hex").and_then(Value::as_str),
2325    )
2326    else {
2327        return false;
2328    };
2329    if bound_request_id != request_id || bound_conversation_id != conversation_id {
2330        return false;
2331    }
2332    let elapsed = now_ms.abs_diff(minted_at_ms);
2333    if elapsed > RESOLVE_TOKEN_TTL_MS {
2334        return false;
2335    }
2336    let Some(sig) = crate::hex::decode(signature_hex) else {
2337        return false;
2338    };
2339    let canonical = resolve_token_canonical(bound_request_id, bound_conversation_id, minted_at_ms);
2340    verify(&signer.public_key_bytes(), &canonical, &sig)
2341}
2342
2343/// The canonical (signature-covered) form of an `admin_model_change` audit
2344/// record (`#787`).
2345///
2346/// Covers who made the change (the authenticated bearer principal), what the
2347/// selection moved from and to, and when — so the durable record cannot be
2348/// forged or re-attributed to a different operator without invalidating the
2349/// signature. `signed_by`/`signature_hex` are appended after signing and are
2350/// not covered.
2351fn admin_model_change_canonical(
2352    principal: &str,
2353    previous_provider: &str,
2354    previous_model: &str,
2355    new_provider: &str,
2356    new_model: &str,
2357    changed_at_ms: u64,
2358) -> Vec<u8> {
2359    canonical_bytes(&AdminModelChangeCanonical {
2360        principal,
2361        previous_provider,
2362        previous_model,
2363        new_provider,
2364        new_model,
2365        changed_at_ms,
2366    })
2367}
2368
2369/// The signed `admin_model_change` field set, in its frozen order.
2370#[derive(Serialize)]
2371struct AdminModelChangeCanonical<'a> {
2372    principal: &'a str,
2373    previous_provider: &'a str,
2374    previous_model: &'a str,
2375    new_provider: &'a str,
2376    new_model: &'a str,
2377    changed_at_ms: u64,
2378}
2379
2380/// JSON payload for a signed `admin_model_change` audit event (`#787`).
2381///
2382/// The durable, tamper-evident record that the live `provider/model`
2383/// selection changed, written on every successful `POST /admin/model` — the
2384/// same posture `#590`/`#594`/`#623` established for other admin-signed
2385/// actions. Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
2386#[must_use]
2387#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
2388pub fn admin_model_change_payload(
2389    principal: &str,
2390    previous_provider: &str,
2391    previous_model: &str,
2392    new_provider: &str,
2393    new_model: &str,
2394    changed_at_ms: u64,
2395    signer: &ApprovalSigner,
2396) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2397    Envelope::seal(
2398        AdminModelChangeCanonical {
2399            principal,
2400            previous_provider,
2401            previous_model,
2402            new_provider,
2403            new_model,
2404            changed_at_ms,
2405        },
2406        signer.as_signer(),
2407    )
2408}
2409
2410/// A verified `admin_model_change` audit record.
2411#[derive(Debug, Clone, PartialEq, Eq)]
2412pub struct VerifiedAdminModelChange {
2413    /// The authenticated bearer principal that made the change.
2414    pub principal: String,
2415    /// Provider before the change (may be empty — deferred to harness default).
2416    pub previous_provider: String,
2417    /// Model before the change.
2418    pub previous_model: String,
2419    /// Provider after the change.
2420    pub new_provider: String,
2421    /// Model after the change.
2422    pub new_model: String,
2423    /// Unix ms the change was applied.
2424    pub changed_at_ms: u64,
2425    /// The verified signer's public key (encoded).
2426    pub signer_public_key: Vec<u8>,
2427}
2428
2429/// Verify a persisted `admin_model_change` payload.
2430///
2431/// `None` for a malformed payload or a signature that does not verify — the
2432/// caller treats the record as untrusted (fail closed).
2433#[must_use]
2434pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
2435    let v: Value = serde_json::from_slice(payload).ok()?;
2436    let principal = v.get("principal")?.as_str()?.to_owned();
2437    let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
2438    let previous_model = v.get("previous_model")?.as_str()?.to_owned();
2439    let new_provider = v.get("new_provider")?.as_str()?.to_owned();
2440    let new_model = v.get("new_model")?.as_str()?.to_owned();
2441    let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2442    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2443    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2444    let canonical = admin_model_change_canonical(
2445        &principal,
2446        &previous_provider,
2447        &previous_model,
2448        &new_provider,
2449        &new_model,
2450        changed_at_ms,
2451    );
2452    if verify(&pk, &canonical, &sig) {
2453        Some(VerifiedAdminModelChange {
2454            principal,
2455            previous_provider,
2456            previous_model,
2457            new_provider,
2458            new_model,
2459            changed_at_ms,
2460            signer_public_key: pk,
2461        })
2462    } else {
2463        None
2464    }
2465}
2466
2467/// One key's lifecycle move within a credential mutation
2468/// (`docs/design/credential-store.md`).
2469///
2470/// Carries the key id and the two lifecycle states it moved between, and
2471/// nothing else. A salt, a digest, or a public key must never reach an audit
2472/// record (INV-C3), so this type has nowhere to put one.
2473#[derive(Debug, Clone, PartialEq, Eq)]
2474pub struct CredentialKeyTransition {
2475    /// The key that moved.
2476    pub kid: String,
2477    /// The state it moved from (`"absent"` for a key that did not exist).
2478    pub from: String,
2479    /// The state it moved to.
2480    pub to: String,
2481}
2482
2483/// The canonical (signature-covered) form of a credential-mutation audit
2484/// record (`docs/design/credential-store.md`, INV-C6).
2485///
2486/// Covers which mutation ran, who ran it, which record and key it named, and
2487/// every key lifecycle transition it caused — so a signed enrollment cannot
2488/// be replayed as a revocation, nor re-attributed to a different admin,
2489/// without invalidating the signature. `signed_by`/`signature_hex` are
2490/// appended after signing and are not covered.
2491fn credential_change_canonical(
2492    change: &str,
2493    principal: &str,
2494    edge_id: &str,
2495    kid: &str,
2496    grants: &str,
2497    transitions: &[CredentialKeyTransition],
2498    changed_at_ms: u64,
2499) -> Vec<u8> {
2500    canonical_bytes(&credential_change_fields(
2501        change,
2502        principal,
2503        edge_id,
2504        kid,
2505        grants,
2506        transitions,
2507        changed_at_ms,
2508    ))
2509}
2510
2511/// The signed credential-mutation field set, in its frozen order.
2512///
2513/// The only canonical in this module that nests an object, and so the only one
2514/// where the ordering hazard `#1842` describes applied at two levels: the
2515/// `transitions` elements are ordered by [`CredentialTransition`]'s own field
2516/// declaration, not by a map.
2517#[derive(Serialize)]
2518struct CredentialChangeCanonical<'a> {
2519    change: &'a str,
2520    principal: &'a str,
2521    edge_id: &'a str,
2522    kid: &'a str,
2523    grants: &'a str,
2524    transitions: Vec<CredentialTransition<'a>>,
2525    changed_at_ms: u64,
2526}
2527
2528/// One `transitions` element of a [`CredentialChangeCanonical`], in its frozen
2529/// order.
2530#[derive(Serialize)]
2531struct CredentialTransition<'a> {
2532    kid: &'a str,
2533    from: &'a str,
2534    to: &'a str,
2535}
2536
2537/// Build the credential-mutation canonical struct.
2538///
2539/// Shared by [`credential_change_canonical`] and
2540/// [`credential_change_payload`], which would otherwise each map the
2541/// transitions into their serialized form and could drift.
2542#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
2543fn credential_change_fields<'a>(
2544    change: &'a str,
2545    principal: &'a str,
2546    edge_id: &'a str,
2547    kid: &'a str,
2548    grants: &'a str,
2549    transitions: &'a [CredentialKeyTransition],
2550    changed_at_ms: u64,
2551) -> CredentialChangeCanonical<'a> {
2552    CredentialChangeCanonical {
2553        change,
2554        principal,
2555        edge_id,
2556        kid,
2557        grants,
2558        transitions: transitions
2559            .iter()
2560            .map(|t| CredentialTransition {
2561                kid: &t.kid,
2562                from: &t.from,
2563                to: &t.to,
2564            })
2565            .collect(),
2566        changed_at_ms,
2567    }
2568}
2569
2570/// JSON payload for a signed credential-mutation audit event
2571/// (`docs/design/credential-store.md`).
2572///
2573/// The durable, tamper-evident record that an admin enrolled, rotated, or
2574/// revoked a credential, appended to the `admin-audit` partition on every
2575/// mutation. `change` is the event kind
2576/// (`polyc_proto::kinds::CREDENTIAL_ENROLLED` and its three siblings), which
2577/// is signature-covered so one kind's record cannot be presented as
2578/// another's. `grants` is what the record may be used for after the change,
2579/// also signature-covered: an admin credential and a chat edge move through
2580/// the same verbs, so an audit line that omitted it could not tell them
2581/// apart.
2582///
2583/// Unlike [`admin_model_change_payload`], whose append is best-effort, a
2584/// failed append of this record fails the mutation that produced it: for a
2585/// credential change the audit line is load-bearing.
2586///
2587/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
2588#[must_use]
2589#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
2590pub fn credential_change_payload(
2591    change: &str,
2592    principal: &str,
2593    edge_id: &str,
2594    kid: &str,
2595    grants: &str,
2596    transitions: &[CredentialKeyTransition],
2597    changed_at_ms: u64,
2598    signer: &ApprovalSigner,
2599) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2600    Envelope::seal(
2601        credential_change_fields(
2602            change,
2603            principal,
2604            edge_id,
2605            kid,
2606            grants,
2607            transitions,
2608            changed_at_ms,
2609        ),
2610        signer.as_signer(),
2611    )
2612}
2613
2614/// A verified credential-mutation audit record.
2615#[derive(Debug, Clone, PartialEq, Eq)]
2616pub struct VerifiedCredentialChange {
2617    /// Which mutation ran (the event kind).
2618    pub change: String,
2619    /// The authenticated admin principal that ran it.
2620    pub principal: String,
2621    /// The record that changed.
2622    pub edge_id: String,
2623    /// The key the mutation named (empty for a whole-record revocation).
2624    pub kid: String,
2625    /// What the record grants AFTER the mutation — `"edge"`, `"admin"`,
2626    /// `"edge, admin"`, or `"none"`.
2627    ///
2628    /// Signature-covered, and the reason it exists: an admin credential and
2629    /// a chat edge are the same record shape and the same verbs, so without
2630    /// this an audit trail cannot tell "someone enrolled a chat edge" from
2631    /// "someone granted themselves control of this deployment".
2632    pub grants: String,
2633    /// Every key lifecycle transition the mutation caused.
2634    pub transitions: Vec<CredentialKeyTransition>,
2635    /// Unix ms the mutation was applied.
2636    pub changed_at_ms: u64,
2637    /// The verified signer's public key (encoded).
2638    pub signer_public_key: Vec<u8>,
2639}
2640
2641/// Verify a persisted credential-mutation payload.
2642///
2643/// `None` for a malformed payload or a signature that does not verify — the
2644/// caller treats the record as untrusted (fail closed).
2645///
2646/// # Records written before `grants` existed
2647///
2648/// They return `None` here, indistinguishable from a forgery. `grants` is a
2649/// required field of the canonical form, so a payload without it does not
2650/// verify — deliberately, because loosening the check to tolerate an absent
2651/// field would let a forged record claim the same absence and be believed.
2652/// The credential audit family has no non-test reader yet, so nothing
2653/// regresses today; a deployment that already wrote credential audit lines
2654/// keeps them as bytes in the log and loses only the ability to verify them
2655/// through this function.
2656#[must_use]
2657pub fn verify_credential_change(payload: &[u8]) -> Option<VerifiedCredentialChange> {
2658    let v: Value = serde_json::from_slice(payload).ok()?;
2659    let change = v.get("change")?.as_str()?.to_owned();
2660    let principal = v.get("principal")?.as_str()?.to_owned();
2661    let edge_id = v.get("edge_id")?.as_str()?.to_owned();
2662    let kid = v.get("kid")?.as_str()?.to_owned();
2663    let grants = v.get("grants")?.as_str()?.to_owned();
2664    let mut transitions = Vec::new();
2665    for item in v.get("transitions")?.as_array()? {
2666        transitions.push(CredentialKeyTransition {
2667            kid: item.get("kid")?.as_str()?.to_owned(),
2668            from: item.get("from")?.as_str()?.to_owned(),
2669            to: item.get("to")?.as_str()?.to_owned(),
2670        });
2671    }
2672    let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
2673    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2674    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2675    let canonical = credential_change_canonical(
2676        &change,
2677        &principal,
2678        &edge_id,
2679        &kid,
2680        &grants,
2681        &transitions,
2682        changed_at_ms,
2683    );
2684    if verify(&pk, &canonical, &sig) {
2685        Some(VerifiedCredentialChange {
2686            change,
2687            principal,
2688            edge_id,
2689            kid,
2690            grants,
2691            transitions,
2692            changed_at_ms,
2693            signer_public_key: pk,
2694        })
2695    } else {
2696        None
2697    }
2698}
2699
2700/// Canonical bytes signed for a `routine_created` audit event (`#1497`).
2701fn routine_created_canonical(
2702    routine: &str,
2703    creator_persona: &str,
2704    conversation_id: &str,
2705    tool_call_id: &str,
2706    args_hash: &str,
2707    created_at_ms: u64,
2708) -> Vec<u8> {
2709    canonical_bytes(&RoutineCreatedCanonical {
2710        routine,
2711        creator_persona,
2712        conversation_id,
2713        tool_call_id,
2714        args_hash,
2715        created_at_ms,
2716    })
2717}
2718
2719/// The signed `routine_created` field set, in its frozen order.
2720#[derive(Serialize)]
2721struct RoutineCreatedCanonical<'a> {
2722    routine: &'a str,
2723    creator_persona: &'a str,
2724    conversation_id: &'a str,
2725    tool_call_id: &'a str,
2726    args_hash: &'a str,
2727    created_at_ms: u64,
2728}
2729
2730/// JSON payload for a signed `routine_created` audit event.
2731///
2732/// `#1497`, INV-RL6: who created the routine, from which conversation, and
2733/// when. Mirrors [`admin_model_change_payload`]'s shape — the same
2734/// control-plane-authored-audit-record pattern, a distinct kind. #1495 adds
2735/// the sibling `routine_paused`/`routine_resumed`/`routine_deleted` payloads
2736/// on this same construction.
2737///
2738/// `tool_call_id` (`#1638`) is the harness's id for the tool call this event
2739/// audits, and `args_hash` is the sha256 hex of that call's approved
2740/// `args_json` (mirrors `harness_dialer::approved_args_hash`) — together the
2741/// cross-replay idempotency key `routine_nav`'s dedup check
2742/// (`find_completed_mutation`) looks up before ever touching the CR, so an
2743/// orphan-recovery replay of the same dispatch is a no-op rather than a
2744/// second signed event and a duplicate routine, and the SAME `tool_call_id`
2745/// carrying different args never returns a stale acknowledgement.
2746///
2747/// Returns `(payload_bytes, signature, signer_public_key)`.
2748#[must_use]
2749pub fn routine_created_payload(
2750    routine: &str,
2751    creator_persona: &str,
2752    conversation_id: &str,
2753    tool_call_id: &str,
2754    args_hash: &str,
2755    created_at_ms: u64,
2756    signer: &ApprovalSigner,
2757) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2758    Envelope::seal(
2759        RoutineCreatedCanonical {
2760            routine,
2761            creator_persona,
2762            conversation_id,
2763            tool_call_id,
2764            args_hash,
2765            created_at_ms,
2766        },
2767        signer.as_signer(),
2768    )
2769}
2770
2771/// A verified `routine_created` audit record.
2772#[derive(Debug, Clone, PartialEq, Eq)]
2773pub struct VerifiedRoutineCreated {
2774    /// The created routine's CR name.
2775    pub routine: String,
2776    /// The persona id that created it.
2777    pub creator_persona: String,
2778    /// The originating conversation id.
2779    pub conversation_id: String,
2780    /// The harness tool-call id this event audits (`#1638`).
2781    pub tool_call_id: String,
2782    /// sha256 hex of the approved `args_json` (`#1638`).
2783    pub args_hash: String,
2784    /// Unix ms the CR was written.
2785    pub created_at_ms: u64,
2786    /// The verified signer's public key (encoded).
2787    pub signer_public_key: Vec<u8>,
2788}
2789
2790/// Verify a persisted `routine_created` payload.
2791///
2792/// `None` for a malformed payload or a signature that does not verify — the
2793/// caller treats the record as untrusted (fail closed).
2794#[must_use]
2795pub fn verify_routine_created(payload: &[u8]) -> Option<VerifiedRoutineCreated> {
2796    let v: Value = serde_json::from_slice(payload).ok()?;
2797    let routine = v.get("routine")?.as_str()?.to_owned();
2798    let creator_persona = v.get("creator_persona")?.as_str()?.to_owned();
2799    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2800    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2801    let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2802    let created_at_ms = v.get("created_at_ms")?.as_u64()?;
2803    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2804    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2805    let canonical = routine_created_canonical(
2806        &routine,
2807        &creator_persona,
2808        &conversation_id,
2809        &tool_call_id,
2810        &args_hash,
2811        created_at_ms,
2812    );
2813    if verify(&pk, &canonical, &sig) {
2814        Some(VerifiedRoutineCreated {
2815            routine,
2816            creator_persona,
2817            conversation_id,
2818            tool_call_id,
2819            args_hash,
2820            created_at_ms,
2821            signer_public_key: pk,
2822        })
2823    } else {
2824        None
2825    }
2826}
2827
2828/// Canonical bytes signed for a `routine_paused` audit event (`#1495`).
2829fn routine_paused_canonical(
2830    routine: &str,
2831    actor_persona: &str,
2832    conversation_id: &str,
2833    tool_call_id: &str,
2834    args_hash: &str,
2835    paused_at_ms: u64,
2836    reason: Option<&str>,
2837) -> Vec<u8> {
2838    canonical_bytes(&RoutinePausedCanonical {
2839        routine,
2840        actor_persona,
2841        conversation_id,
2842        tool_call_id,
2843        args_hash,
2844        paused_at_ms,
2845        reason,
2846    })
2847}
2848
2849/// The signed `routine_paused` field set, in its frozen order.
2850///
2851/// `reason` is signed as an explicit `null` when the pauser gave none — the
2852/// key is always present, so the absent case is covered by the signature the
2853/// same way a present one is.
2854#[derive(Serialize)]
2855struct RoutinePausedCanonical<'a> {
2856    routine: &'a str,
2857    actor_persona: &'a str,
2858    conversation_id: &'a str,
2859    tool_call_id: &'a str,
2860    args_hash: &'a str,
2861    paused_at_ms: u64,
2862    reason: Option<&'a str>,
2863}
2864
2865/// JSON payload for a signed `routine_paused` audit event.
2866///
2867/// `#1495`, INV-RL6: who paused the routine, from which conversation, when,
2868/// and (if given) why. Mirrors [`routine_created_payload`]'s shape — the
2869/// same control-plane-authored-audit-record pattern, a distinct kind. Pause
2870/// takes effect immediately with no confirmation (INV-RL2 does not apply to
2871/// pause/resume — see the invariants doc) but still leaves exactly one
2872/// signed event. `args_hash` (`#1638`) mirrors `routine_created_payload`'s
2873/// own field — see its doc.
2874///
2875/// Returns `(payload_bytes, signature, signer_public_key)`.
2876#[must_use]
2877#[allow(clippy::too_many_arguments)] // one distinct signed field per line, mirrors routine_created_payload
2878pub fn routine_paused_payload(
2879    routine: &str,
2880    actor_persona: &str,
2881    conversation_id: &str,
2882    tool_call_id: &str,
2883    args_hash: &str,
2884    paused_at_ms: u64,
2885    reason: Option<&str>,
2886    signer: &ApprovalSigner,
2887) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
2888    Envelope::seal(
2889        RoutinePausedCanonical {
2890            routine,
2891            actor_persona,
2892            conversation_id,
2893            tool_call_id,
2894            args_hash,
2895            paused_at_ms,
2896            reason,
2897        },
2898        signer.as_signer(),
2899    )
2900}
2901
2902/// A verified `routine_paused` audit record.
2903#[derive(Debug, Clone, PartialEq, Eq)]
2904pub struct VerifiedRoutinePaused {
2905    /// The paused routine's CR name.
2906    pub routine: String,
2907    /// The persona id that paused it.
2908    pub actor_persona: String,
2909    /// The originating conversation id.
2910    pub conversation_id: String,
2911    /// The harness tool-call id this event audits (`#1638`).
2912    pub tool_call_id: String,
2913    /// sha256 hex of the approved `args_json` (`#1638`).
2914    pub args_hash: String,
2915    /// Unix ms the pause was recorded.
2916    pub paused_at_ms: u64,
2917    /// Why the routine was paused, if the pauser gave one.
2918    pub reason: Option<String>,
2919    /// The verified signer's public key (encoded).
2920    pub signer_public_key: Vec<u8>,
2921}
2922
2923/// Verify a persisted `routine_paused` payload.
2924///
2925/// `None` for a malformed payload or a signature that does not verify — the
2926/// caller treats the record as untrusted (fail closed).
2927#[must_use]
2928pub fn verify_routine_paused(payload: &[u8]) -> Option<VerifiedRoutinePaused> {
2929    let v: Value = serde_json::from_slice(payload).ok()?;
2930    let routine = v.get("routine")?.as_str()?.to_owned();
2931    let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
2932    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
2933    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
2934    let args_hash = v.get("args_hash")?.as_str()?.to_owned();
2935    let paused_at_ms = v.get("paused_at_ms")?.as_u64()?;
2936    let reason = v.get("reason").and_then(|r| r.as_str()).map(str::to_owned);
2937    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
2938    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
2939    let canonical = routine_paused_canonical(
2940        &routine,
2941        &actor_persona,
2942        &conversation_id,
2943        &tool_call_id,
2944        &args_hash,
2945        paused_at_ms,
2946        reason.as_deref(),
2947    );
2948    if verify(&pk, &canonical, &sig) {
2949        Some(VerifiedRoutinePaused {
2950            routine,
2951            actor_persona,
2952            conversation_id,
2953            tool_call_id,
2954            args_hash,
2955            paused_at_ms,
2956            reason,
2957            signer_public_key: pk,
2958        })
2959    } else {
2960        None
2961    }
2962}
2963
2964/// Canonical bytes signed for a `routine_resumed` audit event (`#1495`).
2965fn routine_resumed_canonical(
2966    routine: &str,
2967    actor_persona: &str,
2968    conversation_id: &str,
2969    tool_call_id: &str,
2970    args_hash: &str,
2971    resumed_at_ms: u64,
2972) -> Vec<u8> {
2973    canonical_bytes(&RoutineResumedCanonical {
2974        routine,
2975        actor_persona,
2976        conversation_id,
2977        tool_call_id,
2978        args_hash,
2979        resumed_at_ms,
2980    })
2981}
2982
2983/// The signed `routine_resumed` field set, in its frozen order.
2984#[derive(Serialize)]
2985struct RoutineResumedCanonical<'a> {
2986    routine: &'a str,
2987    actor_persona: &'a str,
2988    conversation_id: &'a str,
2989    tool_call_id: &'a str,
2990    args_hash: &'a str,
2991    resumed_at_ms: u64,
2992}
2993
2994/// JSON payload for a signed `routine_resumed` audit event.
2995///
2996/// `#1495`, INV-RL6: the un-pause sibling of [`routine_paused_payload`] —
2997/// also immediate, no confirmation. `args_hash` (`#1638`) mirrors
2998/// [`routine_created_payload`]'s own field — see its doc.
2999///
3000/// Returns `(payload_bytes, signature, signer_public_key)`.
3001#[must_use]
3002pub fn routine_resumed_payload(
3003    routine: &str,
3004    actor_persona: &str,
3005    conversation_id: &str,
3006    tool_call_id: &str,
3007    args_hash: &str,
3008    resumed_at_ms: u64,
3009    signer: &ApprovalSigner,
3010) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3011    Envelope::seal(
3012        RoutineResumedCanonical {
3013            routine,
3014            actor_persona,
3015            conversation_id,
3016            tool_call_id,
3017            args_hash,
3018            resumed_at_ms,
3019        },
3020        signer.as_signer(),
3021    )
3022}
3023
3024/// A verified `routine_resumed` audit record.
3025#[derive(Debug, Clone, PartialEq, Eq)]
3026pub struct VerifiedRoutineResumed {
3027    /// The resumed routine's CR name.
3028    pub routine: String,
3029    /// The persona id that resumed it.
3030    pub actor_persona: String,
3031    /// The originating conversation id.
3032    pub conversation_id: String,
3033    /// The harness tool-call id this event audits (`#1638`).
3034    pub tool_call_id: String,
3035    /// sha256 hex of the approved `args_json` (`#1638`).
3036    pub args_hash: String,
3037    /// Unix ms the resume was recorded.
3038    pub resumed_at_ms: u64,
3039    /// The verified signer's public key (encoded).
3040    pub signer_public_key: Vec<u8>,
3041}
3042
3043/// Verify a persisted `routine_resumed` payload.
3044///
3045/// `None` for a malformed payload or a signature that does not verify — the
3046/// caller treats the record as untrusted (fail closed).
3047#[must_use]
3048pub fn verify_routine_resumed(payload: &[u8]) -> Option<VerifiedRoutineResumed> {
3049    let v: Value = serde_json::from_slice(payload).ok()?;
3050    let routine = v.get("routine")?.as_str()?.to_owned();
3051    let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3052    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3053    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3054    let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3055    let resumed_at_ms = v.get("resumed_at_ms")?.as_u64()?;
3056    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3057    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3058    let canonical = routine_resumed_canonical(
3059        &routine,
3060        &actor_persona,
3061        &conversation_id,
3062        &tool_call_id,
3063        &args_hash,
3064        resumed_at_ms,
3065    );
3066    if verify(&pk, &canonical, &sig) {
3067        Some(VerifiedRoutineResumed {
3068            routine,
3069            actor_persona,
3070            conversation_id,
3071            tool_call_id,
3072            args_hash,
3073            resumed_at_ms,
3074            signer_public_key: pk,
3075        })
3076    } else {
3077        None
3078    }
3079}
3080
3081/// Canonical bytes signed for a `routine_deleted` audit event (`#1495`).
3082fn routine_deleted_canonical(
3083    routine: &str,
3084    actor_persona: &str,
3085    conversation_id: &str,
3086    tool_call_id: &str,
3087    args_hash: &str,
3088    deleted_at_ms: u64,
3089) -> Vec<u8> {
3090    canonical_bytes(&RoutineDeletedCanonical {
3091        routine,
3092        actor_persona,
3093        conversation_id,
3094        tool_call_id,
3095        args_hash,
3096        deleted_at_ms,
3097    })
3098}
3099
3100/// The signed `routine_deleted` field set, in its frozen order.
3101#[derive(Serialize)]
3102struct RoutineDeletedCanonical<'a> {
3103    routine: &'a str,
3104    actor_persona: &'a str,
3105    conversation_id: &'a str,
3106    tool_call_id: &'a str,
3107    args_hash: &'a str,
3108    deleted_at_ms: u64,
3109}
3110
3111/// JSON payload for a signed `routine_deleted` audit event.
3112///
3113/// `#1495`, INV-RL2/RL6: who deleted the routine, from which conversation,
3114/// and when. Unlike pause/resume, only ever minted once a human has approved
3115/// the mid-turn confirmation for this delete. `args_hash` (`#1638`) mirrors
3116/// [`routine_created_payload`]'s own field — see its doc.
3117///
3118/// Returns `(payload_bytes, signature, signer_public_key)`.
3119#[must_use]
3120pub fn routine_deleted_payload(
3121    routine: &str,
3122    actor_persona: &str,
3123    conversation_id: &str,
3124    tool_call_id: &str,
3125    args_hash: &str,
3126    deleted_at_ms: u64,
3127    signer: &ApprovalSigner,
3128) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3129    Envelope::seal(
3130        RoutineDeletedCanonical {
3131            routine,
3132            actor_persona,
3133            conversation_id,
3134            tool_call_id,
3135            args_hash,
3136            deleted_at_ms,
3137        },
3138        signer.as_signer(),
3139    )
3140}
3141
3142/// A verified `routine_deleted` audit record.
3143#[derive(Debug, Clone, PartialEq, Eq)]
3144pub struct VerifiedRoutineDeleted {
3145    /// The deleted routine's CR name.
3146    pub routine: String,
3147    /// The persona id that deleted it.
3148    pub actor_persona: String,
3149    /// The originating conversation id.
3150    pub conversation_id: String,
3151    /// The harness tool-call id this event audits (`#1638`).
3152    pub tool_call_id: String,
3153    /// sha256 hex of the approved `args_json` (`#1638`).
3154    pub args_hash: String,
3155    /// Unix ms the deletion was recorded.
3156    pub deleted_at_ms: u64,
3157    /// The verified signer's public key (encoded).
3158    pub signer_public_key: Vec<u8>,
3159}
3160
3161/// Verify a persisted `routine_deleted` payload.
3162///
3163/// `None` for a malformed payload or a signature that does not verify — the
3164/// caller treats the record as untrusted (fail closed).
3165#[must_use]
3166pub fn verify_routine_deleted(payload: &[u8]) -> Option<VerifiedRoutineDeleted> {
3167    let v: Value = serde_json::from_slice(payload).ok()?;
3168    let routine = v.get("routine")?.as_str()?.to_owned();
3169    let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3170    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3171    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3172    let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3173    let deleted_at_ms = v.get("deleted_at_ms")?.as_u64()?;
3174    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3175    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3176    let canonical = routine_deleted_canonical(
3177        &routine,
3178        &actor_persona,
3179        &conversation_id,
3180        &tool_call_id,
3181        &args_hash,
3182        deleted_at_ms,
3183    );
3184    if verify(&pk, &canonical, &sig) {
3185        Some(VerifiedRoutineDeleted {
3186            routine,
3187            actor_persona,
3188            conversation_id,
3189            tool_call_id,
3190            args_hash,
3191            deleted_at_ms,
3192            signer_public_key: pk,
3193        })
3194    } else {
3195        None
3196    }
3197}
3198
3199/// Canonical bytes signed for a `routine_scope_changed` audit event (`#1806`).
3200fn routine_scope_changed_canonical(
3201    routine: &str,
3202    actor_persona: &str,
3203    conversation_id: &str,
3204    tool_call_id: &str,
3205    args_hash: &str,
3206    scope: &str,
3207    changed_at_ms: u64,
3208) -> Vec<u8> {
3209    canonical_bytes(&RoutineScopeChangedCanonical {
3210        routine,
3211        actor_persona,
3212        conversation_id,
3213        tool_call_id,
3214        args_hash,
3215        scope,
3216        changed_at_ms,
3217    })
3218}
3219
3220/// The signed `routine_scope_changed` field set, in its frozen order.
3221#[derive(Serialize)]
3222struct RoutineScopeChangedCanonical<'a> {
3223    routine: &'a str,
3224    actor_persona: &'a str,
3225    conversation_id: &'a str,
3226    tool_call_id: &'a str,
3227    args_hash: &'a str,
3228    scope: &'a str,
3229    changed_at_ms: u64,
3230}
3231
3232/// JSON payload for a signed `routine_scope_changed` audit event.
3233///
3234/// `#1806`, INV-RL6: who flipped the routine's sharing, to which value, from
3235/// which conversation, and when. Mirrors [`routine_paused_payload`]'s shape
3236/// — the same control-plane-authored-audit-record pattern, a distinct kind.
3237/// Unlike pause/resume/delete, only ever minted for the routine's OWNER: an
3238/// admin who is not the owner is refused before this is ever called (see
3239/// `crate::approval::ApprovalSigner`'s caller, `routine_nav::execute_scope`,
3240/// which mirrors `execute_fire`'s INV-RL11 owner-only gate rather than the
3241/// mutation trio's owner-or-admin one). `args_hash` (`#1638`) mirrors
3242/// [`routine_created_payload`]'s own field — see its doc.
3243///
3244/// Returns `(payload_bytes, signature, signer_public_key)`.
3245#[must_use]
3246#[allow(clippy::too_many_arguments)] // one distinct signed field per line, mirrors routine_created_payload
3247pub fn routine_scope_changed_payload(
3248    routine: &str,
3249    actor_persona: &str,
3250    conversation_id: &str,
3251    tool_call_id: &str,
3252    args_hash: &str,
3253    scope: &str,
3254    changed_at_ms: u64,
3255    signer: &ApprovalSigner,
3256) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
3257    Envelope::seal(
3258        RoutineScopeChangedCanonical {
3259            routine,
3260            actor_persona,
3261            conversation_id,
3262            tool_call_id,
3263            args_hash,
3264            scope,
3265            changed_at_ms,
3266        },
3267        signer.as_signer(),
3268    )
3269}
3270
3271/// A verified `routine_scope_changed` audit record.
3272#[derive(Debug, Clone, PartialEq, Eq)]
3273pub struct VerifiedRoutineScopeChanged {
3274    /// The routine's CR name.
3275    pub routine: String,
3276    /// The persona id that flipped its scope — always the routine's owner.
3277    pub actor_persona: String,
3278    /// The originating conversation id.
3279    pub conversation_id: String,
3280    /// The harness tool-call id this event audits (`#1638`).
3281    pub tool_call_id: String,
3282    /// sha256 hex of the approved `args_json` (`#1638`).
3283    pub args_hash: String,
3284    /// The scope the routine was set to (`"public"` or `"private"`).
3285    pub scope: String,
3286    /// Unix ms the flip was recorded.
3287    pub changed_at_ms: u64,
3288    /// The verified signer's public key (encoded).
3289    pub signer_public_key: Vec<u8>,
3290}
3291
3292/// Verify a persisted `routine_scope_changed` payload.
3293///
3294/// `None` for a malformed payload or a signature that does not verify — the
3295/// caller treats the record as untrusted (fail closed).
3296#[must_use]
3297pub fn verify_routine_scope_changed(payload: &[u8]) -> Option<VerifiedRoutineScopeChanged> {
3298    let v: Value = serde_json::from_slice(payload).ok()?;
3299    let routine = v.get("routine")?.as_str()?.to_owned();
3300    let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
3301    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
3302    let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
3303    let args_hash = v.get("args_hash")?.as_str()?.to_owned();
3304    let scope = v.get("scope")?.as_str()?.to_owned();
3305    let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
3306    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
3307    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
3308    let canonical = routine_scope_changed_canonical(
3309        &routine,
3310        &actor_persona,
3311        &conversation_id,
3312        &tool_call_id,
3313        &args_hash,
3314        &scope,
3315        changed_at_ms,
3316    );
3317    if verify(&pk, &canonical, &sig) {
3318        Some(VerifiedRoutineScopeChanged {
3319            routine,
3320            actor_persona,
3321            conversation_id,
3322            tool_call_id,
3323            args_hash,
3324            scope,
3325            changed_at_ms,
3326            signer_public_key: pk,
3327        })
3328    } else {
3329        None
3330    }
3331}
3332
3333/// Extract `request_id` from an `approval_request` payload.
3334#[must_use]
3335pub fn decode_request_id(payload: &[u8]) -> Option<String> {
3336    let v: Value = serde_json::from_slice(payload).ok()?;
3337    Some(v.get("request_id")?.as_str()?.to_owned())
3338}
3339
3340/// Extract `(request_id, tool_name, args_json)` from an `approval_request`
3341/// payload — the fields a v2 `approval_response` must sign to bind the approval
3342/// to the request identity.
3343#[must_use]
3344pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
3345    let v: Value = serde_json::from_slice(payload).ok()?;
3346    Some((
3347        v.get("request_id")?.as_str()?.to_owned(),
3348        v.get("tool_name")?.as_str()?.to_owned(),
3349        v.get("args_json")?.as_str()?.to_owned(),
3350    ))
3351}
3352
3353/// Extract the `sandbox_mode` an `approval_request` was emitted under.
3354///
3355/// The mode the harness was running when it paused the call. Separate from
3356/// [`decode_request_fields`] so its many callers keep their tuple shape; the
3357/// control plane signs this into the response so a remembered approval is
3358/// bound to the mode it was granted under. Empty/absent → `""`.
3359#[must_use]
3360pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
3361    serde_json::from_slice::<Value>(payload)
3362        .ok()
3363        .and_then(|v| {
3364            v.get("sandbox_mode")
3365                .and_then(Value::as_str)
3366                .map(str::to_owned)
3367        })
3368        .unwrap_or_default()
3369}
3370
3371/// Extract the override `reason` an `approval_request` carried.
3372///
3373/// Non-empty only for the lethal-trifecta / Rule-of-Two containment override;
3374/// empty/absent → `""` (an ordinary gated call, or a record written before the
3375/// field existed). Used by the edge to render the gate's explanation and by
3376/// forensics to show why a trifecta-gated call was paused.
3377#[must_use]
3378pub fn decode_request_reason(payload: &[u8]) -> String {
3379    serde_json::from_slice::<Value>(payload)
3380        .ok()
3381        .and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
3382        .unwrap_or_default()
3383}
3384
3385/// Extract the `missing_capabilities` recorded on an `approval_request`
3386/// payload (`#595`) — the capability shortfall the gate computed when it
3387/// paused the call.
3388///
3389/// Read back at respond time and signed into the response as its
3390/// `covered_capabilities`. Absent or malformed decodes to empty: the grant
3391/// then covers nothing beyond the ordinary gate, the narrow direction.
3392#[must_use]
3393pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
3394    serde_json::from_slice::<Value>(payload)
3395        .ok()
3396        .and_then(|v| {
3397            v.get("missing_capabilities")
3398                .and_then(Value::as_array)
3399                .map(|a| {
3400                    a.iter()
3401                        .filter_map(|c| c.as_str().map(str::to_owned))
3402                        .collect()
3403                })
3404        })
3405        .unwrap_or_default()
3406}
3407
3408/// Extract the computed-preview enrichment (`#1496`) an `approval_request` carried.
3409///
3410/// Returns the raw (re-serialized) JSON — `None` for an absent or
3411/// explicit-`null` `preview` key (a call the enrichment doesn't apply to, or
3412/// a record written before this field existed). The caller (`polyc-control-
3413/// plane`) owns the typed shape; this layer stays opaque to it, exactly like
3414/// [`decode_request_fields`]'s `args_json`.
3415#[must_use]
3416pub fn decode_request_preview_json(payload: &[u8]) -> Option<String> {
3417    let v: Value = serde_json::from_slice(payload).ok()?;
3418    let preview = v.get("preview")?;
3419    if preview.is_null() {
3420        None
3421    } else {
3422        Some(preview.to_string())
3423    }
3424}
3425
3426#[cfg(test)]
3427mod tests {
3428    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
3429
3430    use super::*;
3431
3432    // #784: an `ApprovalSigner` built from real key material (the shape a
3433    // secret-store load produces) signs a genuine, verifiable
3434    // `approval_response`, and a signature minted under the well-known
3435    // deterministic `from_seed(1)` key does NOT verify against it — proving
3436    // the loaded key is a distinct, non-derivable key, not the forgeable
3437    // default.
3438    #[test]
3439    fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
3440        // Stand-in for key bytes read back from a secret store (any 32 bytes
3441        // are a valid ed25519 private key — no seed-derivation involved).
3442        let key_bytes = [42u8; 32];
3443        let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
3444        let forged = ApprovalSigner::from_seed(1);
3445        assert_ne!(
3446            loaded.public_key_bytes(),
3447            forged.public_key_bytes(),
3448            "a loaded key must not collide with the public, deterministic seed-1 key"
3449        );
3450
3451        let (payload, _sig, _pk) = response_payload(
3452            "req-1",
3453            "web_fetch",
3454            r#"{"url":"https://a"}"#,
3455            "",
3456            true,
3457            false,
3458            &[],
3459            "slack:T1:U9",
3460            "",
3461            "workspace-write",
3462            "",
3463            "",
3464            "conv-1",
3465            "nonce-1",
3466            &loaded,
3467        );
3468        let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
3469        assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
3470
3471        // The exact same payload, signed under `from_seed(1)` and stamped
3472        // with the LOADED key's public key (impersonation attempt), fails —
3473        // the seed-1 signature does not verify against the loaded key.
3474        let (forged_payload, _sig, _pk) = response_payload(
3475            "req-1",
3476            "web_fetch",
3477            r#"{"url":"https://a"}"#,
3478            "",
3479            true,
3480            false,
3481            &[],
3482            "slack:T1:U9",
3483            "",
3484            "workspace-write",
3485            "",
3486            "",
3487            "conv-1",
3488            "nonce-1",
3489            &forged,
3490        );
3491        let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
3492        v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
3493        assert!(
3494            verify_signed_response(v.to_string().as_bytes()).is_none(),
3495            "a from_seed(1) signature must not verify against the loaded key"
3496        );
3497    }
3498
3499    // #594: a grant_replay audit record round-trips through verification and any
3500    // tamper to a bound field flips verification to false.
3501    #[test]
3502    fn grant_replay_audit_is_signed_and_tamper_evident() {
3503        let signer = ApprovalSigner::from_seed(9);
3504        let covered = vec!["arbitrary-egress".to_owned()];
3505        let (payload, _sig, _pk) = grant_replay_payload(
3506            "conv-1",
3507            "turn-7",
3508            "post_summary",
3509            "deadbeef",
3510            &covered,
3511            "sha256:template-abc",
3512            &signer,
3513        );
3514        assert!(verify_grant_replay(&payload), "the genuine record verifies");
3515        // Tampering any bound field breaks the signature.
3516        for (field, val) in [
3517            ("conversation_id", serde_json::json!("conv-EVIL")),
3518            ("turn_id", serde_json::json!("turn-8")),
3519            ("tool", serde_json::json!("exfiltrate")),
3520            ("grant_ref", serde_json::json!("cafe")),
3521            (
3522                "covered_capabilities",
3523                serde_json::json!(["arbitrary-egress", "mutate-external"]),
3524            ),
3525            ("coverage_hash", serde_json::json!("sha256:other")),
3526        ] {
3527            let mut v: Value = serde_json::from_slice(&payload).unwrap();
3528            v[field] = val;
3529            assert!(
3530                !verify_grant_replay(v.to_string().as_bytes()),
3531                "tampered {field} must fail verification"
3532            );
3533        }
3534        assert!(!verify_grant_replay(b"not json"));
3535    }
3536
3537    // #595: the covered capability set is part of the signed contract — it
3538    // round-trips through verification and cannot be widened after signing.
3539    #[test]
3540    fn covered_capabilities_are_signed_and_tamper_evident() {
3541        let signer = ApprovalSigner::from_seed(42);
3542        let covered = vec!["arbitrary-egress".to_owned()];
3543        let (payload, _sig, _pk) = response_payload(
3544            "req-1",
3545            "web_fetch",
3546            r#"{"url":"https://a"}"#,
3547            "",
3548            true,
3549            true,
3550            &covered,
3551            "slack:T1:U9",
3552            "",
3553            "workspace-write",
3554            "",
3555            "",
3556            "conv-1",
3557            "nonce-1",
3558            &signer,
3559        );
3560        let verified = verify_signed_response(&payload).expect("verifies untampered");
3561        assert_eq!(verified.covered_capabilities, covered);
3562
3563        // Widening the covered set after signing invalidates the signature.
3564        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3565        v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
3566        assert!(
3567            verify_signed_response(v.to_string().as_bytes()).is_none(),
3568            "a tampered covered set must fail verification"
3569        );
3570        // So does shrinking it to hide what a grant covered.
3571        let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3572        v["covered_capabilities"] = serde_json::json!([]);
3573        assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
3574    }
3575
3576    // #595: the request records the gate's capability shortfall and decodes
3577    // it back for the respond path; absent decodes empty (covers nothing).
3578    #[test]
3579    fn request_missing_capabilities_round_trip() {
3580        let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
3581        let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing, "");
3582        assert_eq!(decode_request_missing_capabilities(&bytes), missing);
3583        let bare = request_payload("call-2", "grep", "{}", "", "", &[], "");
3584        assert_eq!(
3585            decode_request_missing_capabilities(&bare),
3586            Vec::<String>::new()
3587        );
3588        assert_eq!(
3589            decode_request_missing_capabilities(b"{\"nope\":1}"),
3590            Vec::<String>::new()
3591        );
3592    }
3593
3594    // #590: excision markers round-trip and are tamper-evident on every
3595    // covered field — widening positions, flipping scope, or re-targeting
3596    // the conversation all fail verification (taint stays, fail closed).
3597    #[test]
3598    fn signed_excision_round_trips_and_is_tamper_evident() {
3599        let signer = ApprovalSigner::from_seed(11);
3600        let (payload, _sig, _pk) = excision_payload(
3601            "conv-1",
3602            EXCISION_SCOPE_CASCADE,
3603            &[17, 23],
3604            "persona-9",
3605            "poisoned fetch",
3606            &signer,
3607        );
3608        let v = verify_signed_excision(&payload).expect("verifies untampered");
3609        assert_eq!(v.conversation_id, "conv-1");
3610        assert!(v.is_cascade());
3611        assert_eq!(v.positions, vec![17, 23]);
3612        assert_eq!(v.requested_by, "persona-9");
3613
3614        for (field, value) in [
3615            ("positions", serde_json::json!([17, 23, 40])),
3616            ("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
3617            ("conversation_id", serde_json::json!("conv-2")),
3618            ("requested_by", serde_json::json!("someone-else")),
3619        ] {
3620            let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3621            t[field] = value;
3622            assert!(
3623                verify_signed_excision(t.to_string().as_bytes()).is_none(),
3624                "tampered {field} must fail verification"
3625            );
3626        }
3627        // An unknown scope is refused even before the signature check.
3628        let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
3629        t["scope"] = serde_json::json!("everything");
3630        assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
3631        // Garbage is refused.
3632        assert!(verify_signed_excision(b"not json").is_none());
3633    }
3634
3635    #[test]
3636    fn signed_response_round_trips() {
3637        let signer = ApprovalSigner::from_seed(42);
3638        // A one-shot approval: approved_for_session = false.
3639        let (payload, _sig, _pk) = response_payload(
3640            "req-1",
3641            "rm",
3642            r#"{"path":"/etc"}"#,
3643            "",
3644            true,
3645            false,
3646            &[],
3647            "slack:T1:U9",
3648            "",
3649            "workspace-write",
3650            "looks fine",
3651            "",
3652            "conv-1",
3653            "nonce-1",
3654            &signer,
3655        );
3656        let verified =
3657            verify_signed_response(&payload).expect("signature verifies on untampered payload");
3658        assert!(verified.approved);
3659        assert_eq!(verified.reason, "looks fine");
3660        assert_eq!(verified.request_id, "req-1");
3661        assert_eq!(verified.tool_name, "rm");
3662        assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
3663        assert_eq!(verified.caller, "slack:T1:U9");
3664        assert_eq!(verified.conversation_id, "conv-1");
3665        assert_eq!(verified.nonce, "nonce-1");
3666        assert!(verified.approved);
3667        // A one-shot approval carries no session scope.
3668        assert!(!verified.approved_for_session);
3669    }
3670
3671    /// #1025 backward compatibility: a payload signed BEFORE the `approver`
3672    /// field existed (no `approver` key at all, not merely an empty one)
3673    /// must still decode and verify byte-for-byte identically — every
3674    /// `approval_response` ever persisted was signed this way, and they are
3675    /// re-verified on every replay.
3676    ///
3677    /// The pre-#1025 shape is a checked-in literal
3678    /// ([`PRE_APPROVER_RESPONSE_PAYLOAD`]). It used to be recomputed inline
3679    /// with a `json!` call, because `Value::to_string()`'s key order depended
3680    /// on `serde_json`'s `preserve_order` feature and so flipped with whatever
3681    /// else the build graph pulled in (`#345`) — which made a literal fixture
3682    /// unstable. `#1842` removed that dependence: the canonical is now a struct
3683    /// serialized in declaration order, identical under every build selection,
3684    /// so the literal is stable and pins the bytes a deployment actually has in
3685    /// its log instead of merely agreeing with the implementation.
3686    /// An `approval_response` persisted before `approver` existed (`#1025`),
3687    /// signed by `ApprovalSigner::from_seed(42)` over the thirteen fields that
3688    /// preceded it. Checked in, never regenerated.
3689    const PRE_APPROVER_RESPONSE_PAYLOAD: &str = r#"{"request_id":"req-1","tool_name":"tool.name","args_json":"{\"a\":1}","modified_args_json":"","approved":true,"approved_for_session":true,"covered_capabilities":["cap.a","cap.b"],"caller":"persona-caller","sandbox_mode":"sandboxed","reason":"looks fine","injected_context":"","conversation_id":"conv-1","nonce":"nonce-1","signed_by":"78eda21ba04a15e2000fe8810fe3e56741d23bb9ae44aa9d5bb21b76675ff34b","signature_hex":"59ffe281c9b866b703eb77ef8a1aff15d96aacf07a3529ae25afb11798317247b7e387343f4fc3e73dccb0b6d4cfac503ca1de8abcefae32fbdce7959f4d490f"}"#;
3690
3691    #[test]
3692    fn pre_approver_field_payload_still_verifies_unchanged() {
3693        let signer = ApprovalSigner::from_seed(42);
3694        let (request_id, tool_name, args_json, modified_args_json) =
3695            ("req-1", "tool.name", r#"{"a":1}"#, "");
3696        let (approved, approved_for_session) = (true, true);
3697        let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
3698        let caller = "persona-caller";
3699        let sandbox_mode = "sandboxed";
3700        let reason = "looks fine";
3701        let injected_context = "";
3702        let conversation_id = "conv-1";
3703        let nonce = "nonce-1";
3704
3705        // The exact pre-#1025 payload — the 13 fields `response_canonical` /
3706        // `response_payload` signed before `approver` existed — checked in as
3707        // literal bytes rather than recomputed here (`#1842`). It used to be
3708        // rebuilt with a `serde_json::json!` call so "both sides agree under
3709        // whatever key ordering is in effect for this build", which quietly
3710        // made the test agree with the implementation under either ordering
3711        // instead of pinning the one a deployment actually persisted. Signed by
3712        // `ApprovalSigner::from_seed(42)`; never regenerate it.
3713        let pre_approver_payload = PRE_APPROVER_RESPONSE_PAYLOAD.as_bytes();
3714        let expected_pk = signer.public_key_bytes();
3715        let expected_sig = crate::hex::decode(
3716            serde_json::from_slice::<Value>(pre_approver_payload)
3717                .expect("the frozen payload is valid JSON")["signature_hex"]
3718                .as_str()
3719                .expect("the frozen payload carries a signature"),
3720        )
3721        .expect("the frozen signature is valid hex");
3722
3723        let verified = verify_signed_response(pre_approver_payload)
3724            .expect("a pre-#1025 payload must still verify");
3725        assert_eq!(verified.request_id, request_id);
3726        assert_eq!(verified.caller, caller);
3727        assert_eq!(
3728            verified.approver, "",
3729            "no approver field existed on this payload — decodes to empty, not an error"
3730        );
3731
3732        // Re-signing the SAME inputs with today's code (empty approver) must
3733        // reproduce byte-identical output — the additive field is omitted
3734        // from the canonical entirely when empty, not merely defaulted.
3735        let (regenerated, sig, pk) = response_payload(
3736            request_id,
3737            tool_name,
3738            args_json,
3739            modified_args_json,
3740            approved,
3741            approved_for_session,
3742            &covered_capabilities,
3743            caller,
3744            "",
3745            sandbox_mode,
3746            reason,
3747            injected_context,
3748            conversation_id,
3749            nonce,
3750            &signer,
3751        );
3752        assert_eq!(
3753            regenerated, pre_approver_payload,
3754            "an empty approver must produce byte-identical canonical/payload to before #1025"
3755        );
3756        assert_eq!(
3757            sig, expected_sig,
3758            "an empty approver must sign byte-identically to before #1025"
3759        );
3760        assert_eq!(pk, expected_pk, "public key must be unchanged");
3761    }
3762
3763    #[test]
3764    fn session_response_round_trips_with_caller_binding() {
3765        let signer = ApprovalSigner::from_seed(42);
3766        let (payload, _sig, _pk) = response_payload(
3767            "req-1",
3768            "grep",
3769            r#"{"pattern":"x"}"#,
3770            "",
3771            true,
3772            true,
3773            &[],
3774            "slack:T1:U9",
3775            "",
3776            "workspace-write",
3777            "remember it",
3778            "",
3779            "conv-1",
3780            "nonce-1",
3781            &signer,
3782        );
3783        let verified = verify_signed_response(&payload).expect("session signature verifies");
3784        assert!(verified.approved);
3785        assert!(verified.approved_for_session, "carries session scope");
3786        assert_eq!(verified.caller, "slack:T1:U9");
3787        assert_eq!(verified.tool_name, "grep");
3788        assert!(verified.approved);
3789    }
3790
3791    #[test]
3792    fn tampered_session_or_caller_fails_verification() {
3793        let signer = ApprovalSigner::from_seed(42);
3794        let (payload, _sig, _pk) = response_payload(
3795            "req-1",
3796            "grep",
3797            "{}",
3798            "",
3799            true,
3800            true,
3801            &[],
3802            "slack:T1:U9",
3803            "",
3804            "workspace-write",
3805            "ok",
3806            "",
3807            "conv-1",
3808            "nonce-1",
3809            &signer,
3810        );
3811        // Every signed field is covered: re-scoping the memory to another user,
3812        // flipping the session flag, swapping the tool, re-targeting the
3813        // conversation, or re-rolling the nonce must all fail.
3814        for (field, val) in [
3815            ("caller", Value::String("slack:T1:ATTACKER".to_owned())),
3816            ("approved_for_session", Value::Bool(false)),
3817            ("tool_name", Value::String("rm".to_owned())),
3818            ("approved", Value::Bool(false)),
3819            ("args_json", Value::String("EVIL".to_owned())),
3820            // The approver's edit and injected context are signed too: forging
3821            // either — swapping in different execution args, or a different
3822            // injected instruction — must invalidate the signature.
3823            (
3824                "modified_args_json",
3825                Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
3826            ),
3827            ("injected_context", Value::String("do EVIL".to_owned())),
3828            (
3829                "sandbox_mode",
3830                Value::String("danger-full-access".to_owned()),
3831            ),
3832            ("conversation_id", Value::String("conv-OTHER".to_owned())),
3833            ("nonce", Value::String("nonce-OTHER".to_owned())),
3834        ] {
3835            let mut v: Value = serde_json::from_slice(&payload).unwrap();
3836            v[field] = val;
3837            assert!(
3838                verify_signed_response(&v.to_string().into_bytes()).is_none(),
3839                "tampering with {field} must fail verification"
3840            );
3841        }
3842    }
3843
3844    /// #67 gate A: an approver can approve with EDITED args. The signed response
3845    /// carries both the model's PROPOSED args (`args_json`, the identity binding)
3846    /// and the approver's EDIT (`modified_args_json`, what executes). Both are
3847    /// covered by the signature. Crucially, the identity binding
3848    /// ([`VerifiedResponse::authorizes_call`]) still matches the PROPOSED args —
3849    /// so a model that re-emits a different call on resume cannot inherit the
3850    /// approval — while the edit is a separate signed field the executor
3851    /// substitutes. The pure resolver (`polyc_agent::resolve_approved_call`) owns
3852    /// the "empty edit ⇒ run proposed" defaulting; here we only pin the crypto
3853    /// contract: both fields round-trip, and identity binds the proposed args.
3854    #[test]
3855    fn edited_response_binds_proposed_and_carries_modified() {
3856        let signer = ApprovalSigner::from_seed(7);
3857        let proposed = r#"{"path":"/etc/shadow"}"#;
3858        let edited = r#"{"path":"/etc/hostname"}"#;
3859        let (payload, _sig, _pk) = response_payload(
3860            "call-1",
3861            "read_file",
3862            proposed,
3863            edited,
3864            true,
3865            false,
3866            &[],
3867            "slack:T1:U9",
3868            "",
3869            "workspace-write",
3870            "narrowed the path",
3871            "",
3872            "conv-1",
3873            "nonce-1",
3874            &signer,
3875        );
3876        let v = verify_signed_response(&payload).expect("edited approval verifies");
3877        assert_eq!(v.args_json, proposed, "identity binds the proposed args");
3878        assert_eq!(
3879            v.modified_args_json, edited,
3880            "the edit is carried and signed"
3881        );
3882        // Identity binding is against the PROPOSED args — this is what the model
3883        // must re-present on resume; the edit is not part of the identity.
3884        assert!(
3885            v.authorizes_call("call-1", "read_file", proposed),
3886            "the exact proposed call is authorized"
3887        );
3888        assert!(
3889            !v.authorizes_call("call-1", "read_file", edited),
3890            "the edited args are NOT the identity — authorizes_call binds proposed"
3891        );
3892    }
3893
3894    /// #67 gate A: an unedited approval carries an empty `modified_args_json` and
3895    /// still authorizes exactly the proposed call — behaviourally identical to the
3896    /// pre-#67 approve path, so the common case is unchanged.
3897    #[test]
3898    fn unedited_response_carries_empty_edit() {
3899        let signer = ApprovalSigner::from_seed(7);
3900        let proposed = r#"{"path":"/tmp/x"}"#;
3901        let (payload, _sig, _pk) = response_payload(
3902            "call-1",
3903            "read_file",
3904            proposed,
3905            "",
3906            true,
3907            false,
3908            &[],
3909            "slack:T1:U9",
3910            "",
3911            "workspace-write",
3912            "ok",
3913            "",
3914            "conv-1",
3915            "nonce-1",
3916            &signer,
3917        );
3918        let v = verify_signed_response(&payload).expect("verifies");
3919        assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
3920        assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
3921        assert!(v.authorizes_call("call-1", "read_file", proposed));
3922    }
3923
3924    /// #67 gate A: an approver can attach context to inject before the tool runs.
3925    /// The injected context is a signed field that round-trips.
3926    #[test]
3927    fn injected_context_round_trips_and_is_signed() {
3928        let signer = ApprovalSigner::from_seed(7);
3929        let (payload, _sig, _pk) = response_payload(
3930            "call-1",
3931            "shell",
3932            r#"{"cmd":"ls"}"#,
3933            "",
3934            true,
3935            false,
3936            &[],
3937            "slack:T1:U9",
3938            "",
3939            "workspace-write",
3940            "ok",
3941            "only touch files under src/",
3942            "conv-1",
3943            "nonce-1",
3944            &signer,
3945        );
3946        let v = verify_signed_response(&payload).expect("verifies");
3947        assert_eq!(v.injected_context, "only touch files under src/");
3948    }
3949
3950    /// #67 (#539/#540): a dispatch-mutation record round-trips and verifies;
3951    /// tampering with any covered field — including the kind — fails.
3952    #[test]
3953    fn mutation_round_trips_and_tamper_fails() {
3954        let signer = ApprovalSigner::from_seed(7);
3955        let (payload, _s, _p) = mutation_payload(
3956            "tool_input_rewrite",
3957            "call-1",
3958            "shell",
3959            "conv-1",
3960            r#"{"cmd":"rm -rf /"}"#,
3961            r#"{"cmd":"rm /tmp/x"}"#,
3962            &signer,
3963        );
3964        assert_eq!(
3965            verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
3966            Some((
3967                "tool_input_rewrite".to_owned(),
3968                r#"{"cmd":"rm -rf /"}"#.to_owned(),
3969                r#"{"cmd":"rm /tmp/x"}"#.to_owned()
3970            ))
3971        );
3972        for field in [
3973            "kind",
3974            "tool_call_id",
3975            "tool_name",
3976            "conversation_id",
3977            "before",
3978            "after",
3979        ] {
3980            let mut v: Value = serde_json::from_slice(&payload).unwrap();
3981            v[field] = Value::String("EVIL".to_owned());
3982            assert!(
3983                verify_mutation(&v.to_string().into_bytes()).is_none(),
3984                "tampering with {field} must fail"
3985            );
3986        }
3987    }
3988
3989    /// #67 (#538): a deferred "send back" round-trips and verifies; tampering
3990    /// with any covered field fails.
3991    #[test]
3992    fn deferred_round_trips_and_tamper_fails() {
3993        let signer = ApprovalSigner::from_seed(7);
3994        let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
3995        assert_eq!(
3996            verify_deferred(&payload),
3997            Some((
3998                "call-1".to_owned(),
3999                "conv-1".to_owned(),
4000                "need more info".to_owned()
4001            ))
4002        );
4003        for field in ["request_id", "conversation_id", "reason"] {
4004            let mut v: Value = serde_json::from_slice(&payload).unwrap();
4005            v[field] = Value::String("EVIL".to_owned());
4006            assert!(
4007                verify_deferred(&v.to_string().into_bytes()).is_none(),
4008                "tampering with {field} must fail"
4009            );
4010        }
4011    }
4012
4013    #[test]
4014    fn wire_verification_round_trips_and_binds_session_and_caller() {
4015        let signer = ApprovalSigner::from_seed(7);
4016        let (payload, _sig, _pk) = response_payload(
4017            "req-x",
4018            "grep",
4019            r#"{"p":"x"}"#,
4020            "",
4021            true,
4022            true,
4023            &[],
4024            "slack:T1:U9",
4025            "",
4026            "workspace-write",
4027            "go",
4028            "",
4029            "conv-7",
4030            "nonce-7",
4031            &signer,
4032        );
4033        let d = decode_response_full(&payload).expect("decoded payload");
4034        // decode_response_full surfaces every signed field.
4035        assert!(d.approved_for_session);
4036        assert_eq!(d.caller, "slack:T1:U9");
4037        assert_eq!(d.sandbox_mode, "workspace-write");
4038        assert_eq!(d.conversation_id, "conv-7");
4039        assert_eq!(d.nonce, "nonce-7");
4040        assert!(verify_wire_response(
4041            &d.request_id,
4042            &d.tool_name,
4043            &d.args_json,
4044            "",
4045            d.approved,
4046            d.approved_for_session,
4047            &[],
4048            &d.caller,
4049            &d.approver,
4050            &d.sandbox_mode,
4051            &d.reason,
4052            "",
4053            &d.conversation_id,
4054            &d.nonce,
4055            &d.signer_pk_hex,
4056            &d.signature_hex
4057        ));
4058        // Re-scoping the remembered grant to a different caller over the wire
4059        // must fail — the caller is covered by the signature.
4060        assert!(!verify_wire_response(
4061            &d.request_id,
4062            &d.tool_name,
4063            &d.args_json,
4064            "",
4065            d.approved,
4066            d.approved_for_session,
4067            &[],
4068            "slack:T1:ATTACKER",
4069            &d.approver,
4070            &d.sandbox_mode,
4071            &d.reason,
4072            "",
4073            &d.conversation_id,
4074            &d.nonce,
4075            &d.signer_pk_hex,
4076            &d.signature_hex
4077        ));
4078        // Tampering with the bound args over the wire invalidates the sig.
4079        assert!(!verify_wire_response(
4080            &d.request_id,
4081            &d.tool_name,
4082            r#"{"p":"EVIL"}"#,
4083            "",
4084            d.approved,
4085            d.approved_for_session,
4086            &[],
4087            &d.caller,
4088            &d.approver,
4089            &d.sandbox_mode,
4090            &d.reason,
4091            "",
4092            &d.conversation_id,
4093            &d.nonce,
4094            &d.signer_pk_hex,
4095            &d.signature_hex
4096        ));
4097        // Replaying the token into a DIFFERENT conversation over the wire must
4098        // fail — `conversation_id` is covered by the signature (#370, #77 3B).
4099        assert!(!verify_wire_response(
4100            &d.request_id,
4101            &d.tool_name,
4102            &d.args_json,
4103            "",
4104            d.approved,
4105            d.approved_for_session,
4106            &[],
4107            &d.caller,
4108            &d.approver,
4109            &d.sandbox_mode,
4110            &d.reason,
4111            "",
4112            "conv-OTHER",
4113            &d.nonce,
4114            &d.signer_pk_hex,
4115            &d.signature_hex
4116        ));
4117    }
4118
4119    /// #1025: `approver` is a distinct fact from `caller`, recoverable from a
4120    /// signed response and covered by the signature — the same-person and
4121    /// different-person (admin-approves-for-someone-else) cases both round-trip
4122    /// correctly, and the two identities are never conflated.
4123    #[test]
4124    fn approver_is_recoverable_and_distinct_from_caller() {
4125        let signer = ApprovalSigner::from_seed(3);
4126
4127        // Same-person: the caller approving their own paused turn signs an
4128        // approver equal to caller. Distinguishable by field, still equal by
4129        // value — this is the common case, not a degenerate one.
4130        let (self_approved, ..) = response_payload(
4131            "req-1",
4132            "grep",
4133            r#"{"q":"x"}"#,
4134            "",
4135            true,
4136            false,
4137            &[],
4138            "slack:T1:U9",
4139            "slack:T1:U9",
4140            "workspace-write",
4141            "self-approved",
4142            "",
4143            "conv-1",
4144            "nonce-1",
4145            &signer,
4146        );
4147        let self_decoded = decode_response_full(&self_approved).expect("decodes");
4148        assert_eq!(self_decoded.caller, "slack:T1:U9");
4149        assert_eq!(self_decoded.approver, "slack:T1:U9");
4150        let self_verified = verify_signed_response(&self_approved).expect("verifies");
4151        assert_eq!(self_verified.caller, self_verified.approver);
4152
4153        // Different-person: an admin approves on behalf of the turn's own
4154        // beneficiary. `caller` stays the beneficiary (principal_ref's own
4155        // resume-attribution source, per the sibling #1024 fix); `approver` is
4156        // the admin — a genuinely different persona, both recoverable and
4157        // distinguishable from the same signed record.
4158        let (admin_approved, ..) = response_payload(
4159            "req-2",
4160            "rm",
4161            r#"{"path":"/tmp/x"}"#,
4162            "",
4163            true,
4164            false,
4165            &[],
4166            "slack:T1:BENEFICIARY",
4167            "slack:T1:ADMIN",
4168            "workspace-write",
4169            "approved on your behalf",
4170            "",
4171            "conv-2",
4172            "nonce-2",
4173            &signer,
4174        );
4175        let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
4176        assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
4177        assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
4178        assert_ne!(
4179            admin_decoded.caller, admin_decoded.approver,
4180            "admin-approves-for-someone-else must decode two DISTINCT identities"
4181        );
4182        let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
4183        assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
4184        assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
4185
4186        // Tampering with the signed approver (re-scoping the policy/audit fact
4187        // to a different identity post-signing) invalidates the signature —
4188        // it is covered exactly like `caller`.
4189        let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
4190        v["approver"] = serde_json::json!("slack:T1:ATTACKER");
4191        assert!(
4192            verify_signed_response(v.to_string().as_bytes()).is_none(),
4193            "a tampered approver must fail verification"
4194        );
4195    }
4196
4197    /// `#377` core invariant: a reviewer auto-approval signs a payload that is
4198    /// BYTE-IDENTICAL to the one a human signs for the same decision, yet is
4199    /// distinguishable in the audit log by its signed `reason`.
4200    ///
4201    /// There is a single signing function ([`response_payload`]); the reviewer
4202    /// path is just that function with `approved == true`,
4203    /// `approved_for_session == false`, and an auto-review `reason`. We pin two
4204    /// properties:
4205    ///   1. With every argument INCLUDING the reason held equal, the auto and
4206    ///      human calls produce identical bytes + signature — proving the auto
4207    ///      path adds no hidden field and shares the human signing contract
4208    ///      exactly (guards against a future forked auto-signer).
4209    ///   2. With the auto-review reason, the response still verifies, still
4210    ///      carries `approved && !approved_for_session`, and is flagged by
4211    ///      [`is_auto_review_reason`] while a human reason is not — so the event
4212    ///      log can tell machine from human consent off the signed field alone.
4213    ///   3. Substitution resistance: flipping ANY one bound field — the tool, the
4214    ///      args, the beneficiary caller, or the request id — changes both the
4215    ///      canonical bytes AND the signature, so a forged or substituted
4216    ///      approval (signed for one call, replayed to authorize another) cannot
4217    ///      match. Without this, (1)'s byte-identical property would be vacuous.
4218    #[test]
4219    fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
4220        let signer = ApprovalSigner::from_seed(7);
4221        let (rid, tool, args, caller, mode, conv, nonce) = (
4222            "req-9",
4223            "file_read",
4224            r#"{"path":"a.txt"}"#,
4225            "slack:T1:U9",
4226            "read-only",
4227            "conv-9",
4228            "nonce-9",
4229        );
4230
4231        // (1) Same decision + same reason via the one signing path ⇒ identical
4232        // bytes regardless of which side "produced" it. The reviewer is not a
4233        // separate signer; it cannot diverge structurally from the human path.
4234        let shared_reason = auto_review_reason("low");
4235        let human_like = response_payload(
4236            rid,
4237            tool,
4238            args,
4239            "",
4240            true,
4241            false,
4242            &[],
4243            caller,
4244            "",
4245            mode,
4246            &shared_reason,
4247            "",
4248            conv,
4249            nonce,
4250            &signer,
4251        );
4252        let reviewer = response_payload(
4253            rid,
4254            tool,
4255            args,
4256            "",
4257            true,
4258            false,
4259            &[],
4260            caller,
4261            "",
4262            mode,
4263            &shared_reason,
4264            "",
4265            conv,
4266            nonce,
4267            &signer,
4268        );
4269        assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
4270        assert_eq!(human_like.1, reviewer.1, "signature must be identical");
4271
4272        // (2) The auto-review response verifies and is an approve-once decision.
4273        let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
4274        assert!(verified.approved);
4275        assert!(
4276            !verified.approved_for_session,
4277            "a machine decision is never remembered per-caller"
4278        );
4279        assert!(
4280            is_auto_review_reason(&verified.reason),
4281            "the signed reason marks this as an auto-approval"
4282        );
4283
4284        // A genuine human approval over the same call is NOT flagged as auto —
4285        // the distinguisher reads the signed reason, so it is unforgeable.
4286        let (human_payload, _s, _p) = response_payload(
4287            rid,
4288            tool,
4289            args,
4290            "",
4291            true,
4292            false,
4293            &[],
4294            caller,
4295            "",
4296            mode,
4297            "looks fine",
4298            "",
4299            conv,
4300            nonce,
4301            &signer,
4302        );
4303        let human = verify_signed_response(&human_payload).expect("human verifies");
4304        assert!(!is_auto_review_reason(&human.reason));
4305
4306        // The two payloads differ ONLY in the reason field — every bound
4307        // identity / decision / scope field is byte-equal, which is what makes
4308        // the auto path indistinguishable from a human one except by reason.
4309        let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
4310        let h: Value = serde_json::from_slice(&human_payload).unwrap();
4311        for field in [
4312            "request_id",
4313            "tool_name",
4314            "args_json",
4315            "modified_args_json",
4316            "approved",
4317            "approved_for_session",
4318            "caller",
4319            "sandbox_mode",
4320            "injected_context",
4321            "conversation_id",
4322            "nonce",
4323        ] {
4324            assert_eq!(a[field], h[field], "{field} must match the human payload");
4325        }
4326        assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
4327
4328        // (3) Substitution resistance: each variant holds every input equal to
4329        // `reviewer` and flips exactly ONE bound field. A different tool / args /
4330        // caller / request_id / conversation / nonce must change BOTH the
4331        // canonical bytes and the signature — so an approval signed for
4332        // `(req-9, file_read, a.txt, U9, conv-9, nonce-9)` can never be replayed to
4333        // authorize a write, a different path, a different beneficiary, a
4334        // different CONVERSATION (#370, #77 3B), or re-presented under a new nonce.
4335        // This is what makes (1)'s "byte-identical for identical inputs" a
4336        // security property and not just determinism.
4337        let base = &reviewer.0;
4338        let base_sig = &reviewer.1;
4339        for (label, variant) in [
4340            (
4341                "tool",
4342                response_payload(
4343                    rid,
4344                    "file_write",
4345                    args,
4346                    "",
4347                    true,
4348                    false,
4349                    &[],
4350                    caller,
4351                    "",
4352                    mode,
4353                    &shared_reason,
4354                    "",
4355                    conv,
4356                    nonce,
4357                    &signer,
4358                ),
4359            ),
4360            (
4361                "args",
4362                response_payload(
4363                    rid,
4364                    tool,
4365                    r#"{"path":"b.txt"}"#,
4366                    "",
4367                    true,
4368                    false,
4369                    &[],
4370                    caller,
4371                    "",
4372                    mode,
4373                    &shared_reason,
4374                    "",
4375                    conv,
4376                    nonce,
4377                    &signer,
4378                ),
4379            ),
4380            (
4381                "caller",
4382                response_payload(
4383                    rid,
4384                    tool,
4385                    args,
4386                    "",
4387                    true,
4388                    false,
4389                    &[],
4390                    "slack:T1:UEVIL",
4391                    "",
4392                    mode,
4393                    &shared_reason,
4394                    "",
4395                    conv,
4396                    nonce,
4397                    &signer,
4398                ),
4399            ),
4400            (
4401                "request_id",
4402                response_payload(
4403                    "req-OTHER",
4404                    tool,
4405                    args,
4406                    "",
4407                    true,
4408                    false,
4409                    &[],
4410                    caller,
4411                    "",
4412                    mode,
4413                    &shared_reason,
4414                    "",
4415                    conv,
4416                    nonce,
4417                    &signer,
4418                ),
4419            ),
4420            (
4421                "conversation_id",
4422                response_payload(
4423                    rid,
4424                    tool,
4425                    args,
4426                    "",
4427                    true,
4428                    false,
4429                    &[],
4430                    caller,
4431                    "",
4432                    mode,
4433                    &shared_reason,
4434                    "",
4435                    "conv-OTHER",
4436                    nonce,
4437                    &signer,
4438                ),
4439            ),
4440            (
4441                "nonce",
4442                response_payload(
4443                    rid,
4444                    tool,
4445                    args,
4446                    "",
4447                    true,
4448                    false,
4449                    &[],
4450                    caller,
4451                    "",
4452                    mode,
4453                    &shared_reason,
4454                    "",
4455                    conv,
4456                    "nonce-OTHER",
4457                    &signer,
4458                ),
4459            ),
4460        ] {
4461            assert_ne!(
4462                &variant.0, base,
4463                "{label}: a different {label} must change the canonical bytes"
4464            );
4465            assert_ne!(
4466                &variant.1, base_sig,
4467                "{label}: a different {label} must change the signature"
4468            );
4469        }
4470    }
4471
4472    /// `#370` (a): a capability token signed for one conversation is REJECTED
4473    /// when presented for another. The `conversation_id` is covered by the
4474    /// signature, so it cannot be re-targeted without invalidating it; the
4475    /// consume gate ([`verify_capability`]) refuses any token whose signed
4476    /// conversation does not match the one it is being consumed in. Closes `#77`
4477    /// bug 3B (one signed response valid across conversations sharing a
4478    /// `request_id`).
4479    #[test]
4480    fn capability_rejected_across_conversations() {
4481        let signer = ApprovalSigner::from_seed(11);
4482        let (payload, _sig, _pk) = response_payload(
4483            "call-0",
4484            "delete_file",
4485            r#"{"path":"/etc/hosts"}"#,
4486            "",
4487            true,
4488            false,
4489            &[],
4490            "slack:T1:U9",
4491            "",
4492            "workspace-write",
4493            "ok",
4494            "",
4495            "conv-A",
4496            "nonce-A",
4497            &signer,
4498        );
4499        let consumed = HashSet::new();
4500        // Same conversation, fresh nonce ⇒ honored.
4501        assert!(
4502            verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4503                .is_some(),
4504            "a token must verify in the conversation it was signed for"
4505        );
4506        // Different conversation ⇒ rejected, even though request_id/tool/args
4507        // are byte-identical (the #77 3B replay).
4508        assert!(
4509            verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
4510                .is_none(),
4511            "a token signed for conv-A must be rejected when consumed in conv-B"
4512        );
4513    }
4514
4515    /// `#370` (b): a single-use token is REJECTED on a second presentation. The
4516    /// consumer records the token's `nonce` after honoring it; a re-presentation
4517    /// of the SAME signed bytes (a captured/replayed token) is then refused.
4518    #[test]
4519    fn capability_is_single_use() {
4520        let signer = ApprovalSigner::from_seed(11);
4521        let (payload, _sig, _pk) = response_payload(
4522            "call-0",
4523            "delete_file",
4524            r#"{"path":"/etc/hosts"}"#,
4525            "",
4526            true,
4527            false,
4528            &[],
4529            "slack:T1:U9",
4530            "",
4531            "workspace-write",
4532            "ok",
4533            "",
4534            "conv-A",
4535            "nonce-A",
4536            &signer,
4537        );
4538        let mut consumed = HashSet::new();
4539        // First presentation is honored and yields the bound nonce.
4540        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4541            .expect("first use honored");
4542        assert_eq!(v.nonce, "nonce-A");
4543        consumed.insert(v.nonce.clone());
4544        // Second presentation of the same token is rejected — single-use.
4545        assert!(
4546            verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4547                .is_none(),
4548            "a spent token must be rejected on re-presentation"
4549        );
4550    }
4551
4552    /// `#370` (c)/(d): the token is ARGS-BOUND. A verified, approved token
4553    /// authorizes ONLY the exact `(request_id, tool_name, args_json)` it was
4554    /// signed for; a call with different args (a captured approval reused with a
4555    /// new payload) is NOT authorized, while the matching call IS.
4556    #[test]
4557    fn capability_authorizes_only_matching_args() {
4558        let signer = ApprovalSigner::from_seed(11);
4559        let (payload, _sig, _pk) = response_payload(
4560            "call-0",
4561            "delete_file",
4562            r#"{"path":"/tmp/scratch"}"#,
4563            "",
4564            true,
4565            false,
4566            &[],
4567            "slack:T1:U9",
4568            "",
4569            "workspace-write",
4570            "ok",
4571            "",
4572            "conv-A",
4573            "nonce-A",
4574            &signer,
4575        );
4576        let consumed = HashSet::new();
4577        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4578            .expect("verifies in conv-A");
4579        // (c) different args ⇒ NOT authorized.
4580        assert!(
4581            !v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
4582            "a token must not authorize a call with different args"
4583        );
4584        // …nor a different tool with the same id.
4585        assert!(
4586            !v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
4587            "a token must not authorize a different tool"
4588        );
4589        // (d) the exact signed call ⇒ authorized (happy path).
4590        assert!(
4591            v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
4592            "the exact signed call must be authorized"
4593        );
4594    }
4595
4596    /// A denial never authorizes a call, regardless of identity match.
4597    #[test]
4598    fn denied_capability_authorizes_nothing() {
4599        let signer = ApprovalSigner::from_seed(11);
4600        let (payload, _sig, _pk) = response_payload(
4601            "call-0",
4602            "delete_file",
4603            "{}",
4604            "",
4605            false,
4606            false,
4607            &[],
4608            "slack:T1:U9",
4609            "",
4610            "workspace-write",
4611            "deny",
4612            "",
4613            "conv-A",
4614            "nonce-A",
4615            &signer,
4616        );
4617        let consumed = HashSet::new();
4618        let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
4619            .expect("verifies");
4620        assert!(!v.approved);
4621        assert!(
4622            !v.authorizes_call("call-0", "delete_file", "{}"),
4623            "a denied token authorizes nothing even on an exact identity match"
4624        );
4625    }
4626
4627    #[test]
4628    fn request_payload_round_trips_id() {
4629        let bytes = request_payload(
4630            "call-7",
4631            "rm",
4632            r#"{"path":"/etc"}"#,
4633            "workspace-write",
4634            "",
4635            &[],
4636            "",
4637        );
4638        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
4639        // An ordinary gated call carries no override reason.
4640        assert_eq!(decode_request_reason(&bytes), "");
4641    }
4642
4643    #[test]
4644    fn request_payload_carries_override_reason() {
4645        // The lethal-trifecta override reason rides the durable approval_request
4646        // so the signed log records WHY a trifecta-gated call was paused.
4647        let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
4648        let bytes = request_payload(
4649            "call-9",
4650            "web_fetch",
4651            r#"{"url":"https://x"}"#,
4652            "",
4653            reason,
4654            &[],
4655            "",
4656        );
4657        assert_eq!(decode_request_reason(&bytes), reason);
4658        // The existing scalar fields still decode unchanged.
4659        assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
4660        assert_eq!(
4661            decode_request_fields(&bytes),
4662            Some((
4663                "call-9".to_owned(),
4664                "web_fetch".to_owned(),
4665                r#"{"url":"https://x"}"#.to_owned()
4666            ))
4667        );
4668    }
4669
4670    // #1496: the computed-preview enrichment round-trips through the durable
4671    // `approval_request` payload, so `ListPending` recovery can re-render the
4672    // same preview a lost-stream card showed.
4673    #[test]
4674    fn request_preview_json_round_trips() {
4675        let preview =
4676            r#"{"prompt_text":"hi","next_fires":[],"zone_name":"UTC","zone_is_fallback":true}"#;
4677        let bytes = request_payload("call-10", "routine_create", "{}", "", "", &[], preview);
4678        let decoded = decode_request_preview_json(&bytes).expect("preview present");
4679        // Compare parsed values, not raw bytes: `preview_json` is re-serialized
4680        // through `serde_json::Value`, which is free to reorder keys.
4681        let expected: Value = serde_json::from_str(preview).unwrap();
4682        let actual: Value = serde_json::from_str(&decoded).unwrap();
4683        assert_eq!(actual, expected);
4684    }
4685
4686    #[test]
4687    fn request_preview_json_is_absent_when_empty_or_missing() {
4688        let bytes = request_payload("call-11", "grep", "{}", "", "", &[], "");
4689        assert_eq!(decode_request_preview_json(&bytes), None);
4690        assert_eq!(decode_request_preview_json(b"{\"nope\":1}"), None);
4691    }
4692
4693    /// Golden test: `receipt_payload` must produce the EXACT **v3** signed-payload
4694    /// bytes — `version` + the six settlement facts + the four binding fields +
4695    /// the two payer-attribution fields, in that order — plus the two uncovered
4696    /// signature fields. We recompute the expected canonical+full JSON inline
4697    /// and assert the struct form is byte-identical, pinning the v3 key set,
4698    /// order, and signature so a future edit cannot silently reorder, rename,
4699    /// or swap a covered field. The expectation is a literal string: the key
4700    /// order is part of what this test pins, and `#1842` made it stable to
4701    /// pin. It used to be recomputed with a `json!` call, because `serde_json`
4702    /// feature unification (`preserve_order`) flipped key order per build
4703    /// selection — but a recomputed expectation agrees with the implementation
4704    /// under either ordering, which pins the field set and nothing else.
4705    ///
4706    /// The `version` below is the frozen literal `3`, not `RECEIPT_VERSION`:
4707    /// this test pins what the *writer* emits, and mirroring the constant would
4708    /// leave it passing while silently pinning v4. A bump re-freezes it against
4709    /// the new version — the already-signed v2 bytes are preserved by
4710    /// `frozen_v2_receipts_survive_a_later_version_bump`.
4711    #[test]
4712    fn receipt_payload_pins_v3_canonical_shape() {
4713        let signer = ApprovalSigner::from_seed(99);
4714        let (reference, amount, currency, recipient, method, timestamp) = (
4715            "tx-abc",
4716            "0.01",
4717            "USDC",
4718            "0xrecipient",
4719            "tempo",
4720            "2026-06-02T00:00:00Z",
4721        );
4722        let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
4723            "outbound_payment_receipt",
4724            "call-1",
4725            "42",
4726            "abcd1234",
4727            "conv-xyz",
4728        );
4729        let (payer_kind, paying_account) = ("linked_wallet", "0xpayer");
4730
4731        // The exact v3 JSON the implementation must build, written out here as
4732        // a literal. `3` is a frozen literal, not `RECEIPT_VERSION`: mirroring
4733        // the constant would let this test keep passing while pinning v4 the
4734        // day the constant moved, which is exactly the drift it exists to
4735        // catch. The key ORDER is likewise pinned by this string rather than
4736        // recomputed with a `json!` call (`#1842`) — a recomputed expectation
4737        // agrees with the implementation under whatever ordering the build
4738        // resolves, which is the opposite of pinning anything.
4739        let expected_canonical = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-abc","amount":"0.01","currency":"USDC","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-1","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-xyz","payer_kind":"linked_wallet","paying_account":"0xpayer"}"#;
4740        let expected_sig = signer.sign(expected_canonical.as_bytes());
4741        let expected_pk = signer.public_key_bytes();
4742        let expected_full = format!(
4743            r#"{{{expected_body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
4744            expected_body = expected_canonical
4745                .trim_start_matches('{')
4746                .trim_end_matches('}'),
4747            pk = crate::hex::lower(&expected_pk),
4748            sig = crate::hex::lower(&expected_sig),
4749        );
4750
4751        let (payload, sig, pk) = receipt_payload(
4752            &ReceiptPayload {
4753                kind,
4754                reference,
4755                amount,
4756                currency,
4757                recipient,
4758                method,
4759                timestamp,
4760                tool_call_id,
4761                approval_pos,
4762                approved_args_hash,
4763                subject,
4764                payer_kind,
4765                paying_account,
4766            },
4767            &signer,
4768        );
4769
4770        assert_eq!(
4771            String::from_utf8(payload).unwrap(),
4772            expected_full,
4773            "v3 receipt payload must be byte-identical to the pinned v3 shape"
4774        );
4775        assert_eq!(
4776            sig, expected_sig,
4777            "signature must match the pinned v3 shape"
4778        );
4779        assert_eq!(pk, expected_pk, "public key must be unchanged");
4780    }
4781
4782    /// Single-source guard: both the signing path (`receipt_payload`) and the
4783    /// verifying path (`verify_signed_receipt`) MUST derive their canonical
4784    /// signed JSON from the one [`ReceiptPayload::canonical_json_v3`] builder,
4785    /// so the signed field set/order cannot drift between sign and verify.
4786    ///
4787    /// We assert the canonical bytes the signer commits to are exactly the
4788    /// bytes `canonical_json_v3` produces for the same fields, and that a receipt
4789    /// reconstructed from `VerifiedReceipt` (the verify path's owned form)
4790    /// yields the identical canonical bytes. If a future edit added a field to
4791    /// one json! block but not the other, those bytes would differ and this
4792    /// (plus the round-trip) would fail.
4793    #[test]
4794    fn receipt_sign_and_verify_share_one_canonical_source() {
4795        let signer = ApprovalSigner::from_seed(99);
4796        let trusted = vec![signer.public_key_bytes()];
4797        let fields = ReceiptPayload {
4798            kind: "outbound_payment_receipt",
4799            reference: "tx-abc",
4800            amount: "0.01",
4801            currency: "USDC",
4802            recipient: "0xrecipient",
4803            method: "tempo",
4804            timestamp: "2026-06-02T00:00:00Z",
4805            tool_call_id: "call-1",
4806            approval_pos: "42",
4807            approved_args_hash: "abcd1234",
4808            subject: "conv-xyz",
4809            payer_kind: "linked_wallet",
4810            paying_account: "0xpayer",
4811        };
4812
4813        // The signed bytes the producer commits to.
4814        let signed_bytes = canonical_bytes(&fields.canonical_json_v3());
4815        let expected_sig = signer.sign(&signed_bytes);
4816        let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
4817        assert_eq!(
4818            sig, expected_sig,
4819            "receipt_payload must sign exactly ReceiptPayload::canonical_json_v3"
4820        );
4821
4822        // The verify path reconstructs the same canonical bytes from its owned
4823        // form before checking the signature.
4824        let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
4825        let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
4826        let verified_fields = ReceiptPayload {
4827            kind: &verified.kind,
4828            reference: &verified.reference,
4829            amount: &verified.amount,
4830            currency: &verified.currency,
4831            recipient: &verified.recipient,
4832            method: &verified.method,
4833            timestamp: &verified.timestamp,
4834            tool_call_id: &verified.tool_call_id,
4835            approval_pos: &verified.approval_pos,
4836            approved_args_hash: &verified.approved_args_hash,
4837            subject: &verified.subject,
4838            payer_kind: &verified.payer_kind,
4839            paying_account: &verified.paying_account,
4840        };
4841        let verified_canonical = canonical_bytes(&verified_fields.canonical_json_v3());
4842        assert_eq!(
4843            verified_canonical, signed_bytes,
4844            "verify path must derive canonical JSON from the same single source"
4845        );
4846    }
4847
4848    #[test]
4849    fn crypto_receipt_payload_signs_and_verifies() {
4850        let signer = ApprovalSigner::from_seed(99);
4851        let trusted = vec![signer.public_key_bytes()];
4852        let (payload, _sig, _pk) = receipt_payload(
4853            &ReceiptPayload {
4854                kind: "outbound_payment_receipt",
4855                reference: "tx-abc",
4856                amount: "0.01",
4857                currency: "USDC",
4858                recipient: "0xrecipient",
4859                method: "tempo",
4860                timestamp: "2026-06-02T00:00:00Z",
4861                tool_call_id: "call-1",
4862                approval_pos: "42",
4863                approved_args_hash: "abcd1234",
4864                subject: "conv-xyz",
4865                payer_kind: "linked_wallet",
4866                paying_account: "0xpayer",
4867            },
4868            &signer,
4869        );
4870        let verified = verify_signed_receipt(&payload, &trusted)
4871            .expect("signature verifies on untampered receipt");
4872        assert_eq!(verified.reference, "tx-abc");
4873        assert_eq!(verified.amount, "0.01");
4874        assert_eq!(verified.currency, "USDC");
4875        assert_eq!(verified.recipient, "0xrecipient");
4876        assert_eq!(verified.method, "tempo");
4877        assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
4878        // The v2 kind + binding tuple + opaque subject round-trip and are
4879        // covered by the signature.
4880        assert_eq!(verified.version, RECEIPT_VERSION);
4881        assert_eq!(verified.kind, "outbound_payment_receipt");
4882        assert_eq!(verified.tool_call_id, "call-1");
4883        assert_eq!(verified.approval_pos, "42");
4884        assert_eq!(verified.approved_args_hash, "abcd1234");
4885        assert_eq!(verified.subject, "conv-xyz");
4886        // The v3 payer attribution round-trips too.
4887        assert_eq!(verified.payer_kind, "linked_wallet");
4888        assert_eq!(verified.paying_account, "0xpayer");
4889    }
4890
4891    #[test]
4892    fn tampered_receipt_fails_verification() {
4893        let signer = ApprovalSigner::from_seed(99);
4894        let trusted = vec![signer.public_key_bytes()];
4895        let (payload, _sig, _pk) = receipt_payload(
4896            &ReceiptPayload {
4897                kind: "outbound_payment_receipt",
4898                reference: "tx-abc",
4899                amount: "0.01",
4900                currency: "USDC",
4901                recipient: "0xrecipient",
4902                method: "tempo",
4903                timestamp: "2026-06-02T00:00:00Z",
4904                tool_call_id: "call-1",
4905                approval_pos: "42",
4906                approved_args_hash: "abcd1234",
4907                subject: "conv-xyz",
4908                payer_kind: "linked_wallet",
4909                paying_account: "0xpayer",
4910            },
4911            &signer,
4912        );
4913        // Tamper with a covered field (amount).
4914        let mut v: Value = serde_json::from_slice(&payload).unwrap();
4915        v["amount"] = Value::String("9999.00".to_owned());
4916        let tampered = v.to_string().into_bytes();
4917        assert!(verify_signed_receipt(&tampered, &trusted).is_none());
4918    }
4919
4920    #[test]
4921    fn tampered_receipt_binding_field_fails_verification() {
4922        let signer = ApprovalSigner::from_seed(99);
4923        let trusted = vec![signer.public_key_bytes()];
4924        let (payload, _sig, _pk) = receipt_payload(
4925            &ReceiptPayload {
4926                kind: "outbound_payment_receipt",
4927                reference: "tx-abc",
4928                amount: "0.01",
4929                currency: "USDC",
4930                recipient: "0xrecipient",
4931                method: "tempo",
4932                timestamp: "2026-06-02T00:00:00Z",
4933                tool_call_id: "call-1",
4934                approval_pos: "42",
4935                approved_args_hash: "abcd1234",
4936                subject: "conv-xyz",
4937                payer_kind: "linked_wallet",
4938                paying_account: "0xpayer",
4939            },
4940            &signer,
4941        );
4942        // Re-pointing the receipt at a different approval position invalidates
4943        // the signature — the binding tuple is covered, not advisory.
4944        let mut v: Value = serde_json::from_slice(&payload).unwrap();
4945        v["approval_pos"] = Value::String("7".to_owned());
4946        let tampered = v.to_string().into_bytes();
4947        assert!(verify_signed_receipt(&tampered, &trusted).is_none());
4948
4949        // Re-filing the payload under the other direction's kind likewise
4950        // fails — the signed `kind` is what makes direction trustworthy
4951        // independent of the (unsigned) stored event kind.
4952        let mut v: Value = serde_json::from_slice(&payload).unwrap();
4953        v["kind"] = Value::String("payment_receipt".to_owned());
4954        let refiled = v.to_string().into_bytes();
4955        assert!(verify_signed_receipt(&refiled, &trusted).is_none());
4956
4957        // Re-attributing the payer likewise fails — payer attribution is
4958        // covered, not advisory: an attacker cannot rewrite "the deployment
4959        // paid" into "the linked wallet paid" (or vice versa) on an
4960        // already-signed receipt.
4961        let mut v: Value = serde_json::from_slice(&payload).unwrap();
4962        v["payer_kind"] = Value::String("deployment".to_owned());
4963        let repayered = v.to_string().into_bytes();
4964        assert!(verify_signed_receipt(&repayered, &trusted).is_none());
4965    }
4966
4967    /// The acceptance gate (issue #806): a receipt signed by a key that is
4968    /// NOT on the trusted-signer allow-list must fail verification, even
4969    /// though its signature is perfectly self-consistent — proving the
4970    /// allow-list, not mere signature validity, gates trust. The SAME
4971    /// receipt verifies once its signer is added to the allow-list.
4972    #[test]
4973    fn receipt_from_non_allowlisted_signer_is_rejected() {
4974        let trusted_signer = ApprovalSigner::from_seed(99);
4975        let attacker_signer = ApprovalSigner::from_seed(31337);
4976        let fields = ReceiptPayload {
4977            kind: "outbound_payment_receipt",
4978            reference: "tx-forged",
4979            amount: "100.00",
4980            currency: "USDC",
4981            recipient: "0xattacker",
4982            method: "tempo",
4983            timestamp: "2026-06-02T00:00:00Z",
4984            tool_call_id: "call-1",
4985            approval_pos: "42",
4986            approved_args_hash: "abcd1234",
4987            subject: "conv-xyz",
4988            payer_kind: "linked_wallet",
4989            paying_account: "0xpayer",
4990        };
4991        // The attacker signs a fully self-consistent receipt with THEIR OWN
4992        // key (not a forged signature over the trusted signer's key).
4993        let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
4994
4995        // An allow-list that does not include the attacker's key rejects it,
4996        // no matter how internally consistent the signature is.
4997        let trusted = vec![trusted_signer.public_key_bytes()];
4998        assert!(
4999            verify_signed_receipt(&payload, &trusted).is_none(),
5000            "a receipt signed by a non-allow-listed key must not verify"
5001        );
5002
5003        // The IDENTICAL payload verifies once the attacker's key is
5004        // allow-listed (proves the rejection above was the allow-list, not
5005        // some other defect).
5006        let trusted_plus_attacker = vec![
5007            trusted_signer.public_key_bytes(),
5008            attacker_signer.public_key_bytes(),
5009        ];
5010        assert!(
5011            verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
5012            "the same receipt must verify once its signer is allow-listed"
5013        );
5014
5015        // An empty allow-list rejects every signer, including the deployment's
5016        // own — fail closed, never fail open on a misconfigured (empty) list.
5017        assert!(verify_signed_receipt(&payload, &[]).is_none());
5018    }
5019
5020    /// The acceptance gate (issue #845): a `grant_replay` audit record signed by
5021    /// a key that is NOT on the trusted-signer allow-list must fail
5022    /// verification, even though its signature is perfectly self-consistent —
5023    /// the allow-list, not mere signature validity, gates trust. The SAME record
5024    /// verifies once its signer is allow-listed. Mirrors
5025    /// [`receipt_from_non_allowlisted_signer_is_rejected`].
5026    #[test]
5027    fn grant_replay_from_non_allowlisted_signer_is_rejected() {
5028        let trusted_signer = ApprovalSigner::from_seed(99);
5029        let attacker_signer = ApprovalSigner::from_seed(31337);
5030        let covered = vec!["arbitrary-egress".to_owned()];
5031        // The attacker signs a fully self-consistent record with THEIR OWN key.
5032        let (payload, _sig, _pk) = grant_replay_payload(
5033            "conv-1",
5034            "turn-7",
5035            "post_summary",
5036            "deadbeef",
5037            &covered,
5038            "sha256:template-abc",
5039            &attacker_signer,
5040        );
5041
5042        // The unpinned verifier accepts it (signature is internally consistent) —
5043        // exactly the forgery surface this issue closes.
5044        assert!(
5045            verify_grant_replay(&payload),
5046            "the unpinned verifier trusts any self-consistent signature"
5047        );
5048
5049        // An allow-list that does not include the attacker's key rejects it.
5050        let trusted = vec![trusted_signer.public_key_bytes()];
5051        assert!(
5052            !verify_grant_replay_pinned(&payload, &trusted),
5053            "a grant_replay signed by a non-allow-listed key must not verify"
5054        );
5055
5056        // The IDENTICAL payload verifies once the attacker's key is allow-listed
5057        // (proves the rejection was the allow-list, not some other defect).
5058        let trusted_plus_attacker = vec![
5059            trusted_signer.public_key_bytes(),
5060            attacker_signer.public_key_bytes(),
5061        ];
5062        assert!(
5063            verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
5064            "the same record must verify once its signer is allow-listed"
5065        );
5066
5067        // An empty allow-list rejects every signer — fail closed.
5068        assert!(!verify_grant_replay_pinned(&payload, &[]));
5069    }
5070
5071    /// The acceptance gate (issue #845): an `approval_response` signed by a key
5072    /// that is NOT on the trusted-signer allow-list must fail verification via
5073    /// both [`verify_signed_response_pinned`] and the single-use
5074    /// [`verify_capability`] gate, even though its signature is self-consistent.
5075    /// The SAME response verifies once its signer is allow-listed. Mirrors
5076    /// [`receipt_from_non_allowlisted_signer_is_rejected`].
5077    #[test]
5078    fn signed_response_from_non_allowlisted_signer_is_rejected() {
5079        let trusted_signer = ApprovalSigner::from_seed(99);
5080        let attacker_signer = ApprovalSigner::from_seed(31337);
5081        // The attacker self-signs a fully consistent approval with their key.
5082        let (payload, _sig, _pk) = response_payload(
5083            "call-0",
5084            "delete_file",
5085            r#"{"path":"/etc/hosts"}"#,
5086            "",
5087            true,
5088            false,
5089            &[],
5090            "slack:T1:U9",
5091            "slack:T1:U9",
5092            "workspace-write",
5093            "ok",
5094            "",
5095            "conv-A",
5096            "nonce-A",
5097            &attacker_signer,
5098        );
5099
5100        // The unpinned verifier accepts it — the forgery surface this closes.
5101        assert!(
5102            verify_signed_response(&payload).is_some(),
5103            "the unpinned verifier trusts any self-consistent signature"
5104        );
5105
5106        // Pinned to a legit allow-list that excludes the attacker ⇒ rejected,
5107        // both as a bare response and through the capability consume gate.
5108        let trusted = vec![trusted_signer.public_key_bytes()];
5109        let consumed = HashSet::new();
5110        assert!(
5111            verify_signed_response_pinned(&payload, &trusted).is_none(),
5112            "an approval_response signed by a non-allow-listed key must not verify"
5113        );
5114        assert!(
5115            verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
5116            "the capability gate must reject a non-allow-listed signer"
5117        );
5118
5119        // The IDENTICAL payload verifies once the attacker's key is allow-listed.
5120        let trusted_plus_attacker = vec![
5121            trusted_signer.public_key_bytes(),
5122            attacker_signer.public_key_bytes(),
5123        ];
5124        assert!(
5125            verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
5126            "the same response must verify once its signer is allow-listed"
5127        );
5128        assert!(
5129            verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
5130            "the capability gate honors an allow-listed signer"
5131        );
5132
5133        // An empty allow-list rejects every signer — fail closed.
5134        assert!(verify_signed_response_pinned(&payload, &[]).is_none());
5135        assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
5136    }
5137
5138    /// A receipt persisted before the v2 binding (no `version`, six fields only)
5139    /// must still verify for forensics, surfacing as `version == 1` with empty
5140    /// binding fields. Mirrors the legacy approval-response path.
5141    #[test]
5142    fn legacy_v1_receipt_still_verifies() {
5143        let signer = ApprovalSigner::from_seed(99);
5144        let trusted = vec![signer.public_key_bytes()];
5145        let verified = verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted)
5146            .expect("a valid v1 receipt still verifies");
5147        assert_eq!(verified.version, 1);
5148        assert_eq!(verified.reference, "tx-old");
5149        assert!(verified.kind.is_empty());
5150        assert!(verified.tool_call_id.is_empty());
5151        assert!(verified.approval_pos.is_empty());
5152        assert!(verified.subject.is_empty());
5153        // A pre-payer receipt reports payer as explicit unknown, never
5154        // inferred.
5155        assert!(verified.payer_kind.is_empty());
5156        assert!(verified.paying_account.is_empty());
5157    }
5158
5159    /// Injecting a `version` key into a validly-signed legacy receipt must not
5160    /// verify: the v1 canonical does not cover `version`, so dispatching the
5161    /// claimed version to the v1 canonical would let the signature check pass
5162    /// while `VerifiedReceipt.version` echoed an unsigned, writer-chosen value.
5163    /// Absence — and only absence — reads as v1; every present `version` has to
5164    /// name a frozen version whose canonical covers the key, which no v1-signed
5165    /// body can.
5166    #[test]
5167    fn injected_version_on_v1_signed_receipt_fails() {
5168        let signer = ApprovalSigner::from_seed(99);
5169        let trusted = vec![signer.public_key_bytes()];
5170        let full: Value =
5171            serde_json::from_str(GOLDEN_V1_RECEIPT).expect("the frozen v1 receipt is valid JSON");
5172
5173        // A claimed future version, `0`, an explicit `1`, the current version
5174        // spelled as a float or a string, `null`, and a negative number are all
5175        // refused outright (fail closed) — never verified against a guessed
5176        // canonical. `2` itself is absent from this list only because a
5177        // v1-signed body relabelled `2` cannot satisfy the v2 canonical
5178        // anyway; the shape of the refusal is what is pinned here.
5179        for injected in [
5180            Value::from(7_u64),
5181            Value::from(0_u64),
5182            Value::from(1_u64),
5183            Value::from(2.0_f64),
5184            Value::String("2".to_owned()),
5185            Value::Null,
5186            Value::from(-1_i64),
5187        ] {
5188            let mut tampered = full.clone();
5189            tampered["version"] = injected;
5190            assert!(
5191                verify_signed_receipt(&tampered.to_string().into_bytes(), &trusted).is_none(),
5192                "a writer-chosen version key must never verify"
5193            );
5194        }
5195        // Sanity: without the injected key the same payload verifies as v1.
5196        assert!(verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted).is_some());
5197    }
5198
5199    /// A `payment_receipt` persisted before the v2 binding existed: the six
5200    /// settlement facts, no `version` key, signed by
5201    /// `ApprovalSigner::from_seed(99)` over the frozen v1 canonical. Checked
5202    /// in, never regenerated — the deployment's settled history is what this
5203    /// pins.
5204    const GOLDEN_V1_RECEIPT: &str = r#"{"reference":"tx-old","amount":"0.02","currency":"USDC","recipient":"0xr","method":"tempo","timestamp":"2026-06-01T00:00:00Z","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0b2d980be185a6154d34da71e1ab6226d6dfc65bcd331fcf87b1b190765c4734301a3adececb1f2a80cf38007bc37d61dd4768eed4460a8326032b026a043407"}"#;
5205
5206    /// A `payment_receipt` signed under **v2**, checked in as literal bytes
5207    /// rather than minted at test time.
5208    ///
5209    /// Signed by `ApprovalSigner::from_seed(99)` over the v2 canonical of the
5210    /// fields [`golden_v2_receipt_fields`] names. Nothing in this string is
5211    /// derived from `RECEIPT_VERSION`, which is the entire point: it is a
5212    /// receipt out of the past, and the day the constant moves it must keep
5213    /// verifying byte for byte. Never regenerate it — regenerating it is the
5214    /// bug it exists to catch.
5215    ///
5216    /// One literal, not two (`#1842`). It used to be a pair — one under sorted
5217    /// keys, one under insertion order — selected by probing which ordering the
5218    /// build's `serde_json::Map` happened to use, because the canonical was a
5219    /// [`serde_json::Value`] and its bytes therefore depended on whether
5220    /// anything in the build graph enabled `preserve_order`. These are the
5221    /// insertion-order bytes: the ones every deployment has actually signed and
5222    /// persisted, since the control plane and the harness both resolve that
5223    /// feature. Now that the canonical is a struct serialized in declaration
5224    /// order, every build produces exactly these bytes, so the sorted-key
5225    /// literal named a form nothing has ever signed or can now produce, and it
5226    /// is gone.
5227    const GOLDEN_V2_RECEIPT: &str = r#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"4f73999b3885188a976c4a2da45c0fc5b6917102fd5a17a10efbea7c9dc9c13216602de44b52388f6811d07ad3a9225288528828f05d436e8c1d3241e472c809"}"#;
5228
5229    /// The exact fields [`GOLDEN_V2_RECEIPT`] was signed over. v2 does not
5230    /// cover payer attribution, so `payer_kind`/`paying_account` are `""` —
5231    /// present in the struct (every `ReceiptPayload` construction site names
5232    /// them) but not part of what this fixture's signature covers.
5233    const fn golden_v2_receipt_fields() -> ReceiptPayload<'static> {
5234        ReceiptPayload {
5235            kind: "outbound_payment_receipt",
5236            reference: "tx-frozen-v2",
5237            amount: "10000",
5238            currency: "0xToken",
5239            recipient: "0xrecipient",
5240            method: "tempo",
5241            timestamp: "2026-06-02T00:00:00Z",
5242            tool_call_id: "call-frozen",
5243            approval_pos: "42",
5244            approved_args_hash: "abcd1234",
5245            subject: "conv-frozen",
5246            payer_kind: "",
5247            paying_account: "",
5248        }
5249    }
5250
5251    /// Bumping `RECEIPT_VERSION` must not orphan the receipts already signed
5252    /// under the outgoing version.
5253    ///
5254    /// The receipt under test is a *literal*, signed under v2 and checked in —
5255    /// not one minted here by `receipt_payload`, which by design signs whatever
5256    /// the current version is. That distinction is the test: on the day someone
5257    /// raises `RECEIPT_VERSION` to 3, a minted "v2" payload silently becomes a
5258    /// v3 payload and stops exercising anything, while this literal keeps being
5259    /// exactly what a deployment has sitting in its log. It must still verify,
5260    /// binding tuple intact, with `version` reading 2 — not the current
5261    /// constant. If it ever stops, every fold that filters on verification has
5262    /// just dropped the deployment's entire settled v2 history.
5263    #[test]
5264    fn frozen_v2_receipts_survive_a_later_version_bump() {
5265        let signer = ApprovalSigner::from_seed(99);
5266        let trusted = vec![signer.public_key_bytes()];
5267
5268        let verified = verify_signed_receipt(GOLDEN_V2_RECEIPT.as_bytes(), &trusted)
5269            .expect("the frozen v2 canonical must keep verifying receipts already signed under it");
5270
5271        // Deliberately `2`, never `RECEIPT_VERSION`: the receipt reports the
5272        // version it was signed under, whatever this build's current one is.
5273        assert_eq!(verified.version, 2);
5274        assert_eq!(verified.reference, "tx-frozen-v2");
5275        assert_eq!(verified.amount, "10000");
5276        assert_eq!(verified.currency, "0xToken");
5277        assert_eq!(verified.recipient, "0xrecipient");
5278        assert_eq!(verified.method, "tempo");
5279        assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
5280        // The v2 binding tuple survives the round trip; a canonical that had
5281        // drifted would fail the signature check above, not merely blank these.
5282        assert_eq!(verified.kind, "outbound_payment_receipt");
5283        assert_eq!(verified.tool_call_id, "call-frozen");
5284        assert_eq!(verified.approval_pos, "42");
5285        assert_eq!(verified.approved_args_hash, "abcd1234");
5286        assert_eq!(verified.subject, "conv-frozen");
5287        // v2 signed no payer attribution at all, so it reports payer as
5288        // explicit unknown — never inferred.
5289        assert!(verified.payer_kind.is_empty());
5290        assert!(verified.paying_account.is_empty());
5291        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5292
5293        // Fail-closed is untouched by the freeze: relabel that same signed body
5294        // with a version this build has frozen no canonical for and it is
5295        // refused outright rather than checked against a guessed one. `3` is
5296        // no longer in this list — it is now a frozen version in its own
5297        // right (v3, payer attribution).
5298        let mut relabelled: Value = serde_json::from_str(GOLDEN_V2_RECEIPT).unwrap();
5299        for unknown in [Value::from(4_u64), Value::from(5_u64)] {
5300            relabelled["version"] = unknown;
5301            assert!(
5302                verify_signed_receipt(&relabelled.to_string().into_bytes(), &trusted).is_none(),
5303                "a version with no frozen canonical must never be guessed at"
5304            );
5305        }
5306    }
5307
5308    /// A `payment_receipt` signed under **v3** (payer attribution), checked in
5309    /// as literal bytes rather than minted at test time. Mirrors
5310    /// [`GOLDEN_V2_RECEIPT`]'s reasoning exactly, one version later.
5311    const GOLDEN_V3_RECEIPT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v3","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0f5b4e37955044f7ae10ed1e9a57872200ddfc80d9d24a9a2958b9bb0dbe9a0fe6317cba772807a3797beaef84072a17317c2a220d3ba367077547b4dd77520f"}"#;
5312
5313    /// The exact fields [`GOLDEN_V3_RECEIPT`] was signed over.
5314    const fn golden_v3_receipt_fields() -> ReceiptPayload<'static> {
5315        ReceiptPayload {
5316            kind: "outbound_payment_receipt",
5317            reference: "tx-frozen-v3",
5318            amount: "10000",
5319            currency: "0xToken",
5320            recipient: "0xrecipient",
5321            method: "tempo",
5322            timestamp: "2026-06-02T00:00:00Z",
5323            tool_call_id: "call-frozen",
5324            approval_pos: "42",
5325            approved_args_hash: "abcd1234",
5326            subject: "conv-frozen",
5327            payer_kind: "linked_wallet",
5328            paying_account: "0xpayer-frozen",
5329        }
5330    }
5331
5332    /// Bumping `RECEIPT_VERSION` again must not orphan the receipts already
5333    /// signed under v3. Mirrors [`frozen_v2_receipts_survive_a_later_version_bump`].
5334    #[test]
5335    fn frozen_v3_receipts_survive_a_later_version_bump() {
5336        let signer = ApprovalSigner::from_seed(99);
5337        let trusted = vec![signer.public_key_bytes()];
5338
5339        let verified = verify_signed_receipt(GOLDEN_V3_RECEIPT.as_bytes(), &trusted)
5340            .expect("the frozen v3 canonical must keep verifying receipts already signed under it");
5341
5342        assert_eq!(verified.version, 3);
5343        assert_eq!(verified.reference, "tx-frozen-v3");
5344        assert_eq!(verified.kind, "outbound_payment_receipt");
5345        assert_eq!(verified.tool_call_id, "call-frozen");
5346        assert_eq!(verified.approval_pos, "42");
5347        assert_eq!(verified.approved_args_hash, "abcd1234");
5348        assert_eq!(verified.subject, "conv-frozen");
5349        assert_eq!(verified.payer_kind, "linked_wallet");
5350        assert_eq!(verified.paying_account, "0xpayer-frozen");
5351        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5352    }
5353
5354    /// The frozen v2 fixture is a genuine product of the frozen v2 canonical,
5355    /// not a hand-typed string: rebuilding its canonical bytes from
5356    /// [`golden_v2_receipt_fields`] and re-signing reproduces it exactly. Uses
5357    /// [`ReceiptSchema::V2`] directly (not [`receipt_payload`], which always
5358    /// signs the *current* version) so this assertion stays pinned to v2 no
5359    /// matter how many times `RECEIPT_VERSION` moves on.
5360    #[test]
5361    fn the_frozen_v2_fixture_reproduces_via_its_own_canonical() {
5362        let signer = ApprovalSigner::from_seed(99);
5363        let fields = golden_v2_receipt_fields();
5364        let canonical = ReceiptSchema::V2.canonical(&fields);
5365        let sig = signer.sign(&canonical);
5366        let full = format!(
5367            r#"{{{body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
5368            body = String::from_utf8(canonical)
5369                .unwrap()
5370                .trim_start_matches('{')
5371                .trim_end_matches('}'),
5372            pk = crate::hex::lower(&signer.public_key_bytes()),
5373            sig = crate::hex::lower(&sig),
5374        );
5375        assert_eq!(
5376            full, GOLDEN_V2_RECEIPT,
5377            "the checked-in v2 fixture must be exactly what the frozen v2 canonical produces"
5378        );
5379    }
5380
5381    /// The frozen v3 fixture is a genuine product of the writer, not a
5382    /// hand-typed string: signing its fields today (v3 is the current
5383    /// version) reproduces its bytes exactly.
5384    ///
5385    /// This is the assertion that a `RECEIPT_VERSION` bump is *expected* to
5386    /// break, and the split is deliberate — this one tracks the writer, which
5387    /// always signs the current version, while
5388    /// [`frozen_v3_receipts_survive_a_later_version_bump`] tracks history,
5389    /// which never moves. A bumper re-freezes this one against a v4 fixture
5390    /// and leaves the v2/v3 literals alone.
5391    #[test]
5392    fn the_frozen_v3_fixture_is_what_todays_writer_signs() {
5393        let signer = ApprovalSigner::from_seed(99);
5394        let (payload, _sig, _pk) = receipt_payload(&golden_v3_receipt_fields(), &signer);
5395        assert_eq!(
5396            String::from_utf8(payload).unwrap(),
5397            GOLDEN_V3_RECEIPT,
5398            "the checked-in v3 fixture must be exactly what receipt_payload emits today"
5399        );
5400    }
5401}
5402
5403#[cfg(test)]
5404mod resolve_token_tests {
5405    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5406    use super::*;
5407
5408    #[test]
5409    fn resolve_token_verifies_for_its_own_request_and_conversation() {
5410        let signer = ApprovalSigner::from_seed(11);
5411        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5412        assert!(verify_resolve_token(
5413            &token, "call-1", "conv-a", 1_000, &signer
5414        ));
5415    }
5416
5417    #[test]
5418    fn resolve_token_rejects_a_different_request_id() {
5419        let signer = ApprovalSigner::from_seed(11);
5420        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5421        assert!(!verify_resolve_token(
5422            &token, "call-2", "conv-a", 1_000, &signer
5423        ));
5424    }
5425
5426    #[test]
5427    fn resolve_token_rejects_a_different_conversation() {
5428        let signer = ApprovalSigner::from_seed(11);
5429        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5430        assert!(!verify_resolve_token(
5431            &token, "call-1", "conv-b", 1_000, &signer
5432        ));
5433    }
5434
5435    #[test]
5436    fn resolve_token_rejects_wrong_signer() {
5437        let signer = ApprovalSigner::from_seed(11);
5438        let other = ApprovalSigner::from_seed(12);
5439        let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
5440        assert!(!verify_resolve_token(
5441            &token, "call-1", "conv-a", 1_000, &other
5442        ));
5443    }
5444
5445    #[test]
5446    fn resolve_token_rejects_after_ttl_elapses() {
5447        let signer = ApprovalSigner::from_seed(11);
5448        let token = mint_resolve_token("call-1", "conv-a", 0, &signer);
5449        assert!(verify_resolve_token(
5450            &token,
5451            "call-1",
5452            "conv-a",
5453            RESOLVE_TOKEN_TTL_MS,
5454            &signer
5455        ));
5456        assert!(!verify_resolve_token(
5457            &token,
5458            "call-1",
5459            "conv-a",
5460            RESOLVE_TOKEN_TTL_MS + 1,
5461            &signer
5462        ));
5463    }
5464
5465    #[test]
5466    fn resolve_token_rejects_garbage() {
5467        let signer = ApprovalSigner::from_seed(11);
5468        assert!(!verify_resolve_token(
5469            "not-hex", "call-1", "conv-a", 0, &signer
5470        ));
5471        assert!(!verify_resolve_token("", "call-1", "conv-a", 0, &signer));
5472    }
5473}
5474
5475#[cfg(test)]
5476mod admin_model_change_tests {
5477    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5478    use super::*;
5479
5480    #[test]
5481    fn admin_model_change_round_trips_and_is_tamper_evident() {
5482        let signer = ApprovalSigner::from_seed(31);
5483        let (payload, _sig, _pk) = admin_model_change_payload(
5484            "team-a",
5485            "vertex",
5486            "old-model",
5487            "vertex",
5488            "new-model",
5489            1_000,
5490            &signer,
5491        );
5492        let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
5493        assert_eq!(verified.principal, "team-a");
5494        assert_eq!(verified.new_model, "new-model");
5495        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5496
5497        for (field, val) in [
5498            ("principal", serde_json::json!("attacker")),
5499            ("new_model", serde_json::json!("evil-model")),
5500            ("new_provider", serde_json::json!("evil-provider")),
5501        ] {
5502            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5503            v[field] = val;
5504            assert!(
5505                verify_admin_model_change(v.to_string().as_bytes()).is_none(),
5506                "tampered {field} must fail verification"
5507            );
5508        }
5509    }
5510}
5511
5512#[cfg(test)]
5513mod routine_created_tests {
5514    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5515    use super::*;
5516
5517    /// #1497 INV-RL6: the signed `routine_created` payload round-trips its
5518    /// who/when/originating-conversation fields and is tamper-evident.
5519    #[test]
5520    fn routine_created_round_trips_and_is_tamper_evident() {
5521        let signer = ApprovalSigner::from_seed(41);
5522        let (payload, _sig, _pk) = routine_created_payload(
5523            "daily-standup-a1b2",
5524            "persona-1",
5525            "conv-1",
5526            "call-1",
5527            "hash-1",
5528            1_000,
5529            &signer,
5530        );
5531        let verified = verify_routine_created(&payload).expect("genuine record verifies");
5532        assert_eq!(verified.routine, "daily-standup-a1b2");
5533        assert_eq!(verified.creator_persona, "persona-1");
5534        assert_eq!(verified.conversation_id, "conv-1");
5535        assert_eq!(verified.tool_call_id, "call-1");
5536        assert_eq!(verified.args_hash, "hash-1");
5537        assert_eq!(verified.created_at_ms, 1_000);
5538        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5539
5540        for (field, val) in [
5541            ("routine", serde_json::json!("someone-elses-routine")),
5542            ("creator_persona", serde_json::json!("attacker")),
5543            ("conversation_id", serde_json::json!("conv-other")),
5544            ("tool_call_id", serde_json::json!("call-other")),
5545            ("args_hash", serde_json::json!("hash-other")),
5546        ] {
5547            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5548            v[field] = val;
5549            assert!(
5550                verify_routine_created(v.to_string().as_bytes()).is_none(),
5551                "tampered {field} must fail verification"
5552            );
5553        }
5554    }
5555
5556    #[test]
5557    fn malformed_routine_created_payload_fails_closed() {
5558        assert!(verify_routine_created(b"not json").is_none());
5559        assert!(verify_routine_created(b"{}").is_none());
5560    }
5561}
5562
5563#[cfg(test)]
5564mod routine_paused_tests {
5565    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5566    use super::*;
5567
5568    /// #1495 INV-RL6: the signed `routine_paused` payload round-trips its
5569    /// who/when/originating-conversation/reason fields and is tamper-evident.
5570    #[test]
5571    fn routine_paused_round_trips_and_is_tamper_evident() {
5572        let signer = ApprovalSigner::from_seed(51);
5573        let (payload, _sig, _pk) = routine_paused_payload(
5574            "daily-standup-a1b2",
5575            "persona-1",
5576            "conv-1",
5577            "call-1",
5578            "hash-1",
5579            1_000,
5580            Some("rotating content"),
5581            &signer,
5582        );
5583        let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5584        assert_eq!(verified.routine, "daily-standup-a1b2");
5585        assert_eq!(verified.actor_persona, "persona-1");
5586        assert_eq!(verified.conversation_id, "conv-1");
5587        assert_eq!(verified.tool_call_id, "call-1");
5588        assert_eq!(verified.args_hash, "hash-1");
5589        assert_eq!(verified.paused_at_ms, 1_000);
5590        assert_eq!(verified.reason.as_deref(), Some("rotating content"));
5591        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5592
5593        for (field, val) in [
5594            ("routine", serde_json::json!("someone-elses-routine")),
5595            ("actor_persona", serde_json::json!("attacker")),
5596            ("conversation_id", serde_json::json!("conv-other")),
5597            ("tool_call_id", serde_json::json!("call-other")),
5598            ("args_hash", serde_json::json!("hash-other")),
5599            ("reason", serde_json::json!("a different reason")),
5600        ] {
5601            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5602            v[field] = val;
5603            assert!(
5604                verify_routine_paused(v.to_string().as_bytes()).is_none(),
5605                "tampered {field} must fail verification"
5606            );
5607        }
5608    }
5609
5610    /// A pause with no reason given round-trips `reason: None` rather than an
5611    /// empty string.
5612    #[test]
5613    fn routine_paused_with_no_reason_round_trips_none() {
5614        let signer = ApprovalSigner::from_seed(52);
5615        let (payload, _sig, _pk) = routine_paused_payload(
5616            "weekly-digest",
5617            "persona-2",
5618            "conv-2",
5619            "call-2",
5620            "hash-2",
5621            2_000,
5622            None,
5623            &signer,
5624        );
5625        let verified = verify_routine_paused(&payload).expect("genuine record verifies");
5626        assert_eq!(verified.reason, None);
5627    }
5628
5629    #[test]
5630    fn malformed_routine_paused_payload_fails_closed() {
5631        assert!(verify_routine_paused(b"not json").is_none());
5632        assert!(verify_routine_paused(b"{}").is_none());
5633    }
5634}
5635
5636#[cfg(test)]
5637mod routine_resumed_tests {
5638    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5639    use super::*;
5640
5641    /// #1495 INV-RL6: the signed `routine_resumed` payload round-trips its
5642    /// who/when/originating-conversation fields and is tamper-evident.
5643    #[test]
5644    fn routine_resumed_round_trips_and_is_tamper_evident() {
5645        let signer = ApprovalSigner::from_seed(53);
5646        let (payload, _sig, _pk) = routine_resumed_payload(
5647            "daily-standup-a1b2",
5648            "persona-1",
5649            "conv-1",
5650            "call-1",
5651            "hash-1",
5652            3_000,
5653            &signer,
5654        );
5655        let verified = verify_routine_resumed(&payload).expect("genuine record verifies");
5656        assert_eq!(verified.routine, "daily-standup-a1b2");
5657        assert_eq!(verified.actor_persona, "persona-1");
5658        assert_eq!(verified.conversation_id, "conv-1");
5659        assert_eq!(verified.tool_call_id, "call-1");
5660        assert_eq!(verified.args_hash, "hash-1");
5661        assert_eq!(verified.resumed_at_ms, 3_000);
5662        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5663
5664        for (field, val) in [
5665            ("routine", serde_json::json!("someone-elses-routine")),
5666            ("actor_persona", serde_json::json!("attacker")),
5667            ("conversation_id", serde_json::json!("conv-other")),
5668            ("tool_call_id", serde_json::json!("call-other")),
5669            ("args_hash", serde_json::json!("hash-other")),
5670        ] {
5671            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5672            v[field] = val;
5673            assert!(
5674                verify_routine_resumed(v.to_string().as_bytes()).is_none(),
5675                "tampered {field} must fail verification"
5676            );
5677        }
5678    }
5679
5680    #[test]
5681    fn malformed_routine_resumed_payload_fails_closed() {
5682        assert!(verify_routine_resumed(b"not json").is_none());
5683        assert!(verify_routine_resumed(b"{}").is_none());
5684    }
5685}
5686
5687#[cfg(test)]
5688mod routine_deleted_tests {
5689    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5690    use super::*;
5691
5692    /// #1495 INV-RL2/RL6: the signed `routine_deleted` payload round-trips its
5693    /// who/when/originating-conversation fields and is tamper-evident.
5694    #[test]
5695    fn routine_deleted_round_trips_and_is_tamper_evident() {
5696        let signer = ApprovalSigner::from_seed(54);
5697        let (payload, _sig, _pk) = routine_deleted_payload(
5698            "daily-standup-a1b2",
5699            "persona-1",
5700            "conv-1",
5701            "call-1",
5702            "hash-1",
5703            4_000,
5704            &signer,
5705        );
5706        let verified = verify_routine_deleted(&payload).expect("genuine record verifies");
5707        assert_eq!(verified.routine, "daily-standup-a1b2");
5708        assert_eq!(verified.actor_persona, "persona-1");
5709        assert_eq!(verified.conversation_id, "conv-1");
5710        assert_eq!(verified.tool_call_id, "call-1");
5711        assert_eq!(verified.args_hash, "hash-1");
5712        assert_eq!(verified.deleted_at_ms, 4_000);
5713        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5714
5715        for (field, val) in [
5716            ("routine", serde_json::json!("someone-elses-routine")),
5717            ("actor_persona", serde_json::json!("attacker")),
5718            ("conversation_id", serde_json::json!("conv-other")),
5719            ("tool_call_id", serde_json::json!("call-other")),
5720            ("args_hash", serde_json::json!("hash-other")),
5721        ] {
5722            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5723            v[field] = val;
5724            assert!(
5725                verify_routine_deleted(v.to_string().as_bytes()).is_none(),
5726                "tampered {field} must fail verification"
5727            );
5728        }
5729    }
5730
5731    #[test]
5732    fn malformed_routine_deleted_payload_fails_closed() {
5733        assert!(verify_routine_deleted(b"not json").is_none());
5734        assert!(verify_routine_deleted(b"{}").is_none());
5735    }
5736}
5737
5738#[cfg(test)]
5739mod routine_scope_changed_tests {
5740    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5741    use super::*;
5742
5743    /// #1806 INV-RL6: the signed `routine_scope_changed` payload round-trips
5744    /// its who/when/originating-conversation/target-scope fields and is
5745    /// tamper-evident.
5746    #[test]
5747    fn routine_scope_changed_round_trips_and_is_tamper_evident() {
5748        let signer = ApprovalSigner::from_seed(55);
5749        let (payload, _sig, _pk) = routine_scope_changed_payload(
5750            "daily-standup-a1b2",
5751            "persona-1",
5752            "conv-1",
5753            "call-1",
5754            "hash-1",
5755            "public",
5756            5_000,
5757            &signer,
5758        );
5759        let verified = verify_routine_scope_changed(&payload).expect("genuine record verifies");
5760        assert_eq!(verified.routine, "daily-standup-a1b2");
5761        assert_eq!(verified.actor_persona, "persona-1");
5762        assert_eq!(verified.conversation_id, "conv-1");
5763        assert_eq!(verified.tool_call_id, "call-1");
5764        assert_eq!(verified.args_hash, "hash-1");
5765        assert_eq!(verified.scope, "public");
5766        assert_eq!(verified.changed_at_ms, 5_000);
5767        assert_eq!(verified.signer_public_key, signer.public_key_bytes());
5768
5769        for (field, val) in [
5770            ("routine", serde_json::json!("someone-elses-routine")),
5771            ("actor_persona", serde_json::json!("attacker")),
5772            ("conversation_id", serde_json::json!("conv-other")),
5773            ("tool_call_id", serde_json::json!("call-other")),
5774            ("args_hash", serde_json::json!("hash-other")),
5775            ("scope", serde_json::json!("private")),
5776        ] {
5777            let mut v: Value = serde_json::from_slice(&payload).unwrap();
5778            v[field] = val;
5779            assert!(
5780                verify_routine_scope_changed(v.to_string().as_bytes()).is_none(),
5781                "tampered {field} must fail verification"
5782            );
5783        }
5784    }
5785
5786    #[test]
5787    fn malformed_routine_scope_changed_payload_fails_closed() {
5788        assert!(verify_routine_scope_changed(b"not json").is_none());
5789        assert!(verify_routine_scope_changed(b"{}").is_none());
5790    }
5791}
5792
5793#[cfg(test)]
5794mod canonical_freeze {
5795    //! Every signed canonical in this module, frozen as literal bytes (`#1842`).
5796    //!
5797    //! These are the bytes a deployment has already signed and has sitting in
5798    //! its log. They are checked in, never regenerated: regenerating one is the
5799    //! defect this module exists to catch, because a canonical whose bytes move
5800    //! invalidates every signature ever minted over the old ones.
5801    //!
5802    //! The reason they can be single literals at all is the fix `#1842` made.
5803    //! Before it, each canonical was a [`serde_json::Value`], whose object is a
5804    //! `BTreeMap` (keys sorted) by default and an `IndexMap` (insertion order)
5805    //! whenever anything in the build graph enables `serde_json/preserve_order`
5806    //! — so the same payload signed by two binaries with different dependency
5807    //! sets produced different bytes and different signatures. Run this module
5808    //! under either selection and every literal below holds:
5809    //!
5810    //! ```text
5811    //! cargo nextest run -p polyc-crypto                    # no preserve_order
5812    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
5813    //! ```
5814    //!
5815    //! Deliberately absent: `request_payload`. The `approval_request` event is
5816    //! unsigned — the *response* is what carries a signature — so no signature
5817    //! depends on its bytes, and its `preview` field is caller-supplied JSON
5818    //! whose key order is the caller's. Nothing here can freeze that, and
5819    //! nothing needs to.
5820    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
5821
5822    use super::*;
5823
5824    /// Assert a canonical's bytes are exactly the frozen literal.
5825    fn frozen(label: &str, got: &[u8], want: &str) {
5826        assert_eq!(
5827            String::from_utf8(got.to_vec()).unwrap(),
5828            want,
5829            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
5830        );
5831    }
5832
5833    fn caps() -> Vec<String> {
5834        vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
5835    }
5836
5837    fn transitions() -> Vec<CredentialKeyTransition> {
5838        vec![
5839            CredentialKeyTransition {
5840                kid: "k1".to_owned(),
5841                from: "absent".to_owned(),
5842                to: "active".to_owned(),
5843            },
5844            CredentialKeyTransition {
5845                kid: "k2".to_owned(),
5846                from: "active".to_owned(),
5847                to: "revoked".to_owned(),
5848            },
5849        ]
5850    }
5851
5852    const AT_MS: u64 = 1_750_000_000_000;
5853    /// The routine canonicals' replay-idempotency key (`#1638`), sampled here
5854    /// so every routine literal below is minted over one fixed pair.
5855    const ROUTINE_CALL: &str = "call-1";
5856    const ROUTINE_ARGS_HASH: &str = "abcd1234";
5857
5858    #[test]
5859    fn approval_response_canonical_is_frozen() {
5860        let caps = caps();
5861        frozen(
5862            "response_canonical (no approver)",
5863            &response_canonical(
5864                "req-1",
5865                "paid_fetch",
5866                "{\"a\":1}",
5867                "{\"a\":2}",
5868                true,
5869                false,
5870                &caps,
5871                "persona:alice",
5872                "",
5873                "workspace-write",
5874                "looks fine",
5875                "ctx",
5876                "conv-1",
5877                "nonce-1",
5878            ),
5879            NO_APPROVER_CANONICAL,
5880        );
5881        // A non-empty `approver` is appended after `nonce`, never sorted into
5882        // the middle — the omit-when-empty rule of #1025, now a property of
5883        // `ResponseCanonical`'s field order rather than of a map.
5884        frozen(
5885            "response_canonical (approver)",
5886            &response_canonical(
5887                "req-1",
5888                "paid_fetch",
5889                "{\"a\":1}",
5890                "{\"a\":2}",
5891                true,
5892                true,
5893                &caps,
5894                "persona:alice",
5895                "persona:admin",
5896                "workspace-write",
5897                "looks fine",
5898                "ctx",
5899                "conv-1",
5900                "nonce-1",
5901            ),
5902            APPROVER_CANONICAL,
5903        );
5904        let (full, sig, _pk) = response_payload(
5905            "req-1",
5906            "paid_fetch",
5907            "{\"a\":1}",
5908            "{\"a\":2}",
5909            true,
5910            true,
5911            &caps,
5912            "persona:alice",
5913            "persona:admin",
5914            "workspace-write",
5915            "looks fine",
5916            "ctx",
5917            "conv-1",
5918            "nonce-1",
5919            &ApprovalSigner::from_seed(99),
5920        );
5921        frozen("response_payload", &full, RESPONSE_PAYLOAD);
5922        assert_eq!(crate::hex::lower(&sig), RESPONSE_SIG);
5923    }
5924
5925    const NO_APPROVER_CANONICAL: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":false,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1"}"#;
5926    const APPROVER_CANONICAL: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":true,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1","approver":"persona:admin"}"#;
5927    const RESPONSE_PAYLOAD: &str = r#"{"request_id":"req-1","tool_name":"paid_fetch","args_json":"{\"a\":1}","modified_args_json":"{\"a\":2}","approved":true,"approved_for_session":true,"covered_capabilities":["arbitrary-egress","mutate-external"],"caller":"persona:alice","sandbox_mode":"workspace-write","reason":"looks fine","injected_context":"ctx","conversation_id":"conv-1","nonce":"nonce-1","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300","approver":"persona:admin"}"#;
5928    const RESPONSE_SIG: &str = "aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300";
5929
5930    #[test]
5931    fn excision_canonical_is_frozen() {
5932        let signer = ApprovalSigner::from_seed(99);
5933        frozen(
5934            "excision_canonical",
5935            &excision_canonical(
5936                "conv-1",
5937                EXCISION_SCOPE_CASCADE,
5938                &[17, 23, 40],
5939                "persona:alice",
5940                "prompt injection",
5941            ),
5942            EXCISION_CANONICAL_LIT,
5943        );
5944        let (full, sig, _) = excision_payload(
5945            "conv-1",
5946            EXCISION_SCOPE_CASCADE,
5947            &[17, 23, 40],
5948            "persona:alice",
5949            "prompt injection",
5950            &signer,
5951        );
5952        frozen("excision_payload", &full, EXCISION_PAYLOAD_LIT);
5953        assert_eq!(crate::hex::lower(&sig), EXCISION_SIG_LIT);
5954    }
5955
5956    #[test]
5957    fn grant_replay_canonical_is_frozen() {
5958        let signer = ApprovalSigner::from_seed(99);
5959        let caps = caps();
5960        frozen(
5961            "grant_replay_canonical",
5962            &grant_replay_canonical(
5963                "conv-1",
5964                "turn-8",
5965                "paid_fetch",
5966                "cafe",
5967                &caps,
5968                "sha256:abc",
5969            ),
5970            GRANT_REPLAY_CANONICAL_LIT,
5971        );
5972        let (full, sig, _) = grant_replay_payload(
5973            "conv-1",
5974            "turn-8",
5975            "paid_fetch",
5976            "cafe",
5977            &caps,
5978            "sha256:abc",
5979            &signer,
5980        );
5981        frozen("grant_replay_payload", &full, GRANT_REPLAY_PAYLOAD_LIT);
5982        assert_eq!(crate::hex::lower(&sig), GRANT_REPLAY_SIG_LIT);
5983    }
5984
5985    #[test]
5986    fn deferred_and_mutation_canonicals_are_frozen() {
5987        let signer = ApprovalSigner::from_seed(99);
5988        let (full, sig, _) = deferred_payload("req-1", "conv-1", "needs more detail", &signer);
5989        frozen("deferred_payload", &full, DEFERRED_PAYLOAD_LIT);
5990        assert_eq!(crate::hex::lower(&sig), DEFERRED_SIG_LIT);
5991
5992        let (full, sig, _) = mutation_payload(
5993            "tool_input_rewrite",
5994            "call-1",
5995            "paid_fetch",
5996            "conv-1",
5997            "before-args",
5998            "after-args",
5999            &signer,
6000        );
6001        frozen("mutation_payload", &full, MUTATION_PAYLOAD_LIT);
6002        assert_eq!(crate::hex::lower(&sig), MUTATION_SIG_LIT);
6003    }
6004
6005    #[test]
6006    fn receipt_canonicals_are_frozen() {
6007        let signer = ApprovalSigner::from_seed(99);
6008        let fields = ReceiptPayload {
6009            kind: "outbound_payment_receipt",
6010            reference: "tx-frozen-v2",
6011            amount: "10000",
6012            currency: "0xToken",
6013            recipient: "0xrecipient",
6014            method: "tempo",
6015            timestamp: "2026-06-02T00:00:00Z",
6016            tool_call_id: "call-frozen",
6017            approval_pos: "42",
6018            approved_args_hash: "abcd1234",
6019            subject: "conv-frozen",
6020            // v1/v2 canonicals don't cover payer attribution, so these are
6021            // never read by either builder below — present only because
6022            // `ReceiptPayload` names them for every construction site.
6023            payer_kind: "",
6024            paying_account: "",
6025        };
6026        frozen(
6027            "canonical_json_v2",
6028            &canonical_bytes(&fields.canonical_json_v2()),
6029            RECEIPT_V2_CANONICAL_LIT,
6030        );
6031        frozen(
6032            "canonical_json_v1",
6033            &canonical_bytes(&fields.canonical_json_v1()),
6034            RECEIPT_V1_CANONICAL_LIT,
6035        );
6036
6037        // `receipt_payload` always signs the CURRENT version (v3), so its
6038        // pinning needs payer attribution and its own v3 literal — the v1/v2
6039        // canonical pins above are untouched, since they call the frozen
6040        // per-version builders directly rather than `receipt_payload`.
6041        let fields_v3 = ReceiptPayload {
6042            payer_kind: "linked_wallet",
6043            paying_account: "0xpayer-frozen",
6044            ..fields
6045        };
6046        frozen(
6047            "canonical_json_v3",
6048            &canonical_bytes(&fields_v3.canonical_json_v3()),
6049            RECEIPT_V3_CANONICAL_LIT,
6050        );
6051        let (full, sig, _) = receipt_payload(&fields_v3, &signer);
6052        frozen("receipt_payload", &full, RECEIPT_PAYLOAD_LIT);
6053        assert_eq!(crate::hex::lower(&sig), RECEIPT_SIG_LIT);
6054    }
6055
6056    #[test]
6057    fn resolve_token_canonical_is_frozen() {
6058        let signer = ApprovalSigner::from_seed(99);
6059        frozen(
6060            "resolve_token_canonical",
6061            &resolve_token_canonical("req-1", "conv-1", AT_MS),
6062            RESOLVE_TOKEN_CANONICAL_LIT,
6063        );
6064        assert_eq!(
6065            mint_resolve_token("req-1", "conv-1", AT_MS, &signer),
6066            RESOLVE_TOKEN_LIT,
6067            "a minted resolve token's bytes are frozen — a token is hex of the whole object"
6068        );
6069    }
6070
6071    #[test]
6072    fn admin_model_change_canonical_is_frozen() {
6073        let signer = ApprovalSigner::from_seed(99);
6074        frozen(
6075            "admin_model_change_canonical",
6076            &admin_model_change_canonical(
6077                "admin:root",
6078                "prov-a",
6079                "model-a",
6080                "prov-b",
6081                "model-b",
6082                AT_MS,
6083            ),
6084            ADMIN_MODEL_CANONICAL_LIT,
6085        );
6086        let (full, sig, _) = admin_model_change_payload(
6087            "admin:root",
6088            "prov-a",
6089            "model-a",
6090            "prov-b",
6091            "model-b",
6092            AT_MS,
6093            &signer,
6094        );
6095        frozen("admin_model_change_payload", &full, ADMIN_MODEL_PAYLOAD_LIT);
6096        assert_eq!(crate::hex::lower(&sig), ADMIN_MODEL_SIG_LIT);
6097    }
6098
6099    /// The one canonical here that nests an object: each `transitions` element
6100    /// is its own `{kid, from, to}`, and its key order was map-dependent for
6101    /// exactly the same reason the outer object's was.
6102    #[test]
6103    fn credential_change_canonical_is_frozen() {
6104        let signer = ApprovalSigner::from_seed(99);
6105        let transitions = transitions();
6106        frozen(
6107            "credential_change_canonical",
6108            &credential_change_canonical(
6109                "credential_enrolled",
6110                "admin:root",
6111                "edge-1",
6112                "k1",
6113                "edge, admin",
6114                &transitions,
6115                AT_MS,
6116            ),
6117            CREDENTIAL_CANONICAL_LIT,
6118        );
6119        let (full, sig, _) = credential_change_payload(
6120            "credential_enrolled",
6121            "admin:root",
6122            "edge-1",
6123            "k1",
6124            "edge, admin",
6125            &transitions,
6126            AT_MS,
6127            &signer,
6128        );
6129        frozen("credential_change_payload", &full, CREDENTIAL_PAYLOAD_LIT);
6130        assert_eq!(crate::hex::lower(&sig), CREDENTIAL_SIG_LIT);
6131    }
6132
6133    #[test]
6134    fn routine_audit_canonicals_are_frozen() {
6135        let signer = ApprovalSigner::from_seed(99);
6136
6137        frozen(
6138            "routine_created_canonical",
6139            &routine_created_canonical(
6140                "r-1",
6141                "persona:alice",
6142                "conv-1",
6143                ROUTINE_CALL,
6144                ROUTINE_ARGS_HASH,
6145                AT_MS,
6146            ),
6147            ROUTINE_CREATED_CANONICAL_LIT,
6148        );
6149        let (full, sig, _) = routine_created_payload(
6150            "r-1",
6151            "persona:alice",
6152            "conv-1",
6153            ROUTINE_CALL,
6154            ROUTINE_ARGS_HASH,
6155            AT_MS,
6156            &signer,
6157        );
6158        frozen(
6159            "routine_created_payload",
6160            &full,
6161            ROUTINE_CREATED_PAYLOAD_LIT,
6162        );
6163        assert_eq!(crate::hex::lower(&sig), ROUTINE_CREATED_SIG_LIT);
6164
6165        frozen(
6166            "routine_paused_canonical (reason)",
6167            &routine_paused_canonical(
6168                "r-1",
6169                "persona:alice",
6170                "conv-1",
6171                ROUTINE_CALL,
6172                ROUTINE_ARGS_HASH,
6173                AT_MS,
6174                Some("too noisy"),
6175            ),
6176            ROUTINE_PAUSED_SOME_LIT,
6177        );
6178        frozen(
6179            "routine_paused_canonical (no reason)",
6180            &routine_paused_canonical(
6181                "r-1",
6182                "persona:alice",
6183                "conv-1",
6184                ROUTINE_CALL,
6185                ROUTINE_ARGS_HASH,
6186                AT_MS,
6187                None,
6188            ),
6189            ROUTINE_PAUSED_NONE_LIT,
6190        );
6191        let (full, sig, _) = routine_paused_payload(
6192            "r-1",
6193            "persona:alice",
6194            "conv-1",
6195            ROUTINE_CALL,
6196            ROUTINE_ARGS_HASH,
6197            AT_MS,
6198            Some("too noisy"),
6199            &signer,
6200        );
6201        frozen("routine_paused_payload", &full, ROUTINE_PAUSED_PAYLOAD_LIT);
6202        assert_eq!(crate::hex::lower(&sig), ROUTINE_PAUSED_SIG_LIT);
6203
6204        frozen(
6205            "routine_resumed_canonical",
6206            &routine_resumed_canonical(
6207                "r-1",
6208                "persona:alice",
6209                "conv-1",
6210                ROUTINE_CALL,
6211                ROUTINE_ARGS_HASH,
6212                AT_MS,
6213            ),
6214            ROUTINE_RESUMED_CANONICAL_LIT,
6215        );
6216        let (full, sig, _) = routine_resumed_payload(
6217            "r-1",
6218            "persona:alice",
6219            "conv-1",
6220            ROUTINE_CALL,
6221            ROUTINE_ARGS_HASH,
6222            AT_MS,
6223            &signer,
6224        );
6225        frozen(
6226            "routine_resumed_payload",
6227            &full,
6228            ROUTINE_RESUMED_PAYLOAD_LIT,
6229        );
6230        assert_eq!(crate::hex::lower(&sig), ROUTINE_RESUMED_SIG_LIT);
6231
6232        frozen(
6233            "routine_deleted_canonical",
6234            &routine_deleted_canonical(
6235                "r-1",
6236                "persona:alice",
6237                "conv-1",
6238                ROUTINE_CALL,
6239                ROUTINE_ARGS_HASH,
6240                AT_MS,
6241            ),
6242            ROUTINE_DELETED_CANONICAL_LIT,
6243        );
6244        let (full, sig, _) = routine_deleted_payload(
6245            "r-1",
6246            "persona:alice",
6247            "conv-1",
6248            ROUTINE_CALL,
6249            ROUTINE_ARGS_HASH,
6250            AT_MS,
6251            &signer,
6252        );
6253        frozen(
6254            "routine_deleted_payload",
6255            &full,
6256            ROUTINE_DELETED_PAYLOAD_LIT,
6257        );
6258        assert_eq!(crate::hex::lower(&sig), ROUTINE_DELETED_SIG_LIT);
6259
6260        frozen(
6261            "routine_scope_changed_canonical",
6262            &routine_scope_changed_canonical(
6263                "r-1",
6264                "persona:alice",
6265                "conv-1",
6266                ROUTINE_CALL,
6267                ROUTINE_ARGS_HASH,
6268                "public",
6269                AT_MS,
6270            ),
6271            ROUTINE_SCOPE_CHANGED_CANONICAL_LIT,
6272        );
6273        let (full, sig, _) = routine_scope_changed_payload(
6274            "r-1",
6275            "persona:alice",
6276            "conv-1",
6277            ROUTINE_CALL,
6278            ROUTINE_ARGS_HASH,
6279            "public",
6280            AT_MS,
6281            &signer,
6282        );
6283        frozen(
6284            "routine_scope_changed_payload",
6285            &full,
6286            ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT,
6287        );
6288        assert_eq!(crate::hex::lower(&sig), ROUTINE_SCOPE_CHANGED_SIG_LIT);
6289    }
6290
6291    const EXCISION_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection"}"#;
6292    const EXCISION_PAYLOAD_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b"}"#;
6293    const EXCISION_SIG_LIT: &str = "6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b";
6294    const GRANT_REPLAY_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","turn_id":"turn-8","tool":"paid_fetch","grant_ref":"cafe","covered_capabilities":["arbitrary-egress","mutate-external"],"coverage_hash":"sha256:abc"}"#;
6295    const GRANT_REPLAY_PAYLOAD_LIT: &str = r#"{"conversation_id":"conv-1","turn_id":"turn-8","tool":"paid_fetch","grant_ref":"cafe","covered_capabilities":["arbitrary-egress","mutate-external"],"coverage_hash":"sha256:abc","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a"}"#;
6296    const GRANT_REPLAY_SIG_LIT: &str = "b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a";
6297    const DEFERRED_PAYLOAD_LIT: &str = r#"{"request_id":"req-1","conversation_id":"conv-1","reason":"needs more detail","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506"}"#;
6298    const DEFERRED_SIG_LIT: &str = "95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506";
6299    const MUTATION_PAYLOAD_LIT: &str = r#"{"kind":"tool_input_rewrite","tool_call_id":"call-1","tool_name":"paid_fetch","conversation_id":"conv-1","before":"before-args","after":"after-args","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03"}"#;
6300    const MUTATION_SIG_LIT: &str = "c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03";
6301    const RECEIPT_V2_CANONICAL_LIT: &str = r#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen"}"#;
6302    const RECEIPT_V1_CANONICAL_LIT: &str = r#"{"reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z"}"#;
6303    const RECEIPT_V3_CANONICAL_LIT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen"}"#;
6304    const RECEIPT_PAYLOAD_LIT: &str = r#"{"version":3,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","payer_kind":"linked_wallet","paying_account":"0xpayer-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00"}"#;
6305    const RECEIPT_SIG_LIT: &str = "eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00";
6306    const RESOLVE_TOKEN_CANONICAL_LIT: &str =
6307        r#"{"request_id":"req-1","conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
6308    const RESOLVE_TOKEN_LIT: &str = "7b22726571756573745f6964223a227265712d31222c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223234303734613638613638396335613137643037343334353530376535653437626263623531366165616232633363376566656431363435663235383164643665313165616237396466343537333463646136623230306463663938393334333630306365366161303064653161643234633534363733356462336236653034227d";
6309    const ADMIN_MODEL_CANONICAL_LIT: &str = r#"{"principal":"admin:root","previous_provider":"prov-a","previous_model":"model-a","new_provider":"prov-b","new_model":"model-b","changed_at_ms":1750000000000}"#;
6310    const ADMIN_MODEL_PAYLOAD_LIT: &str = r#"{"principal":"admin:root","previous_provider":"prov-a","previous_model":"model-a","new_provider":"prov-b","new_model":"model-b","changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903"}"#;
6311    const ADMIN_MODEL_SIG_LIT: &str = "891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903";
6312    const CREDENTIAL_CANONICAL_LIT: &str = r#"{"change":"credential_enrolled","principal":"admin:root","edge_id":"edge-1","kid":"k1","grants":"edge, admin","transitions":[{"kid":"k1","from":"absent","to":"active"},{"kid":"k2","from":"active","to":"revoked"}],"changed_at_ms":1750000000000}"#;
6313    const CREDENTIAL_PAYLOAD_LIT: &str = r#"{"change":"credential_enrolled","principal":"admin:root","edge_id":"edge-1","kid":"k1","grants":"edge, admin","transitions":[{"kid":"k1","from":"absent","to":"active"},{"kid":"k2","from":"active","to":"revoked"}],"changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b"}"#;
6314    const CREDENTIAL_SIG_LIT: &str = "e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b";
6315    const ROUTINE_CREATED_CANONICAL_LIT: &str = r#"{"routine":"r-1","creator_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","created_at_ms":1750000000000}"#;
6316    const ROUTINE_CREATED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","creator_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","created_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e"}"#;
6317    const ROUTINE_CREATED_SIG_LIT: &str = "65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e";
6318    const ROUTINE_PAUSED_SOME_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":"too noisy"}"#;
6319    const ROUTINE_PAUSED_NONE_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":null}"#;
6320    const ROUTINE_PAUSED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","paused_at_ms":1750000000000,"reason":"too noisy","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04"}"#;
6321    const ROUTINE_PAUSED_SIG_LIT: &str = "9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04";
6322    const ROUTINE_RESUMED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","resumed_at_ms":1750000000000}"#;
6323    const ROUTINE_RESUMED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","resumed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06"}"#;
6324    const ROUTINE_RESUMED_SIG_LIT: &str = "1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06";
6325    const ROUTINE_DELETED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","deleted_at_ms":1750000000000}"#;
6326    const ROUTINE_DELETED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","deleted_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f"}"#;
6327    const ROUTINE_DELETED_SIG_LIT: &str = "c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f";
6328    const ROUTINE_SCOPE_CHANGED_CANONICAL_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","scope":"public","changed_at_ms":1750000000000}"#;
6329    const ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT: &str = r#"{"routine":"r-1","actor_persona":"persona:alice","conversation_id":"conv-1","tool_call_id":"call-1","args_hash":"abcd1234","scope":"public","changed_at_ms":1750000000000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e"}"#;
6330    const ROUTINE_SCOPE_CHANGED_SIG_LIT: &str = "e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e";
6331}
6332
6333/// Drives `polyc-conformance-vectors::PAYMENT_REFUSAL`
6334/// (`crates/conformance-vectors/vectors/payment-refusal.json`) — the pinned,
6335/// cross-language conformance vectors for the durable payment-refusal event
6336/// (`#2090`, INV-W5).
6337#[cfg(test)]
6338mod payment_refusal_conformance_tests {
6339    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6340
6341    use serde_json::Value;
6342
6343    use super::*;
6344
6345    fn vectors() -> Value {
6346        serde_json::from_str(polyc_conformance_vectors::PAYMENT_REFUSAL).expect("valid JSON")
6347    }
6348
6349    fn signer() -> ApprovalSigner {
6350        let v = vectors();
6351        let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6352        ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6353    }
6354
6355    /// A vector's `fields` object, decoded into owned `String`s — a named
6356    /// struct rather than a positional tuple, the same swap-proofing
6357    /// [`RefusalClassification`] (`crates/control-plane/src/harness_dialer.rs`)
6358    /// applies to the production recording seam.
6359    struct VectorFields {
6360        kind: String,
6361        reason: String,
6362        reason_detail: String,
6363        merchant_host: String,
6364        requested_base_units: String,
6365        permitted_base_units: String,
6366        tool_call_id: String,
6367        subject: String,
6368        timestamp: String,
6369    }
6370
6371    impl VectorFields {
6372        fn from_json(v: &Value) -> Self {
6373            let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6374            Self {
6375                kind: field("kind"),
6376                reason: field("reason"),
6377                reason_detail: field("reason_detail"),
6378                merchant_host: field("merchant_host"),
6379                requested_base_units: field("requested_base_units"),
6380                permitted_base_units: field("permitted_base_units"),
6381                tool_call_id: field("tool_call_id"),
6382                subject: field("subject"),
6383                timestamp: field("timestamp"),
6384            }
6385        }
6386
6387        fn as_payload(&self) -> RefusalPayload<'_> {
6388            RefusalPayload {
6389                kind: self.kind.as_str(),
6390                reason: self.reason.as_str(),
6391                reason_detail: self.reason_detail.as_str(),
6392                merchant_host: self.merchant_host.as_str(),
6393                requested_base_units: self.requested_base_units.as_str(),
6394                permitted_base_units: self.permitted_base_units.as_str(),
6395                tool_call_id: self.tool_call_id.as_str(),
6396                subject: self.subject.as_str(),
6397                timestamp: self.timestamp.as_str(),
6398            }
6399        }
6400    }
6401
6402    #[test]
6403    fn the_known_good_vector_reproduces_its_bytes_and_signature() {
6404        let v = vectors();
6405        let signer = signer();
6406        let vf = VectorFields::from_json(&v["vector"]);
6407        let fields = vf.as_payload();
6408        let expected_canonical = crate::hex::decode(
6409            v["vector"]["expected_canonical_bytes_hex"]
6410                .as_str()
6411                .unwrap(),
6412        )
6413        .unwrap();
6414        assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6415
6416        let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6417        assert_eq!(
6418            crate::hex::lower(&sig),
6419            v["vector"]["expected_signature_hex"].as_str().unwrap()
6420        );
6421        let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
6422        assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6423
6424        let trusted = vec![
6425            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6426        ];
6427        let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6428        assert_eq!(verified.reason, vf.reason);
6429        assert_eq!(verified.requested_base_units, vf.requested_base_units);
6430        assert_eq!(verified.permitted_base_units, vf.permitted_base_units);
6431        assert_eq!(verified.timestamp, vf.timestamp);
6432    }
6433
6434    /// INV-W5b: an unrecognized `reason` tag still verifies — the crypto
6435    /// layer never special-cases `reason`'s value.
6436    #[test]
6437    fn the_unknown_reason_vector_still_verifies() {
6438        let v = vectors();
6439        let signer = signer();
6440        let vf = VectorFields::from_json(&v["unknown_reason_vector"]);
6441        let fields = vf.as_payload();
6442        let (payload, sig, _pk) = refusal_payload(&fields, &signer);
6443        assert_eq!(
6444            crate::hex::lower(&sig),
6445            v["unknown_reason_vector"]["expected_signature_hex"]
6446                .as_str()
6447                .unwrap()
6448        );
6449
6450        let trusted = vec![
6451            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6452        ];
6453        let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
6454        assert_eq!(verified.reason, "some_future_reason_v7");
6455    }
6456
6457    #[test]
6458    fn the_tampered_reason_vector_must_not_verify() {
6459        let v = vectors();
6460        let entry = v["must_not_verify"]
6461            .as_array()
6462            .unwrap()
6463            .iter()
6464            .find(|e| e["id"] == "tampered-reason")
6465            .unwrap();
6466        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6467        let trusted = vec![
6468            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6469        ];
6470        assert!(verify_signed_refusal(payload, &trusted).is_none());
6471    }
6472
6473    /// The untrusted-signer `must_not_verify` case ships a REAL, executable
6474    /// payload (`#2090` review, item 6) — not description-only: a real
6475    /// second signer minted a genuinely valid signature over it (it
6476    /// verifies against ITS OWN embedded key), and the case proves it is
6477    /// rejected ONLY because that key sits outside the main vector's
6478    /// trusted-signer allow-list, never because anything is malformed.
6479    #[test]
6480    fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6481        let v = vectors();
6482        let entry = v["must_not_verify"]
6483            .as_array()
6484            .unwrap()
6485            .iter()
6486            .find(|e| e["id"] == "untrusted-signer")
6487            .unwrap();
6488        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6489
6490        // Self-consistent: verifies against its OWN embedded key.
6491        let own_key =
6492            crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6493        assert!(
6494            verify_signed_refusal(payload, &[own_key]).is_some(),
6495            "the untrusted-signer vector's payload must be internally consistent — a \
6496             genuinely valid signature over an out-of-allowlist key, not a malformed one"
6497        );
6498
6499        // Rejected against the MAIN vector's trusted-signer set.
6500        let main_trusted = vec![
6501            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6502        ];
6503        assert!(verify_signed_refusal(payload, &main_trusted).is_none());
6504    }
6505}
6506
6507/// Drives `polyc-conformance-vectors::WALLET_LINK_LIFECYCLE`
6508/// (`crates/conformance-vectors/vectors/wallet-link-lifecycle.json`) — the
6509/// pinned, cross-language conformance vectors for the durable
6510/// wallet-link-lifecycle event (`#2123`).
6511#[cfg(test)]
6512mod wallet_link_lifecycle_conformance_tests {
6513    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6514
6515    use serde_json::Value;
6516
6517    use super::*;
6518
6519    fn vectors() -> Value {
6520        serde_json::from_str(polyc_conformance_vectors::WALLET_LINK_LIFECYCLE).expect("valid JSON")
6521    }
6522
6523    fn signer() -> ApprovalSigner {
6524        let v = vectors();
6525        let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6526        ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6527    }
6528
6529    /// A vector's `fields` object, decoded into owned `String`s — a named
6530    /// struct rather than a positional tuple, the same swap-proofing
6531    /// [`RefusalPayload`]'s own vector test struct applies.
6532    struct VectorFields {
6533        kind: String,
6534        transition: String,
6535        subject: String,
6536        wallet_address: String,
6537        currency: String,
6538        chain_id: String,
6539        limit_base_units: String,
6540        limit_human: String,
6541        period_secs: String,
6542        expiry_unix: String,
6543        recipients: String,
6544        conversation_id: String,
6545        timestamp: String,
6546    }
6547
6548    impl VectorFields {
6549        fn from_json(v: &Value) -> Self {
6550            let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
6551            Self {
6552                kind: field("kind"),
6553                transition: field("transition"),
6554                subject: field("subject"),
6555                wallet_address: field("wallet_address"),
6556                currency: field("currency"),
6557                chain_id: field("chain_id"),
6558                limit_base_units: field("limit_base_units"),
6559                limit_human: field("limit_human"),
6560                period_secs: field("period_secs"),
6561                expiry_unix: field("expiry_unix"),
6562                recipients: field("recipients"),
6563                conversation_id: field("conversation_id"),
6564                timestamp: field("timestamp"),
6565            }
6566        }
6567
6568        fn as_payload(&self) -> WalletLinkLifecyclePayload<'_> {
6569            WalletLinkLifecyclePayload {
6570                kind: self.kind.as_str(),
6571                transition: self.transition.as_str(),
6572                subject: self.subject.as_str(),
6573                wallet_address: self.wallet_address.as_str(),
6574                currency: self.currency.as_str(),
6575                chain_id: self.chain_id.as_str(),
6576                limit_base_units: self.limit_base_units.as_str(),
6577                limit_human: self.limit_human.as_str(),
6578                period_secs: self.period_secs.as_str(),
6579                expiry_unix: self.expiry_unix.as_str(),
6580                recipients: self.recipients.as_str(),
6581                conversation_id: self.conversation_id.as_str(),
6582                timestamp: self.timestamp.as_str(),
6583            }
6584        }
6585    }
6586
6587    fn assert_vector_reproduces(vector_key: &str) {
6588        let v = vectors();
6589        let signer = signer();
6590        let vf = VectorFields::from_json(&v[vector_key]);
6591        let fields = vf.as_payload();
6592        let expected_canonical = crate::hex::decode(
6593            v[vector_key]["expected_canonical_bytes_hex"]
6594                .as_str()
6595                .unwrap(),
6596        )
6597        .unwrap();
6598        assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
6599
6600        let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6601        assert_eq!(
6602            crate::hex::lower(&sig),
6603            v[vector_key]["expected_signature_hex"].as_str().unwrap()
6604        );
6605        let expected_full = v[vector_key]["expected_full_payload"].as_str().unwrap();
6606        assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6607
6608        let trusted = vec![
6609            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6610        ];
6611        let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6612        assert_eq!(verified.transition, vf.transition);
6613        assert_eq!(verified.limit_base_units, vf.limit_base_units);
6614        assert_eq!(verified.limit_human, vf.limit_human);
6615        assert_eq!(verified.period_secs, vf.period_secs);
6616        assert_eq!(verified.expiry_unix, vf.expiry_unix);
6617        assert_eq!(verified.timestamp, vf.timestamp);
6618    }
6619
6620    #[test]
6621    fn the_known_good_linked_vector_reproduces_its_bytes_and_signature() {
6622        assert_vector_reproduces("linked_vector");
6623    }
6624
6625    /// `renewed` leaves `period_secs`/`expiry_unix` empty (TIP-1011's
6626    /// `updateSpendingLimit` cannot change either) — this vector pins that
6627    /// the recorder never invents values for them.
6628    #[test]
6629    fn the_known_good_renewed_vector_reproduces_its_bytes_and_signature() {
6630        assert_vector_reproduces("renewed_vector");
6631    }
6632
6633    /// `revoked` leaves every spend-policy field empty — no cap survives a
6634    /// dead link.
6635    #[test]
6636    fn the_known_good_revoked_vector_reproduces_its_bytes_and_signature() {
6637        assert_vector_reproduces("revoked_vector");
6638    }
6639
6640    /// INV-W5b analogue: an unrecognized `transition` tag still verifies —
6641    /// the crypto layer never special-cases `transition`'s value.
6642    #[test]
6643    fn the_unknown_transition_vector_still_verifies() {
6644        let v = vectors();
6645        let signer = signer();
6646        let vf = VectorFields::from_json(&v["unknown_transition_vector"]);
6647        let fields = vf.as_payload();
6648        let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
6649        assert_eq!(
6650            crate::hex::lower(&sig),
6651            v["unknown_transition_vector"]["expected_signature_hex"]
6652                .as_str()
6653                .unwrap()
6654        );
6655
6656        let trusted = vec![
6657            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6658        ];
6659        let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
6660        assert_eq!(verified.transition, "some_future_transition_v7");
6661    }
6662
6663    #[test]
6664    fn the_tampered_transition_vector_must_not_verify() {
6665        let v = vectors();
6666        let entry = v["must_not_verify"]
6667            .as_array()
6668            .unwrap()
6669            .iter()
6670            .find(|e| e["id"] == "tampered-transition")
6671            .unwrap();
6672        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6673        let trusted = vec![
6674            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6675        ];
6676        assert!(verify_signed_wallet_link_lifecycle(payload, &trusted).is_none());
6677    }
6678
6679    /// The untrusted-signer `must_not_verify` case ships a REAL, executable
6680    /// payload — not description-only: a real second signer minted a
6681    /// genuinely valid signature over it (it verifies against ITS OWN
6682    /// embedded key), and the case proves it is rejected ONLY because that
6683    /// key sits outside the main vector's trusted-signer allow-list, never
6684    /// because anything is malformed.
6685    #[test]
6686    fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6687        let v = vectors();
6688        let entry = v["must_not_verify"]
6689            .as_array()
6690            .unwrap()
6691            .iter()
6692            .find(|e| e["id"] == "untrusted-signer")
6693            .unwrap();
6694        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6695
6696        // Self-consistent: verifies against its OWN embedded key.
6697        let own_key =
6698            crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6699        assert!(
6700            verify_signed_wallet_link_lifecycle(payload, &[own_key]).is_some(),
6701            "the untrusted-signer vector's payload must be internally consistent — a \
6702             genuinely valid signature over an out-of-allowlist key, not a malformed one"
6703        );
6704
6705        // Rejected against the MAIN vector's trusted-signer set.
6706        let main_trusted = vec![
6707            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6708        ];
6709        assert!(verify_signed_wallet_link_lifecycle(payload, &main_trusted).is_none());
6710    }
6711}
6712
6713/// Drives `polyc-conformance-vectors::PAYMENT_RECEIPT`
6714/// (`crates/conformance-vectors/vectors/payment-receipt.json`) — the pinned,
6715/// cross-language conformance vectors for the durable payment-receipt event's
6716/// v3 payer-attribution addition (`#2099`). Mirrors
6717/// `payment_refusal_conformance_tests`' shape exactly.
6718#[cfg(test)]
6719mod payment_receipt_conformance_tests {
6720    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
6721
6722    use serde_json::Value;
6723
6724    use super::*;
6725
6726    fn vectors() -> Value {
6727        serde_json::from_str(polyc_conformance_vectors::PAYMENT_RECEIPT).expect("valid JSON")
6728    }
6729
6730    fn signer() -> ApprovalSigner {
6731        let v = vectors();
6732        let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
6733        ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
6734    }
6735
6736    /// A vector's `fields` object, decoded into owned `String`s — named
6737    /// fields, no positional tuple, matching [`ReceiptPayload`]'s own
6738    /// swap-proofing reasoning.
6739    struct VectorFields {
6740        kind: String,
6741        reference: String,
6742        amount: String,
6743        currency: String,
6744        recipient: String,
6745        method: String,
6746        timestamp: String,
6747        tool_call_id: String,
6748        approval_pos: String,
6749        approved_args_hash: String,
6750        subject: String,
6751        payer_kind: String,
6752        paying_account: String,
6753    }
6754
6755    impl VectorFields {
6756        /// `payer_kind`/`paying_account` default to `""` when a vector's
6757        /// `fields` object omits them entirely (the frozen v2 vector, which
6758        /// predates the v3 addition and never had them to begin with).
6759        fn from_json(v: &Value) -> Self {
6760            let field = |name: &str| {
6761                v["fields"]
6762                    .get(name)
6763                    .and_then(Value::as_str)
6764                    .unwrap_or("")
6765                    .to_owned()
6766            };
6767            Self {
6768                kind: field("kind"),
6769                reference: field("reference"),
6770                amount: field("amount"),
6771                currency: field("currency"),
6772                recipient: field("recipient"),
6773                method: field("method"),
6774                timestamp: field("timestamp"),
6775                tool_call_id: field("tool_call_id"),
6776                approval_pos: field("approval_pos"),
6777                approved_args_hash: field("approved_args_hash"),
6778                subject: field("subject"),
6779                payer_kind: field("payer_kind"),
6780                paying_account: field("paying_account"),
6781            }
6782        }
6783
6784        fn as_payload(&self) -> ReceiptPayload<'_> {
6785            ReceiptPayload {
6786                kind: self.kind.as_str(),
6787                reference: self.reference.as_str(),
6788                amount: self.amount.as_str(),
6789                currency: self.currency.as_str(),
6790                recipient: self.recipient.as_str(),
6791                method: self.method.as_str(),
6792                timestamp: self.timestamp.as_str(),
6793                tool_call_id: self.tool_call_id.as_str(),
6794                approval_pos: self.approval_pos.as_str(),
6795                approved_args_hash: self.approved_args_hash.as_str(),
6796                subject: self.subject.as_str(),
6797                payer_kind: self.payer_kind.as_str(),
6798                paying_account: self.paying_account.as_str(),
6799            }
6800        }
6801    }
6802
6803    #[test]
6804    fn the_known_good_v3_vector_reproduces_its_bytes_and_signature() {
6805        let v = vectors();
6806        let signer = signer();
6807        let vf = VectorFields::from_json(&v["vector"]);
6808        let fields = vf.as_payload();
6809        let expected_canonical = crate::hex::decode(
6810            v["vector"]["expected_canonical_bytes_hex"]
6811                .as_str()
6812                .unwrap(),
6813        )
6814        .unwrap();
6815        assert_eq!(
6816            canonical_bytes(&fields.canonical_json_v3()),
6817            expected_canonical
6818        );
6819
6820        let (payload, sig, _pk) = receipt_payload(&fields, &signer);
6821        assert_eq!(
6822            crate::hex::lower(&sig),
6823            v["vector"]["expected_signature_hex"].as_str().unwrap()
6824        );
6825        let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
6826        assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
6827
6828        let trusted = vec![
6829            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6830        ];
6831        let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
6832        assert_eq!(verified.version, 3);
6833        assert_eq!(verified.reference, vf.reference);
6834        assert_eq!(verified.payer_kind, vf.payer_kind);
6835        assert_eq!(verified.paying_account, vf.paying_account);
6836    }
6837
6838    /// The frozen v2 vector — signed before payer attribution existed — must
6839    /// still verify byte-for-byte, and its payer fields must decode as
6840    /// explicit unknown (`""`), never inferred.
6841    #[test]
6842    fn the_frozen_v2_vector_still_verifies_with_payer_unknown() {
6843        let v = vectors();
6844        let entry = &v["frozen_v2_vector"];
6845        let full = entry["expected_full_payload"].as_str().unwrap();
6846        let trusted = vec![
6847            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6848        ];
6849        let verified = verify_signed_receipt(full.as_bytes(), &trusted)
6850            .expect("the frozen v2 vector verifies");
6851        assert_eq!(verified.version, 2);
6852        assert_eq!(verified.reference, "tx-conformance-v2");
6853        assert!(verified.payer_kind.is_empty());
6854        assert!(verified.paying_account.is_empty());
6855    }
6856
6857    #[test]
6858    fn the_tampered_payer_vector_must_not_verify() {
6859        let v = vectors();
6860        let entry = v["must_not_verify"]
6861            .as_array()
6862            .unwrap()
6863            .iter()
6864            .find(|e| e["id"] == "tampered-payer")
6865            .unwrap();
6866        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6867        let trusted = vec![
6868            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6869        ];
6870        assert!(verify_signed_receipt(payload, &trusted).is_none());
6871    }
6872
6873    /// The untrusted-signer `must_not_verify` case ships a REAL, executable
6874    /// payload: a real second signer minted a genuinely valid signature over
6875    /// it (it verifies against its own embedded key), and the case proves it
6876    /// is rejected ONLY because that key sits outside the main vector's
6877    /// trusted-signer allow-list, never because anything is malformed.
6878    #[test]
6879    fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
6880        let v = vectors();
6881        let entry = v["must_not_verify"]
6882            .as_array()
6883            .unwrap()
6884            .iter()
6885            .find(|e| e["id"] == "untrusted-signer")
6886            .unwrap();
6887        let payload = entry["full_payload"].as_str().unwrap().as_bytes();
6888
6889        let own_key =
6890            crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
6891        assert!(
6892            verify_signed_receipt(payload, &[own_key]).is_some(),
6893            "the untrusted-signer vector's payload must be internally consistent — a \
6894             genuinely valid signature over an out-of-allowlist key, not a malformed one"
6895        );
6896
6897        let main_trusted = vec![
6898            crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
6899        ];
6900        assert!(verify_signed_receipt(payload, &main_trusted).is_none());
6901    }
6902}