Skip to main content

polyc_crypto/
question.rs

1//! `ask_question` (`#1660`) signed-answer canonical, verification, and the
2//! per-question correlation-token contract.
3//!
4//! A **sibling to the HITL approval flow's `approval_response` machinery in
5//! [`crate::approval`], not a reuse of it** — see issue #1660 and its parent
6//! PRD #1659. The trust shape is identical (THIN signing: an edge sends an
7//! *unsigned* answer intent, the control plane resolves and signs
8//! server-side, the harness re-verifies before trusting the answer) but the
9//! signed canonical is its own, with its own field set and its own key names
10//! (`question_call_id`/`question_index`, not `request_id`/`tool_name`), so an
11//! approval signature can never verify as a question answer and vice versa.
12//! The [`ApprovalSigner`] role intentionally covers this human-decision
13//! answer family as well as tool approvals. Session, turn-read, web-session
14//! grants, and journal roots use different roles.
15
16use std::collections::HashSet;
17
18use serde::Serialize;
19use serde_json::Value;
20
21use crate::approval::ApprovalSigner;
22use crate::signed::{Envelope, canonical_bytes};
23use crate::verify;
24
25/// The signed `state` value for a question a human explicitly answered by
26/// picking an option.
27pub const ANSWERED_STATE: &str = "answered";
28/// The signed `state` value for a question a human explicitly declined to
29/// choose ("use your own judgment") — distinct from [`ANSWERED_STATE`] so the
30/// agent never mistakes a decline for a real answer.
31pub const DECLINED_STATE: &str = "declined";
32/// The signed `state` value for a question nobody answered before its idle
33/// window elapsed.
34///
35/// The control plane auto-resolved it to the recommended option (or the
36/// first option if none was marked recommended). Signed and unforgeable, so
37/// the agent is told this was an assumption, not a genuine human answer.
38pub const AUTO_RESOLVED_STATE: &str = "auto_resolved";
39
40/// TTL for a minted answer token (`#1660`).
41///
42/// Mirrors [`crate::approval::RESOLVE_TOKEN_TTL_MS`]'s reasoning: generous
43/// enough that a human has time to see and answer the question card (which
44/// can sit in a chat thread for hours or days), short enough that a token
45/// captured off a stale card cannot answer the question indefinitely.
46pub const ANSWER_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
47
48/// The single source for the signed `question_response` canonical JSON. Both
49/// the signing path ([`answer_payload`]) and the verifying paths
50/// ([`verify_signed_answer`], [`verify_wire_answer`]) route through this so
51/// the covered field set/order cannot drift. Adding/renaming/reordering here
52/// is a signed-contract change.
53///
54/// Deliberately uses `question_call_id`/`question_index` rather than the
55/// approval canonical's `request_id`/`tool_name` key names: even though both
56/// canonicals are JSON objects an attacker might try to splice together, no
57/// approval signature can ever verify against this shape (the signed BYTES
58/// differ) and no answer signature can ever verify as an approval.
59///
60/// `turn_id` is the turn that emitted this question occurrence: a provider
61/// re-mints `(call_id, index)` across turns, so the turn is what makes the
62/// answer name one occurrence rather than every occurrence that ever shared
63/// the pair. `question_args_json` is the `ask_question` call's full raw
64/// arguments (every question in the call, not just this one) — the audit binding a verifier
65/// matches against [`VerifiedQuestionAnswer::binds_question`], mirroring how
66/// the approval canonical binds `args_json`. `conversation_id` and `nonce`
67/// make the answer a single-use, conversation-bound capability, exactly like
68/// `#370`'s approval-response token.
69#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
70fn answer_canonical(
71    turn_id: &str,
72    call_id: &str,
73    index: u32,
74    question_args_json: &str,
75    state: &str,
76    selected_index: Option<u32>,
77    selected_label: &str,
78    answered_by: &str,
79    conversation_id: &str,
80    nonce: &str,
81) -> Vec<u8> {
82    canonical_bytes(&answer_fields(
83        turn_id,
84        call_id,
85        index,
86        question_args_json,
87        state,
88        selected_index,
89        selected_label,
90        answered_by,
91        conversation_id,
92        nonce,
93    ))
94}
95
96/// The signed `question_response` field set, in its frozen order.
97///
98/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
99/// 0009. `selected_index` is serialized as an explicit `null` for a decline
100/// rather than skipped, which is what the `json!` object this replaced emitted
101/// and therefore what every already-signed `question_response` in a
102/// deployment's log covers.
103#[derive(Serialize)]
104struct AnswerCanonical<'a> {
105    // Absent only on durable answers written before occurrence identity. An
106    // empty turn is skipped, so a historical record's canonical bytes — and
107    // therefore its signature — are reproduced exactly.
108    #[serde(skip_serializing_if = "str::is_empty")]
109    turn_id: &'a str,
110    question_call_id: &'a str,
111    question_index: u32,
112    question_args_json: &'a str,
113    state: &'a str,
114    selected_index: Option<u32>,
115    selected_label: &'a str,
116    answered_by: &'a str,
117    conversation_id: &'a str,
118    nonce: &'a str,
119}
120
121/// The answer body both [`answer_canonical`] and [`answer_payload`] build, so
122/// the signed field list exists once rather than in two hand-maintained copies
123/// (`#1845`).
124#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
125const fn answer_fields<'a>(
126    turn_id: &'a str,
127    call_id: &'a str,
128    index: u32,
129    question_args_json: &'a str,
130    state: &'a str,
131    selected_index: Option<u32>,
132    selected_label: &'a str,
133    answered_by: &'a str,
134    conversation_id: &'a str,
135    nonce: &'a str,
136) -> AnswerCanonical<'a> {
137    AnswerCanonical {
138        turn_id,
139        question_call_id: call_id,
140        question_index: index,
141        question_args_json,
142        state,
143        selected_index,
144        selected_label,
145        answered_by,
146        conversation_id,
147        nonce,
148    }
149}
150
151/// JSON payload for a `question_response` event.
152///
153/// The signature commits to the canonical (unsigned) JSON form: the question
154/// identity (`call_id`, `index`, `question_args_json`), the resolution
155/// (`state`/`selected_index`/`selected_label`), who resolved it
156/// (`answered_by`, empty for [`AUTO_RESOLVED_STATE`]), and the
157/// conversation/nonce binding. `signed_by`/`signature_hex` are populated
158/// *after* the signer runs and are NOT covered by the signature.
159///
160/// Returns `(full_payload_bytes, signature_bytes, public_key_bytes)`.
161#[must_use]
162#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
163pub fn answer_payload(
164    turn_id: &str,
165    call_id: &str,
166    index: u32,
167    question_args_json: &str,
168    state: &str,
169    selected_index: Option<u32>,
170    selected_label: &str,
171    answered_by: &str,
172    conversation_id: &str,
173    nonce: &str,
174    signer: &ApprovalSigner,
175) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
176    Envelope::seal(
177        answer_fields(
178            turn_id,
179            call_id,
180            index,
181            question_args_json,
182            state,
183            selected_index,
184            selected_label,
185            answered_by,
186            conversation_id,
187            nonce,
188        ),
189        signer.as_signer(),
190    )
191}
192
193/// A verified, decoded `question_response` payload.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct VerifiedQuestionAnswer {
196    /// Turn that emitted this occurrence. Empty only on durable answers
197    /// written before occurrence identity was introduced.
198    pub turn_id: String,
199    /// The `ask_question` tool-call id this answer resolves.
200    pub call_id: String,
201    /// This answer's question index within its call's `questions` array.
202    pub index: u32,
203    /// The bound raw `ask_question` call arguments (every question in the
204    /// call) — the audit binding [`Self::binds_question`] matches
205    /// byte-for-byte.
206    pub question_args_json: String,
207    /// One of [`ANSWERED_STATE`], [`DECLINED_STATE`], or
208    /// [`AUTO_RESOLVED_STATE`].
209    pub state: String,
210    /// The chosen option's index, when `state == ANSWERED_STATE` or
211    /// `AUTO_RESOLVED_STATE`. `None` for a decline.
212    pub selected_index: Option<u32>,
213    /// The chosen option's label, mirrored alongside the index so a consumer
214    /// never has to re-resolve it against the (possibly since-compacted)
215    /// original question payload.
216    pub selected_label: String,
217    /// Who answered — empty for [`AUTO_RESOLVED_STATE`] (nobody did).
218    pub answered_by: String,
219    /// The conversation this answer was signed for, covered by the
220    /// signature so a token signed for one conversation cannot be replayed
221    /// into another.
222    pub conversation_id: String,
223    /// Per-answer unique value, covered by the signature. A consumer
224    /// records it on use so the answer cannot be re-applied once spent
225    /// (single-use, mirrors `#370`).
226    pub nonce: String,
227    /// The verified signer's public key (encoded).
228    pub signer_public_key: Vec<u8>,
229    /// Hex-encoded ed25519 public key of the signer — the same bytes as
230    /// [`Self::signer_public_key`], kept alongside it in the wire-ready
231    /// encoding so a consumer forwarding this answer onward (the control
232    /// plane's outstanding-answer collector, to the harness) never
233    /// re-derives the hex itself. Mirrors
234    /// `polyc_crypto::approval::DecodedResponse::signer_pk_hex`.
235    pub signer_pk_hex: String,
236    /// Hex-encoded ed25519 signature over the canonical answer tuple —
237    /// forwarded onward so the harness can independently re-verify (THIN
238    /// signing) rather than trust the control plane's own verification.
239    /// Mirrors `polyc_crypto::approval::DecodedResponse::signature_hex`.
240    pub signature_hex: String,
241}
242
243impl VerifiedQuestionAnswer {
244    /// Whether this verified answer is bound to the EXACT question
245    /// `(call_id, index, question_args_json)` — the args-binding a resume
246    /// path must check before applying the answer, mirroring
247    /// [`crate::approval::VerifiedResponse::authorizes_call`].
248    #[must_use]
249    pub fn binds_question(&self, call_id: &str, index: u32, question_args_json: &str) -> bool {
250        self.call_id == call_id
251            && self.index == index
252            && self.question_args_json == question_args_json
253    }
254}
255
256/// Verify a persisted `question_response` payload.
257///
258/// Returns `Some(record)` if the signature checks out against the embedded
259/// public key (the caller is responsible for trusting that public key — see
260/// [`verify_signed_answer_pinned`]). Returns `None` if the payload is
261/// malformed, the hex fields don't decode, or the signature doesn't verify.
262#[must_use]
263pub fn verify_signed_answer(payload: &[u8]) -> Option<VerifiedQuestionAnswer> {
264    let v: Value = serde_json::from_slice(payload).ok()?;
265    // Durable answers written before occurrence identity carry their turn in
266    // the event kind only. Empty preserves their frozen canonical exactly.
267    let turn_id = v
268        .get("turn_id")
269        .and_then(Value::as_str)
270        .unwrap_or_default()
271        .to_owned();
272    let call_id = v.get("question_call_id")?.as_str()?.to_owned();
273    let index = u32::try_from(v.get("question_index")?.as_u64()?).ok()?;
274    let question_args_json = v.get("question_args_json")?.as_str()?.to_owned();
275    let state = v.get("state")?.as_str()?.to_owned();
276    let selected_index = match v.get("selected_index") {
277        Some(Value::Null) | None => None,
278        Some(x) => Some(u32::try_from(x.as_u64()?).ok()?),
279    };
280    let selected_label = v.get("selected_label")?.as_str()?.to_owned();
281    let answered_by = v.get("answered_by")?.as_str()?.to_owned();
282    let conversation_id = v.get("conversation_id")?.as_str()?.to_owned();
283    let nonce = v.get("nonce")?.as_str()?.to_owned();
284    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
285    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
286
287    let canonical = answer_canonical(
288        &turn_id,
289        &call_id,
290        index,
291        &question_args_json,
292        &state,
293        selected_index,
294        &selected_label,
295        &answered_by,
296        &conversation_id,
297        &nonce,
298    );
299    if verify(&pk, &canonical, &sig) {
300        Some(VerifiedQuestionAnswer {
301            turn_id,
302            call_id,
303            index,
304            question_args_json,
305            state,
306            selected_index,
307            selected_label,
308            answered_by,
309            conversation_id,
310            nonce,
311            signer_pk_hex: crate::hex::lower(&pk),
312            signature_hex: crate::hex::lower(&sig),
313            signer_public_key: pk,
314        })
315    } else {
316        None
317    }
318}
319
320/// Verify a persisted `question_response` payload against a **trusted-signer
321/// allow-list**.
322///
323/// Mirrors [`crate::approval::verify_signed_response_pinned`]: without this
324/// gate an attacker could sign a well-formed answer with their own key,
325/// embed it, and have it honored. Returns `None` (fail closed) on a
326/// malformed payload, an untrusted signer, or a bad signature.
327#[must_use]
328pub fn verify_signed_answer_pinned(
329    payload: &[u8],
330    trusted_signers: &[Vec<u8>],
331) -> Option<VerifiedQuestionAnswer> {
332    let verified = verify_signed_answer(payload)?;
333    if !trusted_signers
334        .iter()
335        .any(|k| k.as_slice() == verified.signer_public_key)
336    {
337        return None;
338    }
339    Some(verified)
340}
341
342/// Verify a persisted `question_response` as a SINGLE-USE, conversation-bound
343/// capability (invariant I3/I7), gated on a trusted-signer allow-list.
344///
345/// Returns the verified answer only when ALL hold: the embedded signer is
346/// trusted; the signature verifies; the signed `conversation_id` equals
347/// `conversation_id`; the signed `turn_id` names the occurrence `turn_id`
348/// (or is empty — see below); the signed `nonce` is non-empty AND not already
349/// in `consumed`. The caller MUST record the returned nonce into its
350/// `consumed` set before honoring the answer, so a second presentation is
351/// rejected.
352///
353/// The empty-turn carve-out is the durable-record rule, not a loophole: an
354/// answer signed before occurrence identity existed derives its turn from its
355/// own append-only event kind, which the caller supplies as `turn_id`. Every
356/// newly minted answer signs its turn, so it must name the occurrence it is
357/// applied to.
358#[must_use]
359pub fn verify_answer_capability<S: std::hash::BuildHasher>(
360    payload: &[u8],
361    conversation_id: &str,
362    turn_id: &str,
363    consumed: &HashSet<String, S>,
364    trusted_signers: &[Vec<u8>],
365) -> Option<VerifiedQuestionAnswer> {
366    let verified = verify_signed_answer_pinned(payload, trusted_signers)?;
367    if verified.conversation_id != conversation_id {
368        return None;
369    }
370    if !verified.turn_id.is_empty() && verified.turn_id != turn_id {
371        return None;
372    }
373    if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
374        return None;
375    }
376    Some(verified)
377}
378
379/// Verify a wire-form `question_response`, binding the answer to its full
380/// signed identity.
381///
382/// Used by the harness when it receives an answer over the wire and must
383/// confirm provenance AND identity before feeding it back into the turn as
384/// the `ask_question` call's result — the trust boundary this exists for:
385/// silent acceptance would let a compromised control plane forge a user's
386/// answer. Returns `true` only if `signer_pk_hex + signature_hex` validates
387/// against the canonical.
388#[must_use]
389#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
390pub fn verify_wire_answer(
391    turn_id: &str,
392    call_id: &str,
393    index: u32,
394    question_args_json: &str,
395    state: &str,
396    selected_index: Option<u32>,
397    selected_label: &str,
398    answered_by: &str,
399    conversation_id: &str,
400    nonce: &str,
401    signer_pk_hex: &str,
402    signature_hex: &str,
403) -> bool {
404    let Some(pk) = crate::hex::decode(signer_pk_hex) else {
405        return false;
406    };
407    let Some(sig) = crate::hex::decode(signature_hex) else {
408        return false;
409    };
410    let canonical = answer_canonical(
411        turn_id,
412        call_id,
413        index,
414        question_args_json,
415        state,
416        selected_index,
417        selected_label,
418        answered_by,
419        conversation_id,
420        nonce,
421    );
422    verify(&pk, &canonical, &sig)
423}
424
425// ── Per-question correlation token ──────────────────────────────────────────
426
427/// The canonical (signature-covered) form of an answer token (`#1660`).
428///
429/// Deliberately uses the SAME `question_call_id`/`question_index` key names
430/// [`answer_canonical`] uses, but a DIFFERENT key set than
431/// [`crate::approval`]'s `resolve_token_canonical` (`request_id` vs
432/// `question_call_id`) — an approval resolve token must never verify as an
433/// answer token and vice versa; [`verify_answer_token`]'s own test pins this
434/// cross-domain rejection.
435fn answer_token_canonical(
436    turn_id: &str,
437    call_id: &str,
438    index: u32,
439    conversation_id: &str,
440    minted_at_ms: u64,
441) -> Vec<u8> {
442    canonical_bytes(&AnswerTokenCanonical {
443        turn_id,
444        question_call_id: call_id,
445        question_index: index,
446        conversation_id,
447        minted_at_ms,
448    })
449}
450
451/// The signed answer-token field set, in its frozen order. See ADR 0009.
452///
453/// `turn_id` is an ordinary REQUIRED field, unlike [`AnswerCanonical`]'s
454/// skipped one. A token lives at most [`ANSWER_TOKEN_TTL_MS`], so no token
455/// minted before occurrence identity can outlive its own deployment: a token
456/// with no turn fails closed rather than authorizing an occurrence it never
457/// named.
458#[derive(Serialize)]
459struct AnswerTokenCanonical<'a> {
460    turn_id: &'a str,
461    question_call_id: &'a str,
462    question_index: u32,
463    conversation_id: &'a str,
464    minted_at_ms: u64,
465}
466
467/// A minted answer token: the canonical body plus its signature.
468///
469/// The token carries no `signed_by` — it is verified against the control
470/// plane's own key, supplied by the caller of [`verify_answer_token`], never
471/// one carried on the token itself. Mirrors
472/// `polyc_crypto::approval::mint_resolve_token`'s shape, so
473/// [`crate::signed::Envelope`] (which always writes both provenance fields)
474/// does not apply here.
475#[derive(Serialize)]
476struct AnswerToken<'a> {
477    #[serde(flatten)]
478    body: AnswerTokenCanonical<'a>,
479    signature_hex: String,
480}
481
482/// Mint a short-lived, signed answer token scoped to one pending question.
483///
484/// Scoped to `(turn_id, call_id, index, conversation_id)` — the
485/// correlation-token
486/// contract every downstream edge slice (#1661-#1664) consumes unchanged.
487/// Mirrors [`crate::approval::mint_resolve_token`]'s shape and
488/// unforgeability argument: opaque to, and unforgeable by, anything
489/// downstream of the mint point. `minted_at_ms` is the caller's wall clock
490/// at mint time; pass a real timestamp in production, an injected one in
491/// tests.
492///
493/// Returns the token as a lowercase-hex opaque string, safe to carry on any
494/// wire surface (a button value, a proto field) alongside `call_id`/`index`.
495#[must_use]
496pub fn mint_answer_token(
497    turn_id: &str,
498    call_id: &str,
499    index: u32,
500    conversation_id: &str,
501    minted_at_ms: u64,
502    signer: &ApprovalSigner,
503) -> String {
504    // Built once and reused for both the signature and the envelope. Writing the
505    // field list a second time here would be a second hand-maintained copy of a
506    // signed field set — the drift this module's `answer_fields` exists to stop.
507    let body = AnswerTokenCanonical {
508        turn_id,
509        question_call_id: call_id,
510        question_index: index,
511        conversation_id,
512        minted_at_ms,
513    };
514    let signature = signer.sign(&canonical_bytes(&body));
515    let full = AnswerToken {
516        body,
517        signature_hex: crate::hex::lower(&signature),
518    };
519    crate::hex::lower(&canonical_bytes(&full))
520}
521
522/// Verify an answer token minted by [`mint_answer_token`] against the
523/// `(turn_id, call_id, index, conversation_id)` a `Respond` call presents it
524/// for.
525///
526/// Returns `true` only when ALL hold: the token decodes; its embedded
527/// signature verifies against `signer`'s public key; its bound identity
528/// equals the ones supplied; and `now_ms - minted_at_ms` is within
529/// [`ANSWER_TOKEN_TTL_MS`] (a token minted in the future, by clock skew
530/// beyond the TTL, also fails — fail closed rather than trust an
531/// out-of-bounds clock). Fails closed on any malformed field.
532#[must_use]
533pub fn verify_answer_token(
534    token: &str,
535    turn_id: &str,
536    call_id: &str,
537    index: u32,
538    conversation_id: &str,
539    now_ms: u64,
540    signer: &ApprovalSigner,
541) -> bool {
542    let Some(bytes) = crate::hex::decode(token) else {
543        return false;
544    };
545    let Ok(v) = serde_json::from_slice::<Value>(&bytes) else {
546        return false;
547    };
548    let (
549        Some(bound_turn_id),
550        Some(bound_call_id),
551        Some(bound_index),
552        Some(bound_conversation_id),
553        Some(minted_at_ms),
554        Some(signature_hex),
555    ) = (
556        v.get("turn_id").and_then(Value::as_str),
557        v.get("question_call_id").and_then(Value::as_str),
558        v.get("question_index").and_then(Value::as_u64),
559        v.get("conversation_id").and_then(Value::as_str),
560        v.get("minted_at_ms").and_then(Value::as_u64),
561        v.get("signature_hex").and_then(Value::as_str),
562    )
563    else {
564        return false;
565    };
566    let Ok(bound_index) = u32::try_from(bound_index) else {
567        return false;
568    };
569    if bound_turn_id != turn_id
570        || bound_call_id != call_id
571        || bound_index != index
572        || bound_conversation_id != conversation_id
573    {
574        return false;
575    }
576    let elapsed = now_ms.abs_diff(minted_at_ms);
577    if elapsed > ANSWER_TOKEN_TTL_MS {
578        return false;
579    }
580    let Some(sig) = crate::hex::decode(signature_hex) else {
581        return false;
582    };
583    let canonical = answer_token_canonical(
584        bound_turn_id,
585        bound_call_id,
586        bound_index,
587        bound_conversation_id,
588        minted_at_ms,
589    );
590    verify(&signer.public_key_bytes(), &canonical, &sig)
591}
592
593#[cfg(test)]
594mod tests {
595    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
596    use super::*;
597
598    const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
599    const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
600
601    /// Mint the pre-occurrence durable shape these historical fixtures cover.
602    ///
603    /// An empty turn is skipped by the canonical, so this reproduces exactly
604    /// the bytes a deployment signed before occurrence identity existed.
605    #[allow(clippy::too_many_arguments)]
606    fn answer_payload(
607        call_id: &str,
608        index: u32,
609        question_args_json: &str,
610        state: &str,
611        selected_index: Option<u32>,
612        selected_label: &str,
613        answered_by: &str,
614        conversation_id: &str,
615        nonce: &str,
616        signer: &ApprovalSigner,
617    ) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
618        super::answer_payload(
619            "",
620            call_id,
621            index,
622            question_args_json,
623            state,
624            selected_index,
625            selected_label,
626            answered_by,
627            conversation_id,
628            nonce,
629            signer,
630        )
631    }
632
633    /// [`super::verify_answer_capability`] for a historical record, whose turn
634    /// comes from its own event kind rather than its signature.
635    fn verify_answer_capability<S: std::hash::BuildHasher>(
636        payload: &[u8],
637        conversation_id: &str,
638        consumed: &HashSet<String, S>,
639        trusted_signers: &[Vec<u8>],
640    ) -> Option<VerifiedQuestionAnswer> {
641        super::verify_answer_capability(payload, conversation_id, TURN, consumed, trusted_signers)
642    }
643
644    fn valid_answer_payload(signer: &ApprovalSigner) -> Vec<u8> {
645        answer_payload(
646            "call-1",
647            0,
648            r#"{"questions":[]}"#,
649            ANSWERED_STATE,
650            Some(1),
651            "Production",
652            "slack:T1:U9",
653            "conv-A",
654            "nonce-A",
655            signer,
656        )
657        .0
658    }
659
660    #[test]
661    fn signed_answer_round_trips() {
662        let signer = ApprovalSigner::from_seed(1);
663        let payload = valid_answer_payload(&signer);
664        let verified = verify_signed_answer(&payload).expect("signature verifies");
665        assert_eq!(verified.call_id, "call-1");
666        assert_eq!(verified.index, 0);
667        assert_eq!(verified.state, ANSWERED_STATE);
668        assert_eq!(verified.selected_index, Some(1));
669        assert_eq!(verified.selected_label, "Production");
670        assert_eq!(verified.answered_by, "slack:T1:U9");
671        assert!(verified.binds_question("call-1", 0, r#"{"questions":[]}"#));
672        assert!(!verified.binds_question("call-2", 0, r#"{"questions":[]}"#));
673    }
674
675    /// `#1660` Step 7: the decoded record carries the RAW signer/signature
676    /// hex alongside the verified fields — not just `signer_public_key`
677    /// bytes — so a consumer (the control plane's outstanding-answer
678    /// collector) can re-forward the exact signed bytes to the harness for
679    /// its own independent re-verification (THIN signing), mirroring
680    /// `polyc_crypto::approval::DecodedResponse::{signer_pk_hex,
681    /// signature_hex}`.
682    #[test]
683    fn verified_answer_carries_raw_signer_and_signature_hex() {
684        let signer = ApprovalSigner::from_seed(1);
685        let payload = valid_answer_payload(&signer);
686        let verified = verify_signed_answer(&payload).expect("signature verifies");
687        assert_eq!(
688            verified.signer_pk_hex,
689            crate::hex::lower(&signer.public_key_bytes())
690        );
691        assert!(!verified.signature_hex.is_empty());
692        // The hex must actually verify against the canonical this payload
693        // signs — not just be non-empty — so re-derive the signature bytes
694        // and check them against `verify` directly.
695        let sig_bytes = crate::hex::decode(&verified.signature_hex).expect("valid hex");
696        let canonical = answer_canonical(
697            &verified.turn_id,
698            &verified.call_id,
699            verified.index,
700            &verified.question_args_json,
701            &verified.state,
702            verified.selected_index,
703            &verified.selected_label,
704            &verified.answered_by,
705            &verified.conversation_id,
706            &verified.nonce,
707        );
708        assert!(verify(&signer.public_key_bytes(), &canonical, &sig_bytes));
709    }
710
711    #[test]
712    fn declined_and_auto_resolved_states_round_trip_with_no_selection_or_answerer() {
713        let signer = ApprovalSigner::from_seed(1);
714        let (payload, ..) = answer_payload(
715            "call-1",
716            0,
717            "{}",
718            DECLINED_STATE,
719            None,
720            "",
721            "slack:T1:U9",
722            "conv-A",
723            "nonce-B",
724            &signer,
725        );
726        let v = verify_signed_answer(&payload).expect("verifies");
727        assert_eq!(v.state, DECLINED_STATE);
728        assert_eq!(v.selected_index, None);
729
730        let (payload, ..) = answer_payload(
731            "call-1",
732            0,
733            "{}",
734            AUTO_RESOLVED_STATE,
735            Some(0),
736            "Staging",
737            "",
738            "conv-A",
739            "nonce-C",
740            &signer,
741        );
742        let v = verify_signed_answer(&payload).expect("verifies");
743        assert_eq!(v.state, AUTO_RESOLVED_STATE);
744        assert_eq!(
745            v.answered_by, "",
746            "nobody answered an auto-resolved question"
747        );
748    }
749
750    /// Invariant I3: every signed field is covered — tampering with any one
751    /// (the state, the selection, who answered, the conversation, or the
752    /// nonce) must fail verification.
753    #[test]
754    fn tampered_state_or_selection_fails_verification() {
755        let signer = ApprovalSigner::from_seed(1);
756        let payload = valid_answer_payload(&signer);
757        let mut v: Value = serde_json::from_slice(&payload).unwrap();
758        for (field, val) in [
759            ("state", Value::String(DECLINED_STATE.to_owned())),
760            ("selected_index", Value::Number(2.into())),
761            ("selected_label", Value::String("Staging".into())),
762            ("answered_by", Value::String("slack:T1:U0".into())),
763            ("conversation_id", Value::String("conv-B".into())),
764            ("nonce", Value::String("nonce-Z".into())),
765            ("question_call_id", Value::String("call-2".into())),
766            ("question_index", Value::Number(1.into())),
767        ] {
768            let mut tampered = v.clone();
769            tampered[field] = val;
770            let bytes = serde_json::to_vec(&tampered).unwrap();
771            assert!(
772                verify_signed_answer(&bytes).is_none(),
773                "tampering `{field}` must invalidate the signature"
774            );
775        }
776        // Sanity: the untampered payload still verifies.
777        assert!(verify_signed_answer(&serde_json::to_vec(&v.take()).unwrap()).is_some());
778    }
779
780    /// Invariant I3: a signature from a signer outside the trusted allow-list
781    /// authorizes nothing, even though it is internally self-consistent.
782    #[test]
783    fn answer_from_non_allowlisted_signer_is_rejected() {
784        let trusted = ApprovalSigner::from_seed(1);
785        let attacker = ApprovalSigner::from_seed(666);
786        let payload = valid_answer_payload(&attacker);
787        assert!(
788            verify_signed_answer_pinned(&payload, &[trusted.public_key_bytes()]).is_none(),
789            "an untrusted signer's answer must never verify"
790        );
791        assert!(
792            verify_signed_answer_pinned(&payload, &[attacker.public_key_bytes()]).is_some(),
793            "sanity: the attacker's own key does verify its own signature"
794        );
795    }
796
797    /// INV-3 at the signature layer: the answer canonical binds the turn, so
798    /// an answer minted for turn A cannot be re-verified as turn B's — the
799    /// wire check the harness performs before applying it.
800    #[test]
801    fn answer_signature_binds_the_turn_occurrence() {
802        let signer = ApprovalSigner::from_seed(7);
803        let (payload, ..) = super::answer_payload(
804            TURN,
805            "call-0",
806            0,
807            r#"{"questions":[]}"#,
808            ANSWERED_STATE,
809            Some(1),
810            "Production",
811            "persona:alice",
812            "conv-A",
813            "nonce-occurrence",
814            &signer,
815        );
816        let v = verify_signed_answer(&payload).expect("the occurrence answer verifies");
817        assert_eq!(v.turn_id, TURN);
818        let wire = |turn: &str| {
819            verify_wire_answer(
820                turn,
821                &v.call_id,
822                v.index,
823                &v.question_args_json,
824                &v.state,
825                v.selected_index,
826                &v.selected_label,
827                &v.answered_by,
828                &v.conversation_id,
829                &v.nonce,
830                &v.signer_pk_hex,
831                &v.signature_hex,
832            )
833        };
834        assert!(wire(TURN));
835        assert!(
836            !wire(OTHER_TURN),
837            "an answer for one occurrence must not verify as another's"
838        );
839        assert!(
840            !wire(""),
841            "an occurrence answer must not verify as a turn-less historical one"
842        );
843    }
844
845    /// INV-2 + INV-1's carve-out: an answer written before occurrence identity
846    /// signs no turn, still verifies, and is redeemable against whatever turn
847    /// its own event kind names — while an answer that DOES sign a turn is
848    /// refused for any other occurrence.
849    #[test]
850    fn historical_answer_verifies_and_redeems_under_its_event_kind_turn() {
851        let signer = ApprovalSigner::from_seed(7);
852        let trusted = [signer.public_key_bytes()];
853        let consumed = HashSet::new();
854
855        let historical = valid_answer_payload(&signer);
856        let decoded =
857            verify_signed_answer(&historical).expect("a historical answer still verifies");
858        assert!(
859            decoded.turn_id.is_empty(),
860            "a pre-occurrence answer signs no turn"
861        );
862        for turn in [TURN, OTHER_TURN] {
863            assert!(
864                super::verify_answer_capability(&historical, "conv-A", turn, &consumed, &trusted)
865                    .is_some(),
866                "a historical answer's turn comes from its event kind, not its signature"
867            );
868        }
869
870        let (current, ..) = super::answer_payload(
871            TURN,
872            "call-1",
873            0,
874            r#"{"questions":[]}"#,
875            ANSWERED_STATE,
876            Some(1),
877            "Production",
878            "slack:T1:U9",
879            "conv-A",
880            "nonce-current",
881            &signer,
882        );
883        assert!(
884            super::verify_answer_capability(&current, "conv-A", TURN, &consumed, &trusted)
885                .is_some()
886        );
887        assert!(
888            super::verify_answer_capability(&current, "conv-A", OTHER_TURN, &consumed, &trusted)
889                .is_none(),
890            "a newly minted answer must name the occurrence it is applied to"
891        );
892    }
893
894    /// Invariant I3/`#370`-style replay guard: a token signed for one
895    /// conversation must be rejected when presented for another, even though
896    /// every other field is byte-identical.
897    #[test]
898    fn answer_rejected_across_conversations() {
899        let signer = ApprovalSigner::from_seed(1);
900        let payload = valid_answer_payload(&signer);
901        let consumed = HashSet::new();
902        assert!(
903            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
904                .is_some()
905        );
906        assert!(
907            verify_answer_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
908                .is_none(),
909            "an answer signed for conv-A must be rejected when presented for conv-B"
910        );
911    }
912
913    /// Invariant I2/I7: a single-use answer is rejected on a second
914    /// presentation, mirroring `capability_is_single_use` for approvals.
915    #[test]
916    fn question_answer_capability_is_single_use() {
917        let signer = ApprovalSigner::from_seed(1);
918        let payload = valid_answer_payload(&signer);
919        let mut consumed = HashSet::new();
920        let v =
921            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
922                .expect("first use honored");
923        assert_eq!(v.nonce, "nonce-A");
924        consumed.insert(v.nonce.clone());
925        assert!(
926            verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
927                .is_none(),
928            "a spent answer must be rejected on re-presentation"
929        );
930    }
931
932    /// Cross-domain canonical separation: an approval's own signed
933    /// `approval_response`/`resolve_token` bytes must never verify as a
934    /// question answer/token, and vice versa — the two canonicals use
935    /// different key names specifically so this can never accidentally work.
936    #[test]
937    fn approval_payloads_never_verify_as_question_answers() {
938        let signer = ApprovalSigner::from_seed(1);
939        let (approval_payload, ..) = crate::approval::response_payload(
940            "call-1",
941            "ask_question",
942            r#"{"questions":[]}"#,
943            "",
944            true,
945            false,
946            &[],
947            "slack:T1:U9",
948            "",
949            "",
950            "",
951            "",
952            "conv-A",
953            "nonce-A",
954            "",
955            &signer,
956        );
957        assert!(
958            verify_signed_answer(&approval_payload).is_none(),
959            "an approval_response payload must never decode+verify as a question_response"
960        );
961
962        let resolve_token = crate::approval::mint_resolve_token(
963            "018f47f0-5f70-7cc5-98df-123456789abc",
964            "call-1",
965            "conv-A",
966            1_000,
967            &signer,
968        );
969        assert!(
970            !verify_answer_token(&resolve_token, TURN, "call-1", 0, "conv-A", 1_000, &signer),
971            "an approval resolve_token must never verify as an answer token"
972        );
973    }
974
975    /// `polyc_proto::tool_display::question_answered_text` duplicates
976    /// `ANSWERED_STATE`/`DECLINED_STATE`/`AUTO_RESOLVED_STATE` as local
977    /// string literals (`polyc-proto` can't depend on `polyc-crypto` for the
978    /// real consts — see that function's own doc for the layer reason).
979    /// `polyc-crypto` depends on `polyc-proto`, so this crate is the one
980    /// that CAN cross-check them: feed this crate's own real consts in and
981    /// confirm each produces its OWN distinct sentence shape, not the
982    /// declined-shaped fallback every unrecognized string renders as.
983    #[test]
984    fn proto_question_answered_text_recognizes_this_crates_real_state_consts() {
985        let answered = polyc_proto::question_answered_text(
986            "Deploy target",
987            ANSWERED_STATE,
988            "@ada",
989            "Production",
990        );
991        assert!(
992            answered.contains("@ada answered"),
993            "ANSWERED_STATE must render as a real answer, not the declined fallback: {answered}"
994        );
995
996        let auto_resolved = polyc_proto::question_answered_text(
997            "Deploy target",
998            AUTO_RESOLVED_STATE,
999            "",
1000            "Production",
1001        );
1002        assert!(
1003            auto_resolved.contains("Nobody answered"),
1004            "AUTO_RESOLVED_STATE must render as an assumption, not the declined fallback: {auto_resolved}"
1005        );
1006
1007        let declined =
1008            polyc_proto::question_answered_text("Deploy target", DECLINED_STATE, "@ada", "");
1009        assert!(
1010            declined.contains("@ada declined"),
1011            "DECLINED_STATE must render as a real decline: {declined}"
1012        );
1013    }
1014}
1015
1016#[cfg(test)]
1017mod answer_token_tests {
1018    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1019    use super::*;
1020
1021    const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
1022    const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
1023
1024    #[test]
1025    fn answer_token_verifies_for_its_own_question() {
1026        let signer = ApprovalSigner::from_seed(11);
1027        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1028        assert!(verify_answer_token(
1029            &token, TURN, "call-1", 0, "conv-A", 1_000, &signer
1030        ));
1031    }
1032
1033    /// INV-3: the token binds the OCCURRENCE. A token minted for turn A's
1034    /// `call-1#0` must not authorize turn B's re-minted `call-1#0`, which is
1035    /// exactly what a compaction-driven id re-mint produces.
1036    #[test]
1037    fn answer_token_rejects_a_different_occurrence_turn() {
1038        let signer = ApprovalSigner::from_seed(11);
1039        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1040        assert!(!verify_answer_token(
1041            &token, OTHER_TURN, "call-1", 0, "conv-A", 1_000, &signer
1042        ));
1043    }
1044
1045    /// A token minted before occurrence identity carries no turn at all. It
1046    /// fails closed rather than authorizing an occurrence it never named —
1047    /// the deliberate in-flight-card cost, bounded by
1048    /// [`ANSWER_TOKEN_TTL_MS`].
1049    #[test]
1050    fn pre_occurrence_token_without_a_turn_fails_closed() {
1051        let signer = ApprovalSigner::from_seed(11);
1052        let old_body = serde_json::json!({
1053            "question_call_id": "call-1",
1054            "question_index": 0,
1055            "conversation_id": "conv-A",
1056            "minted_at_ms": 1_000,
1057        });
1058        let old_signature = signer.sign(&canonical_bytes(&old_body));
1059        let old_token = crate::hex::lower(
1060            &serde_json::to_vec(&serde_json::json!({
1061                "question_call_id": "call-1",
1062                "question_index": 0,
1063                "conversation_id": "conv-A",
1064                "minted_at_ms": 1_000,
1065                "signature_hex": crate::hex::lower(&old_signature),
1066            }))
1067            .expect("the pre-occurrence token serializes"),
1068        );
1069        assert!(!verify_answer_token(
1070            &old_token, TURN, "call-1", 0, "conv-A", 1_000, &signer,
1071        ));
1072    }
1073
1074    #[test]
1075    fn answer_token_rejects_a_different_question_index() {
1076        let signer = ApprovalSigner::from_seed(11);
1077        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1078        assert!(!verify_answer_token(
1079            &token, TURN, "call-1", 1, "conv-A", 1_000, &signer
1080        ));
1081    }
1082
1083    #[test]
1084    fn answer_token_rejects_a_different_call_id() {
1085        let signer = ApprovalSigner::from_seed(11);
1086        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1087        assert!(!verify_answer_token(
1088            &token, TURN, "call-2", 0, "conv-A", 1_000, &signer
1089        ));
1090    }
1091
1092    #[test]
1093    fn answer_token_rejects_a_different_conversation() {
1094        let signer = ApprovalSigner::from_seed(11);
1095        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1096        assert!(!verify_answer_token(
1097            &token, TURN, "call-1", 0, "conv-B", 1_000, &signer
1098        ));
1099    }
1100
1101    #[test]
1102    fn answer_token_rejects_wrong_signer() {
1103        let signer = ApprovalSigner::from_seed(11);
1104        let other = ApprovalSigner::from_seed(12);
1105        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
1106        assert!(!verify_answer_token(
1107            &token, TURN, "call-1", 0, "conv-A", 1_000, &other
1108        ));
1109    }
1110
1111    #[test]
1112    fn answer_token_rejects_after_ttl_elapses() {
1113        let signer = ApprovalSigner::from_seed(11);
1114        let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 0, &signer);
1115        assert!(verify_answer_token(
1116            &token,
1117            TURN,
1118            "call-1",
1119            0,
1120            "conv-A",
1121            ANSWER_TOKEN_TTL_MS,
1122            &signer
1123        ));
1124        assert!(!verify_answer_token(
1125            &token,
1126            TURN,
1127            "call-1",
1128            0,
1129            "conv-A",
1130            ANSWER_TOKEN_TTL_MS + 1,
1131            &signer
1132        ));
1133    }
1134
1135    #[test]
1136    fn answer_token_rejects_garbage() {
1137        let signer = ApprovalSigner::from_seed(11);
1138        assert!(!verify_answer_token(
1139            "not-hex", TURN, "call-1", 0, "conv-A", 0, &signer
1140        ));
1141        assert!(!verify_answer_token(
1142            "", TURN, "call-1", 0, "conv-A", 0, &signer
1143        ));
1144    }
1145}
1146
1147#[cfg(test)]
1148mod canonical_freeze {
1149    //! Every signed canonical in this module, frozen as literal bytes (`#1845`).
1150    //!
1151    //! These are the bytes a deployment has already signed and has sitting in
1152    //! its log. They are checked in, never regenerated: regenerating one is the
1153    //! defect this module exists to catch, because a canonical whose bytes move
1154    //! invalidates every signature ever minted over the old ones.
1155    //!
1156    //! The reason they can be single literals at all is the conversion `#1845`
1157    //! made, following `#1842`. Before it, each canonical was a
1158    //! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
1159    //! default and an `IndexMap` (insertion order) whenever anything in the
1160    //! build graph enables `serde_json/preserve_order` — so the same payload
1161    //! signed by two binaries with different dependency sets produced different
1162    //! bytes and different signatures. Every literal below is the
1163    //! insertion-order form, which is what a control-plane binary (where
1164    //! `preserve_order` is unified in) has always signed. Run this module under
1165    //! either selection and every literal holds:
1166    //!
1167    //! ```text
1168    //! cargo nextest run -p polyc-crypto                    # no preserve_order
1169    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
1170    //! ```
1171    //!
1172    //! See ADR 0009 for the decision these literals enforce.
1173    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1174
1175    use super::*;
1176
1177    /// Assert a canonical's bytes are exactly the frozen literal.
1178    fn frozen(label: &str, got: &[u8], want: &str) {
1179        assert_eq!(
1180            String::from_utf8(got.to_vec()).unwrap(),
1181            want,
1182            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1183        );
1184    }
1185
1186    const QUESTION_ARGS: &str =
1187        r#"{"questions":[{"prompt":"Ship it?","options":["Hold","Yes, proceed"]}]}"#;
1188    const MINTED_AT_MS: u64 = 1_750_000_000_000;
1189    const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
1190
1191    #[test]
1192    fn answered_canonical_and_payload_are_frozen() {
1193        frozen(
1194            "answer_canonical (answered)",
1195            &answer_canonical(
1196                "",
1197                "call-1",
1198                0,
1199                QUESTION_ARGS,
1200                ANSWERED_STATE,
1201                Some(1),
1202                "Yes, proceed",
1203                "persona:alice",
1204                "conv-1",
1205                "nonce-answer",
1206            ),
1207            ANSWERED_CANONICAL,
1208        );
1209        let (full, sig, _) = answer_payload(
1210            "",
1211            "call-1",
1212            0,
1213            QUESTION_ARGS,
1214            ANSWERED_STATE,
1215            Some(1),
1216            "Yes, proceed",
1217            "persona:alice",
1218            "conv-1",
1219            "nonce-answer",
1220            &ApprovalSigner::from_seed(99),
1221        );
1222        frozen("answer_payload (answered)", &full, ANSWERED_PAYLOAD);
1223        assert_eq!(crate::hex::lower(&sig), ANSWERED_SIG);
1224    }
1225
1226    // A decline signs `selected_index` as an explicit `null`, not an omitted
1227    // key — the shape the `json!` object this replaced emitted, and therefore
1228    // what every already-signed decline in a deployment's log covers.
1229    #[test]
1230    fn declined_canonical_and_payload_are_frozen() {
1231        frozen(
1232            "answer_canonical (declined)",
1233            &answer_canonical(
1234                "",
1235                "call-1",
1236                0,
1237                QUESTION_ARGS,
1238                DECLINED_STATE,
1239                None,
1240                "",
1241                "persona:alice",
1242                "conv-1",
1243                "nonce-answer",
1244            ),
1245            DECLINED_CANONICAL,
1246        );
1247        let (full, sig, _) = answer_payload(
1248            "",
1249            "call-1",
1250            0,
1251            QUESTION_ARGS,
1252            DECLINED_STATE,
1253            None,
1254            "",
1255            "persona:alice",
1256            "conv-1",
1257            "nonce-answer",
1258            &ApprovalSigner::from_seed(99),
1259        );
1260        frozen("answer_payload (declined)", &full, DECLINED_PAYLOAD);
1261        assert_eq!(crate::hex::lower(&sig), DECLINED_SIG);
1262    }
1263
1264    #[test]
1265    fn answer_token_is_frozen() {
1266        frozen(
1267            "answer_token_canonical",
1268            &answer_token_canonical(TURN, "call-1", 0, "conv-1", MINTED_AT_MS),
1269            TOKEN_CANONICAL,
1270        );
1271        assert_eq!(
1272            mint_answer_token(
1273                TURN,
1274                "call-1",
1275                0,
1276                "conv-1",
1277                MINTED_AT_MS,
1278                &ApprovalSigner::from_seed(99)
1279            ),
1280            TOKEN_MINTED,
1281            "a minted answer token's bytes are frozen — a token is hex of the whole object"
1282        );
1283    }
1284
1285    /// The occurrence-carrying canonical, frozen from the day it shipped.
1286    ///
1287    /// Its sibling above is the SAME function called with an empty turn, and
1288    /// that literal is unchanged by this field — which is the whole
1289    /// durable-record argument: a record signed before `turn_id` existed
1290    /// still reproduces its own bytes.
1291    #[test]
1292    fn occurrence_canonical_and_payload_are_frozen() {
1293        frozen(
1294            "answer_canonical (occurrence)",
1295            &answer_canonical(
1296                TURN,
1297                "call-1",
1298                0,
1299                QUESTION_ARGS,
1300                ANSWERED_STATE,
1301                Some(1),
1302                "Yes, proceed",
1303                "persona:alice",
1304                "conv-1",
1305                "nonce-answer",
1306            ),
1307            OCCURRENCE_CANONICAL,
1308        );
1309        let (full, sig, _) = answer_payload(
1310            TURN,
1311            "call-1",
1312            0,
1313            QUESTION_ARGS,
1314            ANSWERED_STATE,
1315            Some(1),
1316            "Yes, proceed",
1317            "persona:alice",
1318            "conv-1",
1319            "nonce-answer",
1320            &ApprovalSigner::from_seed(99),
1321        );
1322        frozen("answer_payload (occurrence)", &full, OCCURRENCE_PAYLOAD);
1323        assert_eq!(crate::hex::lower(&sig), OCCURRENCE_SIG);
1324    }
1325
1326    const OCCURRENCE_CANONICAL: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1327    const OCCURRENCE_PAYLOAD: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"4a99c6e0846437d5f15717355a835544dab85676bdf30d593956c6f3535ec49dc921c7994a4d4cf5c6d0f553e4d058bb2c5cd9c9608130e564c983dce09c1f0b"}"#;
1328    const OCCURRENCE_SIG: &str = "4a99c6e0846437d5f15717355a835544dab85676bdf30d593956c6f3535ec49dc921c7994a4d4cf5c6d0f553e4d058bb2c5cd9c9608130e564c983dce09c1f0b";
1329    const ANSWERED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1330    const ANSWERED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"answered","selected_index":1,"selected_label":"Yes, proceed","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705"}"#;
1331    const ANSWERED_SIG: &str = "e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705";
1332    const DECLINED_CANONICAL: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer"}"#;
1333    const DECLINED_PAYLOAD: &str = r#"{"question_call_id":"call-1","question_index":0,"question_args_json":"{\"questions\":[{\"prompt\":\"Ship it?\",\"options\":[\"Hold\",\"Yes, proceed\"]}]}","state":"declined","selected_index":null,"selected_label":"","answered_by":"persona:alice","conversation_id":"conv-1","nonce":"nonce-answer","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d"}"#;
1334    const DECLINED_SIG: &str = "5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d";
1335    const TOKEN_CANONICAL: &str = r#"{"turn_id":"018f47f0-5f70-7cc5-98df-123456789abc","question_call_id":"call-1","question_index":0,"conversation_id":"conv-1","minted_at_ms":1750000000000}"#;
1336    const TOKEN_MINTED: &str = "7b227475726e5f6964223a2230313866343766302d356637302d376363352d393864662d313233343536373839616263222c227175657374696f6e5f63616c6c5f6964223a2263616c6c2d31222c227175657374696f6e5f696e646578223a302c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223461306239656333303930623666323662633362623436636238316564363764613935303365386235353533323937396661626237646330626234366538336135306436356236643664656231363734363739636134643437643265333836303533636663336133303061623865643330636362353465623739616363653035227d";
1337}