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