Skip to main content

treeship_core/statements/
mod.rs

1/// Returns the canonical MIME payloadType for a statement type suffix.
2///
3/// ```
4/// use treeship_core::statements::payload_type;
5/// assert_eq!(
6///     payload_type("action"),
7///     "application/vnd.treeship.action.v1+json"
8/// );
9/// ```
10pub fn payload_type(suffix: &str) -> String {
11    format!("application/vnd.treeship.{}.v1+json", suffix)
12}
13
14pub const TYPE_ACTION: &str = "treeship/action/v1";
15pub const TYPE_APPROVAL: &str = "treeship/approval/v1";
16pub const TYPE_HANDOFF: &str = "treeship/handoff/v1";
17pub const TYPE_ENDORSEMENT: &str = "treeship/endorsement/v1";
18pub const TYPE_RECEIPT: &str = "treeship/receipt/v1";
19pub const TYPE_BUNDLE: &str = "treeship/bundle/v1";
20pub const TYPE_DECISION: &str = "treeship/decision/v1";
21
22// v0.9.9 Approval Authority schemas. See `approval_use` for details on
23// the journal-side record types and the `replay_check` metadata shape
24// that verify uses to report what level of replay check actually ran.
25mod session_liveness;
26pub use session_liveness::{LivenessVerdict, SessionLivenessStatement, TYPE_SESSION_LIVENESS};
27
28mod approval_use;
29pub use approval_use::{
30    approval_revocation_record_digest, approval_use_record_digest,
31    journal_checkpoint_record_digest, nonce_digest, verify_hub_checkpoint_signature,
32    ApprovalRevocation, ApprovalUse, CheckpointKind, HubCheckpointVerification, JournalCheckpoint,
33    ReplayCheck, ReplayCheckLevel, TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE,
34    TYPE_JOURNAL_CHECKPOINT,
35};
36
37// Phase 1 of the agent-invitations spec (docs/specs/agent-invitations-rooms.md).
38// `invitation` carries the single-use grant; `session_participant`
39// carries the two-sig join event. The two compose with the Approval
40// Use Journal (consume-before-action) without any journal-side schema
41// change.
42pub mod invitation;
43pub mod session_participant;
44pub use invitation::{
45    parse_rfc3339_to_unix, GrantedCapabilities, InvitationError, InvitationStatement,
46    InviteeRestriction, DEFAULT_INVITATION_LIFETIME_SECS, MAX_INVITATION_LIFETIME_SECS,
47    TYPE_INVITATION,
48};
49pub use session_participant::{
50    verify_participant_envelope, ParticipantVerifyError, SessionParticipantStatement,
51    TYPE_SESSION_PARTICIPANT,
52};
53
54// Receipt schema v2 (docs receipt-v2 spec). `action.v2` binds two blocks into
55// the signed payload: `mandate` (the per-hop authorization the action was
56// exercised under) and `effect` (what the action actually touched). The
57// verifier evaluates authorization at `signed_at` and fails closed, reporting
58// `Unverified` rather than a false `Pass` for any layer it cannot check.
59pub mod action_v2;
60pub use action_v2::{
61    action_in_scope, check_resolution, payload_type_v2, resolve_grant_chain, verify_effect,
62    verify_grant_chain, verify_mandate, ActionStatementV2, ChainResolveError, Cost, DeadlineEvent,
63    Effect, EffectConfidence, EffectFinality, EffectVerdict, Grant, GrantChainError, Mandate,
64    MandateVerdict, NoRevocationSource, NoWitnessAuthority, Resolution, ResolutionStatus,
65    Revocation, RevocationSource, RevocationStatus, RuntimeIdentity, Witness, WitnessAuthority,
66    TYPE_ACTION_V2,
67};
68
69use serde::{Deserialize, Serialize};
70
71/// A reference to content being attested, approved, or receipted.
72/// At least one field should be set.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct SubjectRef {
75    /// Content hash: "sha256:<hex>" or "sha3:<hex>"
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub digest: Option<String>,
78
79    /// External URI to the content
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub uri: Option<String>,
82
83    /// ID of another Treeship artifact
84    #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
85    pub artifact_id: Option<String>,
86}
87
88/// Scope constraints on an approval — *who* may perform *what* against
89/// *which subject*, *how many times*, and *until when*.
90///
91/// Treeship's verify pass enforces these constraints statelessly (every
92/// field except `max_actions` can be checked from the signed envelope
93/// alone). `max_actions` is signed into the grant so a future ledger /
94/// Hub layer can enforce single-use across the global view; for now it
95/// is descriptive, and verify reports the replay-check posture honestly
96/// rather than claiming enforcement that did not happen.
97///
98/// An empty `allowed_*` list means "no constraint on that axis."
99/// All-empty scope is equivalent to no scope at all (an unscoped /
100/// bearer approval) — which `verify` flags with a warning so callers
101/// know the binding is the only thing being attested.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct ApprovalScope {
104    /// Maximum number of actions this approval authorises. Signed into
105    /// the grant for future stateful enforcement; not yet checked
106    /// statelessly.
107    #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
108    pub max_actions: Option<u32>,
109
110    /// ISO 8601 timestamp after which the approval is no longer valid.
111    /// Independent of `ApprovalStatement.expires_at` so a single approval
112    /// can have an outer "key valid until X" and a tighter "scope valid
113    /// until Y" if the operator wants both. Verify enforces both.
114    #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
115    pub valid_until: Option<String>,
116
117    /// Actor URIs permitted to consume this approval. Empty = no
118    /// constraint on actor.
119    #[serde(
120        rename = "allowedActors",
121        skip_serializing_if = "Vec::is_empty",
122        default
123    )]
124    pub allowed_actors: Vec<String>,
125
126    /// Action labels permitted under this approval. Empty = no
127    /// constraint on action.
128    #[serde(
129        rename = "allowedActions",
130        skip_serializing_if = "Vec::is_empty",
131        default
132    )]
133    pub allowed_actions: Vec<String>,
134
135    /// Subject URIs permitted as the target of an action under this
136    /// approval. Matched against `ActionStatement.subject.uri` (or
137    /// `artifact_id` for chain-internal subjects). Empty = no
138    /// constraint on subject.
139    #[serde(
140        rename = "allowedSubjects",
141        skip_serializing_if = "Vec::is_empty",
142        default
143    )]
144    pub allowed_subjects: Vec<String>,
145
146    /// Arbitrary additional constraints (e.g. max payment amount).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub extra: Option<serde_json::Value>,
149}
150
151impl ApprovalScope {
152    /// True when no constraint axis is populated. An unscoped approval
153    /// proves only nonce binding -- it does NOT bind actor, action, or
154    /// subject. Verify warns when this is true so the audit reader
155    /// knows the limit of what was signed.
156    pub fn is_unscoped(&self) -> bool {
157        self.max_actions.is_none()
158            && self.valid_until.is_none()
159            && self.allowed_actors.is_empty()
160            && self.allowed_actions.is_empty()
161            && self.allowed_subjects.is_empty()
162            && self.extra.is_none()
163    }
164}
165
166/// Records that an actor performed an action.
167///
168/// Why an action is a retry of an earlier one, signed into the statement.
169///
170/// Without this, attempt two of a flaky call is a sibling of attempt one
171/// and a verifier cannot tell "recovering from a timeout" from "did the
172/// same thing twice". `of` names the attempt being retried, `attempt` is
173/// the 1-based position in the chain, `cause` is a closed vocabulary, and
174/// `idempotency_key` is what the caller sent so a verifier can check it was
175/// the same across attempts. Omitted from the signed bytes when absent, so
176/// every action signed before this field existed keeps its exact id.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct Retry {
179    /// Artifact id of the attempt this one retries (`art_…`).
180    pub of: String,
181    /// 1-based attempt number; the first attempt is 1 and carries no `retry`.
182    pub attempt: u32,
183    /// Why the previous attempt was not accepted: `timeout`, `error`,
184    /// `rate_limited`, `operator`, `unknown`.
185    pub cause: RetryCause,
186    /// Milliseconds waited before this attempt, when a backoff fired.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub backoff_ms: Option<u64>,
189    /// The idempotency key the caller sent with this attempt, if any.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub idempotency_key: Option<String>,
192}
193
194/// Closed vocabulary for `Retry::cause`. Out-of-vocabulary values fail to
195/// deserialize, so a receipt cannot carry a cause the verifier does not name.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum RetryCause {
199    Timeout,
200    Error,
201    RateLimited,
202    Operator,
203    Unknown,
204}
205
206impl RetryCause {
207    pub fn parse(s: &str) -> Option<Self> {
208        match s {
209            "timeout" => Some(Self::Timeout),
210            "error" => Some(Self::Error),
211            "rate_limited" | "rate-limited" => Some(Self::RateLimited),
212            "operator" => Some(Self::Operator),
213            "unknown" => Some(Self::Unknown),
214            _ => None,
215        }
216    }
217    pub fn label(self) -> &'static str {
218        match self {
219            Self::Timeout => "timeout",
220            Self::Error => "error",
221            Self::RateLimited => "rate_limited",
222            Self::Operator => "operator",
223            Self::Unknown => "unknown",
224        }
225    }
226}
227
228/// This is the most common statement type — every tool call, API request,
229/// file write, or agent operation produces one.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ActionStatement {
232    /// Always `TYPE_ACTION`
233    #[serde(rename = "type")]
234    pub type_: String,
235
236    /// RFC 3339 timestamp, set at sign time.
237    pub timestamp: String,
238
239    /// DID-style actor URI. e.g. "agent://researcher", "human://alice"
240    pub actor: String,
241
242    /// Dot-namespaced action label. e.g. "tool.call", "stripe.charge.create"
243    pub action: String,
244
245    #[serde(default, skip_serializing_if = "is_empty_subject")]
246    pub subject: SubjectRef,
247
248    /// Links this artifact to its parent in the chain.
249    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
250    pub parent_id: Option<String>,
251
252    /// Must match the `nonce` field of the approval authorising this action.
253    /// Provides cryptographic one-to-one binding between approval and action,
254    /// preventing approval reuse across multiple actions.
255    #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
256    pub approval_nonce: Option<String>,
257
258    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
259    pub policy_ref: Option<String>,
260
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub meta: Option<serde_json::Value>,
263
264    /// Set when this action retries an earlier one. See [`Retry`].
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub retry: Option<Retry>,
267
268    /// The idempotency key the caller sent with this attempt, signed, so a
269    /// later retry can be checked against it (`package verify` row
270    /// `retries`). Omitted when absent.
271    #[serde(
272        rename = "idempotencyKey",
273        default,
274        skip_serializing_if = "Option::is_none"
275    )]
276    pub idempotency_key: Option<String>,
277}
278
279/// Records that an approver authorised an intent or action.
280///
281/// The `nonce` field is the cornerstone of approval security: the consuming
282/// `ActionStatement` must echo the same nonce in its `approval_nonce` field.
283/// This cryptographically binds each approval to exactly one action (or
284/// `max_actions` actions when set), preventing approval reuse.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ApprovalStatement {
287    #[serde(rename = "type")]
288    pub type_: String,
289    pub timestamp: String,
290
291    /// DID-style approver URI. e.g. "human://alice"
292    pub approver: String,
293
294    #[serde(default, skip_serializing_if = "is_empty_subject")]
295    pub subject: SubjectRef,
296
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub description: Option<String>,
299
300    /// ISO 8601 expiry timestamp. None means no expiry.
301    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
302    pub expires_at: Option<String>,
303
304    /// Whether the receiving actor may re-delegate this approval.
305    pub delegatable: bool,
306
307    /// Random token. The consuming ActionStatement must set its
308    /// `approval_nonce` field to this value. Generated by the SDK if
309    /// not provided by the caller.
310    pub nonce: String,
311
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub scope: Option<ApprovalScope>,
314
315    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
316    pub policy_ref: Option<String>,
317
318    /// Irreversibility class of the actions this approval authorizes.
319    /// One of `IRREVERSIBILITY_CLASSES` (fail-closed: producers must
320    /// reject any other value; absent means undeclared, the pre-existing
321    /// behavior). Consequential-or-worse classes gate on memory
322    /// quarantine evidence at minting; see
323    /// docs/specs/memory-provenance-binding.md §2.4-2.5.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub irreversibility: Option<String>,
326
327    /// Artifact id of the `memory.quarantine-check.v1` receipt that
328    /// gated this grant. Signed into the approval so the evidence link
329    /// is tamper-evident: a verifier can walk grant -> check receipt ->
330    /// provider key -> chain root.
331    #[serde(rename = "quarantineReceipt", skip_serializing_if = "Option::is_none")]
332    pub quarantine_receipt: Option<String>,
333
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub meta: Option<serde_json::Value>,
336}
337
338/// The irreversibility vocabulary, ordered from most to least recoverable.
339/// A grant's class is a claim about the worst-case effect of the actions it
340/// authorizes, not a property Treeship can observe -- but the vocabulary is
341/// closed so a self-declared class cannot smuggle an out-of-vocabulary value
342/// past a policy check (the AUD-06 rule, applied here).
343pub const IRREVERSIBILITY_CLASSES: &[&str] = &[
344    "two_way",
345    "one_way_recoverable",
346    "one_way_consequential",
347    "one_way_terminal",
348];
349
350/// True iff `class` is in the closed irreversibility vocabulary.
351pub fn is_irreversibility_class(class: &str) -> bool {
352    IRREVERSIBILITY_CLASSES.contains(&class)
353}
354
355/// True iff a grant of this class requires memory quarantine evidence at
356/// minting (consequential or worse). Unknown classes return true: an
357/// unrecognized claim gets the strictest treatment, never a bypass.
358pub fn irreversibility_requires_quarantine(class: &str) -> bool {
359    !matches!(class, "two_way" | "one_way_recoverable")
360}
361
362/// Records that work moved from one actor/domain to another.
363///
364/// This is the core of Treeship's multi-agent trust story. A handoff
365/// artifact proves custody transfer and carries inherited approvals.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct HandoffStatement {
368    #[serde(rename = "type")]
369    pub type_: String,
370    pub timestamp: String,
371
372    /// Source actor URI
373    pub from: String,
374    /// Destination actor URI
375    pub to: String,
376
377    /// IDs of artifacts being transferred
378    pub artifacts: Vec<String>,
379
380    /// Approval artifact IDs the receiving actor inherits
381    #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
382    pub approval_ids: Vec<String>,
383
384    /// Constraints the receiving actor must satisfy
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub obligations: Vec<String>,
387
388    pub delegatable: bool,
389
390    #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
391    pub task_ref: Option<String>,
392
393    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
394    pub policy_ref: Option<String>,
395
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub meta: Option<serde_json::Value>,
398
399    /// How custody was established for this transfer.
400    ///
401    /// Absent on every handoff minted before custody grading existed and on
402    /// every handoff that recorded no verification. Absent means `asserted`,
403    /// never `live`: the verifier grades a missing block exactly like an
404    /// explicit assertion (see [`HandoffCustody::effective`]). Signed, so a
405    /// holder cannot promote an asserted handoff to live on the wire.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub custody: Option<HandoffCustody>,
408
409    /// Close-loop evidence the sender attached: a sealed local session whose
410    /// receipt digest this handoff binds. It proves the sender ran the
411    /// commands in that session; it does not bind the UI pixels or the task
412    /// result to the presentation key. Optional by design -- slice 4 of
413    /// `docs/specs/agent-to-agent-verification.md` says a receiver may require
414    /// it as policy, and v1 must not.
415    #[serde(rename = "closeLoop", default, skip_serializing_if = "Option::is_none")]
416    pub close_loop: Option<CloseLoopEvidence>,
417}
418
419/// Custody grade vocabulary. Closed on purpose: `verify` treats any other
420/// string as `asserted` and says so, rather than letting a new word read as a
421/// stronger claim than the verifier knows how to check.
422pub const CUSTODY_LIVE: &str = "live";
423pub const CUSTODY_ASSERTED: &str = "asserted";
424
425/// How a handoff's custody was established. Lives inside the signed
426/// statement; every field here is covered by the handoff signature.
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct HandoffCustody {
429    /// `live` or `asserted`. See [`CUSTODY_LIVE`] / [`CUSTODY_ASSERTED`].
430    pub grade: String,
431
432    /// Why the grade is what it is. For `asserted`, the reason the receiver
433    /// gave (`same_computer` for a shared-keystore roster handoff). For `live`,
434    /// normally absent.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub reason: Option<String>,
437
438    /// `sha256:<hex>` of the exact presentation file bytes that verified.
439    /// Required for `live`; a live grade without it is downgraded by the
440    /// verifier.
441    #[serde(
442        rename = "presentationDigest",
443        default,
444        skip_serializing_if = "Option::is_none"
445    )]
446    pub presentation_digest: Option<String>,
447
448    /// The nonce the verifier minted and the bearer answered. Required for
449    /// `live`: without it there was no liveness check, whatever the grade says.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub challenge: Option<String>,
452
453    /// Artifact id of the capability card that verified key-bound.
454    #[serde(rename = "cardId", default, skip_serializing_if = "Option::is_none")]
455    pub card_id: Option<String>,
456
457    /// Actor URI that ran the verification -- the receiving side of the
458    /// handoff, since only the receiver holds the nonce it minted.
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub verifier: Option<String>,
461
462    /// RFC 3339 time the verification ran, by the verifier's clock.
463    #[serde(
464        rename = "verifiedAt",
465        default,
466        skip_serializing_if = "Option::is_none"
467    )]
468    pub verified_at: Option<String>,
469}
470
471/// The grade a verifier reports, after refusing to launder a claim the block
472/// does not support.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct EffectiveCustody {
475    pub live: bool,
476    pub detail: String,
477}
478
479impl HandoffCustody {
480    /// An asserted custody block with an operator-supplied reason.
481    pub fn asserted(reason: impl Into<String>) -> Self {
482        Self {
483            grade: CUSTODY_ASSERTED.into(),
484            reason: Some(reason.into()),
485            presentation_digest: None,
486            challenge: None,
487            card_id: None,
488            verifier: None,
489            verified_at: None,
490        }
491    }
492
493    /// Grade a handoff's custody the way `verify` reports it.
494    ///
495    /// The signed block is the signer's claim. This decides what that claim is
496    /// worth on its face: `live` is honored only when the block carries the
497    /// evidence a live check produces (a presentation digest and the nonce it
498    /// answered). A `live` without them, an unknown grade, or no block at all
499    /// is reported as `asserted` with the reason spelled out, because the
500    /// failure this guards against is a receipt reader seeing "live" and
501    /// stopping there.
502    pub fn effective(custody: Option<&HandoffCustody>) -> EffectiveCustody {
503        let Some(c) = custody else {
504            return EffectiveCustody {
505                live: false,
506                detail: "asserted (no verification recorded)".into(),
507            };
508        };
509        match c.grade.as_str() {
510            CUSTODY_LIVE => {
511                if c.presentation_digest.is_none() || c.challenge.is_none() {
512                    return EffectiveCustody {
513                        live: false,
514                        detail: "asserted (claims live without presentation digest and challenge)"
515                            .into(),
516                    };
517                }
518                let mut detail = String::from("live");
519                if let Some(card) = &c.card_id {
520                    detail.push_str(&format!(" -- card {card}"));
521                }
522                if let Some(v) = &c.verifier {
523                    detail.push_str(&format!(", verified by {v}"));
524                }
525                if let Some(t) = &c.verified_at {
526                    detail.push_str(&format!(" at {t}"));
527                }
528                EffectiveCustody { live: true, detail }
529            }
530            CUSTODY_ASSERTED => EffectiveCustody {
531                live: false,
532                detail: match &c.reason {
533                    Some(r) => format!("asserted ({r})"),
534                    None => "asserted".into(),
535                },
536            },
537            other => EffectiveCustody {
538                live: false,
539                detail: format!("asserted (unknown custody grade {other:?})"),
540            },
541        }
542    }
543}
544
545/// Close-loop evidence bound into a handoff: a sealed session package on the
546/// sender's side whose `receipt.json` digest is recorded here.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct CloseLoopEvidence {
549    /// Evidence kind. `session` is the only kind today.
550    pub kind: String,
551
552    /// The sealed session id (`ssn_...`).
553    #[serde(rename = "sessionId")]
554    pub session_id: String,
555
556    /// `sha256:<hex>` of the package's `receipt.json` bytes, the same digest
557    /// the `session.v1` record binds.
558    #[serde(rename = "receiptDigest")]
559    pub receipt_digest: String,
560}
561
562pub const CLOSE_LOOP_SESSION: &str = "session";
563
564/// Records that a signer asserts confidence about an existing artifact.
565///
566/// Used for post-hoc validation, compliance sign-off, countersignatures.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct EndorsementStatement {
569    #[serde(rename = "type")]
570    pub type_: String,
571    pub timestamp: String,
572
573    /// DID-style endorser URI
574    pub endorser: String,
575    pub subject: SubjectRef,
576
577    /// Endorsement category: "validation", "compliance", "countersignature",
578    /// "review", or any custom string.
579    pub kind: String,
580
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub rationale: Option<String>,
583
584    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
585    pub expires_at: Option<String>,
586
587    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
588    pub policy_ref: Option<String>,
589
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub meta: Option<serde_json::Value>,
592}
593
594impl EndorsementStatement {
595    pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
596        Self {
597            type_: TYPE_ENDORSEMENT.into(),
598            timestamp: now_rfc3339(),
599            endorser: endorser.into(),
600            subject: SubjectRef::default(),
601            kind: kind.into(),
602            rationale: None,
603            expires_at: None,
604            policy_ref: None,
605            meta: None,
606        }
607    }
608}
609
610/// Records that an external system observed or confirmed an event.
611///
612/// Used for Stripe webhooks, RFC 3161 timestamps, inclusion proofs.
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct ReceiptStatement {
615    #[serde(rename = "type")]
616    pub type_: String,
617    pub timestamp: String,
618
619    /// URI of the system producing this receipt.
620    /// e.g. "system://stripe-webhook", "system://tsauthority"
621    pub system: String,
622
623    #[serde(skip_serializing_if = "Option::is_none")]
624    pub subject: Option<SubjectRef>,
625
626    /// Receipt category: "confirmation", "timestamp", "inclusion", "webhook"
627    pub kind: String,
628
629    #[serde(skip_serializing_if = "Option::is_none")]
630    pub payload: Option<serde_json::Value>,
631
632    #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
633    pub payload_digest: Option<String>,
634
635    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
636    pub policy_ref: Option<String>,
637
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub meta: Option<serde_json::Value>,
640
641    /// The artifact this receipt chains onto, inside the signature. A
642    /// receipt minted mid-chain (a halt, a lift, a gate refusal) that carried
643    /// its parent only in storage metadata verified as "claims parent (none)"
644    /// when walked through, and failed `chain_linkage` in a sealed package.
645    /// Omitted when absent, so every receipt signed before this field existed
646    /// keeps its exact bytes and id.
647    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
648    pub parent_id: Option<String>,
649}
650
651/// A reference to one artifact within a bundle.
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct ArtifactRef {
654    pub id: String,
655    pub digest: String,
656    #[serde(rename = "type")]
657    pub type_: String,
658}
659
660/// Groups a set of artifacts into a named, signed bundle.
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct BundleStatement {
663    #[serde(rename = "type")]
664    pub type_: String,
665    pub timestamp: String,
666
667    #[serde(skip_serializing_if = "Option::is_none")]
668    pub tag: Option<String>,
669
670    #[serde(skip_serializing_if = "Option::is_none")]
671    pub description: Option<String>,
672
673    pub artifacts: Vec<ArtifactRef>,
674
675    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
676    pub policy_ref: Option<String>,
677
678    #[serde(skip_serializing_if = "Option::is_none")]
679    pub meta: Option<serde_json::Value>,
680}
681
682/// Records an agent's reasoning and decision context.
683///
684/// This is the "why" layer -- agents provide this explicitly to explain
685/// inference decisions, model usage, and confidence levels.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct DecisionStatement {
688    /// Always `TYPE_DECISION`
689    #[serde(rename = "type")]
690    pub type_: String,
691
692    /// RFC 3339 timestamp, set at sign time.
693    pub timestamp: String,
694
695    /// DID-style actor URI. e.g. "agent://analyst"
696    pub actor: String,
697
698    /// Links this artifact to its parent in the chain.
699    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
700    pub parent_id: Option<String>,
701
702    /// Model used for inference. e.g. "claude-opus-4-7", "kimi-k2", "gpt-5"
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub model: Option<String>,
705
706    /// Model version if known.
707    #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
708    pub model_version: Option<String>,
709
710    /// Provider that hosts the model. e.g. "anthropic", "moonshot",
711    /// "openai", "google", "meta", "mistral", "ollama".
712    ///
713    /// Distinct from `model`: a "surface" (the runtime that runs the
714    /// agent loop -- Claude Code, Cursor, Codex, OpenClaw, Hermes,
715    /// Cline) can be paired with any provider/model. Kimi for
716    /// example is `model = "kimi-k2"` with `provider = "moonshot"`,
717    /// runnable from any surface that speaks OpenAI-compatible APIs.
718    /// Attributing both lets a downstream auditor reason about
719    /// surface, model, and provider independently.
720    ///
721    /// Defaulted on deserialization so pre-v0.10.2 artifacts that
722    /// were signed without provider still parse cleanly.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub provider: Option<String>,
725
726    /// Number of input tokens consumed.
727    #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
728    pub tokens_in: Option<u64>,
729
730    /// Number of output tokens produced.
731    #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
732    pub tokens_out: Option<u64>,
733
734    /// SHA-256 digest of the full prompt (not the prompt itself).
735    #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
736    pub prompt_digest: Option<String>,
737
738    /// Human-readable summary of the decision.
739    #[serde(skip_serializing_if = "Option::is_none")]
740    pub summary: Option<String>,
741
742    /// Confidence level 0.0-1.0 if the agent provides it.
743    #[serde(skip_serializing_if = "Option::is_none")]
744    pub confidence: Option<f64>,
745
746    /// Other options the agent considered.
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub alternatives: Option<Vec<String>>,
749
750    /// Arbitrary additional metadata.
751    #[serde(skip_serializing_if = "Option::is_none")]
752    pub meta: Option<serde_json::Value>,
753}
754
755// Helpers for skip_serializing_if
756fn is_empty_subject(s: &SubjectRef) -> bool {
757    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
758}
759
760// --- Constructors ---
761
762impl ActionStatement {
763    pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
764        Self {
765            type_: TYPE_ACTION.into(),
766            timestamp: now_rfc3339(),
767            actor: actor.into(),
768            action: action.into(),
769            subject: SubjectRef::default(),
770            parent_id: None,
771            approval_nonce: None,
772            policy_ref: None,
773            meta: None,
774            retry: None,
775            idempotency_key: None,
776        }
777    }
778}
779
780impl ApprovalStatement {
781    pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
782        Self {
783            type_: TYPE_APPROVAL.into(),
784            timestamp: now_rfc3339(),
785            approver: approver.into(),
786            subject: SubjectRef::default(),
787            description: None,
788            expires_at: None,
789            delegatable: false,
790            nonce: nonce.into(),
791            scope: None,
792            policy_ref: None,
793            irreversibility: None,
794            quarantine_receipt: None,
795            meta: None,
796        }
797    }
798}
799
800impl HandoffStatement {
801    pub fn new(from: impl Into<String>, to: impl Into<String>, artifacts: Vec<String>) -> Self {
802        Self {
803            type_: TYPE_HANDOFF.into(),
804            timestamp: now_rfc3339(),
805            from: from.into(),
806            to: to.into(),
807            artifacts,
808            approval_ids: vec![],
809            obligations: vec![],
810            delegatable: false,
811            task_ref: None,
812            policy_ref: None,
813            meta: None,
814            custody: None,
815            close_loop: None,
816        }
817    }
818}
819
820impl ReceiptStatement {
821    pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
822        Self {
823            type_: TYPE_RECEIPT.into(),
824            timestamp: now_rfc3339(),
825            system: system.into(),
826            subject: None,
827            kind: kind.into(),
828            payload: None,
829            payload_digest: None,
830            policy_ref: None,
831            meta: None,
832            parent_id: None,
833        }
834    }
835}
836
837impl DecisionStatement {
838    pub fn new(actor: impl Into<String>) -> Self {
839        Self {
840            type_: TYPE_DECISION.into(),
841            timestamp: now_rfc3339(),
842            actor: actor.into(),
843            parent_id: None,
844            model: None,
845            model_version: None,
846            provider: None,
847            tokens_in: None,
848            tokens_out: None,
849            prompt_digest: None,
850            summary: None,
851            confidence: None,
852            alternatives: None,
853            meta: None,
854        }
855    }
856}
857
858fn now_rfc3339() -> String {
859    // std::time gives us duration since UNIX_EPOCH.
860    // Format as ISO 8601 / RFC 3339 without pulling in chrono.
861    use std::time::{SystemTime, UNIX_EPOCH};
862    let secs = SystemTime::now()
863        .duration_since(UNIX_EPOCH)
864        .unwrap_or_default()
865        .as_secs();
866    unix_to_rfc3339(secs)
867}
868
869pub fn unix_to_rfc3339(secs: u64) -> String {
870    // Minimal RFC 3339 formatter — no external deps.
871    // Accurate for dates 1970–2099.
872    let s = secs;
873    let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
874    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
875}
876
877fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
878    let sec = s % 60;
879    let mins = s / 60;
880    let min = mins % 60;
881    let hrs = mins / 60;
882    let hour = hrs % 24;
883    let days = hrs / 24;
884
885    // Gregorian calendar calculation from day count
886    let (y, m, d) = days_to_ymd(days);
887    (y, m, d, hour, min, sec)
888}
889
890fn days_to_ymd(days: u64) -> (u64, u64, u64) {
891    // Days since 1970-01-01
892    let mut d = days;
893    let mut year = 1970u64;
894    loop {
895        let dy = if is_leap(year) { 366 } else { 365 };
896        if d < dy {
897            break;
898        }
899        d -= dy;
900        year += 1;
901    }
902    let months = if is_leap(year) {
903        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
904    } else {
905        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
906    };
907    let mut month = 1u64;
908    for dm in months {
909        if d < dm {
910            break;
911        }
912        d -= dm;
913        month += 1;
914    }
915    (year, month, d + 1)
916}
917
918fn is_leap(y: u64) -> bool {
919    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::attestation::{sign, Ed25519Signer, Verifier};
926
927    #[test]
928    fn payload_type_format() {
929        assert_eq!(
930            payload_type("action"),
931            "application/vnd.treeship.action.v1+json"
932        );
933        assert_eq!(
934            payload_type("approval"),
935            "application/vnd.treeship.approval.v1+json"
936        );
937    }
938
939    #[test]
940    fn action_statement_sign_verify() {
941        let signer = Ed25519Signer::generate("key_test").unwrap();
942        let verifier = Verifier::from_signer(&signer);
943
944        let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
945        stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
946
947        let pt = payload_type("action");
948        let result = sign(&pt, &stmt, &signer).unwrap();
949
950        assert!(result.artifact_id.starts_with("art_"));
951
952        let vr = verifier.verify(&result.envelope).unwrap();
953        assert_eq!(vr.artifact_id, result.artifact_id);
954
955        // Decode and check the payload survived serialization
956        let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
957        assert_eq!(decoded.actor, "agent://researcher");
958        assert_eq!(decoded.action, "tool.call");
959        assert_eq!(decoded.type_, TYPE_ACTION);
960    }
961
962    #[test]
963    fn approval_statement_with_nonce() {
964        let signer = Ed25519Signer::generate("key_human").unwrap();
965
966        let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
967        approval.description = Some("approve laptop purchase < $1500".into());
968        approval.scope = Some(ApprovalScope {
969            max_actions: Some(1),
970            allowed_actions: vec!["stripe.payment_intent.create".into()],
971            ..Default::default()
972        });
973
974        let pt = payload_type("approval");
975        let result = sign(&pt, &approval, &signer).unwrap();
976        assert!(result.artifact_id.starts_with("art_"));
977
978        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
979        assert_eq!(decoded.nonce, "nonce_abc123");
980        assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
981    }
982
983    #[test]
984    fn approval_without_irreversibility_keeps_canonical_bytes() {
985        // The new optional fields must not appear in the serialized payload
986        // when absent -- content addressing means any accidental emission
987        // would change every existing approval's artifact id.
988        let approval = ApprovalStatement::new("human://alice", "nonce_abc123");
989        let bytes = serde_json::to_string(&approval).unwrap();
990        assert!(!bytes.contains("irreversibility"));
991        assert!(!bytes.contains("quarantineReceipt"));
992    }
993
994    #[test]
995    fn handoff_without_custody_keeps_canonical_bytes() {
996        // Handoffs are content-addressed. If the new optional blocks were ever
997        // emitted when absent, every existing handoff would change id.
998        let handoff = HandoffStatement::new("agent://a", "agent://b", vec!["art_1".into()]);
999        let bytes = serde_json::to_string(&handoff).unwrap();
1000        assert!(!bytes.contains("custody"));
1001        assert!(!bytes.contains("closeLoop"));
1002    }
1003
1004    #[test]
1005    fn handoff_custody_and_close_loop_roundtrip_signed() {
1006        let signer = Ed25519Signer::generate("key_receiver").unwrap();
1007        let mut handoff =
1008            HandoffStatement::new("agent://grok", "agent://claude", vec!["art_1".into()]);
1009        handoff.custody = Some(HandoffCustody {
1010            grade: CUSTODY_LIVE.into(),
1011            reason: None,
1012            presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
1013            challenge: Some("0123456789abcdef0123456789abcdef".into()),
1014            card_id: Some("art_card".into()),
1015            verifier: Some("agent://claude".into()),
1016            verified_at: Some("2026-09-02T00:00:00Z".into()),
1017        });
1018        handoff.close_loop = Some(CloseLoopEvidence {
1019            kind: CLOSE_LOOP_SESSION.into(),
1020            session_id: "ssn_0011".into(),
1021            receipt_digest: format!("sha256:{}", "cd".repeat(32)),
1022        });
1023        let pt = payload_type("handoff");
1024        let result = sign(&pt, &handoff, &signer).unwrap();
1025        let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
1026        assert_eq!(decoded.custody, handoff.custody);
1027        assert_eq!(decoded.close_loop, handoff.close_loop);
1028    }
1029
1030    #[test]
1031    fn custody_missing_block_is_asserted() {
1032        let e = HandoffCustody::effective(None);
1033        assert!(!e.live);
1034        assert_eq!(e.detail, "asserted (no verification recorded)");
1035    }
1036
1037    #[test]
1038    fn custody_live_without_evidence_is_downgraded() {
1039        // A signer can write `live` into the block; without the digest and the
1040        // nonce there was no liveness check, and the verifier must not repeat
1041        // the word.
1042        let c = HandoffCustody {
1043            grade: CUSTODY_LIVE.into(),
1044            reason: None,
1045            presentation_digest: None,
1046            challenge: Some("0123456789abcdef0123456789abcdef".into()),
1047            card_id: None,
1048            verifier: None,
1049            verified_at: None,
1050        };
1051        let e = HandoffCustody::effective(Some(&c));
1052        assert!(!e.live);
1053        assert!(
1054            e.detail.starts_with("asserted (claims live"),
1055            "{}",
1056            e.detail
1057        );
1058    }
1059
1060    #[test]
1061    fn custody_unknown_grade_is_asserted_and_named() {
1062        let mut c = HandoffCustody::asserted("x");
1063        c.grade = "verified".into();
1064        let e = HandoffCustody::effective(Some(&c));
1065        assert!(!e.live);
1066        assert_eq!(e.detail, "asserted (unknown custody grade \"verified\")");
1067    }
1068
1069    #[test]
1070    fn custody_asserted_carries_its_reason() {
1071        let c = HandoffCustody::asserted("same_computer");
1072        let e = HandoffCustody::effective(Some(&c));
1073        assert!(!e.live);
1074        assert_eq!(e.detail, "asserted (same_computer)");
1075    }
1076
1077    #[test]
1078    fn custody_live_with_evidence_is_live() {
1079        let c = HandoffCustody {
1080            grade: CUSTODY_LIVE.into(),
1081            reason: None,
1082            presentation_digest: Some(format!("sha256:{}", "ab".repeat(32))),
1083            challenge: Some("0123456789abcdef0123456789abcdef".into()),
1084            card_id: Some("art_card".into()),
1085            verifier: Some("agent://claude".into()),
1086            verified_at: Some("2026-09-02T00:00:00Z".into()),
1087        };
1088        let e = HandoffCustody::effective(Some(&c));
1089        assert!(e.live);
1090        assert_eq!(
1091            e.detail,
1092            "live -- card art_card, verified by agent://claude at 2026-09-02T00:00:00Z"
1093        );
1094    }
1095
1096    #[test]
1097    fn approval_irreversibility_fields_roundtrip_signed() {
1098        let signer = Ed25519Signer::generate("key_human").unwrap();
1099        let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
1100        approval.irreversibility = Some("one_way_consequential".into());
1101        approval.quarantine_receipt = Some("art_deadbeef00112233".into());
1102
1103        let pt = payload_type("approval");
1104        let result = sign(&pt, &approval, &signer).unwrap();
1105        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1106        assert_eq!(
1107            decoded.irreversibility.as_deref(),
1108            Some("one_way_consequential")
1109        );
1110        assert_eq!(
1111            decoded.quarantine_receipt.as_deref(),
1112            Some("art_deadbeef00112233")
1113        );
1114    }
1115
1116    #[test]
1117    fn irreversibility_vocabulary_is_closed_and_fails_strict() {
1118        for c in IRREVERSIBILITY_CLASSES {
1119            assert!(is_irreversibility_class(c));
1120        }
1121        assert!(!is_irreversibility_class("reversible"));
1122        assert!(!is_irreversibility_class(""));
1123        // Recoverable classes do not gate; consequential and terminal do.
1124        assert!(!irreversibility_requires_quarantine("two_way"));
1125        assert!(!irreversibility_requires_quarantine("one_way_recoverable"));
1126        assert!(irreversibility_requires_quarantine("one_way_consequential"));
1127        assert!(irreversibility_requires_quarantine("one_way_terminal"));
1128        // Unknown classes get the strictest treatment, never a bypass.
1129        assert!(irreversibility_requires_quarantine(
1130            "definitely_fine_trust_me"
1131        ));
1132    }
1133
1134    #[test]
1135    fn approval_scope_full_grant_roundtrips() {
1136        // Every scope axis populated -- the full "allowed_actors +
1137        // allowed_actions + allowed_subjects + max_uses" grant must
1138        // serialize, sign, deserialize, and read back identically.
1139        let signer = Ed25519Signer::generate("key_piyush").unwrap();
1140
1141        let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
1142        approval.description = Some("Deploy production after final review".into());
1143        approval.scope = Some(ApprovalScope {
1144            max_actions: Some(1),
1145            valid_until: None,
1146            allowed_actors: vec!["agent://deployer".into()],
1147            allowed_actions: vec!["deploy.production".into()],
1148            allowed_subjects: vec!["env://production".into()],
1149            extra: None,
1150        });
1151
1152        let pt = payload_type("approval");
1153        let result = sign(&pt, &approval, &signer).unwrap();
1154        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
1155        let scope = decoded.scope.expect("scope must round-trip");
1156
1157        assert_eq!(scope.allowed_actors, vec!["agent://deployer".to_string()]);
1158        assert_eq!(scope.allowed_actions, vec!["deploy.production".to_string()]);
1159        assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
1160        assert_eq!(scope.max_actions, Some(1));
1161    }
1162
1163    #[test]
1164    fn approval_scope_is_unscoped_predicate() {
1165        // Default scope = unscoped.
1166        assert!(ApprovalScope::default().is_unscoped());
1167
1168        // Any single populated axis flips the predicate.
1169        assert!(!ApprovalScope {
1170            max_actions: Some(1),
1171            ..Default::default()
1172        }
1173        .is_unscoped());
1174        assert!(!ApprovalScope {
1175            valid_until: Some("2030-01-01T00:00:00Z".into()),
1176            ..Default::default()
1177        }
1178        .is_unscoped());
1179        assert!(!ApprovalScope {
1180            allowed_actors: vec!["agent://x".into()],
1181            ..Default::default()
1182        }
1183        .is_unscoped());
1184        assert!(!ApprovalScope {
1185            allowed_actions: vec!["doit".into()],
1186            ..Default::default()
1187        }
1188        .is_unscoped());
1189        assert!(!ApprovalScope {
1190            allowed_subjects: vec!["env://prod".into()],
1191            ..Default::default()
1192        }
1193        .is_unscoped());
1194    }
1195
1196    #[test]
1197    fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
1198        // Pre-0.9.6 payloads that omitted allowed_actors / allowed_subjects
1199        // must continue to deserialize cleanly. We construct the JSON shape
1200        // directly to simulate an envelope from an older signer.
1201        let legacy = serde_json::json!({
1202            "maxActions": 1,
1203            "allowedActions": ["stripe.payment_intent.create"]
1204        });
1205        let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
1206        assert_eq!(scope.max_actions, Some(1));
1207        assert_eq!(
1208            scope.allowed_actions,
1209            vec!["stripe.payment_intent.create".to_string()]
1210        );
1211        // New fields default to empty -- not present in legacy payload.
1212        assert!(scope.allowed_actors.is_empty());
1213        assert!(scope.allowed_subjects.is_empty());
1214        assert!(!scope.is_unscoped()); // because max_actions IS set
1215    }
1216
1217    #[test]
1218    fn handoff_statement() {
1219        let signer = Ed25519Signer::generate("key_agent").unwrap();
1220
1221        let handoff = HandoffStatement::new(
1222            "agent://researcher",
1223            "agent://checkout",
1224            vec!["art_aabbccdd11223344aabbccdd11223344".into()],
1225        );
1226
1227        let pt = payload_type("handoff");
1228        let result = sign(&pt, &handoff, &signer).unwrap();
1229        let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
1230
1231        assert_eq!(decoded.from, "agent://researcher");
1232        assert_eq!(decoded.to, "agent://checkout");
1233        assert_eq!(decoded.artifacts.len(), 1);
1234    }
1235
1236    #[test]
1237    fn receipt_statement() {
1238        let signer = Ed25519Signer::generate("key_system").unwrap();
1239
1240        let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
1241        receipt.payload = Some(serde_json::json!({
1242            "eventId": "evt_abc123",
1243            "status": "succeeded"
1244        }));
1245
1246        let pt = payload_type("receipt");
1247        let result = sign(&pt, &receipt, &signer).unwrap();
1248        let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
1249
1250        assert_eq!(decoded.system, "system://stripe-webhook");
1251        assert_eq!(decoded.kind, "confirmation");
1252    }
1253
1254    #[test]
1255    fn nonce_binding_survives_serialization() {
1256        let signer = Ed25519Signer::generate("key_test").unwrap();
1257
1258        // The nonce in the approval must survive a sign→verify→decode round-trip.
1259        // The verifier checks that action.approval_nonce == approval.nonce.
1260        let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
1261        let pt = payload_type("approval");
1262        let signed = sign(&pt, &approval, &signer).unwrap();
1263
1264        let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
1265        assert_eq!(
1266            decoded.nonce, "secure_nonce_xyz",
1267            "nonce must survive serialization"
1268        );
1269    }
1270
1271    #[test]
1272    fn decision_statement_sign_verify() {
1273        let signer = Ed25519Signer::generate("key_test").unwrap();
1274        let verifier = Verifier::from_signer(&signer);
1275
1276        let mut stmt = DecisionStatement::new("agent://analyst");
1277        stmt.model = Some("claude-opus-4".into());
1278        stmt.tokens_in = Some(8432);
1279        stmt.tokens_out = Some(1247);
1280        stmt.summary = Some("Contract looks standard.".into());
1281        stmt.confidence = Some(0.91);
1282
1283        let pt = payload_type("decision");
1284        let result = sign(&pt, &stmt, &signer).unwrap();
1285
1286        assert!(result.artifact_id.starts_with("art_"));
1287
1288        let vr = verifier.verify(&result.envelope).unwrap();
1289        assert_eq!(vr.artifact_id, result.artifact_id);
1290
1291        // Decode and check the payload survived serialization
1292        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1293        assert_eq!(decoded.actor, "agent://analyst");
1294        assert_eq!(decoded.model, Some("claude-opus-4".into()));
1295        assert_eq!(decoded.tokens_in, Some(8432));
1296        assert_eq!(decoded.tokens_out, Some(1247));
1297        assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
1298        assert_eq!(decoded.confidence, Some(0.91));
1299        assert_eq!(decoded.type_, TYPE_DECISION);
1300    }
1301
1302    #[test]
1303    fn decision_statement_provider_roundtrips() {
1304        // v0.10.2 added `provider` so Kimi (model=kimi-k2 / provider=moonshot)
1305        // and similar split-model/provider attributions land on the
1306        // signed artifact, not just on the unsigned session event.
1307        let signer = Ed25519Signer::generate("key_test").unwrap();
1308        let verifier = Verifier::from_signer(&signer);
1309
1310        let mut stmt = DecisionStatement::new("agent://researcher");
1311        stmt.model = Some("kimi-k2".into());
1312        stmt.provider = Some("moonshot".into());
1313
1314        let pt = payload_type("decision");
1315        let result = sign(&pt, &stmt, &signer).unwrap();
1316        verifier.verify(&result.envelope).unwrap();
1317
1318        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
1319        assert_eq!(decoded.model, Some("kimi-k2".into()));
1320        assert_eq!(decoded.provider, Some("moonshot".into()));
1321    }
1322
1323    #[test]
1324    fn decision_statement_legacy_payload_without_provider_decodes() {
1325        // Pre-v0.10.2 artifacts were signed without `provider`. The
1326        // field MUST default to None on deserialize so an old receipt
1327        // verifying against a fresh CLI doesn't fail with
1328        // "missing field provider". Defaulting is configured via
1329        // `#[serde(default)]` -- this test pins that contract.
1330        let raw = serde_json::json!({
1331            "type": TYPE_DECISION,
1332            "timestamp": "2026-04-30T12:00:00Z",
1333            "actor": "agent://legacy",
1334            "model": "claude-opus-4",
1335        });
1336        let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
1337        assert_eq!(parsed.model, Some("claude-opus-4".into()));
1338        assert_eq!(parsed.provider, None);
1339    }
1340
1341    #[test]
1342    fn different_statement_types_different_ids() {
1343        // Action and approval with identical fields but different types
1344        // must produce different artifact IDs — enforced by payloadType in PAE.
1345        let signer = Ed25519Signer::generate("key_test").unwrap();
1346
1347        let action = ActionStatement::new("agent://test", "do.thing");
1348        let approval = ApprovalStatement::new("human://test", "nonce_123");
1349
1350        let r_action = sign(&payload_type("action"), &action, &signer).unwrap();
1351        let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
1352
1353        assert_ne!(r_action.artifact_id, r_approval.artifact_id);
1354    }
1355
1356    #[test]
1357    fn timestamp_format() {
1358        let ts = unix_to_rfc3339(0);
1359        assert_eq!(ts, "1970-01-01T00:00:00Z");
1360
1361        let ts2 = unix_to_rfc3339(1_000_000_000);
1362        assert_eq!(ts2, "2001-09-09T01:46:40Z");
1363    }
1364}