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