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