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