//! HITL approval payload encoders/decoders and signer.
//!
//! The approval flow persists two event payloads in the conversation eventlog:
//!
//! * `approval_request` — minted when `polyc_agent::run_turn` surfaces a
//! pending tool call that needs human consent.
//! * `approval_response` — minted by `ApprovalService::Respond` (a human
//! decision) or, non-interactively, by the `POLYCHROME_APPROVAL_MODE`
//! machine-approval path (the reviewer-agent auto-review or the blanket
//! approve-all mode) carrying a signed decision, distinguishable from a human
//! one only by its signed `reason` (see [`auto_review_reason`] /
//! [`AUTO_REVIEW_REASON_PREFIX`] / [`APPROVE_ALL_DANGEROUS_REASON`]).
//!
//! The encoders / decoders live in `polyc-crypto` (this crate) rather
//! than the control-plane binary so the harness pod can verify inbound
//! `approval_response` payloads on its own — the harness sits in a sandbox
//! and shouldn't trust the wire blindly.
//!
//! [`verify_signed_receipt`] keeps one frozen canonical per receipt schema
//! version, v1 included, and every version it has ever frozen stays. That is
//! not the legacy shim this repo forbids: a signature covers the exact bytes it
//! was minted over, and those bytes are already in an append-only log. Deleting
//! the v1 reader would migrate nothing — it would leave the deployment unable
//! to verify its own settled payment history. Read the per-version builders as
//! cryptographic verification of history, not as a compatibility fallback, and
//! only ever add to them.
use serde::Serialize;
use serde_json::Value;
use std::collections::HashSet;
use crate::signed::{Envelope, canonical_bytes};
use crate::verify;
pub use crate::signing_role::ApprovalSigner;
/// JSON payload for an `approval_request` event.
///
/// `request_id` is the model's tool-call id — stable for the lifetime of the
/// turn and used by `ApprovalService::Respond` to address the matching
/// response.
/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
/// harness was running under when it paused this call. The control plane reads it
/// back at respond time and signs it into the response, so a remembered approval
/// is bound to the mode it was granted under.
/// `reason` is the OVERRIDE explanation for why the call is gated — empty for an
/// ordinary gated call, non-empty only for the lethal-trifecta / Rule-of-Two
/// containment override. It is presentation + audit (NOT covered by any
/// signature — the `approval_request` event itself is unsigned; the *response*
/// is what gets signed), so the durable log records WHY a trifecta-gated call
/// was paused, and the edge can render it on the approval card.
/// `missing_capabilities` records the capability shortfall the gate computed
/// when it paused the call (`#595`): the stable kebab-case names of the
/// capabilities the call required but was not granted. Read back at respond
/// time and signed into the response as `covered_capabilities`, so a
/// remembered grant is scoped to exactly what the approver saw it cover.
/// Empty for an ordinary policy/sandbox gate.
/// `preview_json` (`#1496`) is the pre-serialized computed-preview
/// enrichment — opaque at this layer, exactly like `args_json` above — or
/// empty for every call the enrichment doesn't apply to (anything but
/// `routine_create`). Recorded durably here (not just on the live wire
/// response) so the `ListPending` recovery path re-renders the SAME preview a
/// lost-stream card showed, never a re-derivation that could drift.
///
/// This is the one payload in this module still built as a
/// [`serde_json::Value`], so its key order — and its `preview`'s — follows the
/// map's, which `serde_json/preserve_order` decides per build (`#1842`). That
/// is deliberate and safe: an `approval_request` carries no signature, so no
/// verification depends on its bytes, and every reader looks its fields up by
/// key. `preview` is caller-supplied JSON whose own key order belongs to the
/// caller in any case. Nothing here can be frozen, and nothing needs to be —
/// the signed side is the response.
#[must_use]
pub fn request_payload(
request_id: &str,
tool_name: &str,
args_json: &str,
sandbox_mode: &str,
reason: &str,
missing_capabilities: &[String],
preview_json: &str,
) -> Vec<u8> {
// Null when there is no preview (the overwhelming common case — every
// call but `routine_create`), rather than omitting the key: an absent
// key and an explicit null both decode to "no preview" at
// `decode_request_preview_json`, so this is a style choice, made for
// symmetry with the other always-present fields on this payload.
let preview: Value = if preview_json.is_empty() {
Value::Null
} else {
serde_json::from_str(preview_json).unwrap_or(Value::Null)
};
// canonical-allow: an `approval_request` carries no signature (the *response*
// is what gets signed), so no verification depends on these bytes, and every
// reader looks its fields up by key. `preview` is caller-supplied JSON whose
// own key order belongs to the caller in any case — nothing here can be
// frozen, and nothing needs to be. See the doc comment above.
serde_json::json!({
"tool_name": tool_name,
"args_json": args_json,
"request_id": request_id,
"sandbox_mode": sandbox_mode,
"reason": reason,
"missing_capabilities": missing_capabilities,
"preview": preview,
})
.to_string()
.into_bytes()
}
/// The single source for the signed `approval_response` canonical JSON. Both the
/// signing path ([`response_payload`]) and the verifying paths
/// ([`verify_signed_response`], [`verify_wire_response`]) route through this so
/// the covered field set/order cannot drift. Adding/renaming/reordering here is a
/// signed-contract change.
///
/// The signature binds the approval to the exact call identity (`request_id`,
/// `tool_name`, `args_json`) so a re-emitted same-id call with different
/// args/tool cannot inherit it, AND — for a "don't ask again" decision — to
/// `approved_for_session` plus the `caller` the memory is scoped to, so a
/// remembered approval is per-caller and unforgeable. A one-shot approval simply
/// signs `approved_for_session: false`.
///
/// `approver` (`#1025`, RFC 8693's `act`/actor claim, distinct from `caller`'s
/// `sub`/subject) is the identity that actually resolved this decision, when
/// the edge supplied one — NOT necessarily the same persona as `caller` (the
/// paused turn's own beneficiary): an admin approving on someone else's
/// behalf signs a DIFFERENT `approver` than `caller`. Omitted from the
/// canonical entirely when empty (rather than signed as `""`) so every
/// `approval_response` persisted before this field existed — and every one
/// an edge that doesn't yet supply an approver identity signs today — stays
/// byte-identical and continues to verify unchanged; only a genuinely
/// non-empty `approver` changes the signed shape, and that decision is
/// always signed by code that already knows about this field.
///
/// `sandbox_mode` is the sandbox/permission mode (`POLYCHROME_SANDBOX_MODE`) the
/// PAUSED turn ran under, resolved server-side from the request. Binding it means
/// a remembered approval granted under one mode cannot be replayed to auto-
/// approve a later call running under a different (e.g. more-privileged) mode —
/// the harness re-prompts. Empty when no mode was recorded.
///
/// `conversation_id` and `nonce` make the response a SINGLE-USE, conversation-
/// bound capability token (`#370`, closes `#77` bug 3B). `conversation_id` binds
/// the approval to the one conversation it was granted in, so a signed response
/// copied into a different conversation's log fails to verify against that
/// conversation. `nonce` is a per-approval unique value the consumer records on
/// use, so a captured token cannot be re-presented after it has been spent. Both
/// are covered by the signature, so neither can be re-targeted or replayed
/// without invalidating it.
/// `covered_capabilities` (`#595`) records the capability shortfall this
/// approval covered — the missing set the gate computed when it paused the
/// call. Covered by the signature, so the effective session-grant key is
/// (caller, tool, covered capabilities): if the tool's required set later
/// grows, the old grant does not cover the new capability and the gate asks
/// again. Empty for an approval of an ordinary policy/sandbox gate.
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
fn response_canonical(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
approver_id: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
) -> Vec<u8> {
canonical_bytes(&ResponseCanonical {
body: ResponseBody {
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
},
approver: approver_id,
})
}
/// The thirteen `approval_response` fields every decision signs, in their
/// frozen order.
///
/// Split out from [`ResponseCanonical`] so the canonical and the persisted
/// payload ([`ResponseFull`]) share one declaration of the body — they place
/// the optional `approver` differently (last in both, but the payload has the
/// two provenance fields in between), and that is the only difference between
/// them.
#[derive(Serialize)]
struct ResponseBody<'a> {
request_id: &'a str,
tool_name: &'a str,
args_json: &'a str,
modified_args_json: &'a str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &'a [String],
caller: &'a str,
sandbox_mode: &'a str,
reason: &'a str,
injected_context: &'a str,
conversation_id: &'a str,
nonce: &'a str,
}
/// The signed `approval_response` canonical: the body plus the optional
/// `approver`.
///
/// `approver` is skipped entirely when empty rather than signed as `""` (see
/// [`response_canonical`], `#1025`), which keeps every `approval_response`
/// persisted before the field existed byte-identical and still verifiable.
#[derive(Serialize)]
struct ResponseCanonical<'a> {
#[serde(flatten)]
body: ResponseBody<'a>,
#[serde(skip_serializing_if = "str::is_empty")]
approver: &'a str,
}
/// The persisted `approval_response` payload: the body, the two provenance
/// fields, then the optional `approver`.
///
/// The `approver` trails the signature fields because that is where appending
/// it to the built object always put it, and the order is now the source's
/// rather than a map's (`#1842`).
#[derive(Serialize)]
struct ResponseFull<'a> {
#[serde(flatten)]
body: ResponseBody<'a>,
signed_by: String,
signature_hex: String,
#[serde(skip_serializing_if = "str::is_empty")]
approver: &'a str,
}
/// JSON payload for an `approval_response` event.
///
/// The signature commits to the canonical (unsigned) JSON form: the call
/// identity (`request_id`, `tool_name`, `args_json`), the decision
/// (`approved`), the session scope
/// (`approved_for_session`) and the `caller` it is bound to. `signed_by` and
/// `signature_hex` are populated *after* the signer runs and are NOT covered by
/// the signature.
///
/// Each parameter is a distinct signed field, so they're passed individually
/// rather than wrapped in a struct (the canonical form is the contract).
///
/// `conversation_id` binds the token to the conversation it was granted in and
/// `nonce` is a per-approval unique value (the control plane mints a fresh one
/// per response); together they make the approval a single-use, conversation-
/// bound capability (`#370`). Returns
/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
pub fn response_payload(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
approver_id: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let canonical = response_canonical(
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
approver_id,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
);
let signature = signer.sign(&canonical);
let pk = signer.public_key_bytes();
// Same omit-when-empty rule as `response_canonical` (#1025) — keeps the
// persisted payload byte-identical to before this field existed.
let full = ResponseFull {
body: ResponseBody {
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
},
signed_by: crate::hex::lower(&pk),
signature_hex: crate::hex::lower(&signature),
approver: approver_id,
};
(canonical_bytes(&full), signature, pk)
}
/// Scope names for a signed taint-excision marker (`#590`).
///
/// `cascade` is the sound default: the named positions are excised AND so is
/// every model-authored content event after the earliest of them — the
/// recovery literature shows a model re-derives an injected instruction from
/// its own retained reasoning if only the source is removed. `source-only`
/// excises exactly the named positions: an explicit, human-vouched override
/// for content the person read and judged benign, named in the signed
/// payload so the audit trail shows which posture the human chose.
pub const EXCISION_SCOPE_CASCADE: &str = "cascade";
/// See [`EXCISION_SCOPE_CASCADE`].
pub const EXCISION_SCOPE_SOURCE_ONLY: &str = "source-only";
/// The canonical (signature-covered) form of a `taint_excision` marker.
///
/// Covers the conversation (a marker signed for one conversation cannot be
/// replayed into another), the scope, the named journal positions, and who
/// requested the excision — so a marker can be neither forged, re-targeted,
/// nor widened. `signed_by`/`signature_hex` are appended after signing and
/// are not covered.
fn excision_canonical(
conversation_id: &str,
scope: &str,
positions: &[u64],
requested_by: &str,
reason: &str,
) -> Vec<u8> {
canonical_bytes(&ExcisionCanonical {
conversation_id,
scope,
positions,
requested_by,
reason,
})
}
/// The signed `taint_excision` field set, in its frozen order.
#[derive(Serialize)]
struct ExcisionCanonical<'a> {
conversation_id: &'a str,
scope: &'a str,
positions: &'a [u64],
requested_by: &'a str,
reason: &'a str,
}
/// JSON payload for a signed `taint_excision` event (`#590`).
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn excision_payload(
conversation_id: &str,
scope: &str,
positions: &[u64],
requested_by: &str,
reason: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
ExcisionCanonical {
conversation_id,
scope,
positions,
requested_by,
reason,
},
signer.as_signer(),
)
}
/// The canonical (signature-covered) form of a `grant_replay` audit record
/// (`#594`).
///
/// Binds the conversation and turn the replay happened in, the tool the grant
/// cleared, the [`crate::grant::grant_ref`] of the grant that cleared it, the
/// capability names the grant kept against taint, and the template-coverage hash
/// (#618) the grant matched — so the durable record commits to exactly which
/// grant kept which capabilities on which turn. `signed_by`/`signature_hex` are
/// appended after signing and are not covered.
fn grant_replay_canonical(
conversation_id: &str,
turn_id: &str,
tool: &str,
grant_ref: &str,
covered_capabilities: &[String],
coverage_hash: &str,
) -> Vec<u8> {
canonical_bytes(&GrantReplayCanonical {
conversation_id,
turn_id,
tool,
grant_ref,
covered_capabilities,
coverage_hash,
})
}
/// The signed `grant_replay` field set, in its frozen order.
#[derive(Serialize)]
struct GrantReplayCanonical<'a> {
conversation_id: &'a str,
turn_id: &'a str,
tool: &'a str,
grant_ref: &'a str,
covered_capabilities: &'a [String],
coverage_hash: &'a str,
}
/// JSON payload for a signed `grant_replay` audit event (`#594`).
///
/// The durable, trust-tagged record PRD §12 requires that a replayed grant kept
/// a capability taint would have removed — appended by the control plane once per
/// grant-cleared gate (never a `tracing` line). Platform-signed (ed25519, the
/// [`ApprovalSigner`]) so the record is tamper-evident and attributable. The
/// field names match `polychrome.events.v1.GrantReplayEvent`, so the forensics
/// decoder renders it. Returns `(full_payload_bytes, signature_bytes,
/// public_key_bytes)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // each arg is a distinct field of the audit record
pub fn grant_replay_payload(
conversation_id: &str,
turn_id: &str,
tool: &str,
grant_ref: &str,
covered_capabilities: &[String],
coverage_hash: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
GrantReplayCanonical {
conversation_id,
turn_id,
tool,
grant_ref,
covered_capabilities,
coverage_hash,
},
signer.as_signer(),
)
}
/// Verify a persisted `grant_replay` audit payload against its embedded signer
/// (`#594`).
///
/// Rebuilds the canonical from the payload's own fields and checks the embedded
/// ed25519 signature. Returns `true` only when the signature covers the exact
/// record — a tamper to any bound field flips it to `false`. Fail-closed on any
/// malformed field or bad hex.
#[must_use]
pub fn verify_grant_replay(payload: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(payload) else {
return false;
};
let (
Some(conversation_id),
Some(turn_id),
Some(tool),
Some(grant_ref),
Some(covered),
Some(coverage_hash),
Some(signed_by),
Some(signature_hex),
) = (
v.get("conversation_id").and_then(Value::as_str),
v.get("turn_id").and_then(Value::as_str),
v.get("tool").and_then(Value::as_str),
v.get("grant_ref").and_then(Value::as_str),
v.get("covered_capabilities").and_then(Value::as_array),
v.get("coverage_hash").and_then(Value::as_str),
v.get("signed_by").and_then(Value::as_str),
v.get("signature_hex").and_then(Value::as_str),
)
else {
return false;
};
let Some(covered_capabilities) = covered
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()
else {
return false;
};
let (Some(pk), Some(sig)) = (
crate::hex::decode(signed_by),
crate::hex::decode(signature_hex),
) else {
return false;
};
let canonical = grant_replay_canonical(
conversation_id,
turn_id,
tool,
grant_ref,
&covered_capabilities,
coverage_hash,
);
crate::verify(&pk, &canonical, &sig)
}
/// Whether `signer_pk` is a member of the deployment's pinned approval
/// allow-list (`#845`).
///
/// An internally-consistent ed25519 signature proves only that a payload was
/// not altered after it was signed; it says nothing about whether the signer is
/// one the deployment trusts, since anyone can mint a keypair, embed its own
/// public key, and self-sign an arbitrary payload. Every trust-gated verifier
/// therefore checks membership here before honoring a payload. An empty
/// allow-list trusts no one (fail closed). Shared by the grant-replay and
/// approval-response pinned verifiers so the allow-list check cannot drift
/// between them or from [`verify_signed_receipt`]'s.
fn signer_is_trusted(signer_pk: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
trusted_signers.iter().any(|k| k.as_slice() == signer_pk)
}
/// Verify a persisted `grant_replay` audit payload against a **trusted-signer
/// allow-list** (`#845`).
///
/// Like [`verify_grant_replay`] but additionally rejects any payload whose
/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
/// pinned approval public key(s), typically the running
/// [`ApprovalSigner::public_key_bytes`], since every `grant_replay` record this
/// control plane persists is self-signed with it. Without this gate an attacker
/// could sign a well-formed record with their own key, embed it, and have the
/// audit trail render it as `valid`. Mirrors [`verify_signed_receipt`]'s
/// allow-list gate. Returns `true` only when the signer is trusted AND the
/// signature covers the exact record; `false` (fail closed) on a malformed
/// payload, an untrusted signer, or a bad signature.
#[must_use]
pub fn verify_grant_replay_pinned(payload: &[u8], trusted_signers: &[Vec<u8>]) -> bool {
// Gate on the allow-list before the (single-sourced) signature check: an
// untrusted signer is rejected no matter how internally consistent its
// signature is.
let Some(pk) = serde_json::from_slice::<Value>(payload).ok().and_then(|v| {
v.get("signed_by")
.and_then(Value::as_str)
.and_then(crate::hex::decode)
}) else {
return false;
};
if !signer_is_trusted(&pk, trusted_signers) {
return false;
}
verify_grant_replay(payload)
}
/// A verified `taint_excision` marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedExcision {
/// The conversation the marker is bound to.
pub conversation_id: String,
/// [`EXCISION_SCOPE_CASCADE`] or [`EXCISION_SCOPE_SOURCE_ONLY`].
pub scope: String,
/// The named journal positions.
pub positions: Vec<u64>,
/// Who requested the excision (persona id or operator identity).
pub requested_by: String,
/// Free-text audit reason.
pub reason: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
impl VerifiedExcision {
/// Whether this marker's scope is the cascading (sound-default) one.
#[must_use]
pub fn is_cascade(&self) -> bool {
self.scope == EXCISION_SCOPE_CASCADE
}
}
/// Verify a persisted `taint_excision` payload.
///
/// `None` for a malformed payload, an unknown scope, or a signature that
/// does not verify — the caller ignores the marker and taint stays (fail
/// closed).
#[must_use]
pub fn verify_signed_excision(payload: &[u8]) -> Option<VerifiedExcision> {
let v: Value = serde_json::from_slice(payload).ok()?;
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let scope = v.get("scope")?.as_str()?.to_owned();
if scope != EXCISION_SCOPE_CASCADE && scope != EXCISION_SCOPE_SOURCE_ONLY {
return None;
}
let positions: Vec<u64> = v
.get("positions")?
.as_array()?
.iter()
.map(serde_json::Value::as_u64)
.collect::<Option<Vec<_>>>()?;
let requested_by = v.get("requested_by")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical =
excision_canonical(&conversation_id, &scope, &positions, &requested_by, &reason);
if verify(&pk, &canonical, &sig) {
Some(VerifiedExcision {
conversation_id,
scope,
positions,
requested_by,
reason,
signer_public_key: pk,
})
} else {
None
}
}
/// JSON payload for an `approval_deferred` event (`#67` "send back").
///
/// A defer records that the approver bounced the call back without approving or
/// denying it — the audit trail shows the intent, but the pending
/// `approval_request` is NOT resolved (no `approval_response`), so the call stays
/// open. The signature commits to the call identity (`request_id`), the
/// `conversation_id` it was deferred in, and the free-form `reason`; `signed_by`
/// and `signature_hex` are appended after signing and are not covered.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn deferred_payload(
request_id: &str,
conversation_id: &str,
reason: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
DeferredCanonical {
request_id,
conversation_id,
reason,
},
signer.as_signer(),
)
}
/// The signed `approval_deferred` field set, in its frozen order.
#[derive(Serialize)]
struct DeferredCanonical<'a> {
request_id: &'a str,
conversation_id: &'a str,
reason: &'a str,
}
/// Verify a persisted `approval_deferred` payload (`#67`).
///
/// Returns `Some((request_id, conversation_id, reason))` when the signature
/// checks out against the embedded key, else `None`.
#[must_use]
pub fn verify_deferred(payload: &[u8]) -> Option<(String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = canonical_bytes(&DeferredCanonical {
request_id: &request_id,
conversation_id: &conversation_id,
reason: &reason,
});
verify(&pk, &canonical, &sig).then_some((request_id, conversation_id, reason))
}
/// JSON payload for a dispatch-mutation event (`#67`, #539/#540).
///
/// A signed record that a policy rewrote a call's args (`tool_input_rewrite`),
/// injected context (`tool_context_injection`), or redacted a result
/// (`tool_result_redaction`).
///
/// The signature commits to the event `kind` (so a record can't be re-filed under
/// another mutation kind), the call identity (`tool_call_id`, `tool_name`), the
/// conversation, and the mutation's `before`/`after` (proposed→executed args,
/// or empty→context, or original→redacted result). `signed_by` / `signature_hex`
/// are appended after signing and not covered. Returns
/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // each is a distinct signed field of the canonical contract
pub fn mutation_payload(
kind: &str,
tool_call_id: &str,
tool_name: &str,
conversation_id: &str,
before: &str,
after: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
MutationCanonical {
kind,
tool_call_id,
tool_name,
conversation_id,
before,
after,
},
signer.as_signer(),
)
}
/// The signed dispatch-mutation field set, in its frozen order.
#[derive(Serialize)]
struct MutationCanonical<'a> {
kind: &'a str,
tool_call_id: &'a str,
tool_name: &'a str,
conversation_id: &'a str,
before: &'a str,
after: &'a str,
}
/// Verify a persisted dispatch-mutation payload (`#67`).
///
/// Returns the signed `(kind, tool_call_id, tool_name, conversation_id, before,
/// after)` when the signature checks out against the embedded key, else `None`.
#[must_use]
pub fn verify_mutation(payload: &[u8]) -> Option<(String, String, String, String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let kind = v.get("kind")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let tool_name = v.get("tool_name")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let before = v.get("before")?.as_str()?.to_owned();
let after = v.get("after")?.as_str()?.to_owned();
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = canonical_bytes(&MutationCanonical {
kind: &kind,
tool_call_id: &tool_call_id,
tool_name: &tool_name,
conversation_id: &conversation_id,
before: &before,
after: &after,
});
verify(&pk, &canonical, &sig).then_some((
kind,
tool_call_id,
tool_name,
conversation_id,
before,
after,
))
}
/// Reason-string prefix marking an `approval_response` as a reviewer-agent
/// auto-approval (`#377`), as opposed to a human decision.
///
/// The reviewer signs the EXACT same canonical `approval_response` a human
/// would — same [`response_payload`], same signer, same bound identity
/// (`request_id` + `tool_name` + `args_json` + `caller` + `sandbox_mode`),
/// `approved == true`, `approved_for_session == false` — so the wire/signature
/// contract is byte-for-byte identical and every existing verify path accepts
/// it unchanged. The ONLY field distinguishing an auto-approval from a human
/// one is the signed `reason`, which carries this prefix. Because `reason` is
/// covered by the signature (`response_canonical`), the distinction is
/// unforgeable: a compromised forwarder can neither launder an auto-approval as
/// human nor a human decision as auto without invalidating the signature. The
/// event log is therefore auditable for machine-vs-human consent off this one
/// signed field — the guardrail `#377` requires.
pub const AUTO_REVIEW_REASON_PREFIX: &str = "auto-review:";
/// Build the signed `reason` for a reviewer auto-approval at risk `tier`.
///
/// Carries [`AUTO_REVIEW_REASON_PREFIX`] so the audit log can tell it from a
/// human decision; `tier` (e.g. `"low"`) records WHY the classifier deemed the
/// call auto-eligible. The control plane passes the result as the `reason`
/// argument to the SAME [`response_payload`] the human path uses, so no
/// separate signing surface exists.
#[must_use]
pub fn auto_review_reason(tier: &str) -> String {
format!("{AUTO_REVIEW_REASON_PREFIX}{tier}")
}
/// Whether a signed `reason` marks its `approval_response` as a reviewer
/// auto-approval (`#377`) rather than a human decision.
///
/// The audit distinguisher; it reads the signed `reason` field, so it cannot be
/// spoofed without breaking the signature.
#[must_use]
pub fn is_auto_review_reason(reason: &str) -> bool {
reason.starts_with(AUTO_REVIEW_REASON_PREFIX)
}
/// Signed `reason` recorded for the blanket `approve-all-dangerous` mode.
///
/// Set by `POLYCHROME_APPROVAL_MODE=approve-all-dangerous` — the legible single
/// approve-all surface that replaced the legacy `POLYCHROME_APPROVE_ALL` flag.
/// Unlike [`auto_review_reason`] this is NOT a risk-classified verdict: it marks
/// an unconditional machine approval, so the audit log can tell a blanket
/// test-rig approval apart from both a human decision and a reviewer
/// auto-approval. Like every other reason it is covered by the signature, so the
/// distinction is unforgeable.
pub const APPROVE_ALL_DANGEROUS_REASON: &str = "approve-all-dangerous: blanket machine approval";
/// A decoded `approval_response` payload after signature verification.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
/// The tool-call id this response answers.
pub request_id: String,
/// The bound tool name — the approval applies only to this exact call.
pub tool_name: String,
/// The bound `args_json` — the model's PROPOSED args, the identity the
/// approval is bound to. [`Self::authorizes_call`] matches this byte-for-byte,
/// so a re-emitted call with different args cannot inherit the approval. This
/// is the args the approver saw, NOT necessarily the args that execute.
pub args_json: String,
/// The approver's EDIT to the proposed args — the args to actually execute,
/// or empty when the approver did not edit (execute `args_json` unchanged).
/// Signed, so the edit is unforgeable and auditable; the delta from
/// `args_json` is the recorded mutation. Resolve the effective execution args
/// with the pure `polyc_agent::resolve_approved_call`.
pub modified_args_json: String,
/// Whether the request was approved.
pub approved: bool,
/// Whether the approval is remembered for the rest of the session ("don't
/// ask again"); `false` for a one-shot approval.
pub approved_for_session: bool,
/// The capability shortfall this approval covered (`#595`): the stable
/// kebab-case capability names the gate reported missing when it paused
/// the call. Covered by the signature, so the effective session-grant key
/// is (caller, tool, covered capabilities) — a grant recorded against one
/// covered set never satisfies the same tool after its required set grows.
/// Empty for an approval of an ordinary policy/sandbox gate.
pub covered_capabilities: Vec<String>,
/// The caller identity the (session) approval is scoped to — the paused
/// turn's own beneficiary (RFC 8693 `sub`/subject), NOT necessarily who
/// clicked. Set by the trusted control plane and covered by the
/// signature, so a session grant cannot be re-scoped to a different user.
pub caller: String,
/// The identity that actually resolved this decision (`#1025`, RFC 8693
/// `act`/actor), when the edge supplied one — empty otherwise (no edge
/// integration yet, or a payload signed before this field existed).
/// Distinct from `caller`: an admin approving on someone else's behalf
/// signs a different `approver` than `caller`. For approval-policy
/// checks and the audit trail ONLY — never fed into `principal_ref` (see
/// `caller`'s own resume-attribution use in the control plane).
pub approver: String,
/// The sandbox/permission mode the paused turn ran under, covered by the
/// signature so a grant cannot be replayed under a different mode.
pub sandbox_mode: String,
/// Free-form human-supplied reason.
pub reason: String,
/// Context the approver attached to inject before the tool runs — prepended
/// as an `internal_only` message ahead of execution, or empty when none.
/// Signed, so an injected instruction is unforgeable and recorded.
pub injected_context: String,
/// The conversation the approval was granted in, covered by the signature so
/// a token signed for one conversation cannot be replayed into another
/// (`#370`, closes `#77` bug 3B).
pub conversation_id: String,
/// Per-approval unique value, covered by the signature. A consumer records it
/// on use so the token cannot be re-presented once spent (single-use, `#370`).
pub nonce: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
impl VerifiedResponse {
/// Whether this verified, approved token authorizes the EXACT call
/// `(request_id, tool_name, args_json)` — the args-binding (`#370` item 3).
///
/// The signed `args_json` is matched byte-for-byte, so a re-emitted same-id
/// call with different arguments (or a different tool) is NOT authorized: a
/// captured approval can never be reused to run a different action. A denial
/// (`approved == false`) authorizes nothing.
#[must_use]
pub fn authorizes_call(&self, request_id: &str, tool_name: &str, args_json: &str) -> bool {
self.approved
&& self.request_id == request_id
&& self.tool_name == tool_name
&& self.args_json == args_json
}
}
/// Verify a persisted `approval_response` payload.
///
/// Returns `Some(record)` if the signature checks out against the embedded
/// public key (the caller is responsible for trusting that public key — a key
/// allow-list lives alongside this in production). Returns `None` if the payload
/// is malformed, the hex fields don't decode, or the signature doesn't verify.
#[must_use]
pub fn verify_signed_response(payload: &[u8]) -> Option<VerifiedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let tool_name = v.get("tool_name")?.as_str()?.to_owned();
let args_json = v.get("args_json")?.as_str()?.to_owned();
let modified_args_json = v.get("modified_args_json")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
let approved_for_session = v.get("approved_for_session")?.as_bool()?;
let covered_capabilities: Vec<String> = v
.get("covered_capabilities")?
.as_array()?
.iter()
.map(|c| c.as_str().map(str::to_owned))
.collect::<Option<Vec<_>>>()?;
let caller = v.get("caller")?.as_str()?.to_owned();
// #1025: absent on every payload signed before this field existed (and
// on any signed today with an empty approver — omitted, not `""`, at
// sign time) — defaults to empty, NOT a decode failure.
let approver_id = v
.get("approver")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_owned();
let sandbox_mode = v.get("sandbox_mode")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let injected_context = v.get("injected_context")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let nonce = v.get("nonce")?.as_str()?.to_owned();
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = response_canonical(
&request_id,
&tool_name,
&args_json,
&modified_args_json,
approved,
approved_for_session,
&covered_capabilities,
&caller,
&approver_id,
&sandbox_mode,
&reason,
&injected_context,
&conversation_id,
&nonce,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedResponse {
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
approver: approver_id,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
signer_public_key: pk,
})
} else {
None
}
}
/// Verify a persisted `approval_response` payload against a **trusted-signer
/// allow-list** (`#845`).
///
/// Like [`verify_signed_response`] but additionally rejects any payload whose
/// embedded `signed_by` key is not one of `trusted_signers` — the deployment's
/// pinned approval public key(s), typically the running
/// [`ApprovalSigner::public_key_bytes`], since every `approval_response` this
/// control plane persists is self-signed with it. Without this gate an attacker
/// could sign a well-formed decision with their own key, embed it, and have it
/// honored as an approval. Mirrors [`verify_signed_receipt`]'s allow-list gate.
/// Returns `None` (fail closed) on a malformed payload, an untrusted signer, or
/// a bad signature.
#[must_use]
pub fn verify_signed_response_pinned(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedResponse> {
let verified = verify_signed_response(payload)?;
// Reject an untrusted signer even though its signature is self-consistent:
// anyone can mint a keypair, embed its public key, and self-sign.
if !signer_is_trusted(&verified.signer_public_key, trusted_signers) {
return None;
}
Some(verified)
}
/// Verify a persisted `approval_response` as a SINGLE-USE, conversation-bound
/// capability token (`#370`), gated on a **trusted-signer allow-list** (`#845`).
///
/// Returns the verified response only when ALL hold:
/// * the embedded `signed_by` key is a member of `trusted_signers` — the
/// deployment's pinned approval public key(s) (see
/// [`verify_signed_response_pinned`]); an internally-consistent signature over
/// an untrusted key authorizes nothing;
/// * the signature verifies against that key (provenance);
/// * the signed `conversation_id` equals `conversation_id` — a token signed for
/// one conversation is rejected when presented for another (closes `#77`
/// bug 3B);
/// * the signed `nonce` is non-empty AND not already in `consumed` — a token
/// that has been spent (its nonce recorded on a prior use) is rejected.
///
/// The caller binds the token to a specific call by matching the returned
/// [`VerifiedResponse::authorizes_call`], and MUST record the returned
/// [`VerifiedResponse::nonce`] into its `consumed` set before honoring it, so a
/// second presentation of the same token is rejected. An empty nonce is treated
/// as malformed and fails closed (every minted token carries one).
#[must_use]
pub fn verify_capability<S: std::hash::BuildHasher>(
payload: &[u8],
conversation_id: &str,
consumed: &HashSet<String, S>,
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedResponse> {
let verified = verify_signed_response_pinned(payload, trusted_signers)?;
if verified.conversation_id != conversation_id {
return None;
}
if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
return None;
}
Some(verified)
}
/// Verify a wire-form `approval_response`, binding the approval to its full
/// signed identity `(request_id, tool_name, args_json, approved,
/// approved_for_session, caller, reason)`.
///
/// Used by the harness when it receives `HarnessMessage.approval_responses` over
/// the wire and must confirm provenance AND identity before executing the paused
/// tool or honoring a "don't ask again" grant. The `caller` is covered by the
/// signature, so a compromised control plane cannot re-scope a remembered
/// approval onto a different user. Returns `true` only if `signer_pk_hex +
/// signature_hex` validates against the canonical.
#[must_use]
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
pub fn verify_wire_response(
request_id: &str,
tool_name: &str,
args_json: &str,
modified_args_json: &str,
approved: bool,
approved_for_session: bool,
covered_capabilities: &[String],
caller: &str,
approver_id: &str,
sandbox_mode: &str,
reason: &str,
injected_context: &str,
conversation_id: &str,
nonce: &str,
signer_pk_hex: &str,
signature_hex: &str,
) -> bool {
let Some(pk) = crate::hex::decode(signer_pk_hex) else {
return false;
};
let Some(sig) = crate::hex::decode(signature_hex) else {
return false;
};
let canonical = response_canonical(
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
covered_capabilities,
caller,
approver_id,
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
);
verify(&pk, &canonical, &sig)
}
/// Whether an approval response is a session ("don't ask again") grant for
/// `current_caller`.
///
/// True when it is approved, flagged for the session, and bound to a non-empty
/// `caller` equal to the current turn's caller.
///
/// This is the per-USER isolation invariant — user A's remembered approval must
/// never auto-approve user B in a shared conversation. It lives here, in one
/// place, so the harness (which re-verifies signed wire responses) and the
/// control plane (the in-process path) cannot drift on *who* a remembered
/// approval applies to. Callers still gate the TOOL on its idempotency
/// separately ([`crate`] does not know tool policy).
#[must_use]
pub fn is_session_grant_for(
approved: bool,
approved_for_session: bool,
caller: &str,
current_caller: &str,
) -> bool {
approved && approved_for_session && !caller.is_empty() && caller == current_caller
}
/// Extract `(request_id, approved)` from an `approval_response` payload.
///
/// Used by replay to find which pending requests have been answered. Skips
/// signature verification on the assumption the caller has already accepted
/// the entry — pair with [`verify_signed_response`] when trust matters.
#[must_use]
pub fn decode_response_minimal(payload: &[u8]) -> Option<(String, bool)> {
let v: Value = serde_json::from_slice(payload).ok()?;
let request_id = v.get("request_id")?.as_str()?.to_owned();
let approved = v.get("approved")?.as_bool()?;
Some((request_id, approved))
}
/// Every decoded field of an `approval_response` payload (unverified).
#[derive(Debug, Clone)]
pub struct DecodedResponse {
/// Tool-call id this response answers.
pub request_id: String,
/// Bound tool name.
pub tool_name: String,
/// Bound `args_json` — the model's proposed args (identity binding).
pub args_json: String,
/// The approver's edit to the proposed args (empty = unedited).
pub modified_args_json: String,
/// Approve / deny decision.
pub approved: bool,
/// Whether the approval is remembered for the session ("don't ask again").
pub approved_for_session: bool,
/// The capability shortfall this approval covered (`#595`).
pub covered_capabilities: Vec<String>,
/// The caller identity the (session) approval is scoped to — the paused
/// turn's own beneficiary, NOT necessarily who clicked. See `approver`.
pub caller: String,
/// The identity that actually resolved this decision (`#1025`), when the
/// edge supplied one — empty otherwise (no edge integration yet, or a
/// payload signed before this field existed). Distinct from `caller`.
pub approver: String,
/// The sandbox/permission mode the grant was made under.
pub sandbox_mode: String,
/// Human-supplied reason.
pub reason: String,
/// Context the approver attached to inject before execution (empty = none).
pub injected_context: String,
/// The conversation the approval was granted in (`#370` binding).
pub conversation_id: String,
/// Per-approval single-use nonce (`#370` binding).
pub nonce: String,
/// Signer public key, hex.
pub signer_pk_hex: String,
/// Signature, hex.
pub signature_hex: String,
}
/// Decode every field of an `approval_response` payload without verifying.
///
/// Used by the control plane to forward signed responses onto the harness wire;
/// the harness re-verifies on receipt.
#[must_use]
pub fn decode_response_full(payload: &[u8]) -> Option<DecodedResponse> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(DecodedResponse {
request_id: v.get("request_id")?.as_str()?.to_owned(),
tool_name: v.get("tool_name")?.as_str()?.to_owned(),
args_json: v.get("args_json")?.as_str()?.to_owned(),
modified_args_json: v.get("modified_args_json")?.as_str()?.to_owned(),
approved: v.get("approved")?.as_bool()?,
approved_for_session: v.get("approved_for_session")?.as_bool()?,
covered_capabilities: v
.get("covered_capabilities")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|c| c.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default(),
caller: v.get("caller")?.as_str()?.to_owned(),
// #1025: absent on every payload signed before this field existed —
// defaults to empty, NOT a decode failure (see `response_canonical`).
approver: v
.get("approver")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_owned(),
sandbox_mode: v.get("sandbox_mode")?.as_str()?.to_owned(),
reason: v.get("reason")?.as_str()?.to_owned(),
injected_context: v.get("injected_context")?.as_str()?.to_owned(),
conversation_id: v.get("conversation_id")?.as_str()?.to_owned(),
nonce: v.get("nonce")?.as_str()?.to_owned(),
signer_pk_hex: v.get("signed_by")?.as_str()?.to_owned(),
signature_hex: v.get("signature_hex")?.as_str()?.to_owned(),
})
}
/// Current signed `payment_receipt` schema version.
///
/// **v2** makes a settled receipt self-describing: alongside the original
/// settlement facts it covers the event `kind` (direction — without it an
/// inbound payload could be re-filed under the outbound kind, or vice versa,
/// since the stored `Event.kind` is not itself signed), the binding fields
/// (`tool_call_id`, `approval_pos`, `approved_args_hash`), and an opaque
/// `subject` — so an auditor can bind the receipt to the exact approved tool
/// call it answered without walking the log to the separate
/// `outbound_payment_attempt` event. **v3** adds payer attribution
/// (`payer_kind`, `paying_account`) — which account actually paid: a linked
/// wallet or the deployment's own signer — computed at settlement and now
/// carried on the durable receipt instead of only reaching the tool result.
/// **v1** (legacy, no `version` field) signed only the six settlement facts;
/// it still *verifies* for forensics but carries no kind, binding tuple, or
/// payer attribution.
pub const RECEIPT_VERSION: u64 = 3;
// Compile-time tripwire for a `RECEIPT_VERSION` bump. `receipt_payload` signs
// the frozen `ReceiptPayload::canonical_json_v3` *by name*, and `ReceiptSchema`
// resolves each persisted version to its own frozen builder. Raising the
// constant without doing the rest would leave new receipts signing v3 bytes
// while claiming the new version, so make that mismatch fail the build instead
// of shipping. The message lists every site a bump touches: the enum's
// exhaustive matches force (1) and (2), `dead_code` on an unreachable variant
// forces (3), this assert itself catches (4), and (5) is on the bumper.
const _: () = assert!(
RECEIPT_VERSION == 3,
"RECEIPT_VERSION changed. A bump is five edits, not one: (1) add \
ReceiptPayload::canonical_json_v<N> beside the frozen builders; (2) add \
ReceiptSchema::V<N> and answer number(), covers_binding() — say which \
fields the new canonical actually covers — and canonical(); (3) give \
ReceiptSchema::resolve an arm mapping the claimed number to it; (4) \
repoint receipt_payload at the new builder; (5) in the tests, add a \
golden v<N> fixture beside GOLDEN_V2_RECEIPT and re-freeze the pinned \
writer shape, leaving every already-frozen v2 literal untouched"
);
/// The receipt body fields [`receipt_payload`] signs, named at the call site.
///
/// Replaces positional `&str` arguments: with the positional form, two
/// same-typed fields (e.g. `recipient` and `method`) could be swapped at a call
/// site and still compile, silently signing a corrupt receipt. Naming the
/// fields here makes such a swap a compile error.
///
/// The field set, names, and the order they are serialized in
/// [`receipt_payload`] are the signed-payload contract: they must NOT change
/// without a version bump, or previously-persisted receipts stop verifying.
///
/// `kind` and the trailing four fields are **v2** additions: the event kind
/// (direction) plus the binding tuple and an opaque `subject`. Inbound (the
/// server was *paid*) receipts have no approved tool call, so they pass empty
/// strings for the binding tuple and subject; outbound (the control plane
/// *paid* a 402 service) receipts populate them. Both directions sign their
/// `kind`.
#[derive(Debug, Clone, Copy)]
pub struct ReceiptPayload<'a> {
/// The event kind the payload is stored under (`payment_receipt` for
/// inbound, `outbound_payment_receipt` for outbound). Signed so a payload
/// cannot be re-filed under the other direction's kind (v2; empty for
/// legacy v1).
pub kind: &'a str,
/// Chain/transaction reference (e.g. tx id) the receipt settles.
pub reference: &'a str,
/// Settled amount as a string, never a float (no drift, no rounding).
///
/// The UNIT is the direction's, not this type's, and the two directions do
/// not agree: an inbound receipt (`payment_receipt`) carries a decimal
/// figure in the settlement currency (`"0.01"`), an outbound one
/// (`outbound_payment_receipt`) carries the settlement token's base units
/// (`"10000"`, or `""` when the payment proxy could not capture the
/// charge). Read it through the matching `polyc_payments::amount` reader,
/// dispatched on [`Self::kind`] — never with a bare parse, and never by
/// guessing from the string's shape.
pub amount: &'a str,
/// Currency / asset label. Follows the same per-direction split as
/// [`Self::amount`]: the settlement currency symbol inbound, the
/// settlement token's contract address outbound.
pub currency: &'a str,
/// Recipient address.
pub recipient: &'a str,
/// Settlement method (e.g. `tempo`).
pub method: &'a str,
/// RFC3339 settlement timestamp.
pub timestamp: &'a str,
/// The `paid_fetch` tool-call id this payment answered (v2; empty for
/// inbound and legacy v1).
pub tool_call_id: &'a str,
/// Decimal string of the `approval_request` log position the payment
/// answered (v2; empty for inbound and legacy v1).
pub approval_pos: &'a str,
/// sha256 hex of the approved `args_json` — the same idempotency-key
/// component the `outbound_payment_attempt` marker carries, so a verifier
/// can cross-check the receipt against the attempt (v2; empty otherwise).
pub approved_args_hash: &'a str,
/// Opaque principal the spend is attributed to. Currently the conversation
/// id; the structured agent/tenant identity is supplied later by the
/// declarative-catalog identity model. Treat as opaque (v2; empty otherwise).
pub subject: &'a str,
/// Which account actually paid: `"linked_wallet"` or `"deployment"` — the
/// stable vocabulary `PayerKind::as_str` mints (in the payments client
/// resolver). Explicit unknown (empty string) for v1/v2 receipts and for
/// inbound receipts, which have no payer concept — never inferred (v3).
pub payer_kind: &'a str,
/// The paying account's own address, when known. Empty when the payer is
/// the deployment's own signer (there is no separate "paying account" to
/// name beyond the signer itself) or when this build cannot observe it
/// (v3; empty for v1/v2 and inbound receipts).
pub paying_account: &'a str,
}
impl ReceiptPayload<'_> {
/// Frozen **v3** canonical: the v2 field set plus payer attribution
/// (`payer_kind`, `paying_account`).
///
/// This is the SINGLE source for the signed v3 receipt field set and order:
/// both the signing path ([`receipt_payload`]) and the verifying path
/// ([`verify_signed_receipt`]) route their v3 canonical bytes through here,
/// so the two paths cannot drift. The key set/order is the on-wire signed
/// contract and must NOT change — a new covered field means a new version
/// with its own builder beside this one.
///
/// Frozen exactly like [`Self::canonical_json_v2`], and retained for the
/// same reason: once [`RECEIPT_VERSION`] moves past 3, every receipt
/// already signed under v3 still *verifies* through this builder. That is
/// why the `version` key below is the literal `3` rather than
/// [`RECEIPT_VERSION`] — pinning it to the constant would re-canonicalize
/// every persisted v3 receipt the day the constant changed, and none of
/// them would verify again.
#[must_use]
const fn canonical_json_v3(&self) -> ReceiptCanonicalV3<'_> {
ReceiptCanonicalV3 {
version: 3,
kind: self.kind,
reference: self.reference,
amount: self.amount,
currency: self.currency,
recipient: self.recipient,
method: self.method,
timestamp: self.timestamp,
tool_call_id: self.tool_call_id,
approval_pos: self.approval_pos,
approved_args_hash: self.approved_args_hash,
subject: self.subject,
payer_kind: self.payer_kind,
paying_account: self.paying_account,
}
}
/// Frozen **v2** canonical: the six settlement facts plus the event
/// `kind`, the binding tuple, and the opaque `subject`.
///
/// This is the SINGLE source for the signed v2 receipt field set and order:
/// both the signing path ([`receipt_payload`]) and the verifying path
/// ([`verify_signed_receipt`]) route their v2 canonical bytes through here,
/// so the two paths cannot drift. The key set/order is the on-wire signed
/// contract and must NOT change — a new covered field means a new version
/// with its own builder beside this one.
///
/// Frozen exactly like [`Self::canonical_json_v1`], and retained for the
/// same reason: once [`RECEIPT_VERSION`] moves past 2, every receipt
/// already signed under v2 still *verifies* through this builder. That is
/// why the `version` key below is the literal `2` rather than
/// [`RECEIPT_VERSION`] — pinning it to the constant would re-canonicalize
/// every persisted v2 receipt the day the constant changed, and none of
/// them would verify again.
#[must_use]
const fn canonical_json_v2(&self) -> ReceiptCanonicalV2<'_> {
ReceiptCanonicalV2 {
version: 2,
kind: self.kind,
reference: self.reference,
amount: self.amount,
currency: self.currency,
recipient: self.recipient,
method: self.method,
timestamp: self.timestamp,
tool_call_id: self.tool_call_id,
approval_pos: self.approval_pos,
approved_args_hash: self.approved_args_hash,
subject: self.subject,
}
}
/// Legacy **v1** canonical (the original six settlement fields, no
/// `version`). Retained only so receipts persisted before the v2 binding
/// still *verify* for forensics; new receipts always sign v3.
#[must_use]
const fn canonical_json_v1(&self) -> ReceiptCanonicalV1<'_> {
ReceiptCanonicalV1 {
reference: self.reference,
amount: self.amount,
currency: self.currency,
recipient: self.recipient,
method: self.method,
timestamp: self.timestamp,
}
}
}
/// The frozen **v3** receipt field set, in its frozen order.
///
/// `version` is the literal `3`, never [`RECEIPT_VERSION`], for the reason
/// [`ReceiptPayload::canonical_json_v3`] gives: a receipt already in the log was
/// signed over these exact bytes and must keep verifying after the constant
/// moves on.
#[derive(Serialize)]
struct ReceiptCanonicalV3<'a> {
version: u8,
kind: &'a str,
reference: &'a str,
amount: &'a str,
currency: &'a str,
recipient: &'a str,
method: &'a str,
timestamp: &'a str,
tool_call_id: &'a str,
approval_pos: &'a str,
approved_args_hash: &'a str,
subject: &'a str,
payer_kind: &'a str,
paying_account: &'a str,
}
/// The frozen **v2** receipt field set, in its frozen order.
///
/// `version` is the literal `2`, never [`RECEIPT_VERSION`], for the reason
/// [`ReceiptPayload::canonical_json_v2`] gives: a receipt already in the log was
/// signed over these exact bytes and must keep verifying after the constant
/// moves on.
#[derive(Serialize)]
struct ReceiptCanonicalV2<'a> {
version: u8,
kind: &'a str,
reference: &'a str,
amount: &'a str,
currency: &'a str,
recipient: &'a str,
method: &'a str,
timestamp: &'a str,
tool_call_id: &'a str,
approval_pos: &'a str,
approved_args_hash: &'a str,
subject: &'a str,
}
/// The frozen **v1** receipt field set (the six settlement facts, no
/// `version`), in its frozen order.
#[derive(Serialize)]
struct ReceiptCanonicalV1<'a> {
reference: &'a str,
amount: &'a str,
currency: &'a str,
recipient: &'a str,
method: &'a str,
timestamp: &'a str,
}
/// JSON payload for a `payment_receipt` event.
///
/// Mirrors [`response_payload`] exactly: the signature commits to the
/// canonical (unsigned) JSON form of the receipt body
/// (`reference`, `amount`, `currency`, `recipient`, `method`, `timestamp`).
/// `signed_by` and `signature_hex` are populated *after* the signer runs and
/// are NOT covered by the signature itself. Tampering with any body field
/// invalidates the signature.
///
/// The fields arrive as a single named [`ReceiptPayload`] (rather than six
/// positional strings) so a call site cannot silently swap two same-typed
/// fields; the serialized key set/order is unchanged and remains the signed
/// contract.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn receipt_payload(
fields: &ReceiptPayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
// New receipts always sign the current version's frozen canonical, named
// here rather than looked up, so a `RECEIPT_VERSION` bump has to repoint
// this line deliberately (the tripwire beside the constant enforces it).
//
// The full payload is the canonical body plus the two signature fields,
// which are NOT covered by the signature. `Envelope` flattens the same
// single-source canonical struct, so the body field set still lives only in
// `ReceiptPayload::canonical_json_v3`.
Envelope::seal(fields.canonical_json_v3(), signer.as_signer())
}
/// A decoded `payment_receipt` payload after signature verification.
#[derive(Debug, Clone)]
pub struct VerifiedReceipt {
/// Chain/transaction reference (e.g. tx id) the receipt settles.
pub reference: String,
/// Settled amount as a string, never a float (no drift, no rounding).
///
/// Carries whichever unit its direction stores — a decimal figure in the
/// settlement currency for an inbound `payment_receipt`, the settlement
/// token's base units for an outbound `outbound_payment_receipt` (empty
/// when the proxy could not capture the charge). Every reader of this
/// field dispatches on the direction it read the receipt under and uses
/// the matching `polyc_payments::amount` reader; a bare `parse` here is
/// the #1739 bug class, which silently dropped every inbound charge.
pub amount: String,
/// Currency / asset label — the settlement currency symbol inbound, the
/// settlement token's contract address outbound.
pub currency: String,
/// Recipient address.
pub recipient: String,
/// Settlement method (e.g. `tempo`).
pub method: String,
/// RFC3339 settlement timestamp.
pub timestamp: String,
/// Schema version (`1` = legacy settlement-only, `2` = kind + binding
/// tuple present).
pub version: u64,
/// The signed event kind (`payment_receipt` or `outbound_payment_receipt`).
/// Callers should check it matches the kind the event was stored under —
/// the stored kind itself is not signed (v2; empty for v1).
pub kind: String,
/// The `paid_fetch` tool-call id this payment answered (v2; empty for v1).
pub tool_call_id: String,
/// Decimal string of the `approval_request` log position (v2; empty for v1).
pub approval_pos: String,
/// sha256 hex of the approved `args_json` (v2; empty for v1).
pub approved_args_hash: String,
/// Opaque principal the spend is attributed to (v2; empty for v1).
pub subject: String,
/// Which account actually paid: `"linked_wallet"` or `"deployment"`,
/// explicit unknown (empty) when this build cannot say so — never
/// inferred (v3; empty for v1/v2).
pub payer_kind: String,
/// The paying account's own address, when known (v3; empty for v1/v2, for
/// a deployment-signer payer, or when unobservable).
pub paying_account: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// A receipt schema version this build has frozen a canonical for.
///
/// A version's canonical freezes the instant the first receipt is signed under
/// it: those bytes are already in an append-only log and no later edit can
/// reach them. So this enum only ever *gains* variants — bumping
/// [`RECEIPT_VERSION`] adds one and never replaces one, and nothing on the read
/// path consults the current version to decide what a persisted receipt means.
///
/// An enum rather than a table of function pointers keyed on `u64` because
/// every question a version has to answer is answered by an exhaustive
/// `match self`: [`Self::number`], [`Self::covers_binding`], and
/// [`Self::canonical`] each fail to compile until a new variant says what
/// number it writes, which fields its canonical covers, and how it builds
/// them. A variant no [`Self::resolve`] arm can produce is dead code. The
/// closed variant set is also what makes the fail-closed rule structural: there
/// is no `V1` an explicit `version: 1` can resolve to (see [`Self::resolve`]),
/// so no future edit to a numeric guard can hand a claimed version to the v1
/// canonical.
#[derive(Debug, Clone, Copy)]
enum ReceiptSchema {
/// Legacy: the six settlement facts, signed with no `version` key at all.
V1,
/// Adds the event `kind`, the binding tuple, and the opaque `subject`, and
/// covers its own `version` key.
V2,
/// Adds payer attribution (`payer_kind`, `paying_account`) on top of V2.
V3,
}
impl ReceiptSchema {
/// The frozen schema a persisted receipt's `version` field names, or `None`
/// when this build has frozen no canonical for it.
///
/// A legacy receipt carries no `version` key; absence — and ONLY absence —
/// means [`Self::V1`]. A *present* key must name a version whose canonical
/// actually covers that key, which is why an explicit `1` resolves to
/// nothing: the v1 canonical does not cover `version`, so admitting one
/// would let a valid v1 signature verify while the output echoed an
/// unsigned, writer-chosen version. There is no arm to relax here — a
/// claimed `1` is unreachable by construction, not by a numeric guard.
///
/// Keyed on literal version numbers, never on [`RECEIPT_VERSION`]: a
/// receipt signed under any frozen version keeps verifying after the
/// current version moves on.
#[must_use]
fn resolve(claimed: Option<&Value>) -> Option<Self> {
let Some(claimed) = claimed else {
return Some(Self::V1);
};
// Every non-integer (a string, a float, `null`, a negative number, an
// array, an object) and every version this build has not frozen is
// refused outright — never verified against a guessed canonical.
match claimed.as_u64() {
Some(2) => Some(Self::V2),
Some(3) => Some(Self::V3),
_ => None,
}
}
/// The version number a receipt signed under this schema reports.
#[must_use]
const fn number(self) -> u64 {
match self {
Self::V1 => 1,
Self::V2 => 2,
Self::V3 => 3,
}
}
/// Whether this schema's canonical covers the event `kind`, the binding
/// tuple, and the `subject`.
///
/// Field extraction is per-version for the same reason the canonical is:
/// reading a field the signature does not cover would echo an unsigned
/// value out of [`verify_signed_receipt`]. A schema that covers a
/// *different* subset than v2 does not belong behind this boolean — give it
/// its own accessor and its own extraction branch (see
/// [`Self::covers_payer`] for v3's addition).
#[must_use]
const fn covers_binding(self) -> bool {
match self {
Self::V1 => false,
Self::V2 | Self::V3 => true,
}
}
/// Whether this schema's canonical covers payer attribution (`payer_kind`,
/// `paying_account`).
///
/// A separate accessor from [`Self::covers_binding`] rather than folded
/// into it: v3 covers a strictly larger field set than v2, but the two
/// booleans answer different questions, and a future schema could cover
/// binding without payer (or vice versa) — collapsing them into one flag
/// would stop being able to say which.
#[must_use]
const fn covers_payer(self) -> bool {
match self {
Self::V1 | Self::V2 => false,
Self::V3 => true,
}
}
/// The frozen canonical (unsigned) JSON bytes a receipt under this schema
/// is signed over and verified against.
#[must_use]
fn canonical(self, fields: &ReceiptPayload<'_>) -> Vec<u8> {
match self {
Self::V1 => canonical_bytes(&fields.canonical_json_v1()),
Self::V2 => canonical_bytes(&fields.canonical_json_v2()),
Self::V3 => canonical_bytes(&fields.canonical_json_v3()),
}
}
}
/// Verify a persisted `payment_receipt`/`outbound_payment_receipt` payload
/// against a **trusted-signer allow-list**.
///
/// Returns `Some(record)` only if the signature checks out against the
/// embedded public key AND that key is a member of `trusted_signers`. An
/// internally-consistent signature over an *unknown* key proves the payload
/// was not tampered with after signing — it proves nothing about whether the
/// signer should be trusted; anyone can mint a fresh keypair, embed its own
/// public key, and sign an arbitrary settlement, so a caller MUST supply the
/// deployment's own set of trusted signers here (typically the deployment's
/// [`ApprovalSigner::public_key_bytes`], since receipts are self-signed by
/// this same control plane) rather than treating "verifies" as "trustworthy".
///
/// Every receipt is checked against the frozen canonical of the version it was
/// signed under, never against the current [`RECEIPT_VERSION`]: a v1 receipt
/// (no `version` key) and a v2 one both keep verifying, whatever the constant
/// reads today. A claimed version this build has frozen no canonical for —
/// including an explicit `1`, which the v1 canonical does not cover — is
/// refused outright rather than checked against a guessed canonical.
///
/// Returns `None` if the payload is malformed, the hex fields don't decode,
/// the embedded key is not in `trusted_signers`, the claimed version resolves
/// to no frozen canonical, or the signature doesn't verify.
#[must_use]
pub fn verify_signed_receipt(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedReceipt> {
let v: Value = serde_json::from_slice(payload).ok()?;
let reference = v.get("reference")?.as_str()?.to_owned();
let amount = v.get("amount")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let recipient = v.get("recipient")?.as_str()?.to_owned();
let method = v.get("method")?.as_str()?.to_owned();
let timestamp = v.get("timestamp")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = crate::hex::decode(signed_by_hex)?;
let sig = crate::hex::decode(signature_hex)?;
// Reject an unknown signer before doing any further work (including the
// signature check below): a key that is not on the allow-list is not
// trusted no matter how internally consistent its signature is — anyone
// can mint a keypair and self-sign an arbitrary receipt.
if !signer_is_trusted(&pk, trusted_signers) {
return None;
}
// Resolve the claimed version to a schema this build has frozen — before
// any versioned field is read, and never against `RECEIPT_VERSION`. A
// version with no frozen canonical is refused outright rather than verified
// against a guessed one; `ReceiptSchema::resolve` documents which claims
// resolve and why an explicit `1` is not one of them.
let schema = ReceiptSchema::resolve(v.get("version"))?;
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = if schema.covers_binding()
{
(
v.get("kind")?.as_str()?.to_owned(),
v.get("tool_call_id")?.as_str()?.to_owned(),
v.get("approval_pos")?.as_str()?.to_owned(),
v.get("approved_args_hash")?.as_str()?.to_owned(),
v.get("subject")?.as_str()?.to_owned(),
)
} else {
// v1 signed the six settlement facts and nothing else, so it reports
// nothing else: an uncovered field read off the payload would leave
// `verify_signed_receipt` echoing a value no signature vouches for.
(
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
)
};
let (payer_kind, paying_account) = if schema.covers_payer() {
(
v.get("payer_kind")?.as_str()?.to_owned(),
v.get("paying_account")?.as_str()?.to_owned(),
)
} else {
// v1/v2 signed no payer attribution at all, so a v1/v2 receipt reports
// payer as explicit unknown — never inferred from other fields — for
// the same reason the binding tuple falls back above.
(String::new(), String::new())
};
// Rebuild the canonical bytes via the SAME single source the signer used,
// so the verify path can never check a different field set/order.
let fields = ReceiptPayload {
kind: &kind,
reference: &reference,
amount: &amount,
currency: ¤cy,
recipient: &recipient,
method: &method,
timestamp: ×tamp,
tool_call_id: &tool_call_id,
approval_pos: &approval_pos,
approved_args_hash: &approved_args_hash,
subject: &subject,
payer_kind: &payer_kind,
paying_account: &paying_account,
};
let canonical = schema.canonical(&fields);
if verify(&pk, &canonical, &sig) {
Some(VerifiedReceipt {
reference,
amount,
currency,
recipient,
method,
timestamp,
version: schema.number(),
kind,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
payer_kind,
paying_account,
signer_public_key: pk,
})
} else {
None
}
}
/// The `payment_refusal` field set [`refusal_payload`] signs, named at the call site.
///
/// Mirrors [`ReceiptPayload`]'s reasoning (`#2090`, INV-W5): a positional
/// argument list of same-typed `&str`s invites a silent field swap at the
/// call site, so every field is named here instead.
///
/// Unlike a receipt, a refusal has exactly one schema version — this event
/// kind ships new, with no prior persisted history to keep verifying, so
/// there is no v1/v2 split to carry.
#[derive(Debug, Clone, Copy)]
pub struct RefusalPayload<'a> {
/// The event kind the payload is stored under (always
/// `polyc_proto::kinds::PAYMENT_REFUSAL`). Signed so a payload cannot be
/// re-filed under a different kind, mirroring [`ReceiptPayload::kind`].
pub kind: &'a str,
/// The stable, machine-readable reason tag (e.g. `"over_spend_cap"`) —
/// one of the tags [`crate`]'s callers mint via an exhaustive match over
/// `polyc_payments::proxy::RejectReason`, or `"unknown"` for a reason
/// this build has no tag for. Never the free-text `Display` rendering.
pub reason: &'a str,
/// A non-secret diagnostic detail for the reason — the wrapped error's
/// own `Display` text (e.g. the SSRF-guard host, the mandate failure).
/// Never signer-key or credential material.
pub reason_detail: &'a str,
/// The destination host the fetch would have paid, when the reject site
/// had a host to name (empty otherwise).
pub merchant_host: &'a str,
/// The base-unit amount the call requested, as a decimal string, for a
/// reason that carries one (`over_spend_cap`, `over_budget`); empty for
/// every other reason.
pub requested_base_units: &'a str,
/// The base-unit amount the cap/budget actually permitted, as a decimal
/// string, for a reason that carries one; empty for every other reason.
pub permitted_base_units: &'a str,
/// The `paid_fetch` tool-call id the refused attempt answered.
pub tool_call_id: &'a str,
/// Opaque principal the refused attempt is attributed to — the same
/// status [`ReceiptPayload::subject`] carries.
pub subject: &'a str,
/// Decimal string of the unix-seconds clock value at the moment the
/// reject site recorded the refusal — the `payments` sibling field to
/// [`ReceiptPayload::timestamp`], but unix-seconds rather than RFC3339:
/// the recording seam already carries the turn's dispatch-time
/// `now_unix` (no fresh wall-clock read needed), so this reuses that
/// value verbatim rather than reformatting it. Without this, a blocked
/// row has no time field at all and cannot be placed on a ledger
/// alongside settled receipts.
pub timestamp: &'a str,
}
impl RefusalPayload<'_> {
/// The frozen `payment_refusal` canonical: every field, in this struct's
/// declaration order — the single source both [`refusal_payload`] (the
/// signing path) and [`verify_signed_refusal`] (the verifying path)
/// route their canonical bytes through, so the two paths cannot drift.
#[must_use]
const fn canonical(&self) -> RefusalCanonical<'_> {
RefusalCanonical {
kind: self.kind,
reason: self.reason,
reason_detail: self.reason_detail,
merchant_host: self.merchant_host,
requested_base_units: self.requested_base_units,
permitted_base_units: self.permitted_base_units,
tool_call_id: self.tool_call_id,
subject: self.subject,
timestamp: self.timestamp,
}
}
}
/// The frozen `payment_refusal` field set, in its frozen order — see
/// [`RefusalPayload::canonical`].
#[derive(Serialize)]
struct RefusalCanonical<'a> {
kind: &'a str,
reason: &'a str,
reason_detail: &'a str,
merchant_host: &'a str,
requested_base_units: &'a str,
permitted_base_units: &'a str,
tool_call_id: &'a str,
subject: &'a str,
timestamp: &'a str,
}
/// JSON payload for a `payment_refusal` event (`#2090`, INV-W5).
///
/// Mirrors [`receipt_payload`]: the signature commits to the canonical
/// (unsigned) JSON form of `RefusalPayload::canonical`; `signed_by` and
/// `signature_hex` are appended after signing and are not covered by the
/// signature. Tampering with any field invalidates the signature.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn refusal_payload(
fields: &RefusalPayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(fields.canonical(), signer.as_signer())
}
/// A decoded `payment_refusal` payload after signature verification.
#[derive(Debug, Clone)]
pub struct VerifiedRefusal {
/// The signed event kind (always `payment_refusal`). Callers should
/// check it matches the kind the event was stored under, mirroring
/// [`VerifiedReceipt::kind`].
pub kind: String,
/// The stable, machine-readable reason tag.
pub reason: String,
/// Non-secret diagnostic detail for the reason.
pub reason_detail: String,
/// The destination host the fetch would have paid, or empty.
pub merchant_host: String,
/// Requested base-unit amount as a decimal string, or empty.
pub requested_base_units: String,
/// Permitted base-unit amount as a decimal string, or empty.
pub permitted_base_units: String,
/// The `paid_fetch` tool-call id the refused attempt answered.
pub tool_call_id: String,
/// Opaque principal the refused attempt is attributed to.
pub subject: String,
/// Decimal string of the unix-seconds clock value at the moment the
/// reject site recorded the refusal.
pub timestamp: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `payment_refusal` payload against a **trusted-signer allow-list**.
///
/// The exact same trust posture [`verify_signed_receipt`] documents (an
/// internally-consistent signature over an unknown key proves no tampering,
/// and nothing about trust, so `trusted_signers` must be supplied).
///
/// Returns `None` if the payload is malformed, a field is missing, the hex
/// fields don't decode, the embedded key is not in `trusted_signers`, or the
/// signature doesn't verify. Never panics — a structurally malformed payload
/// (INV-W5b) is exactly the `None` case, not a decode error a caller must
/// handle separately.
#[must_use]
pub fn verify_signed_refusal(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedRefusal> {
let v: Value = serde_json::from_slice(payload).ok()?;
let kind = v.get("kind")?.as_str()?.to_owned();
let reason = v.get("reason")?.as_str()?.to_owned();
let reason_detail = v.get("reason_detail")?.as_str()?.to_owned();
let merchant_host = v.get("merchant_host")?.as_str()?.to_owned();
let requested_base_units = v.get("requested_base_units")?.as_str()?.to_owned();
let permitted_base_units = v.get("permitted_base_units")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let subject = v.get("subject")?.as_str()?.to_owned();
let timestamp = v.get("timestamp")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = crate::hex::decode(signed_by_hex)?;
let sig = crate::hex::decode(signature_hex)?;
if !signer_is_trusted(&pk, trusted_signers) {
return None;
}
let fields = RefusalPayload {
kind: &kind,
reason: &reason,
reason_detail: &reason_detail,
merchant_host: &merchant_host,
requested_base_units: &requested_base_units,
permitted_base_units: &permitted_base_units,
tool_call_id: &tool_call_id,
subject: &subject,
timestamp: ×tamp,
};
let canonical = canonical_bytes(&fields.canonical());
if verify(&pk, &canonical, &sig) {
Some(VerifiedRefusal {
kind,
reason,
reason_detail,
merchant_host,
requested_base_units,
permitted_base_units,
tool_call_id,
subject,
timestamp,
signer_public_key: pk,
})
} else {
None
}
}
/// The `wallet_link_lifecycle` field set [`wallet_link_lifecycle_payload`]
/// signs, named at the call site (`#2123`).
///
/// Mirrors [`RefusalPayload`]'s reasoning: a positional argument list of
/// same-typed `&str`s invites a silent field swap, so every field is named
/// here instead. One schema version, like [`RefusalPayload`] — this event
/// kind ships new, with no prior persisted history to keep verifying.
#[derive(Debug, Clone, Copy)]
pub struct WalletLinkLifecyclePayload<'a> {
/// The event kind the payload is stored under (always
/// `polyc_proto::kinds::WALLET_LINK_LIFECYCLE`).
pub kind: &'a str,
/// Which ceremony transition this event records: `"linked"`,
/// `"renewed"` (limit/expiry updated by `complete_update`), or
/// `"revoked"` — each minted by its own success-arm call site in
/// `crates/control-plane/src/wallet_link.rs`'s `complete`/
/// `complete_revoke`/`complete_update`, never a caller-chosen string.
pub transition: &'a str,
/// Opaque principal the ceremony is attributed to — the same status
/// [`RefusalPayload::subject`] carries.
pub subject: &'a str,
/// The linked wallet's on-chain address.
pub wallet_address: &'a str,
/// Settlement-token contract (hex) the ceremony's spend cap applies to.
pub currency: &'a str,
/// Decimal string of the chain id the ceremony ran on.
pub chain_id: &'a str,
/// The spend cap in the settlement token's base units, as a decimal
/// string; empty for `"revoked"` (no cap to report on a dead link).
pub limit_base_units: &'a str,
/// Human-readable spend cap; empty for `"revoked"`.
pub limit_human: &'a str,
/// Decimal string of the spend cap's reset period in seconds; empty for
/// `"renewed"` (TIP-1011's `updateSpendingLimit` cannot change the
/// period — see `crate::wallet_link::PERIOD_UNCHANGED_NOTICE` in the
/// control plane) and for `"revoked"`.
pub period_secs: &'a str,
/// Decimal string of the unix-seconds authorization expiry; empty for
/// `"renewed"` (the expiry is unchanged, not re-attested — TIP-1011's
/// `updateSpendingLimit` never touches it) and for `"revoked"` (no
/// expiry survives a dead link).
pub expiry_unix: &'a str,
/// Comma-joined recipient allowlist (TIP-1011); empty means any
/// recipient.
pub recipients: &'a str,
/// The conversation the ceremony was minted and redeemed in — also the
/// journal partition (`conv-{conversation_id}`) this event is appended
/// to.
pub conversation_id: &'a str,
/// Decimal string of the unix-**seconds** clock value at the moment the
/// ceremony handler recorded the transition — the
/// `payment_refusal.timestamp` sibling, divided from a millisecond clock
/// exactly once at the recording seam.
pub timestamp: &'a str,
}
impl WalletLinkLifecyclePayload<'_> {
/// The frozen `wallet_link_lifecycle` canonical: every field, in this
/// struct's declaration order — the single source both
/// [`wallet_link_lifecycle_payload`] (the signing path) and
/// [`verify_signed_wallet_link_lifecycle`] (the verifying path) route
/// their canonical bytes through, so the two paths cannot drift.
#[must_use]
const fn canonical(&self) -> WalletLinkLifecycleCanonical<'_> {
WalletLinkLifecycleCanonical {
kind: self.kind,
transition: self.transition,
subject: self.subject,
wallet_address: self.wallet_address,
currency: self.currency,
chain_id: self.chain_id,
limit_base_units: self.limit_base_units,
limit_human: self.limit_human,
period_secs: self.period_secs,
expiry_unix: self.expiry_unix,
recipients: self.recipients,
conversation_id: self.conversation_id,
timestamp: self.timestamp,
}
}
}
/// The frozen `wallet_link_lifecycle` field set, in its frozen order — see
/// [`WalletLinkLifecyclePayload::canonical`].
#[derive(Serialize)]
struct WalletLinkLifecycleCanonical<'a> {
kind: &'a str,
transition: &'a str,
subject: &'a str,
wallet_address: &'a str,
currency: &'a str,
chain_id: &'a str,
limit_base_units: &'a str,
limit_human: &'a str,
period_secs: &'a str,
expiry_unix: &'a str,
recipients: &'a str,
conversation_id: &'a str,
timestamp: &'a str,
}
/// JSON payload for a `wallet_link_lifecycle` event (`#2123`).
///
/// Mirrors [`refusal_payload`]: the signature commits to the canonical
/// (unsigned) JSON form of `WalletLinkLifecyclePayload::canonical`;
/// `signed_by` and `signature_hex` are appended after signing and are not
/// covered by the signature. Tampering with any field invalidates the
/// signature.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
pub fn wallet_link_lifecycle_payload(
fields: &WalletLinkLifecyclePayload<'_>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(fields.canonical(), signer.as_signer())
}
/// A decoded `wallet_link_lifecycle` payload after signature verification.
#[derive(Debug, Clone)]
pub struct VerifiedWalletLinkLifecycle {
/// The signed event kind (always `wallet_link_lifecycle`).
pub kind: String,
/// Which transition this event records: `"linked"`, `"renewed"`, or
/// `"revoked"` — carried through verbatim, with no known-tag allowlist
/// gating verification (mirrors INV-W5b: an unrecognized transition tag
/// still verifies; classifying it is a read-side projection concern).
pub transition: String,
/// Opaque principal the ceremony is attributed to.
pub subject: String,
/// The linked wallet's on-chain address.
pub wallet_address: String,
/// Settlement-token contract (hex).
pub currency: String,
/// Decimal string of the chain id.
pub chain_id: String,
/// Decimal string of the spend cap in base units, or empty.
pub limit_base_units: String,
/// Human-readable spend cap, or empty.
pub limit_human: String,
/// Decimal string of the reset period in seconds, or empty.
pub period_secs: String,
/// Decimal string of the unix-seconds authorization expiry, or empty.
pub expiry_unix: String,
/// Comma-joined recipient allowlist, or empty (any recipient).
pub recipients: String,
/// The minting conversation id.
pub conversation_id: String,
/// Decimal string of the unix-seconds clock value the ceremony handler
/// recorded the transition at.
pub timestamp: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `wallet_link_lifecycle` payload against a
/// **trusted-signer allow-list** (`#2123`).
///
/// The exact same trust posture [`verify_signed_refusal`] documents: an
/// internally-consistent signature over an unknown key proves no tampering,
/// and nothing about trust, so `trusted_signers` must be supplied.
///
/// Returns `None` if the payload is malformed, a field is missing, the hex
/// fields don't decode, the embedded key is not in `trusted_signers`, or the
/// signature doesn't verify. Never panics.
#[must_use]
pub fn verify_signed_wallet_link_lifecycle(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedWalletLinkLifecycle> {
let v: Value = serde_json::from_slice(payload).ok()?;
let kind = v.get("kind")?.as_str()?.to_owned();
let transition = v.get("transition")?.as_str()?.to_owned();
let subject = v.get("subject")?.as_str()?.to_owned();
let wallet_address = v.get("wallet_address")?.as_str()?.to_owned();
let currency = v.get("currency")?.as_str()?.to_owned();
let chain_id = v.get("chain_id")?.as_str()?.to_owned();
let limit_base_units = v.get("limit_base_units")?.as_str()?.to_owned();
let limit_human = v.get("limit_human")?.as_str()?.to_owned();
let period_secs = v.get("period_secs")?.as_str()?.to_owned();
let expiry_unix = v.get("expiry_unix")?.as_str()?.to_owned();
let recipients = v.get("recipients")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let timestamp = v.get("timestamp")?.as_str()?.to_owned();
let signed_by_hex = v.get("signed_by")?.as_str()?;
let signature_hex = v.get("signature_hex")?.as_str()?;
let pk = crate::hex::decode(signed_by_hex)?;
let sig = crate::hex::decode(signature_hex)?;
if !signer_is_trusted(&pk, trusted_signers) {
return None;
}
let fields = WalletLinkLifecyclePayload {
kind: &kind,
transition: &transition,
subject: &subject,
wallet_address: &wallet_address,
currency: ¤cy,
chain_id: &chain_id,
limit_base_units: &limit_base_units,
limit_human: &limit_human,
period_secs: &period_secs,
expiry_unix: &expiry_unix,
recipients: &recipients,
conversation_id: &conversation_id,
timestamp: ×tamp,
};
let canonical = canonical_bytes(&fields.canonical());
if verify(&pk, &canonical, &sig) {
Some(VerifiedWalletLinkLifecycle {
kind,
transition,
subject,
wallet_address,
currency,
chain_id,
limit_base_units,
limit_human,
period_secs,
expiry_unix,
recipients,
conversation_id,
timestamp,
signer_public_key: pk,
})
} else {
None
}
}
/// TTL for a minted `resolve_token` (`#787`).
///
/// Generous enough that a human has time to see and act on the approval card
/// (which can sit in a Slack/Telegram thread for hours), short enough that a
/// token captured off a stale card cannot resolve the request indefinitely.
/// Independent of the underlying approval's own lifetime — a request that
/// outlives the TTL simply needs the control plane to re-mint (a fresh
/// `ListPending` call re-renders the card with a fresh token).
pub const RESOLVE_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
/// The canonical (signature-covered) form of a `resolve_token` (`#787`).
///
/// Binds the token to the exact `(request_id, conversation_id)` pair it was
/// minted for and the time it was minted, so it cannot be replayed against a
/// different request, a different conversation, or presented once its TTL has
/// elapsed.
fn resolve_token_canonical(request_id: &str, conversation_id: &str, minted_at_ms: u64) -> Vec<u8> {
canonical_bytes(&ResolveTokenCanonical {
request_id,
conversation_id,
minted_at_ms,
})
}
/// The signed `resolve_token` field set, in its frozen order.
#[derive(Serialize)]
struct ResolveTokenCanonical<'a> {
request_id: &'a str,
conversation_id: &'a str,
minted_at_ms: u64,
}
/// A minted `resolve_token`: the canonical body plus its signature.
///
/// The only signed payload in this module with no `signed_by`: the token is
/// verified against the control plane's own key, supplied by the caller of
/// [`verify_resolve_token`], never one carried on the token itself.
#[derive(Serialize)]
struct ResolveToken<'a> {
#[serde(flatten)]
body: ResolveTokenCanonical<'a>,
signature_hex: String,
}
/// Mint a short-lived, signed `resolve_token` (`#787`) scoped to one
/// `(request_id, conversation_id)` pair.
///
/// This is the capability `ApprovalService.Respond` requires alongside the
/// plain `request_id`/`conversation_id` it already checks: minted by the
/// control plane at the moment it hands a pending approval to an edge for
/// rendering (`AgentEnd.pending_approvals`, `ListPendingReply.pending`), it is
/// opaque to — and unforgeable by — anything downstream of that mint point,
/// including a compromised harness pod (the harness never holds the signing
/// key and never sees the minted token; it only proposes the tool call the
/// token later authorizes resolving). `minted_at_ms` is the caller's wall
/// clock at mint time; pass a real timestamp in production, an injected one in
/// tests.
///
/// Returns the token as a lowercase-hex opaque string, safe to carry on any
/// wire surface (a button value, a proto field) alongside `request_id`.
#[must_use]
pub fn mint_resolve_token(
request_id: &str,
conversation_id: &str,
minted_at_ms: u64,
signer: &ApprovalSigner,
) -> String {
let canonical = resolve_token_canonical(request_id, conversation_id, minted_at_ms);
let signature = signer.sign(&canonical);
let full = ResolveToken {
body: ResolveTokenCanonical {
request_id,
conversation_id,
minted_at_ms,
},
signature_hex: crate::hex::lower(&signature),
};
crate::hex::lower(&canonical_bytes(&full))
}
/// Verify a `resolve_token` minted by [`mint_resolve_token`] against the
/// `(request_id, conversation_id)` a `Respond` call presents it for.
///
/// Returns `true` only when ALL hold: the token decodes, its embedded
/// signature verifies against `signer`'s public key, its bound `request_id`
/// and `conversation_id` equal the ones supplied, and `now_ms - minted_at_ms`
/// is within [`RESOLVE_TOKEN_TTL_MS`] (a token minted in the future, by clock
/// skew beyond the TTL, also fails — fail closed rather than trust an
/// out-of-bounds clock). Fails closed on any malformed field.
#[must_use]
pub fn verify_resolve_token(
token: &str,
request_id: &str,
conversation_id: &str,
now_ms: u64,
signer: &ApprovalSigner,
) -> bool {
let Some(bytes) = crate::hex::decode(token) else {
return false;
};
let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
return false;
};
let (
Some(bound_request_id),
Some(bound_conversation_id),
Some(minted_at_ms),
Some(signature_hex),
) = (
v.get("request_id").and_then(Value::as_str),
v.get("conversation_id").and_then(Value::as_str),
v.get("minted_at_ms").and_then(Value::as_u64),
v.get("signature_hex").and_then(Value::as_str),
)
else {
return false;
};
if bound_request_id != request_id || bound_conversation_id != conversation_id {
return false;
}
let elapsed = now_ms.abs_diff(minted_at_ms);
if elapsed > RESOLVE_TOKEN_TTL_MS {
return false;
}
let Some(sig) = crate::hex::decode(signature_hex) else {
return false;
};
let canonical = resolve_token_canonical(bound_request_id, bound_conversation_id, minted_at_ms);
verify(&signer.public_key_bytes(), &canonical, &sig)
}
/// The canonical (signature-covered) form of an `admin_model_change` audit
/// record (`#787`).
///
/// Covers who made the change (the authenticated bearer principal), what the
/// selection moved from and to, and when — so the durable record cannot be
/// forged or re-attributed to a different operator without invalidating the
/// signature. `signed_by`/`signature_hex` are appended after signing and are
/// not covered.
fn admin_model_change_canonical(
principal: &str,
previous_provider: &str,
previous_model: &str,
new_provider: &str,
new_model: &str,
changed_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&AdminModelChangeCanonical {
principal,
previous_provider,
previous_model,
new_provider,
new_model,
changed_at_ms,
})
}
/// The signed `admin_model_change` field set, in its frozen order.
#[derive(Serialize)]
struct AdminModelChangeCanonical<'a> {
principal: &'a str,
previous_provider: &'a str,
previous_model: &'a str,
new_provider: &'a str,
new_model: &'a str,
changed_at_ms: u64,
}
/// JSON payload for a signed `admin_model_change` audit event (`#787`).
///
/// The durable, tamper-evident record that the live `provider/model`
/// selection changed, written on every successful `POST /admin/model` — the
/// same posture `#590`/`#594`/`#623` established for other admin-signed
/// actions. Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
pub fn admin_model_change_payload(
principal: &str,
previous_provider: &str,
previous_model: &str,
new_provider: &str,
new_model: &str,
changed_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
AdminModelChangeCanonical {
principal,
previous_provider,
previous_model,
new_provider,
new_model,
changed_at_ms,
},
signer.as_signer(),
)
}
/// A verified `admin_model_change` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAdminModelChange {
/// The authenticated bearer principal that made the change.
pub principal: String,
/// Provider before the change (may be empty — deferred to harness default).
pub previous_provider: String,
/// Model before the change.
pub previous_model: String,
/// Provider after the change.
pub new_provider: String,
/// Model after the change.
pub new_model: String,
/// Unix ms the change was applied.
pub changed_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `admin_model_change` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_admin_model_change(payload: &[u8]) -> Option<VerifiedAdminModelChange> {
let v: Value = serde_json::from_slice(payload).ok()?;
let principal = v.get("principal")?.as_str()?.to_owned();
let previous_provider = v.get("previous_provider")?.as_str()?.to_owned();
let previous_model = v.get("previous_model")?.as_str()?.to_owned();
let new_provider = v.get("new_provider")?.as_str()?.to_owned();
let new_model = v.get("new_model")?.as_str()?.to_owned();
let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = admin_model_change_canonical(
&principal,
&previous_provider,
&previous_model,
&new_provider,
&new_model,
changed_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedAdminModelChange {
principal,
previous_provider,
previous_model,
new_provider,
new_model,
changed_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// One key's lifecycle move within a credential mutation
/// (`docs/design/credential-store.md`).
///
/// Carries the key id and the two lifecycle states it moved between, and
/// nothing else. A salt, a digest, or a public key must never reach an audit
/// record (INV-C3), so this type has nowhere to put one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialKeyTransition {
/// The key that moved.
pub kid: String,
/// The state it moved from (`"absent"` for a key that did not exist).
pub from: String,
/// The state it moved to.
pub to: String,
}
/// The canonical (signature-covered) form of a credential-mutation audit
/// record (`docs/design/credential-store.md`, INV-C6).
///
/// Covers which mutation ran, who ran it, which record and key it named, and
/// every key lifecycle transition it caused — so a signed enrollment cannot
/// be replayed as a revocation, nor re-attributed to a different admin,
/// without invalidating the signature. `signed_by`/`signature_hex` are
/// appended after signing and are not covered.
fn credential_change_canonical(
change: &str,
principal: &str,
edge_id: &str,
kid: &str,
grants: &str,
transitions: &[CredentialKeyTransition],
changed_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&credential_change_fields(
change,
principal,
edge_id,
kid,
grants,
transitions,
changed_at_ms,
))
}
/// The signed credential-mutation field set, in its frozen order.
///
/// The only canonical in this module that nests an object, and so the only one
/// where the ordering hazard `#1842` describes applied at two levels: the
/// `transitions` elements are ordered by [`CredentialTransition`]'s own field
/// declaration, not by a map.
#[derive(Serialize)]
struct CredentialChangeCanonical<'a> {
change: &'a str,
principal: &'a str,
edge_id: &'a str,
kid: &'a str,
grants: &'a str,
transitions: Vec<CredentialTransition<'a>>,
changed_at_ms: u64,
}
/// One `transitions` element of a [`CredentialChangeCanonical`], in its frozen
/// order.
#[derive(Serialize)]
struct CredentialTransition<'a> {
kid: &'a str,
from: &'a str,
to: &'a str,
}
/// Build the credential-mutation canonical struct.
///
/// Shared by [`credential_change_canonical`] and
/// [`credential_change_payload`], which would otherwise each map the
/// transitions into their serialized form and could drift.
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
fn credential_change_fields<'a>(
change: &'a str,
principal: &'a str,
edge_id: &'a str,
kid: &'a str,
grants: &'a str,
transitions: &'a [CredentialKeyTransition],
changed_at_ms: u64,
) -> CredentialChangeCanonical<'a> {
CredentialChangeCanonical {
change,
principal,
edge_id,
kid,
grants,
transitions: transitions
.iter()
.map(|t| CredentialTransition {
kid: &t.kid,
from: &t.from,
to: &t.to,
})
.collect(),
changed_at_ms,
}
}
/// JSON payload for a signed credential-mutation audit event
/// (`docs/design/credential-store.md`).
///
/// The durable, tamper-evident record that an admin enrolled, rotated, or
/// revoked a credential, appended to the `admin-audit` partition on every
/// mutation. `change` is the event kind
/// (`polyc_proto::kinds::CREDENTIAL_ENROLLED` and its three siblings), which
/// is signature-covered so one kind's record cannot be presented as
/// another's. `grants` is what the record may be used for after the change,
/// also signature-covered: an admin credential and a chat edge move through
/// the same verbs, so an audit line that omitted it could not tell them
/// apart.
///
/// Unlike [`admin_model_change_payload`], whose append is best-effort, a
/// failed append of this record fails the mutation that produced it: for a
/// credential change the audit line is load-bearing.
///
/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
pub fn credential_change_payload(
change: &str,
principal: &str,
edge_id: &str,
kid: &str,
grants: &str,
transitions: &[CredentialKeyTransition],
changed_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
credential_change_fields(
change,
principal,
edge_id,
kid,
grants,
transitions,
changed_at_ms,
),
signer.as_signer(),
)
}
/// A verified credential-mutation audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedCredentialChange {
/// Which mutation ran (the event kind).
pub change: String,
/// The authenticated admin principal that ran it.
pub principal: String,
/// The record that changed.
pub edge_id: String,
/// The key the mutation named (empty for a whole-record revocation).
pub kid: String,
/// What the record grants AFTER the mutation — `"edge"`, `"admin"`,
/// `"edge, admin"`, or `"none"`.
///
/// Signature-covered, and the reason it exists: an admin credential and
/// a chat edge are the same record shape and the same verbs, so without
/// this an audit trail cannot tell "someone enrolled a chat edge" from
/// "someone granted themselves control of this deployment".
pub grants: String,
/// Every key lifecycle transition the mutation caused.
pub transitions: Vec<CredentialKeyTransition>,
/// Unix ms the mutation was applied.
pub changed_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted credential-mutation payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
///
/// # Records written before `grants` existed
///
/// They return `None` here, indistinguishable from a forgery. `grants` is a
/// required field of the canonical form, so a payload without it does not
/// verify — deliberately, because loosening the check to tolerate an absent
/// field would let a forged record claim the same absence and be believed.
/// The credential audit family has no non-test reader yet, so nothing
/// regresses today; a deployment that already wrote credential audit lines
/// keeps them as bytes in the log and loses only the ability to verify them
/// through this function.
#[must_use]
pub fn verify_credential_change(payload: &[u8]) -> Option<VerifiedCredentialChange> {
let v: Value = serde_json::from_slice(payload).ok()?;
let change = v.get("change")?.as_str()?.to_owned();
let principal = v.get("principal")?.as_str()?.to_owned();
let edge_id = v.get("edge_id")?.as_str()?.to_owned();
let kid = v.get("kid")?.as_str()?.to_owned();
let grants = v.get("grants")?.as_str()?.to_owned();
let mut transitions = Vec::new();
for item in v.get("transitions")?.as_array()? {
transitions.push(CredentialKeyTransition {
kid: item.get("kid")?.as_str()?.to_owned(),
from: item.get("from")?.as_str()?.to_owned(),
to: item.get("to")?.as_str()?.to_owned(),
});
}
let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = credential_change_canonical(
&change,
&principal,
&edge_id,
&kid,
&grants,
&transitions,
changed_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedCredentialChange {
change,
principal,
edge_id,
kid,
grants,
transitions,
changed_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// Canonical bytes signed for a `routine_created` audit event (`#1497`).
fn routine_created_canonical(
routine: &str,
creator_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
created_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&RoutineCreatedCanonical {
routine,
creator_persona,
conversation_id,
tool_call_id,
args_hash,
created_at_ms,
})
}
/// The signed `routine_created` field set, in its frozen order.
#[derive(Serialize)]
struct RoutineCreatedCanonical<'a> {
routine: &'a str,
creator_persona: &'a str,
conversation_id: &'a str,
tool_call_id: &'a str,
args_hash: &'a str,
created_at_ms: u64,
}
/// JSON payload for a signed `routine_created` audit event.
///
/// `#1497`, INV-RL6: who created the routine, from which conversation, and
/// when. Mirrors [`admin_model_change_payload`]'s shape — the same
/// control-plane-authored-audit-record pattern, a distinct kind. #1495 adds
/// the sibling `routine_paused`/`routine_resumed`/`routine_deleted` payloads
/// on this same construction.
///
/// `tool_call_id` (`#1638`) is the harness's id for the tool call this event
/// audits, and `args_hash` is the sha256 hex of that call's approved
/// `args_json` (mirrors `harness_dialer::approved_args_hash`) — together the
/// cross-replay idempotency key `routine_nav`'s dedup check
/// (`find_completed_mutation`) looks up before ever touching the CR, so an
/// orphan-recovery replay of the same dispatch is a no-op rather than a
/// second signed event and a duplicate routine, and the SAME `tool_call_id`
/// carrying different args never returns a stale acknowledgement.
///
/// Returns `(payload_bytes, signature, signer_public_key)`.
#[must_use]
pub fn routine_created_payload(
routine: &str,
creator_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
created_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
RoutineCreatedCanonical {
routine,
creator_persona,
conversation_id,
tool_call_id,
args_hash,
created_at_ms,
},
signer.as_signer(),
)
}
/// A verified `routine_created` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoutineCreated {
/// The created routine's CR name.
pub routine: String,
/// The persona id that created it.
pub creator_persona: String,
/// The originating conversation id.
pub conversation_id: String,
/// The harness tool-call id this event audits (`#1638`).
pub tool_call_id: String,
/// sha256 hex of the approved `args_json` (`#1638`).
pub args_hash: String,
/// Unix ms the CR was written.
pub created_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `routine_created` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_routine_created(payload: &[u8]) -> Option<VerifiedRoutineCreated> {
let v: Value = serde_json::from_slice(payload).ok()?;
let routine = v.get("routine")?.as_str()?.to_owned();
let creator_persona = v.get("creator_persona")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let args_hash = v.get("args_hash")?.as_str()?.to_owned();
let created_at_ms = v.get("created_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = routine_created_canonical(
&routine,
&creator_persona,
&conversation_id,
&tool_call_id,
&args_hash,
created_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedRoutineCreated {
routine,
creator_persona,
conversation_id,
tool_call_id,
args_hash,
created_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// Canonical bytes signed for a `routine_paused` audit event (`#1495`).
fn routine_paused_canonical(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
paused_at_ms: u64,
reason: Option<&str>,
) -> Vec<u8> {
canonical_bytes(&RoutinePausedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
paused_at_ms,
reason,
})
}
/// The signed `routine_paused` field set, in its frozen order.
///
/// `reason` is signed as an explicit `null` when the pauser gave none — the
/// key is always present, so the absent case is covered by the signature the
/// same way a present one is.
#[derive(Serialize)]
struct RoutinePausedCanonical<'a> {
routine: &'a str,
actor_persona: &'a str,
conversation_id: &'a str,
tool_call_id: &'a str,
args_hash: &'a str,
paused_at_ms: u64,
reason: Option<&'a str>,
}
/// JSON payload for a signed `routine_paused` audit event.
///
/// `#1495`, INV-RL6: who paused the routine, from which conversation, when,
/// and (if given) why. Mirrors [`routine_created_payload`]'s shape — the
/// same control-plane-authored-audit-record pattern, a distinct kind. Pause
/// takes effect immediately with no confirmation (INV-RL2 does not apply to
/// pause/resume — see the invariants doc) but still leaves exactly one
/// signed event. `args_hash` (`#1638`) mirrors `routine_created_payload`'s
/// own field — see its doc.
///
/// Returns `(payload_bytes, signature, signer_public_key)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // one distinct signed field per line, mirrors routine_created_payload
pub fn routine_paused_payload(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
paused_at_ms: u64,
reason: Option<&str>,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
RoutinePausedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
paused_at_ms,
reason,
},
signer.as_signer(),
)
}
/// A verified `routine_paused` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoutinePaused {
/// The paused routine's CR name.
pub routine: String,
/// The persona id that paused it.
pub actor_persona: String,
/// The originating conversation id.
pub conversation_id: String,
/// The harness tool-call id this event audits (`#1638`).
pub tool_call_id: String,
/// sha256 hex of the approved `args_json` (`#1638`).
pub args_hash: String,
/// Unix ms the pause was recorded.
pub paused_at_ms: u64,
/// Why the routine was paused, if the pauser gave one.
pub reason: Option<String>,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `routine_paused` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_routine_paused(payload: &[u8]) -> Option<VerifiedRoutinePaused> {
let v: Value = serde_json::from_slice(payload).ok()?;
let routine = v.get("routine")?.as_str()?.to_owned();
let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let args_hash = v.get("args_hash")?.as_str()?.to_owned();
let paused_at_ms = v.get("paused_at_ms")?.as_u64()?;
let reason = v.get("reason").and_then(|r| r.as_str()).map(str::to_owned);
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = routine_paused_canonical(
&routine,
&actor_persona,
&conversation_id,
&tool_call_id,
&args_hash,
paused_at_ms,
reason.as_deref(),
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedRoutinePaused {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
paused_at_ms,
reason,
signer_public_key: pk,
})
} else {
None
}
}
/// Canonical bytes signed for a `routine_resumed` audit event (`#1495`).
fn routine_resumed_canonical(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
resumed_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&RoutineResumedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
resumed_at_ms,
})
}
/// The signed `routine_resumed` field set, in its frozen order.
#[derive(Serialize)]
struct RoutineResumedCanonical<'a> {
routine: &'a str,
actor_persona: &'a str,
conversation_id: &'a str,
tool_call_id: &'a str,
args_hash: &'a str,
resumed_at_ms: u64,
}
/// JSON payload for a signed `routine_resumed` audit event.
///
/// `#1495`, INV-RL6: the un-pause sibling of [`routine_paused_payload`] —
/// also immediate, no confirmation. `args_hash` (`#1638`) mirrors
/// [`routine_created_payload`]'s own field — see its doc.
///
/// Returns `(payload_bytes, signature, signer_public_key)`.
#[must_use]
pub fn routine_resumed_payload(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
resumed_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
RoutineResumedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
resumed_at_ms,
},
signer.as_signer(),
)
}
/// A verified `routine_resumed` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoutineResumed {
/// The resumed routine's CR name.
pub routine: String,
/// The persona id that resumed it.
pub actor_persona: String,
/// The originating conversation id.
pub conversation_id: String,
/// The harness tool-call id this event audits (`#1638`).
pub tool_call_id: String,
/// sha256 hex of the approved `args_json` (`#1638`).
pub args_hash: String,
/// Unix ms the resume was recorded.
pub resumed_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `routine_resumed` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_routine_resumed(payload: &[u8]) -> Option<VerifiedRoutineResumed> {
let v: Value = serde_json::from_slice(payload).ok()?;
let routine = v.get("routine")?.as_str()?.to_owned();
let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let args_hash = v.get("args_hash")?.as_str()?.to_owned();
let resumed_at_ms = v.get("resumed_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = routine_resumed_canonical(
&routine,
&actor_persona,
&conversation_id,
&tool_call_id,
&args_hash,
resumed_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedRoutineResumed {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
resumed_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// Canonical bytes signed for a `routine_deleted` audit event (`#1495`).
fn routine_deleted_canonical(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
deleted_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&RoutineDeletedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
deleted_at_ms,
})
}
/// The signed `routine_deleted` field set, in its frozen order.
#[derive(Serialize)]
struct RoutineDeletedCanonical<'a> {
routine: &'a str,
actor_persona: &'a str,
conversation_id: &'a str,
tool_call_id: &'a str,
args_hash: &'a str,
deleted_at_ms: u64,
}
/// JSON payload for a signed `routine_deleted` audit event.
///
/// `#1495`, INV-RL2/RL6: who deleted the routine, from which conversation,
/// and when. Unlike pause/resume, only ever minted once a human has approved
/// the mid-turn confirmation for this delete. `args_hash` (`#1638`) mirrors
/// [`routine_created_payload`]'s own field — see its doc.
///
/// Returns `(payload_bytes, signature, signer_public_key)`.
#[must_use]
pub fn routine_deleted_payload(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
deleted_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
RoutineDeletedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
deleted_at_ms,
},
signer.as_signer(),
)
}
/// A verified `routine_deleted` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoutineDeleted {
/// The deleted routine's CR name.
pub routine: String,
/// The persona id that deleted it.
pub actor_persona: String,
/// The originating conversation id.
pub conversation_id: String,
/// The harness tool-call id this event audits (`#1638`).
pub tool_call_id: String,
/// sha256 hex of the approved `args_json` (`#1638`).
pub args_hash: String,
/// Unix ms the deletion was recorded.
pub deleted_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `routine_deleted` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_routine_deleted(payload: &[u8]) -> Option<VerifiedRoutineDeleted> {
let v: Value = serde_json::from_slice(payload).ok()?;
let routine = v.get("routine")?.as_str()?.to_owned();
let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let args_hash = v.get("args_hash")?.as_str()?.to_owned();
let deleted_at_ms = v.get("deleted_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = routine_deleted_canonical(
&routine,
&actor_persona,
&conversation_id,
&tool_call_id,
&args_hash,
deleted_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedRoutineDeleted {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
deleted_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// Canonical bytes signed for a `routine_scope_changed` audit event (`#1806`).
fn routine_scope_changed_canonical(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
scope: &str,
changed_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&RoutineScopeChangedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
scope,
changed_at_ms,
})
}
/// The signed `routine_scope_changed` field set, in its frozen order.
#[derive(Serialize)]
struct RoutineScopeChangedCanonical<'a> {
routine: &'a str,
actor_persona: &'a str,
conversation_id: &'a str,
tool_call_id: &'a str,
args_hash: &'a str,
scope: &'a str,
changed_at_ms: u64,
}
/// JSON payload for a signed `routine_scope_changed` audit event.
///
/// `#1806`, INV-RL6: who flipped the routine's sharing, to which value, from
/// which conversation, and when. Mirrors [`routine_paused_payload`]'s shape
/// — the same control-plane-authored-audit-record pattern, a distinct kind.
/// Unlike pause/resume/delete, only ever minted for the routine's OWNER: an
/// admin who is not the owner is refused before this is ever called (see
/// `crate::approval::ApprovalSigner`'s caller, `routine_nav::execute_scope`,
/// which mirrors `execute_fire`'s INV-RL11 owner-only gate rather than the
/// mutation trio's owner-or-admin one). `args_hash` (`#1638`) mirrors
/// [`routine_created_payload`]'s own field — see its doc.
///
/// Returns `(payload_bytes, signature, signer_public_key)`.
#[must_use]
#[allow(clippy::too_many_arguments)] // one distinct signed field per line, mirrors routine_created_payload
pub fn routine_scope_changed_payload(
routine: &str,
actor_persona: &str,
conversation_id: &str,
tool_call_id: &str,
args_hash: &str,
scope: &str,
changed_at_ms: u64,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
RoutineScopeChangedCanonical {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
scope,
changed_at_ms,
},
signer.as_signer(),
)
}
/// A verified `routine_scope_changed` audit record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoutineScopeChanged {
/// The routine's CR name.
pub routine: String,
/// The persona id that flipped its scope — always the routine's owner.
pub actor_persona: String,
/// The originating conversation id.
pub conversation_id: String,
/// The harness tool-call id this event audits (`#1638`).
pub tool_call_id: String,
/// sha256 hex of the approved `args_json` (`#1638`).
pub args_hash: String,
/// The scope the routine was set to (`"public"` or `"private"`).
pub scope: String,
/// Unix ms the flip was recorded.
pub changed_at_ms: u64,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
}
/// Verify a persisted `routine_scope_changed` payload.
///
/// `None` for a malformed payload or a signature that does not verify — the
/// caller treats the record as untrusted (fail closed).
#[must_use]
pub fn verify_routine_scope_changed(payload: &[u8]) -> Option<VerifiedRoutineScopeChanged> {
let v: Value = serde_json::from_slice(payload).ok()?;
let routine = v.get("routine")?.as_str()?.to_owned();
let actor_persona = v.get("actor_persona")?.as_str()?.to_owned();
let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
let tool_call_id = v.get("tool_call_id")?.as_str()?.to_owned();
let args_hash = v.get("args_hash")?.as_str()?.to_owned();
let scope = v.get("scope")?.as_str()?.to_owned();
let changed_at_ms = v.get("changed_at_ms")?.as_u64()?;
let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
let canonical = routine_scope_changed_canonical(
&routine,
&actor_persona,
&conversation_id,
&tool_call_id,
&args_hash,
&scope,
changed_at_ms,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedRoutineScopeChanged {
routine,
actor_persona,
conversation_id,
tool_call_id,
args_hash,
scope,
changed_at_ms,
signer_public_key: pk,
})
} else {
None
}
}
/// Extract `request_id` from an `approval_request` payload.
#[must_use]
pub fn decode_request_id(payload: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some(v.get("request_id")?.as_str()?.to_owned())
}
/// Extract `(request_id, tool_name, args_json)` from an `approval_request`
/// payload — the fields a v2 `approval_response` must sign to bind the approval
/// to the request identity.
#[must_use]
pub fn decode_request_fields(payload: &[u8]) -> Option<(String, String, String)> {
let v: Value = serde_json::from_slice(payload).ok()?;
Some((
v.get("request_id")?.as_str()?.to_owned(),
v.get("tool_name")?.as_str()?.to_owned(),
v.get("args_json")?.as_str()?.to_owned(),
))
}
/// Extract the `sandbox_mode` an `approval_request` was emitted under.
///
/// The mode the harness was running when it paused the call. Separate from
/// [`decode_request_fields`] so its many callers keep their tuple shape; the
/// control plane signs this into the response so a remembered approval is
/// bound to the mode it was granted under. Empty/absent → `""`.
#[must_use]
pub fn decode_request_sandbox_mode(payload: &[u8]) -> String {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| {
v.get("sandbox_mode")
.and_then(Value::as_str)
.map(str::to_owned)
})
.unwrap_or_default()
}
/// Extract the override `reason` an `approval_request` carried.
///
/// Non-empty only for the lethal-trifecta / Rule-of-Two containment override;
/// empty/absent → `""` (an ordinary gated call, or a record written before the
/// field existed). Used by the edge to render the gate's explanation and by
/// forensics to show why a trifecta-gated call was paused.
#[must_use]
pub fn decode_request_reason(payload: &[u8]) -> String {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| v.get("reason").and_then(Value::as_str).map(str::to_owned))
.unwrap_or_default()
}
/// Extract the `missing_capabilities` recorded on an `approval_request`
/// payload (`#595`) — the capability shortfall the gate computed when it
/// paused the call.
///
/// Read back at respond time and signed into the response as its
/// `covered_capabilities`. Absent or malformed decodes to empty: the grant
/// then covers nothing beyond the ordinary gate, the narrow direction.
#[must_use]
pub fn decode_request_missing_capabilities(payload: &[u8]) -> Vec<String> {
serde_json::from_slice::<Value>(payload)
.ok()
.and_then(|v| {
v.get("missing_capabilities")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|c| c.as_str().map(str::to_owned))
.collect()
})
})
.unwrap_or_default()
}
/// Extract the computed-preview enrichment (`#1496`) an `approval_request` carried.
///
/// Returns the raw (re-serialized) JSON — `None` for an absent or
/// explicit-`null` `preview` key (a call the enrichment doesn't apply to, or
/// a record written before this field existed). The caller (`polyc-control-
/// plane`) owns the typed shape; this layer stays opaque to it, exactly like
/// [`decode_request_fields`]'s `args_json`.
#[must_use]
pub fn decode_request_preview_json(payload: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(payload).ok()?;
let preview = v.get("preview")?;
if preview.is_null() {
None
} else {
Some(preview.to_string())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
// #784: an `ApprovalSigner` built from real key material (the shape a
// secret-store load produces) signs a genuine, verifiable
// `approval_response`, and a signature minted under the well-known
// deterministic `from_seed(1)` key does NOT verify against it — proving
// the loaded key is a distinct, non-derivable key, not the forgeable
// default.
#[test]
fn loaded_key_signature_verifies_and_from_seed_signature_does_not() {
// Stand-in for key bytes read back from a secret store (any 32 bytes
// are a valid ed25519 private key — no seed-derivation involved).
let key_bytes = [42u8; 32];
let loaded = ApprovalSigner::from_key_bytes(&key_bytes).expect("valid key material");
let forged = ApprovalSigner::from_seed(1);
assert_ne!(
loaded.public_key_bytes(),
forged.public_key_bytes(),
"a loaded key must not collide with the public, deterministic seed-1 key"
);
let (payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&loaded,
);
let verified = verify_signed_response(&payload).expect("verifies under the loaded key");
assert_eq!(verified.signer_public_key, loaded.public_key_bytes());
// The exact same payload, signed under `from_seed(1)` and stamped
// with the LOADED key's public key (impersonation attempt), fails —
// the seed-1 signature does not verify against the loaded key.
let (forged_payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&forged,
);
let mut v: serde_json::Value = serde_json::from_slice(&forged_payload).unwrap();
v["signed_by"] = serde_json::json!(crate::hex::lower(&loaded.public_key_bytes()));
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a from_seed(1) signature must not verify against the loaded key"
);
}
// #594: a grant_replay audit record round-trips through verification and any
// tamper to a bound field flips verification to false.
#[test]
fn grant_replay_audit_is_signed_and_tamper_evident() {
let signer = ApprovalSigner::from_seed(9);
let covered = vec!["arbitrary-egress".to_owned()];
let (payload, _sig, _pk) = grant_replay_payload(
"conv-1",
"turn-7",
"post_summary",
"deadbeef",
&covered,
"sha256:template-abc",
&signer,
);
assert!(verify_grant_replay(&payload), "the genuine record verifies");
// Tampering any bound field breaks the signature.
for (field, val) in [
("conversation_id", serde_json::json!("conv-EVIL")),
("turn_id", serde_json::json!("turn-8")),
("tool", serde_json::json!("exfiltrate")),
("grant_ref", serde_json::json!("cafe")),
(
"covered_capabilities",
serde_json::json!(["arbitrary-egress", "mutate-external"]),
),
("coverage_hash", serde_json::json!("sha256:other")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
!verify_grant_replay(v.to_string().as_bytes()),
"tampered {field} must fail verification"
);
}
assert!(!verify_grant_replay(b"not json"));
}
// #595: the covered capability set is part of the signed contract — it
// round-trips through verification and cannot be widened after signing.
#[test]
fn covered_capabilities_are_signed_and_tamper_evident() {
let signer = ApprovalSigner::from_seed(42);
let covered = vec!["arbitrary-egress".to_owned()];
let (payload, _sig, _pk) = response_payload(
"req-1",
"web_fetch",
r#"{"url":"https://a"}"#,
"",
true,
true,
&covered,
"slack:T1:U9",
"",
"workspace-write",
"",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified = verify_signed_response(&payload).expect("verifies untampered");
assert_eq!(verified.covered_capabilities, covered);
// Widening the covered set after signing invalidates the signature.
let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
v["covered_capabilities"] = serde_json::json!(["arbitrary-egress", "mutate-external"]);
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a tampered covered set must fail verification"
);
// So does shrinking it to hide what a grant covered.
let mut v: serde_json::Value = serde_json::from_slice(&payload).unwrap();
v["covered_capabilities"] = serde_json::json!([]);
assert!(verify_signed_response(v.to_string().as_bytes()).is_none());
}
// #595: the request records the gate's capability shortfall and decodes
// it back for the respond path; absent decodes empty (covers nothing).
#[test]
fn request_missing_capabilities_round_trip() {
let missing = vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()];
let bytes = request_payload("call-1", "web_fetch", "{}", "", "", &missing, "");
assert_eq!(decode_request_missing_capabilities(&bytes), missing);
let bare = request_payload("call-2", "grep", "{}", "", "", &[], "");
assert_eq!(
decode_request_missing_capabilities(&bare),
Vec::<String>::new()
);
assert_eq!(
decode_request_missing_capabilities(b"{\"nope\":1}"),
Vec::<String>::new()
);
}
// #590: excision markers round-trip and are tamper-evident on every
// covered field — widening positions, flipping scope, or re-targeting
// the conversation all fail verification (taint stays, fail closed).
#[test]
fn signed_excision_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = excision_payload(
"conv-1",
EXCISION_SCOPE_CASCADE,
&[17, 23],
"persona-9",
"poisoned fetch",
&signer,
);
let v = verify_signed_excision(&payload).expect("verifies untampered");
assert_eq!(v.conversation_id, "conv-1");
assert!(v.is_cascade());
assert_eq!(v.positions, vec![17, 23]);
assert_eq!(v.requested_by, "persona-9");
for (field, value) in [
("positions", serde_json::json!([17, 23, 40])),
("scope", serde_json::json!(EXCISION_SCOPE_SOURCE_ONLY)),
("conversation_id", serde_json::json!("conv-2")),
("requested_by", serde_json::json!("someone-else")),
] {
let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
t[field] = value;
assert!(
verify_signed_excision(t.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
// An unknown scope is refused even before the signature check.
let mut t: serde_json::Value = serde_json::from_slice(&payload).unwrap();
t["scope"] = serde_json::json!("everything");
assert!(verify_signed_excision(t.to_string().as_bytes()).is_none());
// Garbage is refused.
assert!(verify_signed_excision(b"not json").is_none());
}
#[test]
fn signed_response_round_trips() {
let signer = ApprovalSigner::from_seed(42);
// A one-shot approval: approved_for_session = false.
let (payload, _sig, _pk) = response_payload(
"req-1",
"rm",
r#"{"path":"/etc"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"looks fine",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified =
verify_signed_response(&payload).expect("signature verifies on untampered payload");
assert!(verified.approved);
assert_eq!(verified.reason, "looks fine");
assert_eq!(verified.request_id, "req-1");
assert_eq!(verified.tool_name, "rm");
assert_eq!(verified.args_json, r#"{"path":"/etc"}"#);
assert_eq!(verified.caller, "slack:T1:U9");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.nonce, "nonce-1");
assert!(verified.approved);
// A one-shot approval carries no session scope.
assert!(!verified.approved_for_session);
}
/// #1025 backward compatibility: a payload signed BEFORE the `approver`
/// field existed (no `approver` key at all, not merely an empty one)
/// must still decode and verify byte-for-byte identically — every
/// `approval_response` ever persisted was signed this way, and they are
/// re-verified on every replay.
///
/// The pre-#1025 shape is a checked-in literal
/// ([`PRE_APPROVER_RESPONSE_PAYLOAD`]). It used to be recomputed inline
/// with a `json!` call, because `Value::to_string()`'s key order depended
/// on `serde_json`'s `preserve_order` feature and so flipped with whatever
/// else the build graph pulled in (`#345`) — which made a literal fixture
/// unstable. `#1842` removed that dependence: the canonical is now a struct
/// serialized in declaration order, identical under every build selection,
/// so the literal is stable and pins the bytes a deployment actually has in
/// its log instead of merely agreeing with the implementation.
/// An `approval_response` persisted before `approver` existed (`#1025`),
/// signed by `ApprovalSigner::from_seed(42)` over the thirteen fields that
/// preceded it. Checked in, never regenerated.
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"}"#;
#[test]
fn pre_approver_field_payload_still_verifies_unchanged() {
let signer = ApprovalSigner::from_seed(42);
let (request_id, tool_name, args_json, modified_args_json) =
("req-1", "tool.name", r#"{"a":1}"#, "");
let (approved, approved_for_session) = (true, true);
let covered_capabilities = ["cap.a".to_owned(), "cap.b".to_owned()];
let caller = "persona-caller";
let sandbox_mode = "sandboxed";
let reason = "looks fine";
let injected_context = "";
let conversation_id = "conv-1";
let nonce = "nonce-1";
// The exact pre-#1025 payload — the 13 fields `response_canonical` /
// `response_payload` signed before `approver` existed — checked in as
// literal bytes rather than recomputed here (`#1842`). It used to be
// rebuilt with a `serde_json::json!` call so "both sides agree under
// whatever key ordering is in effect for this build", which quietly
// made the test agree with the implementation under either ordering
// instead of pinning the one a deployment actually persisted. Signed by
// `ApprovalSigner::from_seed(42)`; never regenerate it.
let pre_approver_payload = PRE_APPROVER_RESPONSE_PAYLOAD.as_bytes();
let expected_pk = signer.public_key_bytes();
let expected_sig = crate::hex::decode(
serde_json::from_slice::<Value>(pre_approver_payload)
.expect("the frozen payload is valid JSON")["signature_hex"]
.as_str()
.expect("the frozen payload carries a signature"),
)
.expect("the frozen signature is valid hex");
let verified = verify_signed_response(pre_approver_payload)
.expect("a pre-#1025 payload must still verify");
assert_eq!(verified.request_id, request_id);
assert_eq!(verified.caller, caller);
assert_eq!(
verified.approver, "",
"no approver field existed on this payload — decodes to empty, not an error"
);
// Re-signing the SAME inputs with today's code (empty approver) must
// reproduce byte-identical output — the additive field is omitted
// from the canonical entirely when empty, not merely defaulted.
let (regenerated, sig, pk) = response_payload(
request_id,
tool_name,
args_json,
modified_args_json,
approved,
approved_for_session,
&covered_capabilities,
caller,
"",
sandbox_mode,
reason,
injected_context,
conversation_id,
nonce,
&signer,
);
assert_eq!(
regenerated, pre_approver_payload,
"an empty approver must produce byte-identical canonical/payload to before #1025"
);
assert_eq!(
sig, expected_sig,
"an empty approver must sign byte-identically to before #1025"
);
assert_eq!(pk, expected_pk, "public key must be unchanged");
}
#[test]
fn session_response_round_trips_with_caller_binding() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"grep",
r#"{"pattern":"x"}"#,
"",
true,
true,
&[],
"slack:T1:U9",
"",
"workspace-write",
"remember it",
"",
"conv-1",
"nonce-1",
&signer,
);
let verified = verify_signed_response(&payload).expect("session signature verifies");
assert!(verified.approved);
assert!(verified.approved_for_session, "carries session scope");
assert_eq!(verified.caller, "slack:T1:U9");
assert_eq!(verified.tool_name, "grep");
assert!(verified.approved);
}
#[test]
fn tampered_session_or_caller_fails_verification() {
let signer = ApprovalSigner::from_seed(42);
let (payload, _sig, _pk) = response_payload(
"req-1",
"grep",
"{}",
"",
true,
true,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"",
"conv-1",
"nonce-1",
&signer,
);
// Every signed field is covered: re-scoping the memory to another user,
// flipping the session flag, swapping the tool, re-targeting the
// conversation, or re-rolling the nonce must all fail.
for (field, val) in [
("caller", Value::String("slack:T1:ATTACKER".to_owned())),
("approved_for_session", Value::Bool(false)),
("tool_name", Value::String("rm".to_owned())),
("approved", Value::Bool(false)),
("args_json", Value::String("EVIL".to_owned())),
// The approver's edit and injected context are signed too: forging
// either — swapping in different execution args, or a different
// injected instruction — must invalidate the signature.
(
"modified_args_json",
Value::String(r#"{"path":"/EVIL"}"#.to_owned()),
),
("injected_context", Value::String("do EVIL".to_owned())),
(
"sandbox_mode",
Value::String("danger-full-access".to_owned()),
),
("conversation_id", Value::String("conv-OTHER".to_owned())),
("nonce", Value::String("nonce-OTHER".to_owned())),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_signed_response(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail verification"
);
}
}
/// #67 gate A: an approver can approve with EDITED args. The signed response
/// carries both the model's PROPOSED args (`args_json`, the identity binding)
/// and the approver's EDIT (`modified_args_json`, what executes). Both are
/// covered by the signature. Crucially, the identity binding
/// ([`VerifiedResponse::authorizes_call`]) still matches the PROPOSED args —
/// so a model that re-emits a different call on resume cannot inherit the
/// approval — while the edit is a separate signed field the executor
/// substitutes. The pure resolver (`polyc_agent::resolve_approved_call`) owns
/// the "empty edit ⇒ run proposed" defaulting; here we only pin the crypto
/// contract: both fields round-trip, and identity binds the proposed args.
#[test]
fn edited_response_binds_proposed_and_carries_modified() {
let signer = ApprovalSigner::from_seed(7);
let proposed = r#"{"path":"/etc/shadow"}"#;
let edited = r#"{"path":"/etc/hostname"}"#;
let (payload, _sig, _pk) = response_payload(
"call-1",
"read_file",
proposed,
edited,
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"narrowed the path",
"",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("edited approval verifies");
assert_eq!(v.args_json, proposed, "identity binds the proposed args");
assert_eq!(
v.modified_args_json, edited,
"the edit is carried and signed"
);
// Identity binding is against the PROPOSED args — this is what the model
// must re-present on resume; the edit is not part of the identity.
assert!(
v.authorizes_call("call-1", "read_file", proposed),
"the exact proposed call is authorized"
);
assert!(
!v.authorizes_call("call-1", "read_file", edited),
"the edited args are NOT the identity — authorizes_call binds proposed"
);
}
/// #67 gate A: an unedited approval carries an empty `modified_args_json` and
/// still authorizes exactly the proposed call — behaviourally identical to the
/// pre-#67 approve path, so the common case is unchanged.
#[test]
fn unedited_response_carries_empty_edit() {
let signer = ApprovalSigner::from_seed(7);
let proposed = r#"{"path":"/tmp/x"}"#;
let (payload, _sig, _pk) = response_payload(
"call-1",
"read_file",
proposed,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("verifies");
assert!(v.modified_args_json.is_empty(), "no edit ⇒ empty");
assert!(v.injected_context.is_empty(), "no injected context ⇒ empty");
assert!(v.authorizes_call("call-1", "read_file", proposed));
}
/// #67 gate A: an approver can attach context to inject before the tool runs.
/// The injected context is a signed field that round-trips.
#[test]
fn injected_context_round_trips_and_is_signed() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = response_payload(
"call-1",
"shell",
r#"{"cmd":"ls"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"only touch files under src/",
"conv-1",
"nonce-1",
&signer,
);
let v = verify_signed_response(&payload).expect("verifies");
assert_eq!(v.injected_context, "only touch files under src/");
}
/// #67 (#539/#540): a dispatch-mutation record round-trips and verifies;
/// tampering with any covered field — including the kind — fails.
#[test]
fn mutation_round_trips_and_tamper_fails() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _s, _p) = mutation_payload(
"tool_input_rewrite",
"call-1",
"shell",
"conv-1",
r#"{"cmd":"rm -rf /"}"#,
r#"{"cmd":"rm /tmp/x"}"#,
&signer,
);
assert_eq!(
verify_mutation(&payload).map(|t| (t.0, t.4, t.5)),
Some((
"tool_input_rewrite".to_owned(),
r#"{"cmd":"rm -rf /"}"#.to_owned(),
r#"{"cmd":"rm /tmp/x"}"#.to_owned()
))
);
for field in [
"kind",
"tool_call_id",
"tool_name",
"conversation_id",
"before",
"after",
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = Value::String("EVIL".to_owned());
assert!(
verify_mutation(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail"
);
}
}
/// #67 (#538): a deferred "send back" round-trips and verifies; tampering
/// with any covered field fails.
#[test]
fn deferred_round_trips_and_tamper_fails() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = deferred_payload("call-1", "conv-1", "need more info", &signer);
assert_eq!(
verify_deferred(&payload),
Some((
"call-1".to_owned(),
"conv-1".to_owned(),
"need more info".to_owned()
))
);
for field in ["request_id", "conversation_id", "reason"] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = Value::String("EVIL".to_owned());
assert!(
verify_deferred(&v.to_string().into_bytes()).is_none(),
"tampering with {field} must fail"
);
}
}
#[test]
fn wire_verification_round_trips_and_binds_session_and_caller() {
let signer = ApprovalSigner::from_seed(7);
let (payload, _sig, _pk) = response_payload(
"req-x",
"grep",
r#"{"p":"x"}"#,
"",
true,
true,
&[],
"slack:T1:U9",
"",
"workspace-write",
"go",
"",
"conv-7",
"nonce-7",
&signer,
);
let d = decode_response_full(&payload).expect("decoded payload");
// decode_response_full surfaces every signed field.
assert!(d.approved_for_session);
assert_eq!(d.caller, "slack:T1:U9");
assert_eq!(d.sandbox_mode, "workspace-write");
assert_eq!(d.conversation_id, "conv-7");
assert_eq!(d.nonce, "nonce-7");
assert!(verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.approver,
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
// Re-scoping the remembered grant to a different caller over the wire
// must fail — the caller is covered by the signature.
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
"slack:T1:ATTACKER",
&d.approver,
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
// Tampering with the bound args over the wire invalidates the sig.
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
r#"{"p":"EVIL"}"#,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.approver,
&d.sandbox_mode,
&d.reason,
"",
&d.conversation_id,
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
// Replaying the token into a DIFFERENT conversation over the wire must
// fail — `conversation_id` is covered by the signature (#370, #77 3B).
assert!(!verify_wire_response(
&d.request_id,
&d.tool_name,
&d.args_json,
"",
d.approved,
d.approved_for_session,
&[],
&d.caller,
&d.approver,
&d.sandbox_mode,
&d.reason,
"",
"conv-OTHER",
&d.nonce,
&d.signer_pk_hex,
&d.signature_hex
));
}
/// #1025: `approver` is a distinct fact from `caller`, recoverable from a
/// signed response and covered by the signature — the same-person and
/// different-person (admin-approves-for-someone-else) cases both round-trip
/// correctly, and the two identities are never conflated.
#[test]
fn approver_is_recoverable_and_distinct_from_caller() {
let signer = ApprovalSigner::from_seed(3);
// Same-person: the caller approving their own paused turn signs an
// approver equal to caller. Distinguishable by field, still equal by
// value — this is the common case, not a degenerate one.
let (self_approved, ..) = response_payload(
"req-1",
"grep",
r#"{"q":"x"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"slack:T1:U9",
"workspace-write",
"self-approved",
"",
"conv-1",
"nonce-1",
&signer,
);
let self_decoded = decode_response_full(&self_approved).expect("decodes");
assert_eq!(self_decoded.caller, "slack:T1:U9");
assert_eq!(self_decoded.approver, "slack:T1:U9");
let self_verified = verify_signed_response(&self_approved).expect("verifies");
assert_eq!(self_verified.caller, self_verified.approver);
// Different-person: an admin approves on behalf of the turn's own
// beneficiary. `caller` stays the beneficiary (principal_ref's own
// resume-attribution source, per the sibling #1024 fix); `approver` is
// the admin — a genuinely different persona, both recoverable and
// distinguishable from the same signed record.
let (admin_approved, ..) = response_payload(
"req-2",
"rm",
r#"{"path":"/tmp/x"}"#,
"",
true,
false,
&[],
"slack:T1:BENEFICIARY",
"slack:T1:ADMIN",
"workspace-write",
"approved on your behalf",
"",
"conv-2",
"nonce-2",
&signer,
);
let admin_decoded = decode_response_full(&admin_approved).expect("decodes");
assert_eq!(admin_decoded.caller, "slack:T1:BENEFICIARY");
assert_eq!(admin_decoded.approver, "slack:T1:ADMIN");
assert_ne!(
admin_decoded.caller, admin_decoded.approver,
"admin-approves-for-someone-else must decode two DISTINCT identities"
);
let admin_verified = verify_signed_response(&admin_approved).expect("verifies");
assert_eq!(admin_verified.caller, "slack:T1:BENEFICIARY");
assert_eq!(admin_verified.approver, "slack:T1:ADMIN");
// Tampering with the signed approver (re-scoping the policy/audit fact
// to a different identity post-signing) invalidates the signature —
// it is covered exactly like `caller`.
let mut v: serde_json::Value = serde_json::from_slice(&admin_approved).unwrap();
v["approver"] = serde_json::json!("slack:T1:ATTACKER");
assert!(
verify_signed_response(v.to_string().as_bytes()).is_none(),
"a tampered approver must fail verification"
);
}
/// `#377` core invariant: a reviewer auto-approval signs a payload that is
/// BYTE-IDENTICAL to the one a human signs for the same decision, yet is
/// distinguishable in the audit log by its signed `reason`.
///
/// There is a single signing function ([`response_payload`]); the reviewer
/// path is just that function with `approved == true`,
/// `approved_for_session == false`, and an auto-review `reason`. We pin two
/// properties:
/// 1. With every argument INCLUDING the reason held equal, the auto and
/// human calls produce identical bytes + signature — proving the auto
/// path adds no hidden field and shares the human signing contract
/// exactly (guards against a future forked auto-signer).
/// 2. With the auto-review reason, the response still verifies, still
/// carries `approved && !approved_for_session`, and is flagged by
/// [`is_auto_review_reason`] while a human reason is not — so the event
/// log can tell machine from human consent off the signed field alone.
/// 3. Substitution resistance: flipping ANY one bound field — the tool, the
/// args, the beneficiary caller, or the request id — changes both the
/// canonical bytes AND the signature, so a forged or substituted
/// approval (signed for one call, replayed to authorize another) cannot
/// match. Without this, (1)'s byte-identical property would be vacuous.
#[test]
fn auto_review_signs_byte_identical_canonical_and_is_distinguishable() {
let signer = ApprovalSigner::from_seed(7);
let (rid, tool, args, caller, mode, conv, nonce) = (
"req-9",
"file_read",
r#"{"path":"a.txt"}"#,
"slack:T1:U9",
"read-only",
"conv-9",
"nonce-9",
);
// (1) Same decision + same reason via the one signing path ⇒ identical
// bytes regardless of which side "produced" it. The reviewer is not a
// separate signer; it cannot diverge structurally from the human path.
let shared_reason = auto_review_reason("low");
let human_like = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
);
let reviewer = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
);
assert_eq!(human_like.0, reviewer.0, "auto path must be byte-identical");
assert_eq!(human_like.1, reviewer.1, "signature must be identical");
// (2) The auto-review response verifies and is an approve-once decision.
let verified = verify_signed_response(&reviewer.0).expect("auto-review verifies");
assert!(verified.approved);
assert!(
!verified.approved_for_session,
"a machine decision is never remembered per-caller"
);
assert!(
is_auto_review_reason(&verified.reason),
"the signed reason marks this as an auto-approval"
);
// A genuine human approval over the same call is NOT flagged as auto —
// the distinguisher reads the signed reason, so it is unforgeable.
let (human_payload, _s, _p) = response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
"looks fine",
"",
conv,
nonce,
&signer,
);
let human = verify_signed_response(&human_payload).expect("human verifies");
assert!(!is_auto_review_reason(&human.reason));
// The two payloads differ ONLY in the reason field — every bound
// identity / decision / scope field is byte-equal, which is what makes
// the auto path indistinguishable from a human one except by reason.
let a: Value = serde_json::from_slice(&reviewer.0).unwrap();
let h: Value = serde_json::from_slice(&human_payload).unwrap();
for field in [
"request_id",
"tool_name",
"args_json",
"modified_args_json",
"approved",
"approved_for_session",
"caller",
"sandbox_mode",
"injected_context",
"conversation_id",
"nonce",
] {
assert_eq!(a[field], h[field], "{field} must match the human payload");
}
assert_ne!(a["reason"], h["reason"], "reason is the sole distinguisher");
// (3) Substitution resistance: each variant holds every input equal to
// `reviewer` and flips exactly ONE bound field. A different tool / args /
// caller / request_id / conversation / nonce must change BOTH the
// canonical bytes and the signature — so an approval signed for
// `(req-9, file_read, a.txt, U9, conv-9, nonce-9)` can never be replayed to
// authorize a write, a different path, a different beneficiary, a
// different CONVERSATION (#370, #77 3B), or re-presented under a new nonce.
// This is what makes (1)'s "byte-identical for identical inputs" a
// security property and not just determinism.
let base = &reviewer.0;
let base_sig = &reviewer.1;
for (label, variant) in [
(
"tool",
response_payload(
rid,
"file_write",
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"args",
response_payload(
rid,
tool,
r#"{"path":"b.txt"}"#,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"caller",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
"slack:T1:UEVIL",
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"request_id",
response_payload(
"req-OTHER",
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
nonce,
&signer,
),
),
(
"conversation_id",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
"conv-OTHER",
nonce,
&signer,
),
),
(
"nonce",
response_payload(
rid,
tool,
args,
"",
true,
false,
&[],
caller,
"",
mode,
&shared_reason,
"",
conv,
"nonce-OTHER",
&signer,
),
),
] {
assert_ne!(
&variant.0, base,
"{label}: a different {label} must change the canonical bytes"
);
assert_ne!(
&variant.1, base_sig,
"{label}: a different {label} must change the signature"
);
}
}
/// `#370` (a): a capability token signed for one conversation is REJECTED
/// when presented for another. The `conversation_id` is covered by the
/// signature, so it cannot be re-targeted without invalidating it; the
/// consume gate ([`verify_capability`]) refuses any token whose signed
/// conversation does not match the one it is being consumed in. Closes `#77`
/// bug 3B (one signed response valid across conversations sharing a
/// `request_id`).
#[test]
fn capability_rejected_across_conversations() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
// Same conversation, fresh nonce ⇒ honored.
assert!(
verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.is_some(),
"a token must verify in the conversation it was signed for"
);
// Different conversation ⇒ rejected, even though request_id/tool/args
// are byte-identical (the #77 3B replay).
assert!(
verify_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
.is_none(),
"a token signed for conv-A must be rejected when consumed in conv-B"
);
}
/// `#370` (b): a single-use token is REJECTED on a second presentation. The
/// consumer records the token's `nonce` after honoring it; a re-presentation
/// of the SAME signed bytes (a captured/replayed token) is then refused.
#[test]
fn capability_is_single_use() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let mut consumed = HashSet::new();
// First presentation is honored and yields the bound nonce.
let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.expect("first use honored");
assert_eq!(v.nonce, "nonce-A");
consumed.insert(v.nonce.clone());
// Second presentation of the same token is rejected — single-use.
assert!(
verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.is_none(),
"a spent token must be rejected on re-presentation"
);
}
/// `#370` (c)/(d): the token is ARGS-BOUND. A verified, approved token
/// authorizes ONLY the exact `(request_id, tool_name, args_json)` it was
/// signed for; a call with different args (a captured approval reused with a
/// new payload) is NOT authorized, while the matching call IS.
#[test]
fn capability_authorizes_only_matching_args() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/tmp/scratch"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.expect("verifies in conv-A");
// (c) different args ⇒ NOT authorized.
assert!(
!v.authorizes_call("call-0", "delete_file", r#"{"path":"/etc/hosts"}"#),
"a token must not authorize a call with different args"
);
// …nor a different tool with the same id.
assert!(
!v.authorizes_call("call-0", "shell", r#"{"path":"/tmp/scratch"}"#),
"a token must not authorize a different tool"
);
// (d) the exact signed call ⇒ authorized (happy path).
assert!(
v.authorizes_call("call-0", "delete_file", r#"{"path":"/tmp/scratch"}"#),
"the exact signed call must be authorized"
);
}
/// A denial never authorizes a call, regardless of identity match.
#[test]
fn denied_capability_authorizes_nothing() {
let signer = ApprovalSigner::from_seed(11);
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
"{}",
"",
false,
false,
&[],
"slack:T1:U9",
"",
"workspace-write",
"deny",
"",
"conv-A",
"nonce-A",
&signer,
);
let consumed = HashSet::new();
let v = verify_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.expect("verifies");
assert!(!v.approved);
assert!(
!v.authorizes_call("call-0", "delete_file", "{}"),
"a denied token authorizes nothing even on an exact identity match"
);
}
#[test]
fn request_payload_round_trips_id() {
let bytes = request_payload(
"call-7",
"rm",
r#"{"path":"/etc"}"#,
"workspace-write",
"",
&[],
"",
);
assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-7"));
// An ordinary gated call carries no override reason.
assert_eq!(decode_request_reason(&bytes), "");
}
#[test]
fn request_payload_carries_override_reason() {
// The lethal-trifecta override reason rides the durable approval_request
// so the signed log records WHY a trifecta-gated call was paused.
let reason = "lethal-trifecta / Rule-of-Two: untrusted content is in context";
let bytes = request_payload(
"call-9",
"web_fetch",
r#"{"url":"https://x"}"#,
"",
reason,
&[],
"",
);
assert_eq!(decode_request_reason(&bytes), reason);
// The existing scalar fields still decode unchanged.
assert_eq!(decode_request_id(&bytes).as_deref(), Some("call-9"));
assert_eq!(
decode_request_fields(&bytes),
Some((
"call-9".to_owned(),
"web_fetch".to_owned(),
r#"{"url":"https://x"}"#.to_owned()
))
);
}
// #1496: the computed-preview enrichment round-trips through the durable
// `approval_request` payload, so `ListPending` recovery can re-render the
// same preview a lost-stream card showed.
#[test]
fn request_preview_json_round_trips() {
let preview =
r#"{"prompt_text":"hi","next_fires":[],"zone_name":"UTC","zone_is_fallback":true}"#;
let bytes = request_payload("call-10", "routine_create", "{}", "", "", &[], preview);
let decoded = decode_request_preview_json(&bytes).expect("preview present");
// Compare parsed values, not raw bytes: `preview_json` is re-serialized
// through `serde_json::Value`, which is free to reorder keys.
let expected: Value = serde_json::from_str(preview).unwrap();
let actual: Value = serde_json::from_str(&decoded).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn request_preview_json_is_absent_when_empty_or_missing() {
let bytes = request_payload("call-11", "grep", "{}", "", "", &[], "");
assert_eq!(decode_request_preview_json(&bytes), None);
assert_eq!(decode_request_preview_json(b"{\"nope\":1}"), None);
}
/// Golden test: `receipt_payload` must produce the EXACT **v3** signed-payload
/// bytes — `version` + the six settlement facts + the four binding fields +
/// the two payer-attribution fields, in that order — plus the two uncovered
/// signature fields. We recompute the expected canonical+full JSON inline
/// and assert the struct form is byte-identical, pinning the v3 key set,
/// order, and signature so a future edit cannot silently reorder, rename,
/// or swap a covered field. The expectation is a literal string: the key
/// order is part of what this test pins, and `#1842` made it stable to
/// pin. It used to be recomputed with a `json!` call, because `serde_json`
/// feature unification (`preserve_order`) flipped key order per build
/// selection — but a recomputed expectation agrees with the implementation
/// under either ordering, which pins the field set and nothing else.
///
/// The `version` below is the frozen literal `3`, not `RECEIPT_VERSION`:
/// this test pins what the *writer* emits, and mirroring the constant would
/// leave it passing while silently pinning v4. A bump re-freezes it against
/// the new version — the already-signed v2 bytes are preserved by
/// `frozen_v2_receipts_survive_a_later_version_bump`.
#[test]
fn receipt_payload_pins_v3_canonical_shape() {
let signer = ApprovalSigner::from_seed(99);
let (reference, amount, currency, recipient, method, timestamp) = (
"tx-abc",
"0.01",
"USDC",
"0xrecipient",
"tempo",
"2026-06-02T00:00:00Z",
);
let (kind, tool_call_id, approval_pos, approved_args_hash, subject) = (
"outbound_payment_receipt",
"call-1",
"42",
"abcd1234",
"conv-xyz",
);
let (payer_kind, paying_account) = ("linked_wallet", "0xpayer");
// The exact v3 JSON the implementation must build, written out here as
// a literal. `3` is a frozen literal, not `RECEIPT_VERSION`: mirroring
// the constant would let this test keep passing while pinning v4 the
// day the constant moved, which is exactly the drift it exists to
// catch. The key ORDER is likewise pinned by this string rather than
// recomputed with a `json!` call (`#1842`) — a recomputed expectation
// agrees with the implementation under whatever ordering the build
// resolves, which is the opposite of pinning anything.
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"}"#;
let expected_sig = signer.sign(expected_canonical.as_bytes());
let expected_pk = signer.public_key_bytes();
let expected_full = format!(
r#"{{{expected_body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
expected_body = expected_canonical
.trim_start_matches('{')
.trim_end_matches('}'),
pk = crate::hex::lower(&expected_pk),
sig = crate::hex::lower(&expected_sig),
);
let (payload, sig, pk) = receipt_payload(
&ReceiptPayload {
kind,
reference,
amount,
currency,
recipient,
method,
timestamp,
tool_call_id,
approval_pos,
approved_args_hash,
subject,
payer_kind,
paying_account,
},
&signer,
);
assert_eq!(
String::from_utf8(payload).unwrap(),
expected_full,
"v3 receipt payload must be byte-identical to the pinned v3 shape"
);
assert_eq!(
sig, expected_sig,
"signature must match the pinned v3 shape"
);
assert_eq!(pk, expected_pk, "public key must be unchanged");
}
/// Single-source guard: both the signing path (`receipt_payload`) and the
/// verifying path (`verify_signed_receipt`) MUST derive their canonical
/// signed JSON from the one [`ReceiptPayload::canonical_json_v3`] builder,
/// so the signed field set/order cannot drift between sign and verify.
///
/// We assert the canonical bytes the signer commits to are exactly the
/// bytes `canonical_json_v3` produces for the same fields, and that a receipt
/// reconstructed from `VerifiedReceipt` (the verify path's owned form)
/// yields the identical canonical bytes. If a future edit added a field to
/// one json! block but not the other, those bytes would differ and this
/// (plus the round-trip) would fail.
#[test]
fn receipt_sign_and_verify_share_one_canonical_source() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let fields = ReceiptPayload {
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",
};
// The signed bytes the producer commits to.
let signed_bytes = canonical_bytes(&fields.canonical_json_v3());
let expected_sig = signer.sign(&signed_bytes);
let (_payload, sig, _pk) = receipt_payload(&fields, &signer);
assert_eq!(
sig, expected_sig,
"receipt_payload must sign exactly ReceiptPayload::canonical_json_v3"
);
// The verify path reconstructs the same canonical bytes from its owned
// form before checking the signature.
let (payload, _sig, _pk) = receipt_payload(&fields, &signer);
let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
let verified_fields = ReceiptPayload {
kind: &verified.kind,
reference: &verified.reference,
amount: &verified.amount,
currency: &verified.currency,
recipient: &verified.recipient,
method: &verified.method,
timestamp: &verified.timestamp,
tool_call_id: &verified.tool_call_id,
approval_pos: &verified.approval_pos,
approved_args_hash: &verified.approved_args_hash,
subject: &verified.subject,
payer_kind: &verified.payer_kind,
paying_account: &verified.paying_account,
};
let verified_canonical = canonical_bytes(&verified_fields.canonical_json_v3());
assert_eq!(
verified_canonical, signed_bytes,
"verify path must derive canonical JSON from the same single source"
);
}
#[test]
fn crypto_receipt_payload_signs_and_verifies() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
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",
},
&signer,
);
let verified = verify_signed_receipt(&payload, &trusted)
.expect("signature verifies on untampered receipt");
assert_eq!(verified.reference, "tx-abc");
assert_eq!(verified.amount, "0.01");
assert_eq!(verified.currency, "USDC");
assert_eq!(verified.recipient, "0xrecipient");
assert_eq!(verified.method, "tempo");
assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
// The v2 kind + binding tuple + opaque subject round-trip and are
// covered by the signature.
assert_eq!(verified.version, RECEIPT_VERSION);
assert_eq!(verified.kind, "outbound_payment_receipt");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.approval_pos, "42");
assert_eq!(verified.approved_args_hash, "abcd1234");
assert_eq!(verified.subject, "conv-xyz");
// The v3 payer attribution round-trips too.
assert_eq!(verified.payer_kind, "linked_wallet");
assert_eq!(verified.paying_account, "0xpayer");
}
#[test]
fn tampered_receipt_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
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",
},
&signer,
);
// Tamper with a covered field (amount).
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["amount"] = Value::String("9999.00".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered, &trusted).is_none());
}
#[test]
fn tampered_receipt_binding_field_fails_verification() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let (payload, _sig, _pk) = receipt_payload(
&ReceiptPayload {
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",
},
&signer,
);
// Re-pointing the receipt at a different approval position invalidates
// the signature — the binding tuple is covered, not advisory.
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["approval_pos"] = Value::String("7".to_owned());
let tampered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&tampered, &trusted).is_none());
// Re-filing the payload under the other direction's kind likewise
// fails — the signed `kind` is what makes direction trustworthy
// independent of the (unsigned) stored event kind.
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["kind"] = Value::String("payment_receipt".to_owned());
let refiled = v.to_string().into_bytes();
assert!(verify_signed_receipt(&refiled, &trusted).is_none());
// Re-attributing the payer likewise fails — payer attribution is
// covered, not advisory: an attacker cannot rewrite "the deployment
// paid" into "the linked wallet paid" (or vice versa) on an
// already-signed receipt.
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v["payer_kind"] = Value::String("deployment".to_owned());
let repayered = v.to_string().into_bytes();
assert!(verify_signed_receipt(&repayered, &trusted).is_none());
}
/// The acceptance gate (issue #806): a receipt signed by a key that is
/// NOT on the trusted-signer allow-list must fail verification, even
/// though its signature is perfectly self-consistent — proving the
/// allow-list, not mere signature validity, gates trust. The SAME
/// receipt verifies once its signer is added to the allow-list.
#[test]
fn receipt_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
let fields = ReceiptPayload {
kind: "outbound_payment_receipt",
reference: "tx-forged",
amount: "100.00",
currency: "USDC",
recipient: "0xattacker",
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",
};
// The attacker signs a fully self-consistent receipt with THEIR OWN
// key (not a forged signature over the trusted signer's key).
let (payload, _sig, _pk) = receipt_payload(&fields, &attacker_signer);
// An allow-list that does not include the attacker's key rejects it,
// no matter how internally consistent the signature is.
let trusted = vec![trusted_signer.public_key_bytes()];
assert!(
verify_signed_receipt(&payload, &trusted).is_none(),
"a receipt signed by a non-allow-listed key must not verify"
);
// The IDENTICAL payload verifies once the attacker's key is
// allow-listed (proves the rejection above was the allow-list, not
// some other defect).
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_signed_receipt(&payload, &trusted_plus_attacker).is_some(),
"the same receipt must verify once its signer is allow-listed"
);
// An empty allow-list rejects every signer, including the deployment's
// own — fail closed, never fail open on a misconfigured (empty) list.
assert!(verify_signed_receipt(&payload, &[]).is_none());
}
/// The acceptance gate (issue #845): a `grant_replay` audit record signed by
/// a key that is NOT on the trusted-signer allow-list must fail
/// verification, even though its signature is perfectly self-consistent —
/// the allow-list, not mere signature validity, gates trust. The SAME record
/// verifies once its signer is allow-listed. Mirrors
/// [`receipt_from_non_allowlisted_signer_is_rejected`].
#[test]
fn grant_replay_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
let covered = vec!["arbitrary-egress".to_owned()];
// The attacker signs a fully self-consistent record with THEIR OWN key.
let (payload, _sig, _pk) = grant_replay_payload(
"conv-1",
"turn-7",
"post_summary",
"deadbeef",
&covered,
"sha256:template-abc",
&attacker_signer,
);
// The unpinned verifier accepts it (signature is internally consistent) —
// exactly the forgery surface this issue closes.
assert!(
verify_grant_replay(&payload),
"the unpinned verifier trusts any self-consistent signature"
);
// An allow-list that does not include the attacker's key rejects it.
let trusted = vec![trusted_signer.public_key_bytes()];
assert!(
!verify_grant_replay_pinned(&payload, &trusted),
"a grant_replay signed by a non-allow-listed key must not verify"
);
// The IDENTICAL payload verifies once the attacker's key is allow-listed
// (proves the rejection was the allow-list, not some other defect).
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_grant_replay_pinned(&payload, &trusted_plus_attacker),
"the same record must verify once its signer is allow-listed"
);
// An empty allow-list rejects every signer — fail closed.
assert!(!verify_grant_replay_pinned(&payload, &[]));
}
/// The acceptance gate (issue #845): an `approval_response` signed by a key
/// that is NOT on the trusted-signer allow-list must fail verification via
/// both [`verify_signed_response_pinned`] and the single-use
/// [`verify_capability`] gate, even though its signature is self-consistent.
/// The SAME response verifies once its signer is allow-listed. Mirrors
/// [`receipt_from_non_allowlisted_signer_is_rejected`].
#[test]
fn signed_response_from_non_allowlisted_signer_is_rejected() {
let trusted_signer = ApprovalSigner::from_seed(99);
let attacker_signer = ApprovalSigner::from_seed(31337);
// The attacker self-signs a fully consistent approval with their key.
let (payload, _sig, _pk) = response_payload(
"call-0",
"delete_file",
r#"{"path":"/etc/hosts"}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"slack:T1:U9",
"workspace-write",
"ok",
"",
"conv-A",
"nonce-A",
&attacker_signer,
);
// The unpinned verifier accepts it — the forgery surface this closes.
assert!(
verify_signed_response(&payload).is_some(),
"the unpinned verifier trusts any self-consistent signature"
);
// Pinned to a legit allow-list that excludes the attacker ⇒ rejected,
// both as a bare response and through the capability consume gate.
let trusted = vec![trusted_signer.public_key_bytes()];
let consumed = HashSet::new();
assert!(
verify_signed_response_pinned(&payload, &trusted).is_none(),
"an approval_response signed by a non-allow-listed key must not verify"
);
assert!(
verify_capability(&payload, "conv-A", &consumed, &trusted).is_none(),
"the capability gate must reject a non-allow-listed signer"
);
// The IDENTICAL payload verifies once the attacker's key is allow-listed.
let trusted_plus_attacker = vec![
trusted_signer.public_key_bytes(),
attacker_signer.public_key_bytes(),
];
assert!(
verify_signed_response_pinned(&payload, &trusted_plus_attacker).is_some(),
"the same response must verify once its signer is allow-listed"
);
assert!(
verify_capability(&payload, "conv-A", &consumed, &trusted_plus_attacker).is_some(),
"the capability gate honors an allow-listed signer"
);
// An empty allow-list rejects every signer — fail closed.
assert!(verify_signed_response_pinned(&payload, &[]).is_none());
assert!(verify_capability(&payload, "conv-A", &consumed, &[]).is_none());
}
/// A receipt persisted before the v2 binding (no `version`, six fields only)
/// must still verify for forensics, surfacing as `version == 1` with empty
/// binding fields. Mirrors the legacy approval-response path.
#[test]
fn legacy_v1_receipt_still_verifies() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let verified = verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted)
.expect("a valid v1 receipt still verifies");
assert_eq!(verified.version, 1);
assert_eq!(verified.reference, "tx-old");
assert!(verified.kind.is_empty());
assert!(verified.tool_call_id.is_empty());
assert!(verified.approval_pos.is_empty());
assert!(verified.subject.is_empty());
// A pre-payer receipt reports payer as explicit unknown, never
// inferred.
assert!(verified.payer_kind.is_empty());
assert!(verified.paying_account.is_empty());
}
/// Injecting a `version` key into a validly-signed legacy receipt must not
/// verify: the v1 canonical does not cover `version`, so dispatching the
/// claimed version to the v1 canonical would let the signature check pass
/// while `VerifiedReceipt.version` echoed an unsigned, writer-chosen value.
/// Absence — and only absence — reads as v1; every present `version` has to
/// name a frozen version whose canonical covers the key, which no v1-signed
/// body can.
#[test]
fn injected_version_on_v1_signed_receipt_fails() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let full: Value =
serde_json::from_str(GOLDEN_V1_RECEIPT).expect("the frozen v1 receipt is valid JSON");
// A claimed future version, `0`, an explicit `1`, the current version
// spelled as a float or a string, `null`, and a negative number are all
// refused outright (fail closed) — never verified against a guessed
// canonical. `2` itself is absent from this list only because a
// v1-signed body relabelled `2` cannot satisfy the v2 canonical
// anyway; the shape of the refusal is what is pinned here.
for injected in [
Value::from(7_u64),
Value::from(0_u64),
Value::from(1_u64),
Value::from(2.0_f64),
Value::String("2".to_owned()),
Value::Null,
Value::from(-1_i64),
] {
let mut tampered = full.clone();
tampered["version"] = injected;
assert!(
verify_signed_receipt(&tampered.to_string().into_bytes(), &trusted).is_none(),
"a writer-chosen version key must never verify"
);
}
// Sanity: without the injected key the same payload verifies as v1.
assert!(verify_signed_receipt(GOLDEN_V1_RECEIPT.as_bytes(), &trusted).is_some());
}
/// A `payment_receipt` persisted before the v2 binding existed: the six
/// settlement facts, no `version` key, signed by
/// `ApprovalSigner::from_seed(99)` over the frozen v1 canonical. Checked
/// in, never regenerated — the deployment's settled history is what this
/// pins.
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"}"#;
/// A `payment_receipt` signed under **v2**, checked in as literal bytes
/// rather than minted at test time.
///
/// Signed by `ApprovalSigner::from_seed(99)` over the v2 canonical of the
/// fields [`golden_v2_receipt_fields`] names. Nothing in this string is
/// derived from `RECEIPT_VERSION`, which is the entire point: it is a
/// receipt out of the past, and the day the constant moves it must keep
/// verifying byte for byte. Never regenerate it — regenerating it is the
/// bug it exists to catch.
///
/// One literal, not two (`#1842`). It used to be a pair — one under sorted
/// keys, one under insertion order — selected by probing which ordering the
/// build's `serde_json::Map` happened to use, because the canonical was a
/// [`serde_json::Value`] and its bytes therefore depended on whether
/// anything in the build graph enabled `preserve_order`. These are the
/// insertion-order bytes: the ones every deployment has actually signed and
/// persisted, since the control plane and the harness both resolve that
/// feature. Now that the canonical is a struct serialized in declaration
/// order, every build produces exactly these bytes, so the sorted-key
/// literal named a form nothing has ever signed or can now produce, and it
/// is gone.
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"}"#;
/// The exact fields [`GOLDEN_V2_RECEIPT`] was signed over. v2 does not
/// cover payer attribution, so `payer_kind`/`paying_account` are `""` —
/// present in the struct (every `ReceiptPayload` construction site names
/// them) but not part of what this fixture's signature covers.
const fn golden_v2_receipt_fields() -> ReceiptPayload<'static> {
ReceiptPayload {
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: "",
paying_account: "",
}
}
/// Bumping `RECEIPT_VERSION` must not orphan the receipts already signed
/// under the outgoing version.
///
/// The receipt under test is a *literal*, signed under v2 and checked in —
/// not one minted here by `receipt_payload`, which by design signs whatever
/// the current version is. That distinction is the test: on the day someone
/// raises `RECEIPT_VERSION` to 3, a minted "v2" payload silently becomes a
/// v3 payload and stops exercising anything, while this literal keeps being
/// exactly what a deployment has sitting in its log. It must still verify,
/// binding tuple intact, with `version` reading 2 — not the current
/// constant. If it ever stops, every fold that filters on verification has
/// just dropped the deployment's entire settled v2 history.
#[test]
fn frozen_v2_receipts_survive_a_later_version_bump() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let verified = verify_signed_receipt(GOLDEN_V2_RECEIPT.as_bytes(), &trusted)
.expect("the frozen v2 canonical must keep verifying receipts already signed under it");
// Deliberately `2`, never `RECEIPT_VERSION`: the receipt reports the
// version it was signed under, whatever this build's current one is.
assert_eq!(verified.version, 2);
assert_eq!(verified.reference, "tx-frozen-v2");
assert_eq!(verified.amount, "10000");
assert_eq!(verified.currency, "0xToken");
assert_eq!(verified.recipient, "0xrecipient");
assert_eq!(verified.method, "tempo");
assert_eq!(verified.timestamp, "2026-06-02T00:00:00Z");
// The v2 binding tuple survives the round trip; a canonical that had
// drifted would fail the signature check above, not merely blank these.
assert_eq!(verified.kind, "outbound_payment_receipt");
assert_eq!(verified.tool_call_id, "call-frozen");
assert_eq!(verified.approval_pos, "42");
assert_eq!(verified.approved_args_hash, "abcd1234");
assert_eq!(verified.subject, "conv-frozen");
// v2 signed no payer attribution at all, so it reports payer as
// explicit unknown — never inferred.
assert!(verified.payer_kind.is_empty());
assert!(verified.paying_account.is_empty());
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
// Fail-closed is untouched by the freeze: relabel that same signed body
// with a version this build has frozen no canonical for and it is
// refused outright rather than checked against a guessed one. `3` is
// no longer in this list — it is now a frozen version in its own
// right (v3, payer attribution).
let mut relabelled: Value = serde_json::from_str(GOLDEN_V2_RECEIPT).unwrap();
for unknown in [Value::from(4_u64), Value::from(5_u64)] {
relabelled["version"] = unknown;
assert!(
verify_signed_receipt(&relabelled.to_string().into_bytes(), &trusted).is_none(),
"a version with no frozen canonical must never be guessed at"
);
}
}
/// A `payment_receipt` signed under **v3** (payer attribution), checked in
/// as literal bytes rather than minted at test time. Mirrors
/// [`GOLDEN_V2_RECEIPT`]'s reasoning exactly, one version later.
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"}"#;
/// The exact fields [`GOLDEN_V3_RECEIPT`] was signed over.
const fn golden_v3_receipt_fields() -> ReceiptPayload<'static> {
ReceiptPayload {
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",
}
}
/// Bumping `RECEIPT_VERSION` again must not orphan the receipts already
/// signed under v3. Mirrors [`frozen_v2_receipts_survive_a_later_version_bump`].
#[test]
fn frozen_v3_receipts_survive_a_later_version_bump() {
let signer = ApprovalSigner::from_seed(99);
let trusted = vec![signer.public_key_bytes()];
let verified = verify_signed_receipt(GOLDEN_V3_RECEIPT.as_bytes(), &trusted)
.expect("the frozen v3 canonical must keep verifying receipts already signed under it");
assert_eq!(verified.version, 3);
assert_eq!(verified.reference, "tx-frozen-v3");
assert_eq!(verified.kind, "outbound_payment_receipt");
assert_eq!(verified.tool_call_id, "call-frozen");
assert_eq!(verified.approval_pos, "42");
assert_eq!(verified.approved_args_hash, "abcd1234");
assert_eq!(verified.subject, "conv-frozen");
assert_eq!(verified.payer_kind, "linked_wallet");
assert_eq!(verified.paying_account, "0xpayer-frozen");
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
}
/// The frozen v2 fixture is a genuine product of the frozen v2 canonical,
/// not a hand-typed string: rebuilding its canonical bytes from
/// [`golden_v2_receipt_fields`] and re-signing reproduces it exactly. Uses
/// [`ReceiptSchema::V2`] directly (not [`receipt_payload`], which always
/// signs the *current* version) so this assertion stays pinned to v2 no
/// matter how many times `RECEIPT_VERSION` moves on.
#[test]
fn the_frozen_v2_fixture_reproduces_via_its_own_canonical() {
let signer = ApprovalSigner::from_seed(99);
let fields = golden_v2_receipt_fields();
let canonical = ReceiptSchema::V2.canonical(&fields);
let sig = signer.sign(&canonical);
let full = format!(
r#"{{{body},"signed_by":"{pk}","signature_hex":"{sig}"}}"#,
body = String::from_utf8(canonical)
.unwrap()
.trim_start_matches('{')
.trim_end_matches('}'),
pk = crate::hex::lower(&signer.public_key_bytes()),
sig = crate::hex::lower(&sig),
);
assert_eq!(
full, GOLDEN_V2_RECEIPT,
"the checked-in v2 fixture must be exactly what the frozen v2 canonical produces"
);
}
/// The frozen v3 fixture is a genuine product of the writer, not a
/// hand-typed string: signing its fields today (v3 is the current
/// version) reproduces its bytes exactly.
///
/// This is the assertion that a `RECEIPT_VERSION` bump is *expected* to
/// break, and the split is deliberate — this one tracks the writer, which
/// always signs the current version, while
/// [`frozen_v3_receipts_survive_a_later_version_bump`] tracks history,
/// which never moves. A bumper re-freezes this one against a v4 fixture
/// and leaves the v2/v3 literals alone.
#[test]
fn the_frozen_v3_fixture_is_what_todays_writer_signs() {
let signer = ApprovalSigner::from_seed(99);
let (payload, _sig, _pk) = receipt_payload(&golden_v3_receipt_fields(), &signer);
assert_eq!(
String::from_utf8(payload).unwrap(),
GOLDEN_V3_RECEIPT,
"the checked-in v3 fixture must be exactly what receipt_payload emits today"
);
}
}
#[cfg(test)]
mod resolve_token_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn resolve_token_verifies_for_its_own_request_and_conversation() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(verify_resolve_token(
&token, "call-1", "conv-a", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_a_different_request_id() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-2", "conv-a", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_a_different_conversation() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-1", "conv-b", 1_000, &signer
));
}
#[test]
fn resolve_token_rejects_wrong_signer() {
let signer = ApprovalSigner::from_seed(11);
let other = ApprovalSigner::from_seed(12);
let token = mint_resolve_token("call-1", "conv-a", 1_000, &signer);
assert!(!verify_resolve_token(
&token, "call-1", "conv-a", 1_000, &other
));
}
#[test]
fn resolve_token_rejects_after_ttl_elapses() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_resolve_token("call-1", "conv-a", 0, &signer);
assert!(verify_resolve_token(
&token,
"call-1",
"conv-a",
RESOLVE_TOKEN_TTL_MS,
&signer
));
assert!(!verify_resolve_token(
&token,
"call-1",
"conv-a",
RESOLVE_TOKEN_TTL_MS + 1,
&signer
));
}
#[test]
fn resolve_token_rejects_garbage() {
let signer = ApprovalSigner::from_seed(11);
assert!(!verify_resolve_token(
"not-hex", "call-1", "conv-a", 0, &signer
));
assert!(!verify_resolve_token("", "call-1", "conv-a", 0, &signer));
}
}
#[cfg(test)]
mod admin_model_change_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn admin_model_change_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(31);
let (payload, _sig, _pk) = admin_model_change_payload(
"team-a",
"vertex",
"old-model",
"vertex",
"new-model",
1_000,
&signer,
);
let verified = verify_admin_model_change(&payload).expect("genuine record verifies");
assert_eq!(verified.principal, "team-a");
assert_eq!(verified.new_model, "new-model");
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("principal", serde_json::json!("attacker")),
("new_model", serde_json::json!("evil-model")),
("new_provider", serde_json::json!("evil-provider")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_admin_model_change(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
}
#[cfg(test)]
mod routine_created_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// #1497 INV-RL6: the signed `routine_created` payload round-trips its
/// who/when/originating-conversation fields and is tamper-evident.
#[test]
fn routine_created_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(41);
let (payload, _sig, _pk) = routine_created_payload(
"daily-standup-a1b2",
"persona-1",
"conv-1",
"call-1",
"hash-1",
1_000,
&signer,
);
let verified = verify_routine_created(&payload).expect("genuine record verifies");
assert_eq!(verified.routine, "daily-standup-a1b2");
assert_eq!(verified.creator_persona, "persona-1");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.args_hash, "hash-1");
assert_eq!(verified.created_at_ms, 1_000);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("routine", serde_json::json!("someone-elses-routine")),
("creator_persona", serde_json::json!("attacker")),
("conversation_id", serde_json::json!("conv-other")),
("tool_call_id", serde_json::json!("call-other")),
("args_hash", serde_json::json!("hash-other")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_routine_created(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
#[test]
fn malformed_routine_created_payload_fails_closed() {
assert!(verify_routine_created(b"not json").is_none());
assert!(verify_routine_created(b"{}").is_none());
}
}
#[cfg(test)]
mod routine_paused_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// #1495 INV-RL6: the signed `routine_paused` payload round-trips its
/// who/when/originating-conversation/reason fields and is tamper-evident.
#[test]
fn routine_paused_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(51);
let (payload, _sig, _pk) = routine_paused_payload(
"daily-standup-a1b2",
"persona-1",
"conv-1",
"call-1",
"hash-1",
1_000,
Some("rotating content"),
&signer,
);
let verified = verify_routine_paused(&payload).expect("genuine record verifies");
assert_eq!(verified.routine, "daily-standup-a1b2");
assert_eq!(verified.actor_persona, "persona-1");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.args_hash, "hash-1");
assert_eq!(verified.paused_at_ms, 1_000);
assert_eq!(verified.reason.as_deref(), Some("rotating content"));
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("routine", serde_json::json!("someone-elses-routine")),
("actor_persona", serde_json::json!("attacker")),
("conversation_id", serde_json::json!("conv-other")),
("tool_call_id", serde_json::json!("call-other")),
("args_hash", serde_json::json!("hash-other")),
("reason", serde_json::json!("a different reason")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_routine_paused(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
/// A pause with no reason given round-trips `reason: None` rather than an
/// empty string.
#[test]
fn routine_paused_with_no_reason_round_trips_none() {
let signer = ApprovalSigner::from_seed(52);
let (payload, _sig, _pk) = routine_paused_payload(
"weekly-digest",
"persona-2",
"conv-2",
"call-2",
"hash-2",
2_000,
None,
&signer,
);
let verified = verify_routine_paused(&payload).expect("genuine record verifies");
assert_eq!(verified.reason, None);
}
#[test]
fn malformed_routine_paused_payload_fails_closed() {
assert!(verify_routine_paused(b"not json").is_none());
assert!(verify_routine_paused(b"{}").is_none());
}
}
#[cfg(test)]
mod routine_resumed_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// #1495 INV-RL6: the signed `routine_resumed` payload round-trips its
/// who/when/originating-conversation fields and is tamper-evident.
#[test]
fn routine_resumed_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(53);
let (payload, _sig, _pk) = routine_resumed_payload(
"daily-standup-a1b2",
"persona-1",
"conv-1",
"call-1",
"hash-1",
3_000,
&signer,
);
let verified = verify_routine_resumed(&payload).expect("genuine record verifies");
assert_eq!(verified.routine, "daily-standup-a1b2");
assert_eq!(verified.actor_persona, "persona-1");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.args_hash, "hash-1");
assert_eq!(verified.resumed_at_ms, 3_000);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("routine", serde_json::json!("someone-elses-routine")),
("actor_persona", serde_json::json!("attacker")),
("conversation_id", serde_json::json!("conv-other")),
("tool_call_id", serde_json::json!("call-other")),
("args_hash", serde_json::json!("hash-other")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_routine_resumed(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
#[test]
fn malformed_routine_resumed_payload_fails_closed() {
assert!(verify_routine_resumed(b"not json").is_none());
assert!(verify_routine_resumed(b"{}").is_none());
}
}
#[cfg(test)]
mod routine_deleted_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// #1495 INV-RL2/RL6: the signed `routine_deleted` payload round-trips its
/// who/when/originating-conversation fields and is tamper-evident.
#[test]
fn routine_deleted_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(54);
let (payload, _sig, _pk) = routine_deleted_payload(
"daily-standup-a1b2",
"persona-1",
"conv-1",
"call-1",
"hash-1",
4_000,
&signer,
);
let verified = verify_routine_deleted(&payload).expect("genuine record verifies");
assert_eq!(verified.routine, "daily-standup-a1b2");
assert_eq!(verified.actor_persona, "persona-1");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.args_hash, "hash-1");
assert_eq!(verified.deleted_at_ms, 4_000);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("routine", serde_json::json!("someone-elses-routine")),
("actor_persona", serde_json::json!("attacker")),
("conversation_id", serde_json::json!("conv-other")),
("tool_call_id", serde_json::json!("call-other")),
("args_hash", serde_json::json!("hash-other")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_routine_deleted(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
#[test]
fn malformed_routine_deleted_payload_fails_closed() {
assert!(verify_routine_deleted(b"not json").is_none());
assert!(verify_routine_deleted(b"{}").is_none());
}
}
#[cfg(test)]
mod routine_scope_changed_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// #1806 INV-RL6: the signed `routine_scope_changed` payload round-trips
/// its who/when/originating-conversation/target-scope fields and is
/// tamper-evident.
#[test]
fn routine_scope_changed_round_trips_and_is_tamper_evident() {
let signer = ApprovalSigner::from_seed(55);
let (payload, _sig, _pk) = routine_scope_changed_payload(
"daily-standup-a1b2",
"persona-1",
"conv-1",
"call-1",
"hash-1",
"public",
5_000,
&signer,
);
let verified = verify_routine_scope_changed(&payload).expect("genuine record verifies");
assert_eq!(verified.routine, "daily-standup-a1b2");
assert_eq!(verified.actor_persona, "persona-1");
assert_eq!(verified.conversation_id, "conv-1");
assert_eq!(verified.tool_call_id, "call-1");
assert_eq!(verified.args_hash, "hash-1");
assert_eq!(verified.scope, "public");
assert_eq!(verified.changed_at_ms, 5_000);
assert_eq!(verified.signer_public_key, signer.public_key_bytes());
for (field, val) in [
("routine", serde_json::json!("someone-elses-routine")),
("actor_persona", serde_json::json!("attacker")),
("conversation_id", serde_json::json!("conv-other")),
("tool_call_id", serde_json::json!("call-other")),
("args_hash", serde_json::json!("hash-other")),
("scope", serde_json::json!("private")),
] {
let mut v: Value = serde_json::from_slice(&payload).unwrap();
v[field] = val;
assert!(
verify_routine_scope_changed(v.to_string().as_bytes()).is_none(),
"tampered {field} must fail verification"
);
}
}
#[test]
fn malformed_routine_scope_changed_payload_fails_closed() {
assert!(verify_routine_scope_changed(b"not json").is_none());
assert!(verify_routine_scope_changed(b"{}").is_none());
}
}
#[cfg(test)]
mod canonical_freeze {
//! Every signed canonical in this module, frozen as literal bytes (`#1842`).
//!
//! These are the bytes a deployment has already signed and has sitting in
//! its log. They are checked in, never regenerated: regenerating one is the
//! defect this module exists to catch, because a canonical whose bytes move
//! invalidates every signature ever minted over the old ones.
//!
//! The reason they can be single literals at all is the fix `#1842` made.
//! Before it, each canonical was a [`serde_json::Value`], whose object is a
//! `BTreeMap` (keys sorted) by default and an `IndexMap` (insertion order)
//! whenever anything in the build graph enables `serde_json/preserve_order`
//! — so the same payload signed by two binaries with different dependency
//! sets produced different bytes and different signatures. Run this module
//! under either selection and every literal below holds:
//!
//! ```text
//! cargo nextest run -p polyc-crypto # no preserve_order
//! cargo nextest run -p polyc-crypto -p polyc-payments # preserve_order on
//! ```
//!
//! Deliberately absent: `request_payload`. The `approval_request` event is
//! unsigned — the *response* is what carries a signature — so no signature
//! depends on its bytes, and its `preview` field is caller-supplied JSON
//! whose key order is the caller's. Nothing here can freeze that, and
//! nothing needs to.
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
/// Assert a canonical's bytes are exactly the frozen literal.
fn frozen(label: &str, got: &[u8], want: &str) {
assert_eq!(
String::from_utf8(got.to_vec()).unwrap(),
want,
"{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
);
}
fn caps() -> Vec<String> {
vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
}
fn transitions() -> Vec<CredentialKeyTransition> {
vec![
CredentialKeyTransition {
kid: "k1".to_owned(),
from: "absent".to_owned(),
to: "active".to_owned(),
},
CredentialKeyTransition {
kid: "k2".to_owned(),
from: "active".to_owned(),
to: "revoked".to_owned(),
},
]
}
const AT_MS: u64 = 1_750_000_000_000;
/// The routine canonicals' replay-idempotency key (`#1638`), sampled here
/// so every routine literal below is minted over one fixed pair.
const ROUTINE_CALL: &str = "call-1";
const ROUTINE_ARGS_HASH: &str = "abcd1234";
#[test]
fn approval_response_canonical_is_frozen() {
let caps = caps();
frozen(
"response_canonical (no approver)",
&response_canonical(
"req-1",
"paid_fetch",
"{\"a\":1}",
"{\"a\":2}",
true,
false,
&caps,
"persona:alice",
"",
"workspace-write",
"looks fine",
"ctx",
"conv-1",
"nonce-1",
),
NO_APPROVER_CANONICAL,
);
// A non-empty `approver` is appended after `nonce`, never sorted into
// the middle — the omit-when-empty rule of #1025, now a property of
// `ResponseCanonical`'s field order rather than of a map.
frozen(
"response_canonical (approver)",
&response_canonical(
"req-1",
"paid_fetch",
"{\"a\":1}",
"{\"a\":2}",
true,
true,
&caps,
"persona:alice",
"persona:admin",
"workspace-write",
"looks fine",
"ctx",
"conv-1",
"nonce-1",
),
APPROVER_CANONICAL,
);
let (full, sig, _pk) = response_payload(
"req-1",
"paid_fetch",
"{\"a\":1}",
"{\"a\":2}",
true,
true,
&caps,
"persona:alice",
"persona:admin",
"workspace-write",
"looks fine",
"ctx",
"conv-1",
"nonce-1",
&ApprovalSigner::from_seed(99),
);
frozen("response_payload", &full, RESPONSE_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), RESPONSE_SIG);
}
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"}"#;
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"}"#;
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"}"#;
const RESPONSE_SIG: &str = "aea101dfae5a1cf2568540d4d737b11764d060280049ddfb47d865d108e4897f3144fa3aaeaaa8e0c20a59abf5e18834b51b389c986349bf28391e68a5bc5300";
#[test]
fn excision_canonical_is_frozen() {
let signer = ApprovalSigner::from_seed(99);
frozen(
"excision_canonical",
&excision_canonical(
"conv-1",
EXCISION_SCOPE_CASCADE,
&[17, 23, 40],
"persona:alice",
"prompt injection",
),
EXCISION_CANONICAL_LIT,
);
let (full, sig, _) = excision_payload(
"conv-1",
EXCISION_SCOPE_CASCADE,
&[17, 23, 40],
"persona:alice",
"prompt injection",
&signer,
);
frozen("excision_payload", &full, EXCISION_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), EXCISION_SIG_LIT);
}
#[test]
fn grant_replay_canonical_is_frozen() {
let signer = ApprovalSigner::from_seed(99);
let caps = caps();
frozen(
"grant_replay_canonical",
&grant_replay_canonical(
"conv-1",
"turn-8",
"paid_fetch",
"cafe",
&caps,
"sha256:abc",
),
GRANT_REPLAY_CANONICAL_LIT,
);
let (full, sig, _) = grant_replay_payload(
"conv-1",
"turn-8",
"paid_fetch",
"cafe",
&caps,
"sha256:abc",
&signer,
);
frozen("grant_replay_payload", &full, GRANT_REPLAY_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), GRANT_REPLAY_SIG_LIT);
}
#[test]
fn deferred_and_mutation_canonicals_are_frozen() {
let signer = ApprovalSigner::from_seed(99);
let (full, sig, _) = deferred_payload("req-1", "conv-1", "needs more detail", &signer);
frozen("deferred_payload", &full, DEFERRED_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), DEFERRED_SIG_LIT);
let (full, sig, _) = mutation_payload(
"tool_input_rewrite",
"call-1",
"paid_fetch",
"conv-1",
"before-args",
"after-args",
&signer,
);
frozen("mutation_payload", &full, MUTATION_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), MUTATION_SIG_LIT);
}
#[test]
fn receipt_canonicals_are_frozen() {
let signer = ApprovalSigner::from_seed(99);
let fields = ReceiptPayload {
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",
// v1/v2 canonicals don't cover payer attribution, so these are
// never read by either builder below — present only because
// `ReceiptPayload` names them for every construction site.
payer_kind: "",
paying_account: "",
};
frozen(
"canonical_json_v2",
&canonical_bytes(&fields.canonical_json_v2()),
RECEIPT_V2_CANONICAL_LIT,
);
frozen(
"canonical_json_v1",
&canonical_bytes(&fields.canonical_json_v1()),
RECEIPT_V1_CANONICAL_LIT,
);
// `receipt_payload` always signs the CURRENT version (v3), so its
// pinning needs payer attribution and its own v3 literal — the v1/v2
// canonical pins above are untouched, since they call the frozen
// per-version builders directly rather than `receipt_payload`.
let fields_v3 = ReceiptPayload {
payer_kind: "linked_wallet",
paying_account: "0xpayer-frozen",
..fields
};
frozen(
"canonical_json_v3",
&canonical_bytes(&fields_v3.canonical_json_v3()),
RECEIPT_V3_CANONICAL_LIT,
);
let (full, sig, _) = receipt_payload(&fields_v3, &signer);
frozen("receipt_payload", &full, RECEIPT_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), RECEIPT_SIG_LIT);
}
#[test]
fn resolve_token_canonical_is_frozen() {
let signer = ApprovalSigner::from_seed(99);
frozen(
"resolve_token_canonical",
&resolve_token_canonical("req-1", "conv-1", AT_MS),
RESOLVE_TOKEN_CANONICAL_LIT,
);
assert_eq!(
mint_resolve_token("req-1", "conv-1", AT_MS, &signer),
RESOLVE_TOKEN_LIT,
"a minted resolve token's bytes are frozen — a token is hex of the whole object"
);
}
#[test]
fn admin_model_change_canonical_is_frozen() {
let signer = ApprovalSigner::from_seed(99);
frozen(
"admin_model_change_canonical",
&admin_model_change_canonical(
"admin:root",
"prov-a",
"model-a",
"prov-b",
"model-b",
AT_MS,
),
ADMIN_MODEL_CANONICAL_LIT,
);
let (full, sig, _) = admin_model_change_payload(
"admin:root",
"prov-a",
"model-a",
"prov-b",
"model-b",
AT_MS,
&signer,
);
frozen("admin_model_change_payload", &full, ADMIN_MODEL_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), ADMIN_MODEL_SIG_LIT);
}
/// The one canonical here that nests an object: each `transitions` element
/// is its own `{kid, from, to}`, and its key order was map-dependent for
/// exactly the same reason the outer object's was.
#[test]
fn credential_change_canonical_is_frozen() {
let signer = ApprovalSigner::from_seed(99);
let transitions = transitions();
frozen(
"credential_change_canonical",
&credential_change_canonical(
"credential_enrolled",
"admin:root",
"edge-1",
"k1",
"edge, admin",
&transitions,
AT_MS,
),
CREDENTIAL_CANONICAL_LIT,
);
let (full, sig, _) = credential_change_payload(
"credential_enrolled",
"admin:root",
"edge-1",
"k1",
"edge, admin",
&transitions,
AT_MS,
&signer,
);
frozen("credential_change_payload", &full, CREDENTIAL_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), CREDENTIAL_SIG_LIT);
}
#[test]
fn routine_audit_canonicals_are_frozen() {
let signer = ApprovalSigner::from_seed(99);
frozen(
"routine_created_canonical",
&routine_created_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
),
ROUTINE_CREATED_CANONICAL_LIT,
);
let (full, sig, _) = routine_created_payload(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
&signer,
);
frozen(
"routine_created_payload",
&full,
ROUTINE_CREATED_PAYLOAD_LIT,
);
assert_eq!(crate::hex::lower(&sig), ROUTINE_CREATED_SIG_LIT);
frozen(
"routine_paused_canonical (reason)",
&routine_paused_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
Some("too noisy"),
),
ROUTINE_PAUSED_SOME_LIT,
);
frozen(
"routine_paused_canonical (no reason)",
&routine_paused_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
None,
),
ROUTINE_PAUSED_NONE_LIT,
);
let (full, sig, _) = routine_paused_payload(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
Some("too noisy"),
&signer,
);
frozen("routine_paused_payload", &full, ROUTINE_PAUSED_PAYLOAD_LIT);
assert_eq!(crate::hex::lower(&sig), ROUTINE_PAUSED_SIG_LIT);
frozen(
"routine_resumed_canonical",
&routine_resumed_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
),
ROUTINE_RESUMED_CANONICAL_LIT,
);
let (full, sig, _) = routine_resumed_payload(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
&signer,
);
frozen(
"routine_resumed_payload",
&full,
ROUTINE_RESUMED_PAYLOAD_LIT,
);
assert_eq!(crate::hex::lower(&sig), ROUTINE_RESUMED_SIG_LIT);
frozen(
"routine_deleted_canonical",
&routine_deleted_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
),
ROUTINE_DELETED_CANONICAL_LIT,
);
let (full, sig, _) = routine_deleted_payload(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
AT_MS,
&signer,
);
frozen(
"routine_deleted_payload",
&full,
ROUTINE_DELETED_PAYLOAD_LIT,
);
assert_eq!(crate::hex::lower(&sig), ROUTINE_DELETED_SIG_LIT);
frozen(
"routine_scope_changed_canonical",
&routine_scope_changed_canonical(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
"public",
AT_MS,
),
ROUTINE_SCOPE_CHANGED_CANONICAL_LIT,
);
let (full, sig, _) = routine_scope_changed_payload(
"r-1",
"persona:alice",
"conv-1",
ROUTINE_CALL,
ROUTINE_ARGS_HASH,
"public",
AT_MS,
&signer,
);
frozen(
"routine_scope_changed_payload",
&full,
ROUTINE_SCOPE_CHANGED_PAYLOAD_LIT,
);
assert_eq!(crate::hex::lower(&sig), ROUTINE_SCOPE_CHANGED_SIG_LIT);
}
const EXCISION_CANONICAL_LIT: &str = r#"{"conversation_id":"conv-1","scope":"cascade","positions":[17,23,40],"requested_by":"persona:alice","reason":"prompt injection"}"#;
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"}"#;
const EXCISION_SIG_LIT: &str = "6dbe6aef29cd78faf5044e23aa9f49ba281cbda46b06ffdbba8c5b60b7de67f72beafd3fc83710a11fc8bd37a71da51dbfd722eabbc5bd9f509dfb081a35700b";
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"}"#;
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"}"#;
const GRANT_REPLAY_SIG_LIT: &str = "b98f27ea001616a9c5edf49b71df1c65eaf391462572e1526f8be6ac132bed0aeae56644cdaa77b3112bfffc0d2dc3ac849713ec32563654ceaeb741dfc8550a";
const DEFERRED_PAYLOAD_LIT: &str = r#"{"request_id":"req-1","conversation_id":"conv-1","reason":"needs more detail","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506"}"#;
const DEFERRED_SIG_LIT: &str = "95d968b15da8f3b3f248dbf05b7b819c6f7d969c9c6b3470b8ec0657d63ef1dd38e9d0fdaed43aee7b94ec16828d9ac74634940afebe5a5623dd2561c5afd506";
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"}"#;
const MUTATION_SIG_LIT: &str = "c1386859a36e81c0dd0d256a1fb27cd02fb73e59048687d10ec74546b4a95e485aa2f045dab568a4432da72bd7cbcbc0789ee6ce4799017ecc8aaf62efd42e03";
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"}"#;
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"}"#;
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"}"#;
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"}"#;
const RECEIPT_SIG_LIT: &str = "eec04c909fc7ae838bde2e2ab52144a523fe23939e16c355e12420d2d4b354ba528e5162558d9565ce930aac35a8172bfb0801e0c39d80f2864c948181eefe00";
const RESOLVE_TOKEN_CANONICAL_LIT: &str =
r#"{"request_id":"req-1","conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
const RESOLVE_TOKEN_LIT: &str = "7b22726571756573745f6964223a227265712d31222c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223234303734613638613638396335613137643037343334353530376535653437626263623531366165616232633363376566656431363435663235383164643665313165616237396466343537333463646136623230306463663938393334333630306365366161303064653161643234633534363733356462336236653034227d";
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}"#;
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"}"#;
const ADMIN_MODEL_SIG_LIT: &str = "891b2c43aba8d251ad4eb08a70d0cc3791469a4d3995c803e0baae01bfd4a8dc618433f7235ff9746ba1d83d537362d2e490608fd21f3f156794f853d874f903";
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}"#;
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"}"#;
const CREDENTIAL_SIG_LIT: &str = "e5384d9ba22a1497ce9beba54c62f2c234bd0c84ba9c2f3ded6fff8e30c01a74d0485683aab1196e64a1c567b830e34fa3f715d13f7b8746a66dfe80b18a4c0b";
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}"#;
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"}"#;
const ROUTINE_CREATED_SIG_LIT: &str = "65ea620c0447550151ebcedaeb325595d8d10a8ec2f39a98d9215042c636d464ed62f82a88378d254fbf4803a2e7b569b26c78c45e74e498785496ed3701400e";
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"}"#;
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}"#;
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"}"#;
const ROUTINE_PAUSED_SIG_LIT: &str = "9dca477fdc2536aa2b0bd1e7fd4a099e880c5a03742c0b4741fe7d2aa62456aa1b96b137d7c9b211f2721a88b72eb98d1694e06c1744e2fcfdf5f3fed5f97e04";
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}"#;
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"}"#;
const ROUTINE_RESUMED_SIG_LIT: &str = "1a034d680b5a2f034adad6a2034c8f209fdc8336c89541b51edaf889de5066ad59e54af4a8f089ca14720d421c7d85b33d5b75fd3d56c408708ca2812b519b06";
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}"#;
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"}"#;
const ROUTINE_DELETED_SIG_LIT: &str = "c944bb5fea00c41e09ded223f4587d1b13359b9c5a7e38204f3734abafe8dbc747edc7b88dc4b5cab664711c0b1a77f2be928baecdc50d78b4b282812687f50f";
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}"#;
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"}"#;
const ROUTINE_SCOPE_CHANGED_SIG_LIT: &str = "e9b66d0c4c738965f873e85e7d2c3c551a4a41778f289cbb1338be7ab3624705e90c01e3f15ca5df130ee8b075c9a91b0dcb01b9bc20c7a48c5364f9a482210e";
}
/// Drives `polyc-conformance-vectors::PAYMENT_REFUSAL`
/// (`crates/conformance-vectors/vectors/payment-refusal.json`) — the pinned,
/// cross-language conformance vectors for the durable payment-refusal event
/// (`#2090`, INV-W5).
#[cfg(test)]
mod payment_refusal_conformance_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use serde_json::Value;
use super::*;
fn vectors() -> Value {
serde_json::from_str(polyc_conformance_vectors::PAYMENT_REFUSAL).expect("valid JSON")
}
fn signer() -> ApprovalSigner {
let v = vectors();
let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
}
/// A vector's `fields` object, decoded into owned `String`s — a named
/// struct rather than a positional tuple, the same swap-proofing
/// [`RefusalClassification`] (`crates/control-plane/src/harness_dialer.rs`)
/// applies to the production recording seam.
struct VectorFields {
kind: String,
reason: String,
reason_detail: String,
merchant_host: String,
requested_base_units: String,
permitted_base_units: String,
tool_call_id: String,
subject: String,
timestamp: String,
}
impl VectorFields {
fn from_json(v: &Value) -> Self {
let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
Self {
kind: field("kind"),
reason: field("reason"),
reason_detail: field("reason_detail"),
merchant_host: field("merchant_host"),
requested_base_units: field("requested_base_units"),
permitted_base_units: field("permitted_base_units"),
tool_call_id: field("tool_call_id"),
subject: field("subject"),
timestamp: field("timestamp"),
}
}
fn as_payload(&self) -> RefusalPayload<'_> {
RefusalPayload {
kind: self.kind.as_str(),
reason: self.reason.as_str(),
reason_detail: self.reason_detail.as_str(),
merchant_host: self.merchant_host.as_str(),
requested_base_units: self.requested_base_units.as_str(),
permitted_base_units: self.permitted_base_units.as_str(),
tool_call_id: self.tool_call_id.as_str(),
subject: self.subject.as_str(),
timestamp: self.timestamp.as_str(),
}
}
}
#[test]
fn the_known_good_vector_reproduces_its_bytes_and_signature() {
let v = vectors();
let signer = signer();
let vf = VectorFields::from_json(&v["vector"]);
let fields = vf.as_payload();
let expected_canonical = crate::hex::decode(
v["vector"]["expected_canonical_bytes_hex"]
.as_str()
.unwrap(),
)
.unwrap();
assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
let (payload, sig, _pk) = refusal_payload(&fields, &signer);
assert_eq!(
crate::hex::lower(&sig),
v["vector"]["expected_signature_hex"].as_str().unwrap()
);
let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
assert_eq!(verified.reason, vf.reason);
assert_eq!(verified.requested_base_units, vf.requested_base_units);
assert_eq!(verified.permitted_base_units, vf.permitted_base_units);
assert_eq!(verified.timestamp, vf.timestamp);
}
/// INV-W5b: an unrecognized `reason` tag still verifies — the crypto
/// layer never special-cases `reason`'s value.
#[test]
fn the_unknown_reason_vector_still_verifies() {
let v = vectors();
let signer = signer();
let vf = VectorFields::from_json(&v["unknown_reason_vector"]);
let fields = vf.as_payload();
let (payload, sig, _pk) = refusal_payload(&fields, &signer);
assert_eq!(
crate::hex::lower(&sig),
v["unknown_reason_vector"]["expected_signature_hex"]
.as_str()
.unwrap()
);
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_refusal(&payload, &trusted).expect("verifies");
assert_eq!(verified.reason, "some_future_reason_v7");
}
#[test]
fn the_tampered_reason_vector_must_not_verify() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "tampered-reason")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_refusal(payload, &trusted).is_none());
}
/// The untrusted-signer `must_not_verify` case ships a REAL, executable
/// payload (`#2090` review, item 6) — not description-only: a real
/// second signer minted a genuinely valid signature over it (it
/// verifies against ITS OWN embedded key), and the case proves it is
/// rejected ONLY because that key sits outside the main vector's
/// trusted-signer allow-list, never because anything is malformed.
#[test]
fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "untrusted-signer")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
// Self-consistent: verifies against its OWN embedded key.
let own_key =
crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
assert!(
verify_signed_refusal(payload, &[own_key]).is_some(),
"the untrusted-signer vector's payload must be internally consistent — a \
genuinely valid signature over an out-of-allowlist key, not a malformed one"
);
// Rejected against the MAIN vector's trusted-signer set.
let main_trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_refusal(payload, &main_trusted).is_none());
}
}
/// Drives `polyc-conformance-vectors::WALLET_LINK_LIFECYCLE`
/// (`crates/conformance-vectors/vectors/wallet-link-lifecycle.json`) — the
/// pinned, cross-language conformance vectors for the durable
/// wallet-link-lifecycle event (`#2123`).
#[cfg(test)]
mod wallet_link_lifecycle_conformance_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use serde_json::Value;
use super::*;
fn vectors() -> Value {
serde_json::from_str(polyc_conformance_vectors::WALLET_LINK_LIFECYCLE).expect("valid JSON")
}
fn signer() -> ApprovalSigner {
let v = vectors();
let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
}
/// A vector's `fields` object, decoded into owned `String`s — a named
/// struct rather than a positional tuple, the same swap-proofing
/// [`RefusalPayload`]'s own vector test struct applies.
struct VectorFields {
kind: String,
transition: String,
subject: String,
wallet_address: String,
currency: String,
chain_id: String,
limit_base_units: String,
limit_human: String,
period_secs: String,
expiry_unix: String,
recipients: String,
conversation_id: String,
timestamp: String,
}
impl VectorFields {
fn from_json(v: &Value) -> Self {
let field = |name: &str| v["fields"][name].as_str().unwrap().to_owned();
Self {
kind: field("kind"),
transition: field("transition"),
subject: field("subject"),
wallet_address: field("wallet_address"),
currency: field("currency"),
chain_id: field("chain_id"),
limit_base_units: field("limit_base_units"),
limit_human: field("limit_human"),
period_secs: field("period_secs"),
expiry_unix: field("expiry_unix"),
recipients: field("recipients"),
conversation_id: field("conversation_id"),
timestamp: field("timestamp"),
}
}
fn as_payload(&self) -> WalletLinkLifecyclePayload<'_> {
WalletLinkLifecyclePayload {
kind: self.kind.as_str(),
transition: self.transition.as_str(),
subject: self.subject.as_str(),
wallet_address: self.wallet_address.as_str(),
currency: self.currency.as_str(),
chain_id: self.chain_id.as_str(),
limit_base_units: self.limit_base_units.as_str(),
limit_human: self.limit_human.as_str(),
period_secs: self.period_secs.as_str(),
expiry_unix: self.expiry_unix.as_str(),
recipients: self.recipients.as_str(),
conversation_id: self.conversation_id.as_str(),
timestamp: self.timestamp.as_str(),
}
}
}
fn assert_vector_reproduces(vector_key: &str) {
let v = vectors();
let signer = signer();
let vf = VectorFields::from_json(&v[vector_key]);
let fields = vf.as_payload();
let expected_canonical = crate::hex::decode(
v[vector_key]["expected_canonical_bytes_hex"]
.as_str()
.unwrap(),
)
.unwrap();
assert_eq!(canonical_bytes(&fields.canonical()), expected_canonical);
let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
assert_eq!(
crate::hex::lower(&sig),
v[vector_key]["expected_signature_hex"].as_str().unwrap()
);
let expected_full = v[vector_key]["expected_full_payload"].as_str().unwrap();
assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
assert_eq!(verified.transition, vf.transition);
assert_eq!(verified.limit_base_units, vf.limit_base_units);
assert_eq!(verified.limit_human, vf.limit_human);
assert_eq!(verified.period_secs, vf.period_secs);
assert_eq!(verified.expiry_unix, vf.expiry_unix);
assert_eq!(verified.timestamp, vf.timestamp);
}
#[test]
fn the_known_good_linked_vector_reproduces_its_bytes_and_signature() {
assert_vector_reproduces("linked_vector");
}
/// `renewed` leaves `period_secs`/`expiry_unix` empty (TIP-1011's
/// `updateSpendingLimit` cannot change either) — this vector pins that
/// the recorder never invents values for them.
#[test]
fn the_known_good_renewed_vector_reproduces_its_bytes_and_signature() {
assert_vector_reproduces("renewed_vector");
}
/// `revoked` leaves every spend-policy field empty — no cap survives a
/// dead link.
#[test]
fn the_known_good_revoked_vector_reproduces_its_bytes_and_signature() {
assert_vector_reproduces("revoked_vector");
}
/// INV-W5b analogue: an unrecognized `transition` tag still verifies —
/// the crypto layer never special-cases `transition`'s value.
#[test]
fn the_unknown_transition_vector_still_verifies() {
let v = vectors();
let signer = signer();
let vf = VectorFields::from_json(&v["unknown_transition_vector"]);
let fields = vf.as_payload();
let (payload, sig, _pk) = wallet_link_lifecycle_payload(&fields, &signer);
assert_eq!(
crate::hex::lower(&sig),
v["unknown_transition_vector"]["expected_signature_hex"]
.as_str()
.unwrap()
);
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_wallet_link_lifecycle(&payload, &trusted).expect("verifies");
assert_eq!(verified.transition, "some_future_transition_v7");
}
#[test]
fn the_tampered_transition_vector_must_not_verify() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "tampered-transition")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_wallet_link_lifecycle(payload, &trusted).is_none());
}
/// The untrusted-signer `must_not_verify` case ships a REAL, executable
/// payload — not description-only: a real second signer minted a
/// genuinely valid signature over it (it verifies against ITS OWN
/// embedded key), and the case proves it is rejected ONLY because that
/// key sits outside the main vector's trusted-signer allow-list, never
/// because anything is malformed.
#[test]
fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "untrusted-signer")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
// Self-consistent: verifies against its OWN embedded key.
let own_key =
crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
assert!(
verify_signed_wallet_link_lifecycle(payload, &[own_key]).is_some(),
"the untrusted-signer vector's payload must be internally consistent — a \
genuinely valid signature over an out-of-allowlist key, not a malformed one"
);
// Rejected against the MAIN vector's trusted-signer set.
let main_trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_wallet_link_lifecycle(payload, &main_trusted).is_none());
}
}
/// Drives `polyc-conformance-vectors::PAYMENT_RECEIPT`
/// (`crates/conformance-vectors/vectors/payment-receipt.json`) — the pinned,
/// cross-language conformance vectors for the durable payment-receipt event's
/// v3 payer-attribution addition (`#2099`). Mirrors
/// `payment_refusal_conformance_tests`' shape exactly.
#[cfg(test)]
mod payment_receipt_conformance_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use serde_json::Value;
use super::*;
fn vectors() -> Value {
serde_json::from_str(polyc_conformance_vectors::PAYMENT_RECEIPT).expect("valid JSON")
}
fn signer() -> ApprovalSigner {
let v = vectors();
let key_hex = v["signer"]["ed25519_private_key_hex"].as_str().unwrap();
ApprovalSigner::from_key_bytes(&crate::hex::decode(key_hex).unwrap()).expect("valid key")
}
/// A vector's `fields` object, decoded into owned `String`s — named
/// fields, no positional tuple, matching [`ReceiptPayload`]'s own
/// swap-proofing reasoning.
struct VectorFields {
kind: String,
reference: String,
amount: String,
currency: String,
recipient: String,
method: String,
timestamp: String,
tool_call_id: String,
approval_pos: String,
approved_args_hash: String,
subject: String,
payer_kind: String,
paying_account: String,
}
impl VectorFields {
/// `payer_kind`/`paying_account` default to `""` when a vector's
/// `fields` object omits them entirely (the frozen v2 vector, which
/// predates the v3 addition and never had them to begin with).
fn from_json(v: &Value) -> Self {
let field = |name: &str| {
v["fields"]
.get(name)
.and_then(Value::as_str)
.unwrap_or("")
.to_owned()
};
Self {
kind: field("kind"),
reference: field("reference"),
amount: field("amount"),
currency: field("currency"),
recipient: field("recipient"),
method: field("method"),
timestamp: field("timestamp"),
tool_call_id: field("tool_call_id"),
approval_pos: field("approval_pos"),
approved_args_hash: field("approved_args_hash"),
subject: field("subject"),
payer_kind: field("payer_kind"),
paying_account: field("paying_account"),
}
}
fn as_payload(&self) -> ReceiptPayload<'_> {
ReceiptPayload {
kind: self.kind.as_str(),
reference: self.reference.as_str(),
amount: self.amount.as_str(),
currency: self.currency.as_str(),
recipient: self.recipient.as_str(),
method: self.method.as_str(),
timestamp: self.timestamp.as_str(),
tool_call_id: self.tool_call_id.as_str(),
approval_pos: self.approval_pos.as_str(),
approved_args_hash: self.approved_args_hash.as_str(),
subject: self.subject.as_str(),
payer_kind: self.payer_kind.as_str(),
paying_account: self.paying_account.as_str(),
}
}
}
#[test]
fn the_known_good_v3_vector_reproduces_its_bytes_and_signature() {
let v = vectors();
let signer = signer();
let vf = VectorFields::from_json(&v["vector"]);
let fields = vf.as_payload();
let expected_canonical = crate::hex::decode(
v["vector"]["expected_canonical_bytes_hex"]
.as_str()
.unwrap(),
)
.unwrap();
assert_eq!(
canonical_bytes(&fields.canonical_json_v3()),
expected_canonical
);
let (payload, sig, _pk) = receipt_payload(&fields, &signer);
assert_eq!(
crate::hex::lower(&sig),
v["vector"]["expected_signature_hex"].as_str().unwrap()
);
let expected_full = v["vector"]["expected_full_payload"].as_str().unwrap();
assert_eq!(String::from_utf8(payload.clone()).unwrap(), expected_full);
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_receipt(&payload, &trusted).expect("verifies");
assert_eq!(verified.version, 3);
assert_eq!(verified.reference, vf.reference);
assert_eq!(verified.payer_kind, vf.payer_kind);
assert_eq!(verified.paying_account, vf.paying_account);
}
/// The frozen v2 vector — signed before payer attribution existed — must
/// still verify byte-for-byte, and its payer fields must decode as
/// explicit unknown (`""`), never inferred.
#[test]
fn the_frozen_v2_vector_still_verifies_with_payer_unknown() {
let v = vectors();
let entry = &v["frozen_v2_vector"];
let full = entry["expected_full_payload"].as_str().unwrap();
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
let verified = verify_signed_receipt(full.as_bytes(), &trusted)
.expect("the frozen v2 vector verifies");
assert_eq!(verified.version, 2);
assert_eq!(verified.reference, "tx-conformance-v2");
assert!(verified.payer_kind.is_empty());
assert!(verified.paying_account.is_empty());
}
#[test]
fn the_tampered_payer_vector_must_not_verify() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "tampered-payer")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
let trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_receipt(payload, &trusted).is_none());
}
/// The untrusted-signer `must_not_verify` case ships a REAL, executable
/// payload: a real second signer minted a genuinely valid signature over
/// it (it verifies against its own embedded key), and the case proves it
/// is rejected ONLY because that key sits outside the main vector's
/// trusted-signer allow-list, never because anything is malformed.
#[test]
fn the_untrusted_signer_vector_must_not_verify_against_the_main_signer() {
let v = vectors();
let entry = v["must_not_verify"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "untrusted-signer")
.unwrap();
let payload = entry["full_payload"].as_str().unwrap().as_bytes();
let own_key =
crate::hex::decode(entry["signed_by_public_key_hex"].as_str().unwrap()).unwrap();
assert!(
verify_signed_receipt(payload, &[own_key]).is_some(),
"the untrusted-signer vector's payload must be internally consistent — a \
genuinely valid signature over an out-of-allowlist key, not a malformed one"
);
let main_trusted = vec![
crate::hex::decode(v["signer"]["ed25519_public_key_hex"].as_str().unwrap()).unwrap(),
];
assert!(verify_signed_receipt(payload, &main_trusted).is_none());
}
}