//! `ask_question` (`#1660`) signed-answer canonical, verification, and the
//! per-question correlation-token contract.
//!
//! A **sibling to the HITL approval flow's `approval_response` machinery in
//! [`crate::approval`], not a reuse of it** — see issue #1660 and its parent
//! PRD #1659. The trust shape is identical (THIN signing: an edge sends an
//! *unsigned* answer intent, the control plane resolves and signs
//! server-side, the harness re-verifies before trusting the answer) but the
//! signed canonical is its own, with its own field set and its own key names
//! (`question_call_id`/`question_index`, not `request_id`/`tool_name`), so an
//! approval signature can never verify as a question answer and vice versa.
//! The [`ApprovalSigner`] role intentionally covers this human-decision
//! answer family as well as tool approvals. Session, turn-read, web-session
//! grants, and journal roots use different roles.
use std::collections::HashSet;
use serde::Serialize;
use serde_json::Value;
use crate::approval::ApprovalSigner;
use crate::signed::{Envelope, canonical_bytes};
use crate::verify;
/// The signed `state` value for a question a human explicitly answered by
/// picking an option.
pub const ANSWERED_STATE: &str = "answered";
/// The signed `state` value for a question a human explicitly declined to
/// choose ("use your own judgment") — distinct from [`ANSWERED_STATE`] so the
/// agent never mistakes a decline for a real answer.
pub const DECLINED_STATE: &str = "declined";
/// The signed `state` value for a question nobody answered before its idle
/// window elapsed.
///
/// The control plane auto-resolved it to the recommended option (or the
/// first option if none was marked recommended). Signed and unforgeable, so
/// the agent is told this was an assumption, not a genuine human answer.
pub const AUTO_RESOLVED_STATE: &str = "auto_resolved";
/// TTL for a minted answer token (`#1660`).
///
/// Mirrors [`crate::approval::RESOLVE_TOKEN_TTL_MS`]'s reasoning: generous
/// enough that a human has time to see and answer the question card (which
/// can sit in a chat thread for hours or days), short enough that a token
/// captured off a stale card cannot answer the question indefinitely.
pub const ANSWER_TOKEN_TTL_MS: u64 = 24 * 60 * 60 * 1000;
/// The single source for the signed `question_response` canonical JSON. Both
/// the signing path ([`answer_payload`]) and the verifying paths
/// ([`verify_signed_answer`], [`verify_wire_answer`]) route through this so
/// the covered field set/order cannot drift. Adding/renaming/reordering here
/// is a signed-contract change.
///
/// Deliberately uses `question_call_id`/`question_index` rather than the
/// approval canonical's `request_id`/`tool_name` key names: even though both
/// canonicals are JSON objects an attacker might try to splice together, no
/// approval signature can ever verify against this shape (the signed BYTES
/// differ) and no answer signature can ever verify as an approval.
///
/// `turn_id` is the turn that emitted this question occurrence: a provider
/// re-mints `(call_id, index)` across turns, so the turn is what makes the
/// answer name one occurrence rather than every occurrence that ever shared
/// the pair. `question_args_json` is the `ask_question` call's full raw
/// arguments (every question in the call, not just this one) — the audit binding a verifier
/// matches against [`VerifiedQuestionAnswer::binds_question`], mirroring how
/// the approval canonical binds `args_json`. `conversation_id` and `nonce`
/// make the answer a single-use, conversation-bound capability, exactly like
/// `#370`'s approval-response token.
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
fn answer_canonical(
turn_id: &str,
call_id: &str,
index: u32,
question_args_json: &str,
state: &str,
selected_index: Option<u32>,
selected_label: &str,
answered_by: &str,
conversation_id: &str,
nonce: &str,
) -> Vec<u8> {
canonical_bytes(&answer_fields(
turn_id,
call_id,
index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
))
}
/// The signed `question_response` field set, in its frozen order.
///
/// Declaration order IS the signed contract — see [`canonical_bytes`] and ADR
/// 0009. `selected_index` is serialized as an explicit `null` for a decline
/// rather than skipped, which is what the `json!` object this replaced emitted
/// and therefore what every already-signed `question_response` in a
/// deployment's log covers.
#[derive(Serialize)]
struct AnswerCanonical<'a> {
// Absent only on durable answers written before occurrence identity. An
// empty turn is skipped, so a historical record's canonical bytes — and
// therefore its signature — are reproduced exactly.
#[serde(skip_serializing_if = "str::is_empty")]
turn_id: &'a str,
question_call_id: &'a str,
question_index: u32,
question_args_json: &'a str,
state: &'a str,
selected_index: Option<u32>,
selected_label: &'a str,
answered_by: &'a str,
conversation_id: &'a str,
nonce: &'a str,
}
/// The answer body both [`answer_canonical`] and [`answer_payload`] build, so
/// the signed field list exists once rather than in two hand-maintained copies
/// (`#1845`).
#[allow(clippy::too_many_arguments)] // each arg is a distinct signed field of the canonical contract
const fn answer_fields<'a>(
turn_id: &'a str,
call_id: &'a str,
index: u32,
question_args_json: &'a str,
state: &'a str,
selected_index: Option<u32>,
selected_label: &'a str,
answered_by: &'a str,
conversation_id: &'a str,
nonce: &'a str,
) -> AnswerCanonical<'a> {
AnswerCanonical {
turn_id,
question_call_id: call_id,
question_index: index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
}
}
/// JSON payload for a `question_response` event.
///
/// The signature commits to the canonical (unsigned) JSON form: the question
/// identity (`call_id`, `index`, `question_args_json`), the resolution
/// (`state`/`selected_index`/`selected_label`), who resolved it
/// (`answered_by`, empty for [`AUTO_RESOLVED_STATE`]), and the
/// conversation/nonce binding. `signed_by`/`signature_hex` are populated
/// *after* the signer runs and are NOT covered by the signature.
///
/// 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 answer_payload(
turn_id: &str,
call_id: &str,
index: u32,
question_args_json: &str,
state: &str,
selected_index: Option<u32>,
selected_label: &str,
answered_by: &str,
conversation_id: &str,
nonce: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
Envelope::seal(
answer_fields(
turn_id,
call_id,
index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
),
signer.as_signer(),
)
}
/// A verified, decoded `question_response` payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedQuestionAnswer {
/// Turn that emitted this occurrence. Empty only on durable answers
/// written before occurrence identity was introduced.
pub turn_id: String,
/// The `ask_question` tool-call id this answer resolves.
pub call_id: String,
/// This answer's question index within its call's `questions` array.
pub index: u32,
/// The bound raw `ask_question` call arguments (every question in the
/// call) — the audit binding [`Self::binds_question`] matches
/// byte-for-byte.
pub question_args_json: String,
/// One of [`ANSWERED_STATE`], [`DECLINED_STATE`], or
/// [`AUTO_RESOLVED_STATE`].
pub state: String,
/// The chosen option's index, when `state == ANSWERED_STATE` or
/// `AUTO_RESOLVED_STATE`. `None` for a decline.
pub selected_index: Option<u32>,
/// The chosen option's label, mirrored alongside the index so a consumer
/// never has to re-resolve it against the (possibly since-compacted)
/// original question payload.
pub selected_label: String,
/// Who answered — empty for [`AUTO_RESOLVED_STATE`] (nobody did).
pub answered_by: String,
/// The conversation this answer was signed for, covered by the
/// signature so a token signed for one conversation cannot be replayed
/// into another.
pub conversation_id: String,
/// Per-answer unique value, covered by the signature. A consumer
/// records it on use so the answer cannot be re-applied once spent
/// (single-use, mirrors `#370`).
pub nonce: String,
/// The verified signer's public key (encoded).
pub signer_public_key: Vec<u8>,
/// Hex-encoded ed25519 public key of the signer — the same bytes as
/// [`Self::signer_public_key`], kept alongside it in the wire-ready
/// encoding so a consumer forwarding this answer onward (the control
/// plane's outstanding-answer collector, to the harness) never
/// re-derives the hex itself. Mirrors
/// `polyc_crypto::approval::DecodedResponse::signer_pk_hex`.
pub signer_pk_hex: String,
/// Hex-encoded ed25519 signature over the canonical answer tuple —
/// forwarded onward so the harness can independently re-verify (THIN
/// signing) rather than trust the control plane's own verification.
/// Mirrors `polyc_crypto::approval::DecodedResponse::signature_hex`.
pub signature_hex: String,
}
impl VerifiedQuestionAnswer {
/// Whether this verified answer is bound to the EXACT question
/// `(call_id, index, question_args_json)` — the args-binding a resume
/// path must check before applying the answer, mirroring
/// [`crate::approval::VerifiedResponse::authorizes_call`].
#[must_use]
pub fn binds_question(&self, call_id: &str, index: u32, question_args_json: &str) -> bool {
self.call_id == call_id
&& self.index == index
&& self.question_args_json == question_args_json
}
}
/// Verify a persisted `question_response` payload.
///
/// Returns `Some(record)` if the signature checks out against the embedded
/// public key (the caller is responsible for trusting that public key — see
/// [`verify_signed_answer_pinned`]). 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_answer(payload: &[u8]) -> Option<VerifiedQuestionAnswer> {
let v: Value = serde_json::from_slice(payload).ok()?;
// Durable answers written before occurrence identity carry their turn in
// the event kind only. Empty preserves their frozen canonical exactly.
let turn_id = v
.get("turn_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let call_id = v.get("question_call_id")?.as_str()?.to_owned();
let index = u32::try_from(v.get("question_index")?.as_u64()?).ok()?;
let question_args_json = v.get("question_args_json")?.as_str()?.to_owned();
let state = v.get("state")?.as_str()?.to_owned();
let selected_index = match v.get("selected_index") {
Some(Value::Null) | None => None,
Some(x) => Some(u32::try_from(x.as_u64()?).ok()?),
};
let selected_label = v.get("selected_label")?.as_str()?.to_owned();
let answered_by = v.get("answered_by")?.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 = answer_canonical(
&turn_id,
&call_id,
index,
&question_args_json,
&state,
selected_index,
&selected_label,
&answered_by,
&conversation_id,
&nonce,
);
if verify(&pk, &canonical, &sig) {
Some(VerifiedQuestionAnswer {
turn_id,
call_id,
index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
signer_pk_hex: crate::hex::lower(&pk),
signature_hex: crate::hex::lower(&sig),
signer_public_key: pk,
})
} else {
None
}
}
/// Verify a persisted `question_response` payload against a **trusted-signer
/// allow-list**.
///
/// Mirrors [`crate::approval::verify_signed_response_pinned`]: without this
/// gate an attacker could sign a well-formed answer with their own key,
/// embed it, and have it honored. Returns `None` (fail closed) on a
/// malformed payload, an untrusted signer, or a bad signature.
#[must_use]
pub fn verify_signed_answer_pinned(
payload: &[u8],
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedQuestionAnswer> {
let verified = verify_signed_answer(payload)?;
if !trusted_signers
.iter()
.any(|k| k.as_slice() == verified.signer_public_key)
{
return None;
}
Some(verified)
}
/// Verify a persisted `question_response` as a SINGLE-USE, conversation-bound
/// capability (invariant I3/I7), gated on a trusted-signer allow-list.
///
/// Returns the verified answer only when ALL hold: the embedded signer is
/// trusted; the signature verifies; the signed `conversation_id` equals
/// `conversation_id`; the signed `turn_id` names the occurrence `turn_id`
/// (or is empty — see below); the signed `nonce` is non-empty AND not already
/// in `consumed`. The caller MUST record the returned nonce into its
/// `consumed` set before honoring the answer, so a second presentation is
/// rejected.
///
/// The empty-turn carve-out is the durable-record rule, not a loophole: an
/// answer signed before occurrence identity existed derives its turn from its
/// own append-only event kind, which the caller supplies as `turn_id`. Every
/// newly minted answer signs its turn, so it must name the occurrence it is
/// applied to.
#[must_use]
pub fn verify_answer_capability<S: std::hash::BuildHasher>(
payload: &[u8],
conversation_id: &str,
turn_id: &str,
consumed: &HashSet<String, S>,
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedQuestionAnswer> {
let verified = verify_signed_answer_pinned(payload, trusted_signers)?;
if verified.conversation_id != conversation_id {
return None;
}
if !verified.turn_id.is_empty() && verified.turn_id != turn_id {
return None;
}
if verified.nonce.is_empty() || consumed.contains(&verified.nonce) {
return None;
}
Some(verified)
}
/// Verify a wire-form `question_response`, binding the answer to its full
/// signed identity.
///
/// Used by the harness when it receives an answer over the wire and must
/// confirm provenance AND identity before feeding it back into the turn as
/// the `ask_question` call's result — the trust boundary this exists for:
/// silent acceptance would let a compromised control plane forge a user's
/// answer. 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_answer(
turn_id: &str,
call_id: &str,
index: u32,
question_args_json: &str,
state: &str,
selected_index: Option<u32>,
selected_label: &str,
answered_by: &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 = answer_canonical(
turn_id,
call_id,
index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
);
verify(&pk, &canonical, &sig)
}
// ── Per-question correlation token ──────────────────────────────────────────
/// The canonical (signature-covered) form of an answer token (`#1660`).
///
/// Deliberately uses the SAME `question_call_id`/`question_index` key names
/// [`answer_canonical`] uses, but a DIFFERENT key set than
/// [`crate::approval`]'s `resolve_token_canonical` (`request_id` vs
/// `question_call_id`) — an approval resolve token must never verify as an
/// answer token and vice versa; [`verify_answer_token`]'s own test pins this
/// cross-domain rejection.
fn answer_token_canonical(
turn_id: &str,
call_id: &str,
index: u32,
conversation_id: &str,
minted_at_ms: u64,
) -> Vec<u8> {
canonical_bytes(&AnswerTokenCanonical {
turn_id,
question_call_id: call_id,
question_index: index,
conversation_id,
minted_at_ms,
})
}
/// The signed answer-token field set, in its frozen order. See ADR 0009.
///
/// `turn_id` is an ordinary REQUIRED field, unlike [`AnswerCanonical`]'s
/// skipped one. A token lives at most [`ANSWER_TOKEN_TTL_MS`], so no token
/// minted before occurrence identity can outlive its own deployment: a token
/// with no turn fails closed rather than authorizing an occurrence it never
/// named.
#[derive(Serialize)]
struct AnswerTokenCanonical<'a> {
turn_id: &'a str,
question_call_id: &'a str,
question_index: u32,
conversation_id: &'a str,
minted_at_ms: u64,
}
/// A minted answer token: the canonical body plus its signature.
///
/// The token carries no `signed_by` — it is verified against the control
/// plane's own key, supplied by the caller of [`verify_answer_token`], never
/// one carried on the token itself. Mirrors
/// `polyc_crypto::approval::mint_resolve_token`'s shape, so
/// [`crate::signed::Envelope`] (which always writes both provenance fields)
/// does not apply here.
#[derive(Serialize)]
struct AnswerToken<'a> {
#[serde(flatten)]
body: AnswerTokenCanonical<'a>,
signature_hex: String,
}
/// Mint a short-lived, signed answer token scoped to one pending question.
///
/// Scoped to `(turn_id, call_id, index, conversation_id)` — the
/// correlation-token
/// contract every downstream edge slice (#1661-#1664) consumes unchanged.
/// Mirrors [`crate::approval::mint_resolve_token`]'s shape and
/// unforgeability argument: opaque to, and unforgeable by, anything
/// downstream of the mint point. `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 `call_id`/`index`.
#[must_use]
pub fn mint_answer_token(
turn_id: &str,
call_id: &str,
index: u32,
conversation_id: &str,
minted_at_ms: u64,
signer: &ApprovalSigner,
) -> String {
// Built once and reused for both the signature and the envelope. Writing the
// field list a second time here would be a second hand-maintained copy of a
// signed field set — the drift this module's `answer_fields` exists to stop.
let body = AnswerTokenCanonical {
turn_id,
question_call_id: call_id,
question_index: index,
conversation_id,
minted_at_ms,
};
let signature = signer.sign(&canonical_bytes(&body));
let full = AnswerToken {
body,
signature_hex: crate::hex::lower(&signature),
};
crate::hex::lower(&canonical_bytes(&full))
}
/// Verify an answer token minted by [`mint_answer_token`] against the
/// `(turn_id, call_id, index, 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 identity
/// equals the ones supplied; and `now_ms - minted_at_ms` is within
/// [`ANSWER_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_answer_token(
token: &str,
turn_id: &str,
call_id: &str,
index: u32,
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_turn_id),
Some(bound_call_id),
Some(bound_index),
Some(bound_conversation_id),
Some(minted_at_ms),
Some(signature_hex),
) = (
v.get("turn_id").and_then(Value::as_str),
v.get("question_call_id").and_then(Value::as_str),
v.get("question_index").and_then(Value::as_u64),
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;
};
let Ok(bound_index) = u32::try_from(bound_index) else {
return false;
};
if bound_turn_id != turn_id
|| bound_call_id != call_id
|| bound_index != index
|| bound_conversation_id != conversation_id
{
return false;
}
let elapsed = now_ms.abs_diff(minted_at_ms);
if elapsed > ANSWER_TOKEN_TTL_MS {
return false;
}
let Some(sig) = crate::hex::decode(signature_hex) else {
return false;
};
let canonical = answer_token_canonical(
bound_turn_id,
bound_call_id,
bound_index,
bound_conversation_id,
minted_at_ms,
);
verify(&signer.public_key_bytes(), &canonical, &sig)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
/// Mint the pre-occurrence durable shape these historical fixtures cover.
///
/// An empty turn is skipped by the canonical, so this reproduces exactly
/// the bytes a deployment signed before occurrence identity existed.
#[allow(clippy::too_many_arguments)]
fn answer_payload(
call_id: &str,
index: u32,
question_args_json: &str,
state: &str,
selected_index: Option<u32>,
selected_label: &str,
answered_by: &str,
conversation_id: &str,
nonce: &str,
signer: &ApprovalSigner,
) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
super::answer_payload(
"",
call_id,
index,
question_args_json,
state,
selected_index,
selected_label,
answered_by,
conversation_id,
nonce,
signer,
)
}
/// [`super::verify_answer_capability`] for a historical record, whose turn
/// comes from its own event kind rather than its signature.
fn verify_answer_capability<S: std::hash::BuildHasher>(
payload: &[u8],
conversation_id: &str,
consumed: &HashSet<String, S>,
trusted_signers: &[Vec<u8>],
) -> Option<VerifiedQuestionAnswer> {
super::verify_answer_capability(payload, conversation_id, TURN, consumed, trusted_signers)
}
fn valid_answer_payload(signer: &ApprovalSigner) -> Vec<u8> {
answer_payload(
"call-1",
0,
r#"{"questions":[]}"#,
ANSWERED_STATE,
Some(1),
"Production",
"slack:T1:U9",
"conv-A",
"nonce-A",
signer,
)
.0
}
#[test]
fn signed_answer_round_trips() {
let signer = ApprovalSigner::from_seed(1);
let payload = valid_answer_payload(&signer);
let verified = verify_signed_answer(&payload).expect("signature verifies");
assert_eq!(verified.call_id, "call-1");
assert_eq!(verified.index, 0);
assert_eq!(verified.state, ANSWERED_STATE);
assert_eq!(verified.selected_index, Some(1));
assert_eq!(verified.selected_label, "Production");
assert_eq!(verified.answered_by, "slack:T1:U9");
assert!(verified.binds_question("call-1", 0, r#"{"questions":[]}"#));
assert!(!verified.binds_question("call-2", 0, r#"{"questions":[]}"#));
}
/// `#1660` Step 7: the decoded record carries the RAW signer/signature
/// hex alongside the verified fields — not just `signer_public_key`
/// bytes — so a consumer (the control plane's outstanding-answer
/// collector) can re-forward the exact signed bytes to the harness for
/// its own independent re-verification (THIN signing), mirroring
/// `polyc_crypto::approval::DecodedResponse::{signer_pk_hex,
/// signature_hex}`.
#[test]
fn verified_answer_carries_raw_signer_and_signature_hex() {
let signer = ApprovalSigner::from_seed(1);
let payload = valid_answer_payload(&signer);
let verified = verify_signed_answer(&payload).expect("signature verifies");
assert_eq!(
verified.signer_pk_hex,
crate::hex::lower(&signer.public_key_bytes())
);
assert!(!verified.signature_hex.is_empty());
// The hex must actually verify against the canonical this payload
// signs — not just be non-empty — so re-derive the signature bytes
// and check them against `verify` directly.
let sig_bytes = crate::hex::decode(&verified.signature_hex).expect("valid hex");
let canonical = answer_canonical(
&verified.turn_id,
&verified.call_id,
verified.index,
&verified.question_args_json,
&verified.state,
verified.selected_index,
&verified.selected_label,
&verified.answered_by,
&verified.conversation_id,
&verified.nonce,
);
assert!(verify(&signer.public_key_bytes(), &canonical, &sig_bytes));
}
#[test]
fn declined_and_auto_resolved_states_round_trip_with_no_selection_or_answerer() {
let signer = ApprovalSigner::from_seed(1);
let (payload, ..) = answer_payload(
"call-1",
0,
"{}",
DECLINED_STATE,
None,
"",
"slack:T1:U9",
"conv-A",
"nonce-B",
&signer,
);
let v = verify_signed_answer(&payload).expect("verifies");
assert_eq!(v.state, DECLINED_STATE);
assert_eq!(v.selected_index, None);
let (payload, ..) = answer_payload(
"call-1",
0,
"{}",
AUTO_RESOLVED_STATE,
Some(0),
"Staging",
"",
"conv-A",
"nonce-C",
&signer,
);
let v = verify_signed_answer(&payload).expect("verifies");
assert_eq!(v.state, AUTO_RESOLVED_STATE);
assert_eq!(
v.answered_by, "",
"nobody answered an auto-resolved question"
);
}
/// Invariant I3: every signed field is covered — tampering with any one
/// (the state, the selection, who answered, the conversation, or the
/// nonce) must fail verification.
#[test]
fn tampered_state_or_selection_fails_verification() {
let signer = ApprovalSigner::from_seed(1);
let payload = valid_answer_payload(&signer);
let mut v: Value = serde_json::from_slice(&payload).unwrap();
for (field, val) in [
("state", Value::String(DECLINED_STATE.to_owned())),
("selected_index", Value::Number(2.into())),
("selected_label", Value::String("Staging".into())),
("answered_by", Value::String("slack:T1:U0".into())),
("conversation_id", Value::String("conv-B".into())),
("nonce", Value::String("nonce-Z".into())),
("question_call_id", Value::String("call-2".into())),
("question_index", Value::Number(1.into())),
] {
let mut tampered = v.clone();
tampered[field] = val;
let bytes = serde_json::to_vec(&tampered).unwrap();
assert!(
verify_signed_answer(&bytes).is_none(),
"tampering `{field}` must invalidate the signature"
);
}
// Sanity: the untampered payload still verifies.
assert!(verify_signed_answer(&serde_json::to_vec(&v.take()).unwrap()).is_some());
}
/// Invariant I3: a signature from a signer outside the trusted allow-list
/// authorizes nothing, even though it is internally self-consistent.
#[test]
fn answer_from_non_allowlisted_signer_is_rejected() {
let trusted = ApprovalSigner::from_seed(1);
let attacker = ApprovalSigner::from_seed(666);
let payload = valid_answer_payload(&attacker);
assert!(
verify_signed_answer_pinned(&payload, &[trusted.public_key_bytes()]).is_none(),
"an untrusted signer's answer must never verify"
);
assert!(
verify_signed_answer_pinned(&payload, &[attacker.public_key_bytes()]).is_some(),
"sanity: the attacker's own key does verify its own signature"
);
}
/// INV-3 at the signature layer: the answer canonical binds the turn, so
/// an answer minted for turn A cannot be re-verified as turn B's — the
/// wire check the harness performs before applying it.
#[test]
fn answer_signature_binds_the_turn_occurrence() {
let signer = ApprovalSigner::from_seed(7);
let (payload, ..) = super::answer_payload(
TURN,
"call-0",
0,
r#"{"questions":[]}"#,
ANSWERED_STATE,
Some(1),
"Production",
"persona:alice",
"conv-A",
"nonce-occurrence",
&signer,
);
let v = verify_signed_answer(&payload).expect("the occurrence answer verifies");
assert_eq!(v.turn_id, TURN);
let wire = |turn: &str| {
verify_wire_answer(
turn,
&v.call_id,
v.index,
&v.question_args_json,
&v.state,
v.selected_index,
&v.selected_label,
&v.answered_by,
&v.conversation_id,
&v.nonce,
&v.signer_pk_hex,
&v.signature_hex,
)
};
assert!(wire(TURN));
assert!(
!wire(OTHER_TURN),
"an answer for one occurrence must not verify as another's"
);
assert!(
!wire(""),
"an occurrence answer must not verify as a turn-less historical one"
);
}
/// INV-2 + INV-1's carve-out: an answer written before occurrence identity
/// signs no turn, still verifies, and is redeemable against whatever turn
/// its own event kind names — while an answer that DOES sign a turn is
/// refused for any other occurrence.
#[test]
fn historical_answer_verifies_and_redeems_under_its_event_kind_turn() {
let signer = ApprovalSigner::from_seed(7);
let trusted = [signer.public_key_bytes()];
let consumed = HashSet::new();
let historical = valid_answer_payload(&signer);
let decoded =
verify_signed_answer(&historical).expect("a historical answer still verifies");
assert!(
decoded.turn_id.is_empty(),
"a pre-occurrence answer signs no turn"
);
for turn in [TURN, OTHER_TURN] {
assert!(
super::verify_answer_capability(&historical, "conv-A", turn, &consumed, &trusted)
.is_some(),
"a historical answer's turn comes from its event kind, not its signature"
);
}
let (current, ..) = super::answer_payload(
TURN,
"call-1",
0,
r#"{"questions":[]}"#,
ANSWERED_STATE,
Some(1),
"Production",
"slack:T1:U9",
"conv-A",
"nonce-current",
&signer,
);
assert!(
super::verify_answer_capability(¤t, "conv-A", TURN, &consumed, &trusted)
.is_some()
);
assert!(
super::verify_answer_capability(¤t, "conv-A", OTHER_TURN, &consumed, &trusted)
.is_none(),
"a newly minted answer must name the occurrence it is applied to"
);
}
/// Invariant I3/`#370`-style replay guard: a token signed for one
/// conversation must be rejected when presented for another, even though
/// every other field is byte-identical.
#[test]
fn answer_rejected_across_conversations() {
let signer = ApprovalSigner::from_seed(1);
let payload = valid_answer_payload(&signer);
let consumed = HashSet::new();
assert!(
verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.is_some()
);
assert!(
verify_answer_capability(&payload, "conv-B", &consumed, &[signer.public_key_bytes()])
.is_none(),
"an answer signed for conv-A must be rejected when presented for conv-B"
);
}
/// Invariant I2/I7: a single-use answer is rejected on a second
/// presentation, mirroring `capability_is_single_use` for approvals.
#[test]
fn question_answer_capability_is_single_use() {
let signer = ApprovalSigner::from_seed(1);
let payload = valid_answer_payload(&signer);
let mut consumed = HashSet::new();
let v =
verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.expect("first use honored");
assert_eq!(v.nonce, "nonce-A");
consumed.insert(v.nonce.clone());
assert!(
verify_answer_capability(&payload, "conv-A", &consumed, &[signer.public_key_bytes()])
.is_none(),
"a spent answer must be rejected on re-presentation"
);
}
/// Cross-domain canonical separation: an approval's own signed
/// `approval_response`/`resolve_token` bytes must never verify as a
/// question answer/token, and vice versa — the two canonicals use
/// different key names specifically so this can never accidentally work.
#[test]
fn approval_payloads_never_verify_as_question_answers() {
let signer = ApprovalSigner::from_seed(1);
let (approval_payload, ..) = crate::approval::response_payload(
"call-1",
"ask_question",
r#"{"questions":[]}"#,
"",
true,
false,
&[],
"slack:T1:U9",
"",
"",
"",
"",
"conv-A",
"nonce-A",
"",
&signer,
);
assert!(
verify_signed_answer(&approval_payload).is_none(),
"an approval_response payload must never decode+verify as a question_response"
);
let resolve_token = crate::approval::mint_resolve_token(
"018f47f0-5f70-7cc5-98df-123456789abc",
"call-1",
"conv-A",
1_000,
&signer,
);
assert!(
!verify_answer_token(&resolve_token, TURN, "call-1", 0, "conv-A", 1_000, &signer),
"an approval resolve_token must never verify as an answer token"
);
}
/// `polyc_proto::tool_display::question_answered_text` duplicates
/// `ANSWERED_STATE`/`DECLINED_STATE`/`AUTO_RESOLVED_STATE` as local
/// string literals (`polyc-proto` can't depend on `polyc-crypto` for the
/// real consts — see that function's own doc for the layer reason).
/// `polyc-crypto` depends on `polyc-proto`, so this crate is the one
/// that CAN cross-check them: feed this crate's own real consts in and
/// confirm each produces its OWN distinct sentence shape, not the
/// declined-shaped fallback every unrecognized string renders as.
#[test]
fn proto_question_answered_text_recognizes_this_crates_real_state_consts() {
let answered = polyc_proto::question_answered_text(
"Deploy target",
ANSWERED_STATE,
"@ada",
"Production",
);
assert!(
answered.contains("@ada answered"),
"ANSWERED_STATE must render as a real answer, not the declined fallback: {answered}"
);
let auto_resolved = polyc_proto::question_answered_text(
"Deploy target",
AUTO_RESOLVED_STATE,
"",
"Production",
);
assert!(
auto_resolved.contains("Nobody answered"),
"AUTO_RESOLVED_STATE must render as an assumption, not the declined fallback: {auto_resolved}"
);
let declined =
polyc_proto::question_answered_text("Deploy target", DECLINED_STATE, "@ada", "");
assert!(
declined.contains("@ada declined"),
"DECLINED_STATE must render as a real decline: {declined}"
);
}
}
#[cfg(test)]
mod answer_token_tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
const OTHER_TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abd";
#[test]
fn answer_token_verifies_for_its_own_question() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(verify_answer_token(
&token, TURN, "call-1", 0, "conv-A", 1_000, &signer
));
}
/// INV-3: the token binds the OCCURRENCE. A token minted for turn A's
/// `call-1#0` must not authorize turn B's re-minted `call-1#0`, which is
/// exactly what a compaction-driven id re-mint produces.
#[test]
fn answer_token_rejects_a_different_occurrence_turn() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(!verify_answer_token(
&token, OTHER_TURN, "call-1", 0, "conv-A", 1_000, &signer
));
}
/// A token minted before occurrence identity carries no turn at all. It
/// fails closed rather than authorizing an occurrence it never named —
/// the deliberate in-flight-card cost, bounded by
/// [`ANSWER_TOKEN_TTL_MS`].
#[test]
fn pre_occurrence_token_without_a_turn_fails_closed() {
let signer = ApprovalSigner::from_seed(11);
let old_body = serde_json::json!({
"question_call_id": "call-1",
"question_index": 0,
"conversation_id": "conv-A",
"minted_at_ms": 1_000,
});
let old_signature = signer.sign(&canonical_bytes(&old_body));
let old_token = crate::hex::lower(
&serde_json::to_vec(&serde_json::json!({
"question_call_id": "call-1",
"question_index": 0,
"conversation_id": "conv-A",
"minted_at_ms": 1_000,
"signature_hex": crate::hex::lower(&old_signature),
}))
.expect("the pre-occurrence token serializes"),
);
assert!(!verify_answer_token(
&old_token, TURN, "call-1", 0, "conv-A", 1_000, &signer,
));
}
#[test]
fn answer_token_rejects_a_different_question_index() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(!verify_answer_token(
&token, TURN, "call-1", 1, "conv-A", 1_000, &signer
));
}
#[test]
fn answer_token_rejects_a_different_call_id() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(!verify_answer_token(
&token, TURN, "call-2", 0, "conv-A", 1_000, &signer
));
}
#[test]
fn answer_token_rejects_a_different_conversation() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(!verify_answer_token(
&token, TURN, "call-1", 0, "conv-B", 1_000, &signer
));
}
#[test]
fn answer_token_rejects_wrong_signer() {
let signer = ApprovalSigner::from_seed(11);
let other = ApprovalSigner::from_seed(12);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 1_000, &signer);
assert!(!verify_answer_token(
&token, TURN, "call-1", 0, "conv-A", 1_000, &other
));
}
#[test]
fn answer_token_rejects_after_ttl_elapses() {
let signer = ApprovalSigner::from_seed(11);
let token = mint_answer_token(TURN, "call-1", 0, "conv-A", 0, &signer);
assert!(verify_answer_token(
&token,
TURN,
"call-1",
0,
"conv-A",
ANSWER_TOKEN_TTL_MS,
&signer
));
assert!(!verify_answer_token(
&token,
TURN,
"call-1",
0,
"conv-A",
ANSWER_TOKEN_TTL_MS + 1,
&signer
));
}
#[test]
fn answer_token_rejects_garbage() {
let signer = ApprovalSigner::from_seed(11);
assert!(!verify_answer_token(
"not-hex", TURN, "call-1", 0, "conv-A", 0, &signer
));
assert!(!verify_answer_token(
"", TURN, "call-1", 0, "conv-A", 0, &signer
));
}
}
#[cfg(test)]
mod canonical_freeze {
//! Every signed canonical in this module, frozen as literal bytes (`#1845`).
//!
//! 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 conversion `#1845`
//! made, following `#1842`. 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. Every literal below is the
//! insertion-order form, which is what a control-plane binary (where
//! `preserve_order` is unified in) has always signed. Run this module under
//! either selection and every literal holds:
//!
//! ```text
//! cargo nextest run -p polyc-crypto # no preserve_order
//! cargo nextest run -p polyc-crypto -p polyc-payments # preserve_order on
//! ```
//!
//! See ADR 0009 for the decision these literals enforce.
#![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"
);
}
const QUESTION_ARGS: &str =
r#"{"questions":[{"prompt":"Ship it?","options":["Hold","Yes, proceed"]}]}"#;
const MINTED_AT_MS: u64 = 1_750_000_000_000;
const TURN: &str = "018f47f0-5f70-7cc5-98df-123456789abc";
#[test]
fn answered_canonical_and_payload_are_frozen() {
frozen(
"answer_canonical (answered)",
&answer_canonical(
"",
"call-1",
0,
QUESTION_ARGS,
ANSWERED_STATE,
Some(1),
"Yes, proceed",
"persona:alice",
"conv-1",
"nonce-answer",
),
ANSWERED_CANONICAL,
);
let (full, sig, _) = answer_payload(
"",
"call-1",
0,
QUESTION_ARGS,
ANSWERED_STATE,
Some(1),
"Yes, proceed",
"persona:alice",
"conv-1",
"nonce-answer",
&ApprovalSigner::from_seed(99),
);
frozen("answer_payload (answered)", &full, ANSWERED_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), ANSWERED_SIG);
}
// A decline signs `selected_index` as an explicit `null`, not an omitted
// key — the shape the `json!` object this replaced emitted, and therefore
// what every already-signed decline in a deployment's log covers.
#[test]
fn declined_canonical_and_payload_are_frozen() {
frozen(
"answer_canonical (declined)",
&answer_canonical(
"",
"call-1",
0,
QUESTION_ARGS,
DECLINED_STATE,
None,
"",
"persona:alice",
"conv-1",
"nonce-answer",
),
DECLINED_CANONICAL,
);
let (full, sig, _) = answer_payload(
"",
"call-1",
0,
QUESTION_ARGS,
DECLINED_STATE,
None,
"",
"persona:alice",
"conv-1",
"nonce-answer",
&ApprovalSigner::from_seed(99),
);
frozen("answer_payload (declined)", &full, DECLINED_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), DECLINED_SIG);
}
#[test]
fn answer_token_is_frozen() {
frozen(
"answer_token_canonical",
&answer_token_canonical(TURN, "call-1", 0, "conv-1", MINTED_AT_MS),
TOKEN_CANONICAL,
);
assert_eq!(
mint_answer_token(
TURN,
"call-1",
0,
"conv-1",
MINTED_AT_MS,
&ApprovalSigner::from_seed(99)
),
TOKEN_MINTED,
"a minted answer token's bytes are frozen — a token is hex of the whole object"
);
}
/// The occurrence-carrying canonical, frozen from the day it shipped.
///
/// Its sibling above is the SAME function called with an empty turn, and
/// that literal is unchanged by this field — which is the whole
/// durable-record argument: a record signed before `turn_id` existed
/// still reproduces its own bytes.
#[test]
fn occurrence_canonical_and_payload_are_frozen() {
frozen(
"answer_canonical (occurrence)",
&answer_canonical(
TURN,
"call-1",
0,
QUESTION_ARGS,
ANSWERED_STATE,
Some(1),
"Yes, proceed",
"persona:alice",
"conv-1",
"nonce-answer",
),
OCCURRENCE_CANONICAL,
);
let (full, sig, _) = answer_payload(
TURN,
"call-1",
0,
QUESTION_ARGS,
ANSWERED_STATE,
Some(1),
"Yes, proceed",
"persona:alice",
"conv-1",
"nonce-answer",
&ApprovalSigner::from_seed(99),
);
frozen("answer_payload (occurrence)", &full, OCCURRENCE_PAYLOAD);
assert_eq!(crate::hex::lower(&sig), OCCURRENCE_SIG);
}
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"}"#;
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"}"#;
const OCCURRENCE_SIG: &str = "4a99c6e0846437d5f15717355a835544dab85676bdf30d593956c6f3535ec49dc921c7994a4d4cf5c6d0f553e4d058bb2c5cd9c9608130e564c983dce09c1f0b";
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"}"#;
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"}"#;
const ANSWERED_SIG: &str = "e7b877065d853887c0188eff5fb971a9d8e531e2c36e3ffa8d02dc5da2b3cb17a8dc140bfb1d6b4c6c6529d0b4890851516d03b1d633d96b1496d6713b08a705";
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"}"#;
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"}"#;
const DECLINED_SIG: &str = "5961a4cc427c84674a0a60b0f51552143b511751c2295705dbd323e062b504858de0f2209e67bb0c7a6826e80ec9831c2864c9f910cf7b4e137656da273fa30d";
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}"#;
const TOKEN_MINTED: &str = "7b227475726e5f6964223a2230313866343766302d356637302d376363352d393864662d313233343536373839616263222c227175657374696f6e5f63616c6c5f6964223a2263616c6c2d31222c227175657374696f6e5f696e646578223a302c22636f6e766572736174696f6e5f6964223a22636f6e762d31222c226d696e7465645f61745f6d73223a313735303030303030303030302c227369676e61747572655f686578223a223461306239656333303930623666323662633362623436636238316564363764613935303365386235353533323937396661626237646330626234366538336135306436356236643664656231363734363739636134643437643265333836303533636663336133303061623865643330636362353465623739616363653035227d";
}