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 approval_use;
26pub use approval_use::{
27    ApprovalRevocation, ApprovalUse, CheckpointKind, HubCheckpointVerification,
28    JournalCheckpoint, ReplayCheck, ReplayCheckLevel,
29    TYPE_APPROVAL_REVOCATION, TYPE_APPROVAL_USE, TYPE_JOURNAL_CHECKPOINT,
30    approval_revocation_record_digest, approval_use_record_digest,
31    journal_checkpoint_record_digest, nonce_digest, verify_hub_checkpoint_signature,
32};
33
34// Phase 1 of the agent-invitations spec (docs/specs/agent-invitations-rooms.md).
35// `invitation` carries the single-use grant; `session_participant`
36// carries the two-sig join event. The two compose with the Approval
37// Use Journal (consume-before-action) without any journal-side schema
38// change.
39pub mod invitation;
40pub mod session_participant;
41pub use invitation::{
42    GrantedCapabilities, InvitationError, InvitationStatement, InviteeRestriction,
43    TYPE_INVITATION, DEFAULT_INVITATION_LIFETIME_SECS, MAX_INVITATION_LIFETIME_SECS,
44};
45pub use session_participant::{
46    ParticipantVerifyError, SessionParticipantStatement, TYPE_SESSION_PARTICIPANT,
47    verify_participant_envelope,
48};
49
50use serde::{Deserialize, Serialize};
51
52/// A reference to content being attested, approved, or receipted.
53/// At least one field should be set.
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct SubjectRef {
56    /// Content hash: "sha256:<hex>" or "sha3:<hex>"
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub digest: Option<String>,
59
60    /// External URI to the content
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub uri: Option<String>,
63
64    /// ID of another Treeship artifact
65    #[serde(rename = "artifactId", skip_serializing_if = "Option::is_none")]
66    pub artifact_id: Option<String>,
67}
68
69/// Scope constraints on an approval — *who* may perform *what* against
70/// *which subject*, *how many times*, and *until when*.
71///
72/// Treeship's verify pass enforces these constraints statelessly (every
73/// field except `max_actions` can be checked from the signed envelope
74/// alone). `max_actions` is signed into the grant so a future ledger /
75/// Hub layer can enforce single-use across the global view; for now it
76/// is descriptive, and verify reports the replay-check posture honestly
77/// rather than claiming enforcement that did not happen.
78///
79/// An empty `allowed_*` list means "no constraint on that axis."
80/// All-empty scope is equivalent to no scope at all (an unscoped /
81/// bearer approval) — which `verify` flags with a warning so callers
82/// know the binding is the only thing being attested.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct ApprovalScope {
85    /// Maximum number of actions this approval authorises. Signed into
86    /// the grant for future stateful enforcement; not yet checked
87    /// statelessly.
88    #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
89    pub max_actions: Option<u32>,
90
91    /// ISO 8601 timestamp after which the approval is no longer valid.
92    /// Independent of `ApprovalStatement.expires_at` so a single approval
93    /// can have an outer "key valid until X" and a tighter "scope valid
94    /// until Y" if the operator wants both. Verify enforces both.
95    #[serde(rename = "validUntil", skip_serializing_if = "Option::is_none")]
96    pub valid_until: Option<String>,
97
98    /// Actor URIs permitted to consume this approval. Empty = no
99    /// constraint on actor.
100    #[serde(rename = "allowedActors", skip_serializing_if = "Vec::is_empty", default)]
101    pub allowed_actors: Vec<String>,
102
103    /// Action labels permitted under this approval. Empty = no
104    /// constraint on action.
105    #[serde(rename = "allowedActions", skip_serializing_if = "Vec::is_empty", default)]
106    pub allowed_actions: Vec<String>,
107
108    /// Subject URIs permitted as the target of an action under this
109    /// approval. Matched against `ActionStatement.subject.uri` (or
110    /// `artifact_id` for chain-internal subjects). Empty = no
111    /// constraint on subject.
112    #[serde(rename = "allowedSubjects", skip_serializing_if = "Vec::is_empty", default)]
113    pub allowed_subjects: Vec<String>,
114
115    /// Arbitrary additional constraints (e.g. max payment amount).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub extra: Option<serde_json::Value>,
118}
119
120impl ApprovalScope {
121    /// True when no constraint axis is populated. An unscoped approval
122    /// proves only nonce binding -- it does NOT bind actor, action, or
123    /// subject. Verify warns when this is true so the audit reader
124    /// knows the limit of what was signed.
125    pub fn is_unscoped(&self) -> bool {
126        self.max_actions.is_none()
127            && self.valid_until.is_none()
128            && self.allowed_actors.is_empty()
129            && self.allowed_actions.is_empty()
130            && self.allowed_subjects.is_empty()
131            && self.extra.is_none()
132    }
133}
134
135/// Records that an actor performed an action.
136///
137/// This is the most common statement type — every tool call, API request,
138/// file write, or agent operation produces one.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ActionStatement {
141    /// Always `TYPE_ACTION`
142    #[serde(rename = "type")]
143    pub type_: String,
144
145    /// RFC 3339 timestamp, set at sign time.
146    pub timestamp: String,
147
148    /// DID-style actor URI. e.g. "agent://researcher", "human://alice"
149    pub actor: String,
150
151    /// Dot-namespaced action label. e.g. "tool.call", "stripe.charge.create"
152    pub action: String,
153
154    #[serde(default, skip_serializing_if = "is_empty_subject")]
155    pub subject: SubjectRef,
156
157    /// Links this artifact to its parent in the chain.
158    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
159    pub parent_id: Option<String>,
160
161    /// Must match the `nonce` field of the approval authorising this action.
162    /// Provides cryptographic one-to-one binding between approval and action,
163    /// preventing approval reuse across multiple actions.
164    #[serde(rename = "approvalNonce", skip_serializing_if = "Option::is_none")]
165    pub approval_nonce: Option<String>,
166
167    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
168    pub policy_ref: Option<String>,
169
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub meta: Option<serde_json::Value>,
172}
173
174/// Records that an approver authorised an intent or action.
175///
176/// The `nonce` field is the cornerstone of approval security: the consuming
177/// `ActionStatement` must echo the same nonce in its `approval_nonce` field.
178/// This cryptographically binds each approval to exactly one action (or
179/// `max_actions` actions when set), preventing approval reuse.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ApprovalStatement {
182    #[serde(rename = "type")]
183    pub type_: String,
184    pub timestamp: String,
185
186    /// DID-style approver URI. e.g. "human://alice"
187    pub approver: String,
188
189    #[serde(default, skip_serializing_if = "is_empty_subject")]
190    pub subject: SubjectRef,
191
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub description: Option<String>,
194
195    /// ISO 8601 expiry timestamp. None means no expiry.
196    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
197    pub expires_at: Option<String>,
198
199    /// Whether the receiving actor may re-delegate this approval.
200    pub delegatable: bool,
201
202    /// Random token. The consuming ActionStatement must set its
203    /// `approval_nonce` field to this value. Generated by the SDK if
204    /// not provided by the caller.
205    pub nonce: String,
206
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub scope: Option<ApprovalScope>,
209
210    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
211    pub policy_ref: Option<String>,
212
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub meta: Option<serde_json::Value>,
215}
216
217/// Records that work moved from one actor/domain to another.
218///
219/// This is the core of Treeship's multi-agent trust story. A handoff
220/// artifact proves custody transfer and carries inherited approvals.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct HandoffStatement {
223    #[serde(rename = "type")]
224    pub type_: String,
225    pub timestamp: String,
226
227    /// Source actor URI
228    pub from: String,
229    /// Destination actor URI
230    pub to: String,
231
232    /// IDs of artifacts being transferred
233    pub artifacts: Vec<String>,
234
235    /// Approval artifact IDs the receiving actor inherits
236    #[serde(rename = "approvalIds", default, skip_serializing_if = "Vec::is_empty")]
237    pub approval_ids: Vec<String>,
238
239    /// Constraints the receiving actor must satisfy
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub obligations: Vec<String>,
242
243    pub delegatable: bool,
244
245    #[serde(rename = "taskRef", skip_serializing_if = "Option::is_none")]
246    pub task_ref: Option<String>,
247
248    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
249    pub policy_ref: Option<String>,
250
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub meta: Option<serde_json::Value>,
253}
254
255/// Records that a signer asserts confidence about an existing artifact.
256///
257/// Used for post-hoc validation, compliance sign-off, countersignatures.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct EndorsementStatement {
260    #[serde(rename = "type")]
261    pub type_: String,
262    pub timestamp: String,
263
264    /// DID-style endorser URI
265    pub endorser: String,
266    pub subject: SubjectRef,
267
268    /// Endorsement category: "validation", "compliance", "countersignature",
269    /// "review", or any custom string.
270    pub kind: String,
271
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub rationale: Option<String>,
274
275    #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
276    pub expires_at: Option<String>,
277
278    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
279    pub policy_ref: Option<String>,
280
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub meta: Option<serde_json::Value>,
283}
284
285impl EndorsementStatement {
286    pub fn new(endorser: impl Into<String>, kind: impl Into<String>) -> Self {
287        Self {
288            type_: TYPE_ENDORSEMENT.into(),
289            timestamp: now_rfc3339(),
290            endorser: endorser.into(),
291            subject: SubjectRef::default(),
292            kind: kind.into(),
293            rationale: None,
294            expires_at: None,
295            policy_ref: None,
296            meta: None,
297        }
298    }
299}
300
301/// Records that an external system observed or confirmed an event.
302///
303/// Used for Stripe webhooks, RFC 3161 timestamps, inclusion proofs.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ReceiptStatement {
306    #[serde(rename = "type")]
307    pub type_: String,
308    pub timestamp: String,
309
310    /// URI of the system producing this receipt.
311    /// e.g. "system://stripe-webhook", "system://tsauthority"
312    pub system: String,
313
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub subject: Option<SubjectRef>,
316
317    /// Receipt category: "confirmation", "timestamp", "inclusion", "webhook"
318    pub kind: String,
319
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub payload: Option<serde_json::Value>,
322
323    #[serde(rename = "payloadDigest", skip_serializing_if = "Option::is_none")]
324    pub payload_digest: Option<String>,
325
326    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
327    pub policy_ref: Option<String>,
328
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub meta: Option<serde_json::Value>,
331}
332
333/// A reference to one artifact within a bundle.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ArtifactRef {
336    pub id:     String,
337    pub digest: String,
338    #[serde(rename = "type")]
339    pub type_:  String,
340}
341
342/// Groups a set of artifacts into a named, signed bundle.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct BundleStatement {
345    #[serde(rename = "type")]
346    pub type_: String,
347    pub timestamp: String,
348
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub tag: Option<String>,
351
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub description: Option<String>,
354
355    pub artifacts: Vec<ArtifactRef>,
356
357    #[serde(rename = "policyRef", skip_serializing_if = "Option::is_none")]
358    pub policy_ref: Option<String>,
359
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub meta: Option<serde_json::Value>,
362}
363
364/// Records an agent's reasoning and decision context.
365///
366/// This is the "why" layer -- agents provide this explicitly to explain
367/// inference decisions, model usage, and confidence levels.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct DecisionStatement {
370    /// Always `TYPE_DECISION`
371    #[serde(rename = "type")]
372    pub type_: String,
373
374    /// RFC 3339 timestamp, set at sign time.
375    pub timestamp: String,
376
377    /// DID-style actor URI. e.g. "agent://analyst"
378    pub actor: String,
379
380    /// Links this artifact to its parent in the chain.
381    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
382    pub parent_id: Option<String>,
383
384    /// Model used for inference. e.g. "claude-opus-4-7", "kimi-k2", "gpt-5"
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub model: Option<String>,
387
388    /// Model version if known.
389    #[serde(rename = "modelVersion", skip_serializing_if = "Option::is_none")]
390    pub model_version: Option<String>,
391
392    /// Provider that hosts the model. e.g. "anthropic", "moonshot",
393    /// "openai", "google", "meta", "mistral", "ollama".
394    ///
395    /// Distinct from `model`: a "surface" (the runtime that runs the
396    /// agent loop -- Claude Code, Cursor, Codex, OpenClaw, Hermes,
397    /// Cline) can be paired with any provider/model. Kimi for
398    /// example is `model = "kimi-k2"` with `provider = "moonshot"`,
399    /// runnable from any surface that speaks OpenAI-compatible APIs.
400    /// Attributing both lets a downstream auditor reason about
401    /// surface, model, and provider independently.
402    ///
403    /// Defaulted on deserialization so pre-v0.10.2 artifacts that
404    /// were signed without provider still parse cleanly.
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub provider: Option<String>,
407
408    /// Number of input tokens consumed.
409    #[serde(rename = "tokensIn", skip_serializing_if = "Option::is_none")]
410    pub tokens_in: Option<u64>,
411
412    /// Number of output tokens produced.
413    #[serde(rename = "tokensOut", skip_serializing_if = "Option::is_none")]
414    pub tokens_out: Option<u64>,
415
416    /// SHA-256 digest of the full prompt (not the prompt itself).
417    #[serde(rename = "promptDigest", skip_serializing_if = "Option::is_none")]
418    pub prompt_digest: Option<String>,
419
420    /// Human-readable summary of the decision.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub summary: Option<String>,
423
424    /// Confidence level 0.0-1.0 if the agent provides it.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub confidence: Option<f64>,
427
428    /// Other options the agent considered.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub alternatives: Option<Vec<String>>,
431
432    /// Arbitrary additional metadata.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub meta: Option<serde_json::Value>,
435}
436
437// Helpers for skip_serializing_if
438fn is_empty_subject(s: &SubjectRef) -> bool {
439    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
440}
441
442// --- Constructors ---
443
444impl ActionStatement {
445    pub fn new(actor: impl Into<String>, action: impl Into<String>) -> Self {
446        Self {
447            type_: TYPE_ACTION.into(),
448            timestamp: now_rfc3339(),
449            actor: actor.into(),
450            action: action.into(),
451            subject: SubjectRef::default(),
452            parent_id: None,
453            approval_nonce: None,
454            policy_ref: None,
455            meta: None,
456        }
457    }
458}
459
460impl ApprovalStatement {
461    pub fn new(approver: impl Into<String>, nonce: impl Into<String>) -> Self {
462        Self {
463            type_: TYPE_APPROVAL.into(),
464            timestamp: now_rfc3339(),
465            approver: approver.into(),
466            subject: SubjectRef::default(),
467            description: None,
468            expires_at: None,
469            delegatable: false,
470            nonce: nonce.into(),
471            scope: None,
472            policy_ref: None,
473            meta: None,
474        }
475    }
476}
477
478impl HandoffStatement {
479    pub fn new(
480        from:      impl Into<String>,
481        to:        impl Into<String>,
482        artifacts: Vec<String>,
483    ) -> Self {
484        Self {
485            type_: TYPE_HANDOFF.into(),
486            timestamp: now_rfc3339(),
487            from: from.into(),
488            to: to.into(),
489            artifacts,
490            approval_ids: vec![],
491            obligations: vec![],
492            delegatable: false,
493            task_ref: None,
494            policy_ref: None,
495            meta: None,
496        }
497    }
498}
499
500impl ReceiptStatement {
501    pub fn new(system: impl Into<String>, kind: impl Into<String>) -> Self {
502        Self {
503            type_: TYPE_RECEIPT.into(),
504            timestamp: now_rfc3339(),
505            system: system.into(),
506            subject: None,
507            kind: kind.into(),
508            payload: None,
509            payload_digest: None,
510            policy_ref: None,
511            meta: None,
512        }
513    }
514}
515
516impl DecisionStatement {
517    pub fn new(actor: impl Into<String>) -> Self {
518        Self {
519            type_: TYPE_DECISION.into(),
520            timestamp: now_rfc3339(),
521            actor: actor.into(),
522            parent_id: None,
523            model: None,
524            model_version: None,
525            provider: None,
526            tokens_in: None,
527            tokens_out: None,
528            prompt_digest: None,
529            summary: None,
530            confidence: None,
531            alternatives: None,
532            meta: None,
533        }
534    }
535}
536
537fn now_rfc3339() -> String {
538    // std::time gives us duration since UNIX_EPOCH.
539    // Format as ISO 8601 / RFC 3339 without pulling in chrono.
540    use std::time::{SystemTime, UNIX_EPOCH};
541    let secs = SystemTime::now()
542        .duration_since(UNIX_EPOCH)
543        .unwrap_or_default()
544        .as_secs();
545    unix_to_rfc3339(secs)
546}
547
548pub fn unix_to_rfc3339(secs: u64) -> String {
549    // Minimal RFC 3339 formatter — no external deps.
550    // Accurate for dates 1970–2099.
551    let s = secs;
552    let (y, mo, d, h, mi, sec) = seconds_to_ymd_hms(s);
553    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
554}
555
556fn seconds_to_ymd_hms(s: u64) -> (u64, u64, u64, u64, u64, u64) {
557    let sec  = s % 60;
558    let mins = s / 60;
559    let min  = mins % 60;
560    let hrs  = mins / 60;
561    let hour = hrs % 24;
562    let days = hrs / 24;
563
564    // Gregorian calendar calculation from day count
565    let (y, m, d) = days_to_ymd(days);
566    (y, m, d, hour, min, sec)
567}
568
569fn days_to_ymd(days: u64) -> (u64, u64, u64) {
570    // Days since 1970-01-01
571    let mut d = days;
572    let mut year = 1970u64;
573    loop {
574        let dy = if is_leap(year) { 366 } else { 365 };
575        if d < dy { break; }
576        d -= dy;
577        year += 1;
578    }
579    let months = if is_leap(year) {
580        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
581    } else {
582        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
583    };
584    let mut month = 1u64;
585    for dm in months {
586        if d < dm { break; }
587        d -= dm;
588        month += 1;
589    }
590    (year, month, d + 1)
591}
592
593fn is_leap(y: u64) -> bool {
594    (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::attestation::{sign, Ed25519Signer, Verifier};
601
602    #[test]
603    fn payload_type_format() {
604        assert_eq!(
605            payload_type("action"),
606            "application/vnd.treeship.action.v1+json"
607        );
608        assert_eq!(
609            payload_type("approval"),
610            "application/vnd.treeship.approval.v1+json"
611        );
612    }
613
614    #[test]
615    fn action_statement_sign_verify() {
616        let signer   = Ed25519Signer::generate("key_test").unwrap();
617        let verifier = Verifier::from_signer(&signer);
618
619        let mut stmt = ActionStatement::new("agent://researcher", "tool.call");
620        stmt.parent_id = Some("art_aabbccdd11223344aabbccdd11223344".into());
621
622        let pt     = payload_type("action");
623        let result = sign(&pt, &stmt, &signer).unwrap();
624
625        assert!(result.artifact_id.starts_with("art_"));
626
627        let vr = verifier.verify(&result.envelope).unwrap();
628        assert_eq!(vr.artifact_id, result.artifact_id);
629
630        // Decode and check the payload survived serialization
631        let decoded: ActionStatement = result.envelope.unmarshal_statement().unwrap();
632        assert_eq!(decoded.actor, "agent://researcher");
633        assert_eq!(decoded.action, "tool.call");
634        assert_eq!(decoded.type_, TYPE_ACTION);
635    }
636
637    #[test]
638    fn approval_statement_with_nonce() {
639        let signer = Ed25519Signer::generate("key_human").unwrap();
640
641        let mut approval = ApprovalStatement::new("human://alice", "nonce_abc123");
642        approval.description = Some("approve laptop purchase < $1500".into());
643        approval.scope = Some(ApprovalScope {
644            max_actions: Some(1),
645            allowed_actions: vec!["stripe.payment_intent.create".into()],
646            ..Default::default()
647        });
648
649        let pt     = payload_type("approval");
650        let result = sign(&pt, &approval, &signer).unwrap();
651        assert!(result.artifact_id.starts_with("art_"));
652
653        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
654        assert_eq!(decoded.nonce, "nonce_abc123");
655        assert_eq!(decoded.scope.unwrap().max_actions, Some(1));
656    }
657
658    #[test]
659    fn approval_scope_full_grant_roundtrips() {
660        // Every scope axis populated -- the full "allowed_actors +
661        // allowed_actions + allowed_subjects + max_uses" grant must
662        // serialize, sign, deserialize, and read back identically.
663        let signer = Ed25519Signer::generate("key_piyush").unwrap();
664
665        let mut approval = ApprovalStatement::new("human://piyush", "nonce_deadbeef");
666        approval.description = Some("Deploy production after final review".into());
667        approval.scope = Some(ApprovalScope {
668            max_actions:      Some(1),
669            valid_until:      None,
670            allowed_actors:   vec!["agent://deployer".into()],
671            allowed_actions:  vec!["deploy.production".into()],
672            allowed_subjects: vec!["env://production".into()],
673            extra:            None,
674        });
675
676        let pt = payload_type("approval");
677        let result = sign(&pt, &approval, &signer).unwrap();
678        let decoded: ApprovalStatement = result.envelope.unmarshal_statement().unwrap();
679        let scope = decoded.scope.expect("scope must round-trip");
680
681        assert_eq!(scope.allowed_actors,   vec!["agent://deployer".to_string()]);
682        assert_eq!(scope.allowed_actions,  vec!["deploy.production".to_string()]);
683        assert_eq!(scope.allowed_subjects, vec!["env://production".to_string()]);
684        assert_eq!(scope.max_actions,      Some(1));
685    }
686
687    #[test]
688    fn approval_scope_is_unscoped_predicate() {
689        // Default scope = unscoped.
690        assert!(ApprovalScope::default().is_unscoped());
691
692        // Any single populated axis flips the predicate.
693        assert!(!ApprovalScope { max_actions: Some(1), ..Default::default() }.is_unscoped());
694        assert!(!ApprovalScope { valid_until: Some("2030-01-01T00:00:00Z".into()), ..Default::default() }.is_unscoped());
695        assert!(!ApprovalScope { allowed_actors:   vec!["agent://x".into()], ..Default::default() }.is_unscoped());
696        assert!(!ApprovalScope { allowed_actions:  vec!["doit".into()],      ..Default::default() }.is_unscoped());
697        assert!(!ApprovalScope { allowed_subjects: vec!["env://prod".into()], ..Default::default() }.is_unscoped());
698    }
699
700    #[test]
701    fn approval_scope_legacy_payloads_decode_with_empty_new_fields() {
702        // Pre-0.9.6 payloads that omitted allowed_actors / allowed_subjects
703        // must continue to deserialize cleanly. We construct the JSON shape
704        // directly to simulate an envelope from an older signer.
705        let legacy = serde_json::json!({
706            "maxActions": 1,
707            "allowedActions": ["stripe.payment_intent.create"]
708        });
709        let scope: ApprovalScope = serde_json::from_value(legacy).unwrap();
710        assert_eq!(scope.max_actions, Some(1));
711        assert_eq!(scope.allowed_actions, vec!["stripe.payment_intent.create".to_string()]);
712        // New fields default to empty -- not present in legacy payload.
713        assert!(scope.allowed_actors.is_empty());
714        assert!(scope.allowed_subjects.is_empty());
715        assert!(!scope.is_unscoped()); // because max_actions IS set
716    }
717
718    #[test]
719    fn handoff_statement() {
720        let signer = Ed25519Signer::generate("key_agent").unwrap();
721
722        let handoff = HandoffStatement::new(
723            "agent://researcher",
724            "agent://checkout",
725            vec!["art_aabbccdd11223344aabbccdd11223344".into()],
726        );
727
728        let pt     = payload_type("handoff");
729        let result = sign(&pt, &handoff, &signer).unwrap();
730        let decoded: HandoffStatement = result.envelope.unmarshal_statement().unwrap();
731
732        assert_eq!(decoded.from, "agent://researcher");
733        assert_eq!(decoded.to,   "agent://checkout");
734        assert_eq!(decoded.artifacts.len(), 1);
735    }
736
737    #[test]
738    fn receipt_statement() {
739        let signer = Ed25519Signer::generate("key_system").unwrap();
740
741        let mut receipt = ReceiptStatement::new("system://stripe-webhook", "confirmation");
742        receipt.payload = Some(serde_json::json!({
743            "eventId": "evt_abc123",
744            "status": "succeeded"
745        }));
746
747        let pt     = payload_type("receipt");
748        let result = sign(&pt, &receipt, &signer).unwrap();
749        let decoded: ReceiptStatement = result.envelope.unmarshal_statement().unwrap();
750
751        assert_eq!(decoded.system, "system://stripe-webhook");
752        assert_eq!(decoded.kind,   "confirmation");
753    }
754
755    #[test]
756    fn nonce_binding_survives_serialization() {
757        let signer   = Ed25519Signer::generate("key_test").unwrap();
758
759        // The nonce in the approval must survive a sign→verify→decode round-trip.
760        // The verifier checks that action.approval_nonce == approval.nonce.
761        let approval = ApprovalStatement::new("human://alice", "secure_nonce_xyz");
762        let pt       = payload_type("approval");
763        let signed   = sign(&pt, &approval, &signer).unwrap();
764
765        let decoded: ApprovalStatement = signed.envelope.unmarshal_statement().unwrap();
766        assert_eq!(decoded.nonce, "secure_nonce_xyz", "nonce must survive serialization");
767    }
768
769    #[test]
770    fn decision_statement_sign_verify() {
771        let signer = Ed25519Signer::generate("key_test").unwrap();
772        let verifier = Verifier::from_signer(&signer);
773
774        let mut stmt = DecisionStatement::new("agent://analyst");
775        stmt.model = Some("claude-opus-4".into());
776        stmt.tokens_in = Some(8432);
777        stmt.tokens_out = Some(1247);
778        stmt.summary = Some("Contract looks standard.".into());
779        stmt.confidence = Some(0.91);
780
781        let pt = payload_type("decision");
782        let result = sign(&pt, &stmt, &signer).unwrap();
783
784        assert!(result.artifact_id.starts_with("art_"));
785
786        let vr = verifier.verify(&result.envelope).unwrap();
787        assert_eq!(vr.artifact_id, result.artifact_id);
788
789        // Decode and check the payload survived serialization
790        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
791        assert_eq!(decoded.actor, "agent://analyst");
792        assert_eq!(decoded.model, Some("claude-opus-4".into()));
793        assert_eq!(decoded.tokens_in, Some(8432));
794        assert_eq!(decoded.tokens_out, Some(1247));
795        assert_eq!(decoded.summary, Some("Contract looks standard.".into()));
796        assert_eq!(decoded.confidence, Some(0.91));
797        assert_eq!(decoded.type_, TYPE_DECISION);
798    }
799
800    #[test]
801    fn decision_statement_provider_roundtrips() {
802        // v0.10.2 added `provider` so Kimi (model=kimi-k2 / provider=moonshot)
803        // and similar split-model/provider attributions land on the
804        // signed artifact, not just on the unsigned session event.
805        let signer   = Ed25519Signer::generate("key_test").unwrap();
806        let verifier = Verifier::from_signer(&signer);
807
808        let mut stmt = DecisionStatement::new("agent://researcher");
809        stmt.model    = Some("kimi-k2".into());
810        stmt.provider = Some("moonshot".into());
811
812        let pt = payload_type("decision");
813        let result = sign(&pt, &stmt, &signer).unwrap();
814        verifier.verify(&result.envelope).unwrap();
815
816        let decoded: DecisionStatement = result.envelope.unmarshal_statement().unwrap();
817        assert_eq!(decoded.model,    Some("kimi-k2".into()));
818        assert_eq!(decoded.provider, Some("moonshot".into()));
819    }
820
821    #[test]
822    fn decision_statement_legacy_payload_without_provider_decodes() {
823        // Pre-v0.10.2 artifacts were signed without `provider`. The
824        // field MUST default to None on deserialize so an old receipt
825        // verifying against a fresh CLI doesn't fail with
826        // "missing field provider". Defaulting is configured via
827        // `#[serde(default)]` -- this test pins that contract.
828        let raw = serde_json::json!({
829            "type": TYPE_DECISION,
830            "timestamp": "2026-04-30T12:00:00Z",
831            "actor": "agent://legacy",
832            "model": "claude-opus-4",
833        });
834        let parsed: DecisionStatement = serde_json::from_value(raw).unwrap();
835        assert_eq!(parsed.model, Some("claude-opus-4".into()));
836        assert_eq!(parsed.provider, None);
837    }
838
839    #[test]
840    fn different_statement_types_different_ids() {
841        // Action and approval with identical fields but different types
842        // must produce different artifact IDs — enforced by payloadType in PAE.
843        let signer = Ed25519Signer::generate("key_test").unwrap();
844
845        let action   = ActionStatement::new("agent://test", "do.thing");
846        let approval = ApprovalStatement::new("human://test", "nonce_123");
847
848        let r_action   = sign(&payload_type("action"),   &action,   &signer).unwrap();
849        let r_approval = sign(&payload_type("approval"), &approval, &signer).unwrap();
850
851        assert_ne!(r_action.artifact_id, r_approval.artifact_id);
852    }
853
854    #[test]
855    fn timestamp_format() {
856        let ts = unix_to_rfc3339(0);
857        assert_eq!(ts, "1970-01-01T00:00:00Z");
858
859        let ts2 = unix_to_rfc3339(1_000_000_000);
860        assert_eq!(ts2, "2001-09-09T01:46:40Z");
861    }
862}