Skip to main content

treeship_core/statements/
mod.rs

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