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