Skip to main content

treeship_core/statements/
action_v2.rs

1//! `treeship/action/v2` -- the mandate / effect receipt.
2//!
3//! v1 proves *what an agent claims it did*, signed by its own key. That is
4//! still self-report with a signature on it. v2 binds two additional blocks
5//! into the signed payload:
6//!
7//! * `mandate` -- the per-hop authorization the action was exercised under
8//!   (the grant it was minted from, its scope, audience, TTL, delegation
9//!   depth, and how to check revocation). Lets a verifier answer "was this
10//!   action *authorized*?", not just "was it signed?".
11//! * `effect` -- what the action actually touched (input/output hashes, an
12//!   optional externally-observed `readback`, cost, side effects, and a
13//!   `context_snapshot` tying the action to the state it acted from). Lets a
14//!   verifier move from "the agent narrated X" toward "X actually happened".
15//!
16//! Both blocks live inside the DSSE `payload` (the PAE-signed body), so an
17//! attacker cannot strip or edit them without breaking the signature -- the
18//! same binding guarantee the rest of the statement enjoys. A v2-aware
19//! verifier that ignores them would be a silent open-fail; `verify_mandate`
20//! is written to fail closed and to report `Unverified` (never `Pass`) when a
21//! layer cannot be checked, mirroring the AUD-01 honesty posture used
22//! elsewhere in the crate.
23//!
24//! ## Validity is judged at `signed_at`, not "now"
25//!
26//! A receipt signed while its grant was valid stays valid forever -- exactly
27//! like a TLS certificate whose later revocation does not retroactively
28//! invalidate everything it ever signed. So the TTL and revocation checks are
29//! evaluated against `timestamp` (the instant the receipt was signed), and
30//! `revoked_at` is a *timestamp*, never a boolean. This is the stillos /
31//! Concordium correction promoted to an invariant.
32//!
33//! Build order (docs: receipt-v2 spec §9): this module lands step 1 (the
34//! statement + canonical binding + fail-closed verifier) and step 2 (the
35//! first-class grant object + attenuation checks). `receipt export`
36//! emission, external revocation-timestamp resolvers, the ZMEM
37//! `context_snapshot` provider, and the Hermes parent->child->tool demo are
38//! later steps that build on these primitives.
39
40use serde::{Deserialize, Serialize};
41
42use super::invitation::{canonical_json_digest, parse_rfc3339_to_unix};
43use super::SubjectRef;
44use crate::attestation::{Signer, SignerError};
45use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
46use ed25519_dalek::{Signature, VerifyingKey};
47
48/// Statement type tag for the mandate/effect receipt.
49pub const TYPE_ACTION_V2: &str = "treeship/action/v2";
50
51/// Canonical MIME payloadType for a v2 statement suffix. Distinct from the
52/// v1 `payload_type` so the DSSE PAE domain-separates v1 from v2 signatures:
53/// a v1-only verifier checking the signature of a v2 receipt still sees valid
54/// signature math, but the differing payloadType (and `type` tag) is what
55/// lets it recognize the receipt as v2 and surface the mandate blocks as
56/// unverified rather than silently treating it as fully verified.
57pub fn payload_type_v2(suffix: &str) -> String {
58    format!("application/vnd.treeship.{}.v2+json", suffix)
59}
60
61// ---------------------------------------------------------------------------
62// Schema -- mandate / effect blocks
63// ---------------------------------------------------------------------------
64
65/// How a verifier checks revocation-at-signing-time. `revoked_at` is a
66/// timestamp (RFC 3339), never a boolean: a grant revoked at time T does not
67/// retroactively invalidate a receipt signed before T.
68///
69/// The embedded `revoked_at` is authored by the same party that signed the
70/// receipt, so it is only trustworthy for the *positive* direction (an honest
71/// signer recording that the grant was later revoked). It MUST NOT be trusted
72/// to assert non-revocation; that is the job of an external
73/// [`RevocationSource`]. See [`verify_mandate`].
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Revocation {
76    /// Where a verifier resolves the revocation timestamp: `concordium://…`,
77    /// `hub://…`, or `url_json://…`.
78    pub path: String,
79
80    /// RFC 3339 instant the grant was revoked, or `None` if not (yet) revoked.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub revoked_at: Option<String>,
83}
84
85/// The per-hop authorization an action was exercised under.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Mandate {
88    /// Id of the capability grant this hop was minted from.
89    pub grant_id: String,
90
91    /// Who issued the grant (parent hop / operator). Ed25519 pubkey,
92    /// base64url-no-pad, so a verifier can check `issuer_sig` offline.
93    pub grantor: String,
94
95    /// Grantor's signature over the grant's canonical bytes (see [`Grant`]).
96    /// Optional in the receipt because a verifier may resolve the grant (and
97    /// its signature) out of band by `grant_id`; when present it lets the
98    /// grant chain be walked fully offline.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub issuer_sig: Option<String>,
101
102    /// Hash of the declared task/intent this hop serves.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub objective_hash: Option<String>,
105
106    /// Allowed action set. Each entry is an exact label (`payments.charge`)
107    /// or a family glob (`payments.*`). An empty scope authorizes nothing.
108    #[serde(default)]
109    pub scope: Vec<String>,
110
111    /// Who this grant is FOR. Prevents cross-audience replay (a grant minted
112    /// for audience A cannot authorize an action against audience B).
113    pub audience: String,
114
115    /// The delegation edge this hop descends from.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub parent_request_id: Option<String>,
118
119    /// Hops from the root grant. Caps re-delegation together with
120    /// `max_delegation`.
121    #[serde(default)]
122    pub delegation_depth: u32,
123
124    /// RFC 3339 instant the grant became valid.
125    pub issued_at: String,
126
127    /// RFC 3339 instant the grant expires (exclusive upper bound).
128    pub expiry: String,
129
130    /// Deepest this grant may be re-minted.
131    #[serde(default)]
132    pub max_delegation: u32,
133
134    /// Revocation source + (optional) revoked-at timestamp.
135    pub revocation: Revocation,
136}
137
138/// A metered cost attached to an effect.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Cost {
141    pub unit: String,
142    pub amount: u64,
143}
144
145/// An independent observation of an action's effect, made by someone other
146/// than the actor. A witness is the raw material of effect verification: the
147/// actor's own `effect_confidence` is a claim it can mint, but a witness
148/// whose key is NOT the actor's, whose `signature` verifies against a trusted
149/// root, and whose `observation` matches the effect is a signal the actor
150/// could not have forged. Multiple independent witnesses are how a `Verified`
151/// confidence earns its evidence beyond a single self-reported `readback`.
152///
153/// This struct is only the record. It carries NO independent weight on its
154/// own: an unsigned witness, or one signed by the actor's own key, proves
155/// nothing. The reconciliation -- does `observer` resolve to a trusted,
156/// non-actor key? does `signature` verify over the canonical tuple? does
157/// `observation` match the effect? -- happens in verify, never here. Do not
158/// treat the mere presence of a witness as evidence.
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Witness {
161    /// URI or key id of the observer, e.g. "agent://auditor", "key_9f2c".
162    /// Verify resolves this to a trust root and requires it to differ from
163    /// the action's actor.
164    pub observer: String,
165    /// `sha256:<hex>` of what the observer independently saw. Verify checks
166    /// this equals the effect's own observed post-state (`readback` /
167    /// `output_hash`); a witness that observed something else corroborates
168    /// nothing.
169    pub observation: String,
170    /// RFC 3339 instant the observation was made.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub observed_at: Option<String>,
173    /// The observer's signature over its own (observer, observation,
174    /// observed_at) tuple, verifiable against `observer`'s key. Absent means
175    /// unsigned: verify gives it zero independent weight.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub signature: Option<String>,
178}
179
180impl Witness {
181    /// True when the witness at least carries a signature to check. This is a
182    /// necessary-not-sufficient precondition: verify still has to confirm the
183    /// signature verifies, the observer is a trusted non-actor key, and the
184    /// observation matches. A `true` here is NOT evidence by itself.
185    pub fn is_signed(&self) -> bool {
186        self.signature.is_some()
187    }
188}
189
190/// What the action actually touched. Descriptive; every field is optional
191/// because not every action has cheap external ground truth. `readback` is
192/// the strongest claim: a hash of externally-observed post-state the actor
193/// did not author.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub struct Effect {
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub input_hash: Option<String>,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub output_hash: Option<String>,
200    /// Hash of externally-observed post-state (DB readback, provider-API
201    /// state fetch, on-chain balance, second-runtime observation) -- a signal
202    /// the actor cannot mint.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub readback: Option<String>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub bytes_moved: Option<u64>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub cost: Option<Cost>,
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub side_effects: Vec<String>,
211    /// Hash of the state the agent acted *from* (produced by ZMEM). Lets a
212    /// verifier detect action on stale/poisoned context.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub context_snapshot: Option<String>,
215    /// The actor's honest self-declaration of whether the effect actually
216    /// happened ("the ack is not the act"). This is a CLAIM, not proof: the
217    /// verifier cross-checks it against the independent evidence above (a
218    /// `readback` the actor could not mint), and a `Verified` claim carrying no
219    /// such evidence is downgraded, never taken on faith. Absent means the
220    /// actor made no effect claim at all.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub effect_confidence: Option<EffectConfidence>,
223    /// Independent observers who corroborate this effect. Each is a claim the
224    /// actor bundled in; verify decides which (if any) are trustworthy signals
225    /// the actor could not mint. An empty list -- the common case -- means the
226    /// only effect evidence is the actor's own `readback`.
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub witnesses: Vec<Witness>,
229}
230
231/// How confident the actor is that an action's real-world effect happened,
232/// separate from whether the receipt's signature is valid. Encodes the honest
233/// middle ground the "ack is not the act" discourse keeps asking for: an agent
234/// that cannot confirm the effect declares `Unknown` or `NotVerified` instead
235/// of forcing a green success.
236///
237/// A verifier NEVER trusts `Verified` on the actor's word alone — see
238/// [`Effect::has_independent_evidence`] and the effect-confidence check in
239/// `treeship verify`, which reconciles this claim with the evidence present.
240#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum EffectConfidence {
243    /// Independently confirmed: an external read-back or witness the actor
244    /// could not mint shows the intended post-state.
245    Verified,
246    /// Some effect evidence, but incomplete (e.g. the sink accepted the write
247    /// but nothing read the post-state back).
248    Partial,
249    /// The observed state is consistent with more than one outcome.
250    Ambiguous,
251    /// The actor could not determine whether the effect happened.
252    Unknown,
253    /// Attempted, but the effect was not independently verified — the common
254    /// honest default: the tool returned ok and nothing read it back.
255    NotVerified,
256}
257
258impl Effect {
259    /// True when the effect carries a signal the actor could not have minted
260    /// itself (an external read-back). This is what lets a verifier honor a
261    /// `Verified` confidence claim; without it, `Verified` is downgraded.
262    ///
263    /// Deliberately gated on `readback` alone, NOT on `witnesses`: a witness
264    /// only becomes evidence once verify confirms its signature against a
265    /// trusted non-actor key, which this pure-data check cannot do. Counting
266    /// an unverified witness here would let the actor inflate its own ceiling
267    /// with a fabricated observer -- exactly the "ok for the wrong reason" we
268    /// refuse.
269    pub fn has_independent_evidence(&self) -> bool {
270        self.readback.is_some()
271    }
272
273    /// The witnesses that at least carry a signature verify can attempt to
274    /// check. Callers must still run that check; a non-empty result is a
275    /// precondition for witness-backed evidence, never evidence itself.
276    pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
277        self.witnesses.iter().filter(|w| w.is_signed())
278    }
279
280    /// The strongest effect confidence the *evidence* supports, independent of
281    /// what the actor claimed. `Verified` requires independent evidence;
282    /// otherwise the honest ceiling is `NotVerified`. Callers reconcile this
283    /// with `effect_confidence` (the claim): the effective verdict is the
284    /// weaker of the two, so an actor can honestly downgrade but never inflate.
285    pub fn evidence_ceiling(&self) -> EffectConfidence {
286        if self.has_independent_evidence() {
287            EffectConfidence::Verified
288        } else {
289            EffectConfidence::NotVerified
290        }
291    }
292}
293
294/// Who and what produced this action: the model runtime the actor was
295/// executing under at sign time. Binding it into the signed statement lets a
296/// verifier holding a pinned expectation ("this agent must run
297/// claude-opus-4-8 with this tool schema and this system prompt") detect a
298/// swapped model, an altered tool set, or a changed system prompt after the
299/// fact. Where `effect` records *what* the action touched, this records *what
300/// executed it*.
301///
302/// Every field is optional and actor-attested: it is signed by the actor's
303/// key, so it is exactly as trustworthy as the actor, and it carries no
304/// weight on its own. A verifier can only turn it into a Pass by reconciling
305/// it against an out-of-band pinned expectation; absent means "not recorded"
306/// (unverifiable), never a pass. The hashes are `sha256:<hex>` over the exact
307/// bytes presented to the model, so equality is the whole check.
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RuntimeIdentity {
310    /// Model provider, e.g. "anthropic", "openai".
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub provider: Option<String>,
313    /// Model identifier the actor ran under, e.g. "claude-opus-4-8".
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub model: Option<String>,
316    /// Hash of the exact tool schemas the agent had available this turn.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub tool_schema_hash: Option<String>,
319    /// Hash of the exact system prompt the agent ran under.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub system_prompt_hash: Option<String>,
322}
323
324impl RuntimeIdentity {
325    /// True when no field is populated -- the runtime binding attests nothing,
326    /// so a verifier has nothing to pin against an expectation. Verify treats
327    /// this the same as an absent `runtime`: the runtime layer is
328    /// unverifiable, not a pass.
329    pub fn is_unbound(&self) -> bool {
330        self.provider.is_none()
331            && self.model.is_none()
332            && self.tool_schema_hash.is_none()
333            && self.system_prompt_hash.is_none()
334    }
335}
336
337/// `treeship/action/v2` statement. Additive over v1: the v1 core fields are
338/// unchanged; `audience`, `mandate`, `effect`, and `runtime` are new.
339/// `mandate` is required (a v2 receipt with no mandate would just be a v1
340/// receipt); `effect` and `runtime` are optional.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ActionStatementV2 {
343    #[serde(rename = "type")]
344    pub type_: String,
345
346    /// RFC 3339 timestamp, set at sign time. This IS `signed_at`, the instant
347    /// that gates mandate validity.
348    pub timestamp: String,
349
350    pub actor: String,
351    pub action: String,
352
353    /// Audience this action targeted. Checked against `mandate.audience` to
354    /// block cross-audience replay. Absent means the signer did not record a
355    /// target audience, which makes the audience layer unverifiable (not a
356    /// pass).
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub audience: Option<String>,
359
360    #[serde(default, skip_serializing_if = "subject_is_empty")]
361    pub subject: SubjectRef,
362
363    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
364    pub parent_id: Option<String>,
365
366    pub mandate: Mandate,
367
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub effect: Option<Effect>,
370
371    /// The model runtime the actor executed under. See [`RuntimeIdentity`].
372    /// Absent means the signer recorded no runtime, which leaves the runtime
373    /// layer unverifiable (not a pass).
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub runtime: Option<RuntimeIdentity>,
376
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub meta: Option<serde_json::Value>,
379}
380
381fn subject_is_empty(s: &SubjectRef) -> bool {
382    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
383}
384
385impl ActionStatementV2 {
386    /// Construct a v2 action carrying the given mandate.
387    pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
388        Self {
389            type_: TYPE_ACTION_V2.into(),
390            timestamp: super::unix_to_rfc3339(now_unix()),
391            actor: actor.into(),
392            action: action.into(),
393            audience: None,
394            subject: SubjectRef::default(),
395            parent_id: None,
396            mandate,
397            effect: None,
398            runtime: None,
399            meta: None,
400        }
401    }
402}
403
404fn now_unix() -> u64 {
405    use std::time::{SystemTime, UNIX_EPOCH};
406    SystemTime::now()
407        .duration_since(UNIX_EPOCH)
408        .unwrap_or_default()
409        .as_secs()
410}
411
412// ---------------------------------------------------------------------------
413// Scope matching
414// ---------------------------------------------------------------------------
415
416/// True iff `action` is authorized by at least one entry of `scope`. An entry
417/// is either an exact label or a family glob `foo.*` (which matches `foo` and
418/// anything under `foo.`). A bare `*` is deliberately NOT a wildcard: an
419/// unscoped "authorize everything" grant is exactly the open-fail this schema
420/// exists to prevent.
421pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
422    scope.iter().any(|entry| scope_entry_matches(entry, action))
423}
424
425fn scope_entry_matches(entry: &str, action: &str) -> bool {
426    if let Some(prefix) = entry.strip_suffix(".*") {
427        action == prefix || action.starts_with(&format!("{prefix}."))
428    } else {
429        entry == action
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Revocation source
435// ---------------------------------------------------------------------------
436
437/// Result of resolving a grant's revocation state.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum RevocationStatus {
440    /// The source confirms the grant was not revoked.
441    NotRevoked,
442    /// The source reports the grant was revoked at this RFC 3339 instant.
443    RevokedAt(String),
444    /// The source could not be consulted (offline, no resolver configured,
445    /// unknown path). Carries a human-readable reason.
446    Unknown(String),
447}
448
449/// Resolves revocation-at-signing-time for a grant. Implementations back onto
450/// Concordium, the Treeship Hub, or a signed `url_json` list (spec §9 step
451/// 4). The embedded `mandate.revocation.revoked_at` is NOT an authority for
452/// non-revocation, so verification defaults to [`NoRevocationSource`], which
453/// reports `Unknown` and drives an honest `Unverified` verdict.
454pub trait RevocationSource {
455    fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
456}
457
458/// The default: no resolver is configured, so revocation is uncheckable.
459pub struct NoRevocationSource;
460
461impl RevocationSource for NoRevocationSource {
462    fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
463        RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
464    }
465}
466
467// ---------------------------------------------------------------------------
468// Mandate verdict + verifier
469// ---------------------------------------------------------------------------
470
471/// Outcome of checking a v2 receipt's mandate. `Fail` and `Unverified` carry
472/// human-readable reasons for audit output. Precedence: any checkable
473/// violation makes the whole verdict `Fail`; otherwise any uncheckable layer
474/// makes it `Unverified`; only when every layer is checkable and satisfied is
475/// it `Pass`.
476#[derive(Debug, Clone, PartialEq, Eq)]
477pub enum MandateVerdict {
478    Pass,
479    Unverified(Vec<String>),
480    Fail(Vec<String>),
481}
482
483impl MandateVerdict {
484    pub fn is_pass(&self) -> bool {
485        matches!(self, MandateVerdict::Pass)
486    }
487}
488
489/// Verify the mandate layers of a v2 receipt, judged at `signed_at`
490/// (`stmt.timestamp`). Fails closed: a malformed timestamp, an out-of-window
491/// signature, an out-of-scope action, an audience mismatch, or a
492/// revoked-before-signing grant all yield `Fail`. A layer that cannot be
493/// checked (no audience recorded, no revocation resolver) yields `Unverified`
494/// rather than a false `Pass`.
495///
496/// Signature validity is a precondition checked elsewhere (the DSSE envelope
497/// verify); this function assumes the bytes are authentic and evaluates the
498/// *authorization* the signed bytes assert.
499pub fn verify_mandate(
500    stmt: &ActionStatementV2,
501    revocation: &dyn RevocationSource,
502) -> MandateVerdict {
503    let mut fail: Vec<String> = Vec::new();
504    let mut unver: Vec<String> = Vec::new();
505
506    if stmt.type_ != TYPE_ACTION_V2 {
507        return MandateVerdict::Fail(vec![format!(
508            "statement type '{}' is not {TYPE_ACTION_V2}",
509            stmt.type_
510        )]);
511    }
512
513    let m = &stmt.mandate;
514
515    // signed_at is the gating instant. A receipt with an unparseable
516    // timestamp cannot have its mandate evaluated -- fail closed.
517    let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
518        Some(t) => t,
519        None => {
520            return MandateVerdict::Fail(vec![format!(
521                "timestamp '{}' is not RFC 3339",
522                stmt.timestamp
523            )])
524        }
525    };
526
527    // -- scope: the action must be positively authorized.
528    if m.scope.is_empty() {
529        fail.push("mandate.scope is empty: it authorizes no action".into());
530    } else if !action_in_scope(&stmt.action, &m.scope) {
531        fail.push(format!(
532            "action '{}' is not in mandate scope {:?}",
533            stmt.action, m.scope
534        ));
535    }
536
537    // -- audience: block cross-audience replay.
538    if m.audience.trim().is_empty() {
539        fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
540    } else {
541        match &stmt.audience {
542            Some(a) if a == &m.audience => {}
543            Some(a) => fail.push(format!(
544                "action audience '{a}' does not match mandate audience '{}'",
545                m.audience
546            )),
547            None => unver
548                .push("action recorded no audience; cannot confirm it matched the mandate".into()),
549        }
550    }
551
552    // -- TTL: signed_at must be within [issued_at, expiry).
553    match (
554        parse_rfc3339_to_unix(&m.issued_at),
555        parse_rfc3339_to_unix(&m.expiry),
556    ) {
557        (Some(issued), Some(expiry)) => {
558            if expiry <= issued {
559                fail.push(format!(
560                    "mandate expiry '{}' is not after issued_at '{}'",
561                    m.expiry, m.issued_at
562                ));
563            }
564            if signed_at < issued {
565                fail.push(format!(
566                    "signed_at '{}' is before mandate issued_at '{}'",
567                    stmt.timestamp, m.issued_at
568                ));
569            }
570            if signed_at >= expiry {
571                fail.push(format!(
572                    "signed_at '{}' is at or after mandate expiry '{}'",
573                    stmt.timestamp, m.expiry
574                ));
575            }
576        }
577        _ => fail.push(format!(
578            "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
579            m.issued_at, m.expiry
580        )),
581    }
582
583    // -- revocation at signing time. The external source is the authority;
584    // the embedded revoked_at is never trusted to assert non-revocation.
585    match revocation.status(&m.grant_id, &m.revocation.path) {
586        RevocationStatus::NotRevoked => {}
587        RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
588            Some(revoked_at) => {
589                if signed_at >= revoked_at {
590                    fail.push(format!(
591                        "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
592                        stmt.timestamp
593                    ));
594                }
595            }
596            None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
597        },
598        RevocationStatus::Unknown(reason) => {
599            unver.push(format!("revocation could not be checked: {reason}"))
600        }
601    }
602
603    if !fail.is_empty() {
604        MandateVerdict::Fail(fail)
605    } else if !unver.is_empty() {
606        MandateVerdict::Unverified(unver)
607    } else {
608        MandateVerdict::Pass
609    }
610}
611
612// ---------------------------------------------------------------------------
613// Effect verdict + verifier (operational confidence)
614// ---------------------------------------------------------------------------
615
616/// Decides whether a bundled [`Witness`] is a trustworthy, independent
617/// corroboration of an effect. A real implementation MUST require all of:
618/// the witness `signature` verifies against `observer`'s key in the trust
619/// roots; `observer != actor` (a self-witness proves nothing); and
620/// `observation` matches the effect's own observed post-state. The default
621/// [`NoWitnessAuthority`] trusts nothing, so witnesses give zero evidence
622/// lift until an authority is wired in -- fail closed, exactly like
623/// [`NoRevocationSource`].
624pub trait WitnessAuthority {
625    fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
626}
627
628/// The default: no authority configured, so no witness is trusted and
629/// witnesses contribute no evidence.
630pub struct NoWitnessAuthority;
631
632impl WitnessAuthority for NoWitnessAuthority {
633    fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
634        false
635    }
636}
637
638/// The reconciled operational-confidence outcome for a v2 receipt's effect,
639/// kept deliberately separate from cryptographic validity (the DSSE
640/// signature, checked elsewhere). A perfectly-signed receipt can still carry
641/// an effect nobody independently confirmed; this verdict reports how much of
642/// the *effect* the evidence actually supports, never how well it was signed.
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct EffectVerdict {
645    /// The confidence the evidence supports after reconciliation. Equal to the
646    /// actor's claim for every honest (non-`Verified`) claim; a `Verified`
647    /// claim carrying no independent evidence is downgraded to `NotVerified`.
648    /// Never higher than the actor claimed, and never higher than the evidence
649    /// supports.
650    pub effective_confidence: EffectConfidence,
651    /// The actor's own claim, echoed for audit. `None` when the actor recorded
652    /// no `effect_confidence`.
653    pub claimed_confidence: Option<EffectConfidence>,
654    /// Count of bundled witnesses the [`WitnessAuthority`] vouched for.
655    pub trusted_witnesses: usize,
656    /// Audit notes: downgrades applied, and witnesses that were not trusted.
657    pub notes: Vec<String>,
658}
659
660impl EffectVerdict {
661    /// True when the effect is independently confirmed at the strongest level.
662    pub fn is_verified(&self) -> bool {
663        self.effective_confidence == EffectConfidence::Verified
664    }
665}
666
667/// Reconcile a v2 receipt's effect claim against its evidence. Fails safe: the
668/// effective confidence is never higher than what independent, actor-unmintable
669/// evidence supports. Independent evidence is a `readback` the actor could not
670/// mint, or a witness the [`WitnessAuthority`] vouches for (signed by a trusted
671/// key that is not the actor, observing the same post-state).
672///
673/// Only a `Verified` claim asserts the effect definitely happened, so only it
674/// can be inflated and only it is capped. Lesser claims (`Partial`,
675/// `Ambiguous`, `Unknown`, `NotVerified`) are already admissions of incomplete
676/// confidence and pass through unchanged -- the verifier's job is to block
677/// inflation, not to erase an honest actor's own hedging.
678///
679/// Signature validity is a precondition checked elsewhere; this evaluates
680/// operational confidence over bytes assumed authentic.
681pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
682    let effect = match &stmt.effect {
683        Some(e) => e,
684        None => {
685            return EffectVerdict {
686                effective_confidence: EffectConfidence::NotVerified,
687                claimed_confidence: None,
688                trusted_witnesses: 0,
689                notes: vec!["receipt carries no effect block; effect is unverified".into()],
690            }
691        }
692    };
693
694    let mut notes: Vec<String> = Vec::new();
695
696    let trusted_witnesses = effect
697        .witnesses
698        .iter()
699        .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
700        .count();
701    let untrusted = effect.witnesses.len() - trusted_witnesses;
702    if untrusted > 0 {
703        notes.push(format!(
704            "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
705            effect.witnesses.len()
706        ));
707    }
708
709    // The verify layer knows more than the pure-data ceiling: a witness the
710    // authority vouched for is also actor-unmintable evidence.
711    let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
712
713    let claimed = effect.effect_confidence;
714    let effective = match claimed {
715        None => {
716            notes.push("actor recorded no effect_confidence; effect is unverified".into());
717            EffectConfidence::NotVerified
718        }
719        Some(EffectConfidence::Verified) if !has_evidence => {
720            notes.push(
721                "actor claimed Verified but bundled no independent evidence \
722                 (no readback, no trusted witness); downgraded to NotVerified"
723                    .into(),
724            );
725            EffectConfidence::NotVerified
726        }
727        Some(c) => c,
728    };
729
730    EffectVerdict {
731        effective_confidence: effective,
732        claimed_confidence: claimed,
733        trusted_witnesses,
734        notes,
735    }
736}
737
738// ---------------------------------------------------------------------------
739// First-class grant object + attenuation
740// ---------------------------------------------------------------------------
741
742/// A signed capability grant. Each delegation edge mints a narrower grant,
743/// signed by the grantor, so a verifier can walk grant -> parent-grant -> …
744/// -> root offline. Canonical binding mirrors the invitation statement: a
745/// pipe-delimited, version-prefixed line with variable-length fields folded
746/// into digests, so the canonical stays single-line and unambiguous.
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct Grant {
749    pub grant_id: String,
750    /// Ed25519 pubkey of the grantor, base64url-no-pad.
751    pub grantor: String,
752    #[serde(default)]
753    pub scope: Vec<String>,
754    pub audience: String,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub parent_request_id: Option<String>,
757    #[serde(default)]
758    pub delegation_depth: u32,
759    pub issued_at: String,
760    pub expiry: String,
761    #[serde(default)]
762    pub max_delegation: u32,
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub objective_hash: Option<String>,
765}
766
767impl Grant {
768    /// Canonical signing bytes. `v1|grant|` prefixed; the variable-length
769    /// `scope` is folded into a sorted-key JSON digest so the line stays
770    /// single-field-per-position. New fields go through a canonical-version
771    /// bump, never a silent extension.
772    pub fn canonical_for_signing(&self) -> String {
773        let scope_digest = canonical_json_digest(&self.scope);
774        format!(
775            "v1|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
776            self.grant_id,
777            self.grantor,
778            scope_digest,
779            self.audience,
780            self.parent_request_id.as_deref().unwrap_or(""),
781            self.delegation_depth,
782            self.issued_at,
783            self.expiry,
784            self.max_delegation,
785            self.objective_hash.as_deref().unwrap_or(""),
786        )
787    }
788
789    /// Sign the grant's canonical bytes; returns the base64url-no-pad
790    /// signature. The `grantor` field must be `signer`'s public key for the
791    /// grant to later verify.
792    pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
793        let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
794        Ok(URL_SAFE_NO_PAD.encode(sig))
795    }
796
797    /// Verify `signature_b64url` against `self.grantor` over the canonical
798    /// bytes. Returns true only when the pubkey decodes AND the signature
799    /// math checks out. Does not consult trust roots -- the caller decides
800    /// whether `grantor` is a pinned issuer.
801    pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
802        let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
803            Ok(b) if b.len() == 32 => b,
804            _ => return false,
805        };
806        let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
807            Ok(b) if b.len() == 64 => b,
808            _ => return false,
809        };
810        let mut pk = [0u8; 32];
811        pk.copy_from_slice(&pk_bytes);
812        let mut sig = [0u8; 64];
813        sig.copy_from_slice(&sig_bytes);
814        let vk = match VerifyingKey::from_bytes(&pk) {
815            Ok(k) => k,
816            Err(_) => return false,
817        };
818        vk.verify_strict(
819            self.canonical_for_signing().as_bytes(),
820            &Signature::from_bytes(&sig),
821        )
822        .is_ok()
823    }
824}
825
826/// Why a grant chain failed its attenuation invariants.
827#[derive(Debug, Clone, PartialEq, Eq)]
828pub enum GrantChainError {
829    /// Chain was empty.
830    Empty,
831    /// A grant carried an unparseable `issued_at`/`expiry`.
832    BadTimestamp { index: usize },
833    /// child.scope is not a subset of parent.scope.
834    ScopeWidened { parent: usize },
835    /// child.expiry is later than parent.expiry.
836    ExpiryWidened { parent: usize },
837    /// child.delegation_depth is not exactly parent.delegation_depth + 1.
838    DepthNotIncremented { parent: usize },
839    /// child.delegation_depth exceeds parent.max_delegation.
840    DepthExceedsMax { parent: usize },
841    /// child.audience differs from parent.audience.
842    AudienceChanged { parent: usize },
843}
844
845/// Verify attenuation across an ordered grant chain (`chain[0]` is the root,
846/// `chain[last]` is the leaf the action was minted from). Every adjacent pair
847/// must satisfy: scope narrows (child ⊆ parent), expiry does not extend,
848/// delegation depth increments by exactly one and stays within the parent's
849/// `max_delegation`, and audience is preserved. All checks fail closed.
850///
851/// This validates the *shape* of the delegation. Signature verification of
852/// each grant is a separate concern ([`Grant::verify_canonical`]); a full
853/// verifier composes both.
854pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
855    if chain.is_empty() {
856        return Err(GrantChainError::Empty);
857    }
858
859    // Every grant's timestamps must parse, or downstream comparisons would be
860    // meaningless. Fail closed up front.
861    for (i, g) in chain.iter().enumerate() {
862        if parse_rfc3339_to_unix(&g.issued_at).is_none()
863            || parse_rfc3339_to_unix(&g.expiry).is_none()
864        {
865            return Err(GrantChainError::BadTimestamp { index: i });
866        }
867    }
868
869    for (i, pair) in chain.windows(2).enumerate() {
870        let parent = &pair[0];
871        let child = &pair[1];
872
873        if !scope_subset(&child.scope, &parent.scope) {
874            return Err(GrantChainError::ScopeWidened { parent: i });
875        }
876
877        // Safe: timestamps validated above.
878        let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
879        let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
880        if child_expiry > parent_expiry {
881            return Err(GrantChainError::ExpiryWidened { parent: i });
882        }
883
884        if child.delegation_depth != parent.delegation_depth + 1 {
885            return Err(GrantChainError::DepthNotIncremented { parent: i });
886        }
887        if child.delegation_depth > parent.max_delegation {
888            return Err(GrantChainError::DepthExceedsMax { parent: i });
889        }
890
891        if child.audience != parent.audience {
892            return Err(GrantChainError::AudienceChanged { parent: i });
893        }
894    }
895
896    Ok(())
897}
898
899/// True iff every entry of `child` is covered by some entry of `parent`. An
900/// exact label is covered by an equal label or by a parent family glob; a
901/// child family glob is covered only by an equal-or-broader parent glob.
902fn scope_subset(child: &[String], parent: &[String]) -> bool {
903    child
904        .iter()
905        .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
906}
907
908fn scope_entry_covers(parent: &str, child: &str) -> bool {
909    if parent == child {
910        return true;
911    }
912    if let Some(parent_prefix) = parent.strip_suffix(".*") {
913        // A parent glob `foo.*` covers `foo`, anything under `foo.`, and a
914        // narrower child glob like `foo.bar.*` (compare on its prefix).
915        let child_core = child.strip_suffix(".*").unwrap_or(child);
916        child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
917    } else {
918        false
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
926
927    #[test]
928    fn effect_confidence_ceiling_gates_on_independent_evidence() {
929        // A readback the actor could not mint lets the evidence support Verified.
930        let with_evidence = Effect {
931            readback: Some("sha256:observed".into()),
932            effect_confidence: Some(EffectConfidence::Verified),
933            ..Default::default()
934        };
935        assert!(with_evidence.has_independent_evidence());
936        assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
937
938        // No independent evidence: the honest ceiling is NotVerified, so a
939        // `Verified` CLAIM here must be treated as inflated (ack != act).
940        let claim_only = Effect {
941            output_hash: Some("sha256:out".into()),
942            effect_confidence: Some(EffectConfidence::Verified),
943            ..Default::default()
944        };
945        assert!(!claim_only.has_independent_evidence());
946        assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
947
948        // An honest actor can downgrade below the ceiling with no evidence.
949        let honest_downgrade = Effect {
950            effect_confidence: Some(EffectConfidence::Unknown),
951            ..Default::default()
952        };
953        assert_eq!(
954            honest_downgrade.evidence_ceiling(),
955            EffectConfidence::NotVerified
956        );
957    }
958
959    #[test]
960    fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
961        let e = Effect {
962            effect_confidence: Some(EffectConfidence::NotVerified),
963            ..Default::default()
964        };
965        let j = serde_json::to_string(&e).unwrap();
966        assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
967
968        // Absent => omitted entirely (additive, backward-compatible over v1).
969        let empty = Effect::default();
970        assert!(!serde_json::to_string(&empty)
971            .unwrap()
972            .contains("effect_confidence"));
973    }
974
975    /// A test authority that trusts any signed witness whose observer differs
976    /// from the actor and whose observation matches the effect's readback.
977    /// Stands in for the real trust-root + signature check.
978    struct TrustingWitnessAuthority;
979    impl WitnessAuthority for TrustingWitnessAuthority {
980        fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
981            w.is_signed()
982                && w.observer != actor
983                && effect.readback.as_deref() == Some(w.observation.as_str())
984        }
985    }
986
987    #[test]
988    fn verify_effect_downgrades_unbacked_verified_claim() {
989        // Verified claim, no readback, no witness => downgraded to NotVerified.
990        let mut s = good_stmt();
991        s.actor = "agent://worker".into();
992        s.effect = Some(Effect {
993            output_hash: Some("sha256:out".into()),
994            effect_confidence: Some(EffectConfidence::Verified),
995            ..Default::default()
996        });
997        let v = verify_effect(&s, &NoWitnessAuthority);
998        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
999        assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1000        assert!(!v.is_verified());
1001        assert!(
1002            v.notes.iter().any(|n| n.contains("downgraded")),
1003            "{:?}",
1004            v.notes
1005        );
1006    }
1007
1008    #[test]
1009    fn verify_effect_honors_verified_backed_by_readback() {
1010        let mut s = good_stmt();
1011        s.effect = Some(Effect {
1012            readback: Some("sha256:observed".into()),
1013            effect_confidence: Some(EffectConfidence::Verified),
1014            ..Default::default()
1015        });
1016        let v = verify_effect(&s, &NoWitnessAuthority);
1017        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1018        assert!(v.is_verified());
1019    }
1020
1021    #[test]
1022    fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1023        // No readback, but an independent trusted witness observed the same
1024        // post-state the effect commits to: Verified stands.
1025        let mut s = good_stmt();
1026        s.actor = "agent://worker".into();
1027        s.effect = Some(Effect {
1028            readback: Some("sha256:state".into()),
1029            effect_confidence: Some(EffectConfidence::Verified),
1030            witnesses: vec![Witness {
1031                observer: "agent://auditor".into(),
1032                observation: "sha256:state".into(),
1033                observed_at: Some("2026-07-20T10:00:00Z".into()),
1034                signature: Some("ed25519:sig".into()),
1035            }],
1036            ..Default::default()
1037        });
1038        let v = verify_effect(&s, &TrustingWitnessAuthority);
1039        assert_eq!(v.trusted_witnesses, 1);
1040        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1041
1042        // A self-witness (observer == actor) is not trusted, even signed.
1043        let mut self_witness = s.clone();
1044        if let Some(e) = self_witness.effect.as_mut() {
1045            e.readback = None; // remove the readback so only the witness could lift it
1046            e.witnesses[0].observer = "agent://worker".into();
1047        }
1048        let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1049        assert_eq!(v2.trusted_witnesses, 0);
1050        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1051        assert!(v2
1052            .notes
1053            .iter()
1054            .any(|n| n.contains("not independently trusted")));
1055    }
1056
1057    #[test]
1058    fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1059        // Partial/Unknown are admissions, not inflations: no downgrade even
1060        // without independent evidence.
1061        for c in [
1062            EffectConfidence::Partial,
1063            EffectConfidence::Ambiguous,
1064            EffectConfidence::Unknown,
1065            EffectConfidence::NotVerified,
1066        ] {
1067            let mut s = good_stmt();
1068            s.effect = Some(Effect {
1069                effect_confidence: Some(c),
1070                ..Default::default()
1071            });
1072            let v = verify_effect(&s, &NoWitnessAuthority);
1073            assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1074        }
1075    }
1076
1077    #[test]
1078    fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1079        // No effect block at all.
1080        let s = good_stmt();
1081        assert!(s.effect.is_none());
1082        let v = verify_effect(&s, &NoWitnessAuthority);
1083        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1084        assert_eq!(v.claimed_confidence, None);
1085        assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1086
1087        // Effect present but no confidence claim.
1088        let mut s2 = good_stmt();
1089        s2.effect = Some(Effect {
1090            output_hash: Some("sha256:out".into()),
1091            ..Default::default()
1092        });
1093        let v2 = verify_effect(&s2, &NoWitnessAuthority);
1094        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1095        assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1096    }
1097
1098    #[test]
1099    fn witness_does_not_inflate_evidence_ceiling() {
1100        // Security invariant: a witness the actor bundled in -- even a signed
1101        // one -- must NOT lift evidence_ceiling at the data-model layer. Only
1102        // an actor-unmintable readback does that here; witness trust is
1103        // verify's job.
1104        let signed_witness = Witness {
1105            observer: "agent://auditor".into(),
1106            observation: "sha256:observed".into(),
1107            observed_at: Some("2026-07-20T10:00:00Z".into()),
1108            signature: Some("ed25519:sig".into()),
1109        };
1110        let e = Effect {
1111            witnesses: vec![signed_witness.clone()],
1112            effect_confidence: Some(EffectConfidence::Verified),
1113            ..Default::default()
1114        };
1115        assert!(!e.has_independent_evidence());
1116        assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1117        // The signature is visible for verify to check, but that's a
1118        // precondition, not evidence.
1119        assert!(signed_witness.is_signed());
1120        assert_eq!(e.signed_witnesses().count(), 1);
1121
1122        // An unsigned witness isn't even a candidate.
1123        let unsigned = Effect {
1124            witnesses: vec![Witness {
1125                observer: "agent://auditor".into(),
1126                observation: "sha256:observed".into(),
1127                ..Default::default()
1128            }],
1129            ..Default::default()
1130        };
1131        assert_eq!(unsigned.signed_witnesses().count(), 0);
1132    }
1133
1134    #[test]
1135    fn witnesses_serialize_and_omit_when_empty() {
1136        let empty = Effect::default();
1137        assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1138
1139        let e = Effect {
1140            witnesses: vec![Witness {
1141                observer: "key_9f2c".into(),
1142                observation: "sha256:obs".into(),
1143                observed_at: None,
1144                signature: Some("ed25519:sig".into()),
1145            }],
1146            ..Default::default()
1147        };
1148        let j = serde_json::to_string(&e).unwrap();
1149        assert!(j.contains("\"witnesses\":[{"), "{j}");
1150        assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1151        // observed_at absent => omitted, not null.
1152        assert!(!j.contains("observed_at"), "{j}");
1153        let back: Effect = serde_json::from_str(&j).unwrap();
1154        assert_eq!(back.witnesses.len(), 1);
1155        assert!(back.witnesses[0].is_signed());
1156    }
1157
1158    #[test]
1159    fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1160        assert!(RuntimeIdentity::default().is_unbound());
1161
1162        // Any single populated field means the binding attests something.
1163        let with_model = RuntimeIdentity {
1164            model: Some("claude-opus-4-8".into()),
1165            ..Default::default()
1166        };
1167        assert!(!with_model.is_unbound());
1168
1169        let with_prompt = RuntimeIdentity {
1170            system_prompt_hash: Some("sha256:sys".into()),
1171            ..Default::default()
1172        };
1173        assert!(!with_prompt.is_unbound());
1174    }
1175
1176    #[test]
1177    fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1178        let rt = RuntimeIdentity {
1179            provider: Some("anthropic".into()),
1180            model: Some("claude-opus-4-8".into()),
1181            tool_schema_hash: Some("sha256:tools".into()),
1182            system_prompt_hash: None,
1183        };
1184        let j = serde_json::to_string(&rt).unwrap();
1185        assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1186        assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1187        assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1188        // Absent field omitted, not null -- keeps the canonical stable.
1189        assert!(!j.contains("system_prompt_hash"), "{j}");
1190
1191        // All-absent runtime is an empty object, and roundtrips.
1192        let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1193        assert_eq!(empty, "{}");
1194        let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1195        assert!(back.is_unbound());
1196    }
1197
1198    #[test]
1199    fn runtime_is_omitted_from_statement_when_absent() {
1200        // A v2 statement with no runtime must not emit a `runtime` key, so
1201        // existing artifact_ids over runtime-less receipts are unaffected.
1202        let s = good_stmt();
1203        assert!(s.runtime.is_none());
1204        let j = serde_json::to_string(&s).unwrap();
1205        assert!(!j.contains("runtime"), "{j}");
1206
1207        // When present, it rides in the signed statement and roundtrips.
1208        let mut with_rt = good_stmt();
1209        with_rt.runtime = Some(RuntimeIdentity {
1210            model: Some("claude-opus-4-8".into()),
1211            ..Default::default()
1212        });
1213        let j2 = serde_json::to_string(&with_rt).unwrap();
1214        assert!(j2.contains("\"runtime\""), "{j2}");
1215        let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
1216        assert_eq!(
1217            back.runtime.unwrap().model.as_deref(),
1218            Some("claude-opus-4-8")
1219        );
1220    }
1221
1222    fn base_mandate() -> Mandate {
1223        Mandate {
1224            grant_id: "grant_9c2f".into(),
1225            grantor: "key_parent".into(),
1226            issuer_sig: None,
1227            objective_hash: Some("sha256:abc".into()),
1228            scope: vec!["payments.charge".into()],
1229            audience: "acme-payments-api".into(),
1230            parent_request_id: Some("req_7d3e".into()),
1231            delegation_depth: 2,
1232            issued_at: "2026-07-11T19:50:00Z".into(),
1233            expiry: "2026-07-11T20:50:00Z".into(),
1234            max_delegation: 3,
1235            revocation: Revocation {
1236                path: "hub://acme/revocations".into(),
1237                revoked_at: None,
1238            },
1239        }
1240    }
1241
1242    /// A statement whose signed_at sits inside the mandate window, action in
1243    /// scope, audience matching.
1244    fn good_stmt() -> ActionStatementV2 {
1245        let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
1246        s.timestamp = "2026-07-11T19:53:09Z".into();
1247        s.audience = Some("acme-payments-api".into());
1248        s
1249    }
1250
1251    struct StaticRevocation(RevocationStatus);
1252    impl RevocationSource for StaticRevocation {
1253        fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
1254            self.0.clone()
1255        }
1256    }
1257
1258    // ---- scope ----
1259
1260    #[test]
1261    fn scope_exact_and_glob() {
1262        assert!(action_in_scope(
1263            "payments.charge",
1264            &["payments.charge".into()]
1265        ));
1266        assert!(action_in_scope("payments.charge", &["payments.*".into()]));
1267        assert!(action_in_scope("payments", &["payments.*".into()]));
1268        assert!(!action_in_scope(
1269            "payments.refund",
1270            &["payments.charge".into()]
1271        ));
1272        assert!(!action_in_scope("email.send", &["payments.*".into()]));
1273        // A bare "*" is a literal, not a wildcard.
1274        assert!(!action_in_scope("anything", &["*".into()]));
1275        assert!(action_in_scope("*", &["*".into()]));
1276    }
1277
1278    #[test]
1279    fn empty_scope_authorizes_nothing() {
1280        let mut s = good_stmt();
1281        s.mandate.scope = vec![];
1282        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1283            MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
1284            v => panic!("empty scope must fail, got {v:?}"),
1285        }
1286    }
1287
1288    #[test]
1289    fn action_out_of_scope_fails() {
1290        let mut s = good_stmt();
1291        s.action = "payments.refund".into();
1292        assert!(matches!(
1293            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1294            MandateVerdict::Fail(_)
1295        ));
1296    }
1297
1298    // ---- audience ----
1299
1300    #[test]
1301    fn audience_match_passes_layer() {
1302        let s = good_stmt();
1303        assert_eq!(
1304            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1305            MandateVerdict::Pass
1306        );
1307    }
1308
1309    #[test]
1310    fn audience_mismatch_fails() {
1311        let mut s = good_stmt();
1312        s.audience = Some("evil-api".into());
1313        assert!(matches!(
1314            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1315            MandateVerdict::Fail(_)
1316        ));
1317    }
1318
1319    #[test]
1320    fn missing_action_audience_is_unverified_not_pass() {
1321        let mut s = good_stmt();
1322        s.audience = None;
1323        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1324            MandateVerdict::Unverified(rs) => {
1325                assert!(rs.iter().any(|r| r.contains("recorded no audience")))
1326            }
1327            v => panic!("missing audience must be Unverified, got {v:?}"),
1328        }
1329    }
1330
1331    #[test]
1332    fn empty_mandate_audience_fails() {
1333        let mut s = good_stmt();
1334        s.mandate.audience = "".into();
1335        assert!(matches!(
1336            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1337            MandateVerdict::Fail(_)
1338        ));
1339    }
1340
1341    // ---- TTL (validity at signed_at) ----
1342
1343    #[test]
1344    fn signed_before_issued_fails() {
1345        let mut s = good_stmt();
1346        s.timestamp = "2026-07-11T19:49:59Z".into(); // one sec before issued
1347        assert!(matches!(
1348            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1349            MandateVerdict::Fail(_)
1350        ));
1351    }
1352
1353    #[test]
1354    fn signed_at_expiry_fails() {
1355        let mut s = good_stmt();
1356        s.timestamp = "2026-07-11T20:50:00Z".into(); // exactly expiry (exclusive)
1357        assert!(matches!(
1358            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1359            MandateVerdict::Fail(_)
1360        ));
1361    }
1362
1363    #[test]
1364    fn signed_within_window_passes() {
1365        let s = good_stmt(); // 19:53:09 within [19:50, 20:50)
1366        assert_eq!(
1367            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1368            MandateVerdict::Pass
1369        );
1370    }
1371
1372    #[test]
1373    fn malformed_timestamp_fails_closed() {
1374        let mut s = good_stmt();
1375        s.timestamp = "not-a-timestamp".into();
1376        assert!(matches!(
1377            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1378            MandateVerdict::Fail(_)
1379        ));
1380    }
1381
1382    // ---- revocation at signing time ----
1383
1384    #[test]
1385    fn revoked_after_signing_still_passes() {
1386        // Grant revoked at 20:00; receipt signed at 19:53 -> still valid,
1387        // like a TLS cert whose later revocation is not retroactive.
1388        let s = good_stmt();
1389        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
1390        assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
1391    }
1392
1393    #[test]
1394    fn revoked_before_signing_fails() {
1395        let s = good_stmt(); // signed 19:53
1396        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
1397        assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
1398    }
1399
1400    #[test]
1401    fn revocation_unknown_is_unverified() {
1402        let s = good_stmt();
1403        match verify_mandate(&s, &NoRevocationSource) {
1404            MandateVerdict::Unverified(rs) => {
1405                assert!(rs
1406                    .iter()
1407                    .any(|r| r.contains("revocation could not be checked")))
1408            }
1409            v => panic!("no revocation source must be Unverified, got {v:?}"),
1410        }
1411    }
1412
1413    #[test]
1414    fn fail_takes_precedence_over_unverified() {
1415        // Out-of-scope action (fail) AND no revocation source (unverified):
1416        // the verdict must be Fail, never Unverified.
1417        let mut s = good_stmt();
1418        s.action = "payments.refund".into();
1419        assert!(matches!(
1420            verify_mandate(&s, &NoRevocationSource),
1421            MandateVerdict::Fail(_)
1422        ));
1423    }
1424
1425    #[test]
1426    fn wrong_type_fails() {
1427        let mut s = good_stmt();
1428        s.type_ = "treeship/action/v1".into();
1429        assert!(matches!(
1430            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1431            MandateVerdict::Fail(_)
1432        ));
1433    }
1434
1435    // ---- canonical binding: mandate/effect are in the signed bytes ----
1436
1437    #[test]
1438    fn mandate_is_bound_into_signature() {
1439        let signer = Ed25519Signer::generate("key_test").unwrap();
1440        let pt = payload_type_v2("action");
1441
1442        let a = good_stmt();
1443        let mut b = good_stmt();
1444        b.mandate.scope = vec!["payments.*".into()]; // differ only in mandate
1445
1446        let ra = sign(&pt, &a, &signer).unwrap();
1447        let rb = sign(&pt, &b, &signer).unwrap();
1448        assert_ne!(
1449            ra.artifact_id, rb.artifact_id,
1450            "changing mandate.scope must change the signed artifact id"
1451        );
1452    }
1453
1454    #[test]
1455    fn v2_sign_verify_roundtrip() {
1456        let signer = Ed25519Signer::generate("key_test").unwrap();
1457        let verifier = EnvVerifier::from_signer(&signer);
1458        let pt = payload_type_v2("action");
1459
1460        let mut s = good_stmt();
1461        s.effect = Some(Effect {
1462            output_hash: Some("sha256:out".into()),
1463            readback: Some("sha256:observed".into()),
1464            bytes_moved: Some(1_048_576),
1465            cost: Some(Cost {
1466                unit: "usd_micros".into(),
1467                amount: 4200,
1468            }),
1469            side_effects: vec!["db:users.update".into()],
1470            ..Default::default()
1471        });
1472
1473        let signed = sign(&pt, &s, &signer).unwrap();
1474        verifier.verify(&signed.envelope).unwrap();
1475
1476        let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
1477        assert_eq!(decoded.type_, TYPE_ACTION_V2);
1478        assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
1479        assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
1480    }
1481
1482    #[test]
1483    fn v2_payload_type_differs_from_v1() {
1484        assert_eq!(
1485            payload_type_v2("action"),
1486            "application/vnd.treeship.action.v2+json"
1487        );
1488        assert_ne!(
1489            payload_type_v2("action"),
1490            super::super::payload_type("action")
1491        );
1492    }
1493
1494    // ---- grant object + chain attenuation ----
1495
1496    fn grant(
1497        id: &str,
1498        grantor: &str,
1499        scope: &[&str],
1500        depth: u32,
1501        expiry: &str,
1502        max_deleg: u32,
1503    ) -> Grant {
1504        Grant {
1505            grant_id: id.into(),
1506            grantor: grantor.into(),
1507            scope: scope.iter().map(|s| (*s).into()).collect(),
1508            audience: "acme-payments-api".into(),
1509            parent_request_id: None,
1510            delegation_depth: depth,
1511            issued_at: "2026-07-11T19:00:00Z".into(),
1512            expiry: expiry.into(),
1513            max_delegation: max_deleg,
1514            objective_hash: None,
1515        }
1516    }
1517
1518    #[test]
1519    fn grant_sign_verify_roundtrip_and_tamper() {
1520        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
1521        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
1522        let mut g = grant(
1523            "grant_root",
1524            &grantor,
1525            &["payments.*"],
1526            0,
1527            "2026-07-11T21:00:00Z",
1528            3,
1529        );
1530
1531        let sig = g.sign_canonical(&signer).unwrap();
1532        assert!(g.verify_canonical(&sig));
1533
1534        // Tamper after signing -> verification fails.
1535        g.scope.push("email.*".into());
1536        assert!(!g.verify_canonical(&sig));
1537    }
1538
1539    #[test]
1540    fn grant_verify_rejects_wrong_key() {
1541        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
1542        let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
1543        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
1544        let g = grant(
1545            "grant_root",
1546            &grantor,
1547            &["payments.*"],
1548            0,
1549            "2026-07-11T21:00:00Z",
1550            3,
1551        );
1552        let sig = g.sign_canonical(&attacker).unwrap();
1553        assert!(!g.verify_canonical(&sig));
1554    }
1555
1556    #[test]
1557    fn valid_attenuating_chain_ok() {
1558        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1559        let child = grant(
1560            "g1",
1561            "k",
1562            &["payments.charge"],
1563            1,
1564            "2026-07-11T20:30:00Z",
1565            3,
1566        );
1567        assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
1568    }
1569
1570    #[test]
1571    fn scope_widening_rejected() {
1572        let root = grant(
1573            "g0",
1574            "k",
1575            &["payments.charge"],
1576            0,
1577            "2026-07-11T21:00:00Z",
1578            3,
1579        );
1580        let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
1581        assert_eq!(
1582            verify_grant_chain(&[root, child]),
1583            Err(GrantChainError::ScopeWidened { parent: 0 })
1584        );
1585    }
1586
1587    #[test]
1588    fn expiry_widening_rejected() {
1589        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1590        let child = grant(
1591            "g1",
1592            "k",
1593            &["payments.charge"],
1594            1,
1595            "2026-07-11T22:00:00Z",
1596            3,
1597        );
1598        assert_eq!(
1599            verify_grant_chain(&[root, child]),
1600            Err(GrantChainError::ExpiryWidened { parent: 0 })
1601        );
1602    }
1603
1604    #[test]
1605    fn depth_not_incremented_rejected() {
1606        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1607        let child = grant(
1608            "g1",
1609            "k",
1610            &["payments.charge"],
1611            2,
1612            "2026-07-11T21:00:00Z",
1613            3,
1614        );
1615        assert_eq!(
1616            verify_grant_chain(&[root, child]),
1617            Err(GrantChainError::DepthNotIncremented { parent: 0 })
1618        );
1619    }
1620
1621    #[test]
1622    fn depth_exceeds_max_rejected() {
1623        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
1624        let child = grant(
1625            "g1",
1626            "k",
1627            &["payments.charge"],
1628            1,
1629            "2026-07-11T21:00:00Z",
1630            0,
1631        );
1632        assert_eq!(
1633            verify_grant_chain(&[root, child]),
1634            Err(GrantChainError::DepthExceedsMax { parent: 0 })
1635        );
1636    }
1637
1638    #[test]
1639    fn audience_change_rejected() {
1640        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1641        let mut child = grant(
1642            "g1",
1643            "k",
1644            &["payments.charge"],
1645            1,
1646            "2026-07-11T21:00:00Z",
1647            3,
1648        );
1649        child.audience = "other-api".into();
1650        assert_eq!(
1651            verify_grant_chain(&[root, child]),
1652            Err(GrantChainError::AudienceChanged { parent: 0 })
1653        );
1654    }
1655
1656    #[test]
1657    fn empty_chain_rejected() {
1658        assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
1659    }
1660
1661    #[test]
1662    fn bad_timestamp_in_chain_rejected() {
1663        let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1664        root.expiry = "nope".into();
1665        assert_eq!(
1666            verify_grant_chain(&[root]),
1667            Err(GrantChainError::BadTimestamp { index: 0 })
1668        );
1669    }
1670
1671    #[test]
1672    fn single_grant_chain_ok() {
1673        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
1674        assert_eq!(verify_grant_chain(&[root]), Ok(()));
1675    }
1676}