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