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