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 sha2::{Digest, Sha256};
47use ed25519_dalek::{Signature, VerifyingKey};
48
49/// Statement type tag for the mandate/effect receipt.
50pub const TYPE_ACTION_V2: &str = "treeship/action/v2";
51
52/// Canonical MIME payloadType for a v2 statement suffix. Distinct from the
53/// v1 `payload_type` so the DSSE PAE domain-separates v1 from v2 signatures:
54/// a v1-only verifier checking the signature of a v2 receipt still sees valid
55/// signature math, but the differing payloadType (and `type` tag) is what
56/// lets it recognize the receipt as v2 and surface the mandate blocks as
57/// unverified rather than silently treating it as fully verified.
58pub fn payload_type_v2(suffix: &str) -> String {
59    format!("application/vnd.treeship.{}.v2+json", suffix)
60}
61
62// ---------------------------------------------------------------------------
63// Schema -- mandate / effect blocks
64// ---------------------------------------------------------------------------
65
66/// How a verifier checks revocation-at-signing-time. `revoked_at` is a
67/// timestamp (RFC 3339), never a boolean: a grant revoked at time T does not
68/// retroactively invalidate a receipt signed before T.
69///
70/// The embedded `revoked_at` is authored by the same party that signed the
71/// receipt, so it is only trustworthy for the *positive* direction (an honest
72/// signer recording that the grant was later revoked). It MUST NOT be trusted
73/// to assert non-revocation; that is the job of an external
74/// [`RevocationSource`]. See [`verify_mandate`].
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Revocation {
77    /// Where a verifier resolves the revocation timestamp: `concordium://…`,
78    /// `hub://…`, or `url_json://…`.
79    pub path: String,
80
81    /// RFC 3339 instant the grant was revoked, or `None` if not (yet) revoked.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub revoked_at: Option<String>,
84}
85
86/// The per-hop authorization an action was exercised under.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Mandate {
89    /// Id of the capability grant this hop was minted from.
90    pub grant_id: String,
91
92    /// Who issued the grant (parent hop / operator). Ed25519 pubkey,
93    /// base64url-no-pad, so a verifier can check `issuer_sig` offline.
94    pub grantor: String,
95
96    /// Grantor's signature over the grant's canonical bytes (see [`Grant`]).
97    /// Optional in the receipt because a verifier may resolve the grant (and
98    /// its signature) out of band by `grant_id`; when present it lets the
99    /// grant chain be walked fully offline.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub issuer_sig: Option<String>,
102
103    /// Hash of the declared task/intent this hop serves.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub objective_hash: Option<String>,
106
107    /// Allowed action set. Each entry is an exact label (`payments.charge`)
108    /// or a family glob (`payments.*`). An empty scope authorizes nothing.
109    #[serde(default)]
110    pub scope: Vec<String>,
111
112    /// Who this grant is FOR. Prevents cross-audience replay (a grant minted
113    /// for audience A cannot authorize an action against audience B).
114    pub audience: String,
115
116    /// The delegation edge this hop descends from.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub parent_request_id: Option<String>,
119
120    /// Hops from the root grant. Caps re-delegation together with
121    /// `max_delegation`.
122    #[serde(default)]
123    pub delegation_depth: u32,
124
125    /// RFC 3339 instant the grant became valid.
126    pub issued_at: String,
127
128    /// RFC 3339 instant the grant expires (exclusive upper bound).
129    pub expiry: String,
130
131    /// Deepest this grant may be re-minted.
132    #[serde(default)]
133    pub max_delegation: u32,
134
135    /// Revocation source + (optional) revoked-at timestamp.
136    pub revocation: Revocation,
137
138    /// Ancestors of this grant, root-first, each carrying its own
139    /// `issuer_sig`. Optional and skipped when empty so existing v2 receipts
140    /// keep byte-identical canonical bytes.
141    ///
142    /// Carried inline rather than fetched: `issuer_sig` already exists so one
143    /// receipt can be checked offline, and that promise breaks the moment
144    /// verifying a delegated action requires N network round-trips. The
145    /// carrier's *ordering* is not trusted -- see `resolve_grant_chain`.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub chain: Vec<Grant>,
148}
149
150/// A metered cost attached to an effect.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct Cost {
153    pub unit: String,
154    pub amount: u64,
155}
156
157/// An independent observation of an action's effect, made by someone other
158/// than the actor. A witness is the raw material of effect verification: the
159/// actor's own `effect_confidence` is a claim it can mint, but a witness
160/// whose key is NOT the actor's, whose `signature` verifies against a trusted
161/// root, and whose `observation` matches the effect is a signal the actor
162/// could not have forged. Multiple independent witnesses are how a `Verified`
163/// confidence earns its evidence beyond a single self-reported `readback`.
164///
165/// This struct is only the record. It carries NO independent weight on its
166/// own: an unsigned witness, or one signed by the actor's own key, proves
167/// nothing. The reconciliation -- does `observer` resolve to a trusted,
168/// non-actor key? does `signature` verify over the canonical tuple? does
169/// `observation` match the effect? -- happens in verify, never here. Do not
170/// treat the mere presence of a witness as evidence.
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub struct Witness {
173    /// URI or key id of the observer, e.g. "agent://auditor", "key_9f2c".
174    /// Verify resolves this to a trust root and requires it to differ from
175    /// the action's actor.
176    pub observer: String,
177    /// `sha256:<hex>` of what the observer independently saw. Verify checks
178    /// this equals the effect's own observed post-state (`readback` /
179    /// `output_hash`); a witness that observed something else corroborates
180    /// nothing.
181    pub observation: String,
182    /// RFC 3339 instant the observation was made.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub observed_at: Option<String>,
185    /// The observer's signature over its own (observer, observation,
186    /// observed_at) tuple, verifiable against `observer`'s key. Absent means
187    /// unsigned: verify gives it zero independent weight.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub signature: Option<String>,
190}
191
192impl Witness {
193    /// True when the witness at least carries a signature to check. This is a
194    /// necessary-not-sufficient precondition: verify still has to confirm the
195    /// signature verifies, the observer is a trusted non-actor key, and the
196    /// observation matches. A `true` here is NOT evidence by itself.
197    pub fn is_signed(&self) -> bool {
198        self.signature.is_some()
199    }
200}
201
202/// What the action actually touched. Descriptive; every field is optional
203/// because not every action has cheap external ground truth. `readback` is
204/// the strongest claim: a hash of externally-observed post-state the actor
205/// did not author.
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
207pub struct Effect {
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub input_hash: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub output_hash: Option<String>,
212    /// Hash of externally-observed post-state (DB readback, provider-API
213    /// state fetch, on-chain balance, second-runtime observation) -- a signal
214    /// the actor cannot mint.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub readback: Option<String>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub bytes_moved: Option<u64>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub cost: Option<Cost>,
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub side_effects: Vec<String>,
223    /// Hash of the state the agent acted *from* (produced by ZMEM). Lets a
224    /// verifier detect action on stale/poisoned context.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub context_snapshot: Option<String>,
227    /// The actor's honest self-declaration of whether the effect actually
228    /// happened ("the ack is not the act"). This is a CLAIM, not proof: the
229    /// verifier cross-checks it against the independent evidence above (a
230    /// `readback` the actor could not mint), and a `Verified` claim carrying no
231    /// such evidence is downgraded, never taken on faith. Absent means the
232    /// actor made no effect claim at all.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub effect_confidence: Option<EffectConfidence>,
235    /// Independent observers who corroborate this effect. Each is a claim the
236    /// actor bundled in; verify decides which (if any) are trustworthy signals
237    /// the actor could not mint. An empty list -- the common case -- means the
238    /// only effect evidence is the actor's own `readback`.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub witnesses: Vec<Witness>,
241    /// How far the state change got, as distinct from how well it is evidenced.
242    /// Optional and skipped when absent so existing v2 receipts keep
243    /// byte-identical canonical bytes.
244    ///
245    /// A `Finalized` claim is capped by [`verify_effect`] the same way
246    /// `EffectConfidence::Verified` is: both assert something definite, so both
247    /// can be inflated, so both require evidence the actor could not mint.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub finality: Option<EffectFinality>,
250    /// When an unresolved effect must resolve by, and what fires if it does
251    /// not. Absent means no obligation was declared -- which
252    /// [`check_resolution`] reports as `Indefinite` rather than passing over,
253    /// because an unresolved effect with no deadline is the failure shape, not
254    /// the safe default.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub resolution: Option<Resolution>,
257}
258
259/// How confident the actor is that an action's real-world effect happened,
260/// separate from whether the receipt's signature is valid. Encodes the honest
261/// middle ground the "ack is not the act" discourse keeps asking for: an agent
262/// that cannot confirm the effect declares `Unknown` or `NotVerified` instead
263/// of forcing a green success.
264///
265/// A verifier NEVER trusts `Verified` on the actor's word alone — see
266/// [`Effect::has_independent_evidence`] and the effect-confidence check in
267/// `treeship verify`, which reconciles this claim with the evidence present.
268#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum EffectConfidence {
271    /// Independently confirmed: an external read-back or witness the actor
272    /// could not mint shows the intended post-state.
273    Verified,
274    /// Some effect evidence, but incomplete (e.g. the sink accepted the write
275    /// but nothing read the post-state back).
276    Partial,
277    /// The observed state is consistent with more than one outcome.
278    Ambiguous,
279    /// The actor could not determine whether the effect happened.
280    Unknown,
281    /// Attempted, but the effect was not independently verified — the common
282    /// honest default: the tool returned ok and nothing read it back.
283    NotVerified,
284}
285
286/// How far a state change actually got. Orthogonal to [`EffectConfidence`],
287/// which grades the *evidence*: an effect can be `Finalized` with weak evidence,
288/// or `Initiated` with excellent evidence that it is still pending.
289///
290/// Collapsing the two is a real production failure, not a theoretical one. A
291/// receipt that reports a single `success: true` can be accurate in every field
292/// and false as a composite: the write was accepted, acknowledged, assigned an
293/// id, and served back on read -- and never committed. Every predicate held;
294/// "done" did not. Separating the axes is what makes that claim expressible,
295/// and the [`verify_effect`] cap on `Finalized` is what makes it checkable.
296#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "snake_case")]
298pub enum EffectFinality {
299    /// Authority was exercised but no state change was attempted -- a timeout
300    /// or refusal before the call went out. This is the "no authority moved"
301    /// receipt: an explicit signed negative, so absence stops being
302    /// indistinguishable from a check that was never required.
303    NotAttempted,
304    /// The target accepted the change but has not confirmed it as final.
305    /// Unresolved: an acknowledgement is not a commit.
306    Initiated,
307    /// The target confirmed the change is final.
308    Finalized,
309    /// Attempted, and definitively did not take effect.
310    Failed,
311    /// Attempted; whether it took effect could not be established. Distinct
312    /// from `Initiated`, where the target at least said yes-but-not-yet. Here
313    /// nobody knows, which is a state to escalate from, not to retry blindly.
314    Indeterminate,
315}
316
317impl EffectFinality {
318    /// Whether the lifecycle reached a terminal state. `Initiated` and
319    /// `Indeterminate` are open: something is still owed. Only open effects can
320    /// breach a resolution deadline.
321    pub fn is_resolved(self) -> bool {
322        matches!(
323            self,
324            Self::NotAttempted | Self::Finalized | Self::Failed
325        )
326    }
327}
328
329/// When an unresolved effect must resolve by, and what fires if it does not.
330///
331/// Exists because an effect that never resolves emits nothing forever, which is
332/// strictly worse than a timeout: a timeout produces a countable transition,
333/// and silence produces nothing for a monitor to catch. A declared deadline
334/// turns "still pending after 181 days" from an invisible state into a
335/// checkable one.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Resolution {
338    /// RFC3339. After this instant an unresolved effect is stale by definition.
339    pub deadline: String,
340    /// What the declaring party said must happen once the deadline passes.
341    pub on_deadline: DeadlineEvent,
342}
343
344/// The obligation that attaches when a resolution deadline passes.
345#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case")]
347pub enum DeadlineEvent {
348    /// Treat as timed out. The effect did not land; no authority moved.
349    Timeout,
350    /// Hand to a human or a higher authority. Do not decide automatically.
351    Escalate,
352    /// Mark dead and stop serving it as live state.
353    Tombstone,
354    /// The next agent in a handoff chain takes responsibility for resolving it.
355    Inherit,
356}
357
358/// Whether an effect is still owed a resolution, and whether it is overdue.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum ResolutionStatus {
361    /// The lifecycle reached a terminal state; a deadline is moot.
362    Resolved,
363    /// Unresolved with no declared deadline. Reported rather than passed over:
364    /// this is precisely the pending-forever shape, and staying quiet about it
365    /// is what let it survive unnoticed in the first place.
366    Indefinite,
367    /// Unresolved and inside its declared window.
368    Pending { seconds_remaining: i64 },
369    /// Unresolved and past its deadline. Carries the event the declaring party
370    /// committed to, so a consumer knows which obligation it inherited.
371    Breached {
372        on_deadline: DeadlineEvent,
373        seconds_overdue: i64,
374    },
375    /// A deadline was declared but does not parse, so nothing can be concluded
376    /// from it. Fails toward "unknown", never toward "in window".
377    BadDeadline,
378}
379
380/// Evaluate an effect's resolution obligation against a wall-clock instant.
381///
382/// Kept separate from [`verify_effect`] rather than folded into it: that
383/// function is a pure reconciliation over bytes and stays deterministic, while
384/// this one is time-dependent and the caller must supply `now_unix` explicitly.
385/// A verifier that silently reached for the system clock would give different
386/// answers on replay.
387///
388/// An effect with no finality declared is treated as unresolved. That is the
389/// conservative reading: a receipt that never says where its state change got
390/// to has not told us it landed.
391pub fn check_resolution(effect: &Effect, now_unix: i64) -> ResolutionStatus {
392    let resolved = effect
393        .finality
394        .map(EffectFinality::is_resolved)
395        .unwrap_or(false);
396    if resolved {
397        return ResolutionStatus::Resolved;
398    }
399
400    let res = match &effect.resolution {
401        Some(r) => r,
402        None => return ResolutionStatus::Indefinite,
403    };
404
405    // `parse_rfc3339_to_unix` yields u64; compare in i64 so the difference is
406    // signed and cannot wrap when the deadline sits either side of `now`.
407    let deadline = match parse_rfc3339_to_unix(&res.deadline) {
408        Some(t) if t <= i64::MAX as u64 => t as i64,
409        _ => return ResolutionStatus::BadDeadline,
410    };
411
412    if now_unix > deadline {
413        ResolutionStatus::Breached {
414            on_deadline: res.on_deadline,
415            seconds_overdue: now_unix - deadline,
416        }
417    } else {
418        ResolutionStatus::Pending {
419            seconds_remaining: deadline - now_unix,
420        }
421    }
422}
423
424impl Effect {
425    /// True when the effect carries a signal the actor could not have minted
426    /// itself (an external read-back). This is what lets a verifier honor a
427    /// `Verified` confidence claim; without it, `Verified` is downgraded.
428    ///
429    /// Deliberately gated on `readback` alone, NOT on `witnesses`: a witness
430    /// only becomes evidence once verify confirms its signature against a
431    /// trusted non-actor key, which this pure-data check cannot do. Counting
432    /// an unverified witness here would let the actor inflate its own ceiling
433    /// with a fabricated observer -- exactly the "ok for the wrong reason" we
434    /// refuse.
435    pub fn has_independent_evidence(&self) -> bool {
436        self.readback.is_some()
437    }
438
439    /// The witnesses that at least carry a signature verify can attempt to
440    /// check. Callers must still run that check; a non-empty result is a
441    /// precondition for witness-backed evidence, never evidence itself.
442    pub fn signed_witnesses(&self) -> impl Iterator<Item = &Witness> {
443        self.witnesses.iter().filter(|w| w.is_signed())
444    }
445
446    /// The strongest effect confidence the *evidence* supports, independent of
447    /// what the actor claimed. `Verified` requires independent evidence;
448    /// otherwise the honest ceiling is `NotVerified`. Callers reconcile this
449    /// with `effect_confidence` (the claim): the effective verdict is the
450    /// weaker of the two, so an actor can honestly downgrade but never inflate.
451    pub fn evidence_ceiling(&self) -> EffectConfidence {
452        if self.has_independent_evidence() {
453            EffectConfidence::Verified
454        } else {
455            EffectConfidence::NotVerified
456        }
457    }
458}
459
460/// Who and what produced this action: the model runtime the actor was
461/// executing under at sign time. Binding it into the signed statement lets a
462/// verifier holding a pinned expectation ("this agent must run
463/// claude-opus-4-8 with this tool schema and this system prompt") detect a
464/// swapped model, an altered tool set, or a changed system prompt after the
465/// fact. Where `effect` records *what* the action touched, this records *what
466/// executed it*.
467///
468/// Every field is optional and actor-attested: it is signed by the actor's
469/// key, so it is exactly as trustworthy as the actor, and it carries no
470/// weight on its own. A verifier can only turn it into a Pass by reconciling
471/// it against an out-of-band pinned expectation; absent means "not recorded"
472/// (unverifiable), never a pass. The hashes are `sha256:<hex>` over the exact
473/// bytes presented to the model, so equality is the whole check.
474#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
475pub struct RuntimeIdentity {
476    /// Model provider, e.g. "anthropic", "openai".
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub provider: Option<String>,
479    /// Model identifier the actor ran under, e.g. "claude-opus-4-8".
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub model: Option<String>,
482    /// Hash of the exact tool schemas the agent had available this turn.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub tool_schema_hash: Option<String>,
485    /// Hash of the exact system prompt the agent ran under.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub system_prompt_hash: Option<String>,
488}
489
490impl RuntimeIdentity {
491    /// True when no field is populated -- the runtime binding attests nothing,
492    /// so a verifier has nothing to pin against an expectation. Verify treats
493    /// this the same as an absent `runtime`: the runtime layer is
494    /// unverifiable, not a pass.
495    pub fn is_unbound(&self) -> bool {
496        self.provider.is_none()
497            && self.model.is_none()
498            && self.tool_schema_hash.is_none()
499            && self.system_prompt_hash.is_none()
500    }
501}
502
503/// `treeship/action/v2` statement. Additive over v1: the v1 core fields are
504/// unchanged; `audience`, `mandate`, `effect`, and `runtime` are new.
505/// `mandate` is required (a v2 receipt with no mandate would just be a v1
506/// receipt); `effect` and `runtime` are optional.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct ActionStatementV2 {
509    #[serde(rename = "type")]
510    pub type_: String,
511
512    /// RFC 3339 timestamp, set at sign time. This IS `signed_at`, the instant
513    /// that gates mandate validity.
514    pub timestamp: String,
515
516    pub actor: String,
517    pub action: String,
518
519    /// Audience this action targeted. Checked against `mandate.audience` to
520    /// block cross-audience replay. Absent means the signer did not record a
521    /// target audience, which makes the audience layer unverifiable (not a
522    /// pass).
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub audience: Option<String>,
525
526    #[serde(default, skip_serializing_if = "subject_is_empty")]
527    pub subject: SubjectRef,
528
529    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
530    pub parent_id: Option<String>,
531
532    pub mandate: Mandate,
533
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub effect: Option<Effect>,
536
537    /// The model runtime the actor executed under. See [`RuntimeIdentity`].
538    /// Absent means the signer recorded no runtime, which leaves the runtime
539    /// layer unverifiable (not a pass).
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub runtime: Option<RuntimeIdentity>,
542
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub meta: Option<serde_json::Value>,
545}
546
547fn subject_is_empty(s: &SubjectRef) -> bool {
548    s.digest.is_none() && s.uri.is_none() && s.artifact_id.is_none()
549}
550
551impl ActionStatementV2 {
552    /// Construct a v2 action carrying the given mandate.
553    pub fn new(actor: impl Into<String>, action: impl Into<String>, mandate: Mandate) -> Self {
554        Self {
555            type_: TYPE_ACTION_V2.into(),
556            timestamp: super::unix_to_rfc3339(now_unix()),
557            actor: actor.into(),
558            action: action.into(),
559            audience: None,
560            subject: SubjectRef::default(),
561            parent_id: None,
562            mandate,
563            effect: None,
564            runtime: None,
565            meta: None,
566        }
567    }
568}
569
570fn now_unix() -> u64 {
571    use std::time::{SystemTime, UNIX_EPOCH};
572    SystemTime::now()
573        .duration_since(UNIX_EPOCH)
574        .unwrap_or_default()
575        .as_secs()
576}
577
578// ---------------------------------------------------------------------------
579// Scope matching
580// ---------------------------------------------------------------------------
581
582/// True iff `action` is authorized by at least one entry of `scope`. An entry
583/// is either an exact label or a family glob `foo.*` (which matches `foo` and
584/// anything under `foo.`). A bare `*` is deliberately NOT a wildcard: an
585/// unscoped "authorize everything" grant is exactly the open-fail this schema
586/// exists to prevent.
587pub fn action_in_scope(action: &str, scope: &[String]) -> bool {
588    scope.iter().any(|entry| scope_entry_matches(entry, action))
589}
590
591fn scope_entry_matches(entry: &str, action: &str) -> bool {
592    if let Some(prefix) = entry.strip_suffix(".*") {
593        action == prefix || action.starts_with(&format!("{prefix}."))
594    } else {
595        entry == action
596    }
597}
598
599// ---------------------------------------------------------------------------
600// Revocation source
601// ---------------------------------------------------------------------------
602
603/// Result of resolving a grant's revocation state.
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub enum RevocationStatus {
606    /// The source confirms the grant was not revoked.
607    NotRevoked,
608    /// The source reports the grant was revoked at this RFC 3339 instant.
609    RevokedAt(String),
610    /// The source could not be consulted (offline, no resolver configured,
611    /// unknown path). Carries a human-readable reason.
612    Unknown(String),
613}
614
615/// Resolves revocation-at-signing-time for a grant. Implementations back onto
616/// Concordium, the Treeship Hub, or a signed `url_json` list (spec §9 step
617/// 4). The embedded `mandate.revocation.revoked_at` is NOT an authority for
618/// non-revocation, so verification defaults to [`NoRevocationSource`], which
619/// reports `Unknown` and drives an honest `Unverified` verdict.
620pub trait RevocationSource {
621    fn status(&self, grant_id: &str, path: &str) -> RevocationStatus;
622}
623
624/// The default: no resolver is configured, so revocation is uncheckable.
625pub struct NoRevocationSource;
626
627impl RevocationSource for NoRevocationSource {
628    fn status(&self, _grant_id: &str, path: &str) -> RevocationStatus {
629        RevocationStatus::Unknown(format!("no revocation source configured for path '{path}'"))
630    }
631}
632
633// ---------------------------------------------------------------------------
634// Mandate verdict + verifier
635// ---------------------------------------------------------------------------
636
637/// Outcome of checking a v2 receipt's mandate. `Fail` and `Unverified` carry
638/// human-readable reasons for audit output. Precedence: any checkable
639/// violation makes the whole verdict `Fail`; otherwise any uncheckable layer
640/// makes it `Unverified`; only when every layer is checkable and satisfied is
641/// it `Pass`.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub enum MandateVerdict {
644    Pass,
645    Unverified(Vec<String>),
646    Fail(Vec<String>),
647}
648
649impl MandateVerdict {
650    pub fn is_pass(&self) -> bool {
651        matches!(self, MandateVerdict::Pass)
652    }
653}
654
655/// Verify the mandate layers of a v2 receipt, judged at `signed_at`
656/// (`stmt.timestamp`). Fails closed: a malformed timestamp, an out-of-window
657/// signature, an out-of-scope action, an audience mismatch, or a
658/// revoked-before-signing grant all yield `Fail`. A layer that cannot be
659/// checked (no audience recorded, no revocation resolver) yields `Unverified`
660/// rather than a false `Pass`.
661///
662/// Signature validity is a precondition checked elsewhere (the DSSE envelope
663/// verify); this function assumes the bytes are authentic and evaluates the
664/// *authorization* the signed bytes assert.
665pub fn verify_mandate(
666    stmt: &ActionStatementV2,
667    revocation: &dyn RevocationSource,
668) -> MandateVerdict {
669    let mut fail: Vec<String> = Vec::new();
670    let mut unver: Vec<String> = Vec::new();
671
672    if stmt.type_ != TYPE_ACTION_V2 {
673        return MandateVerdict::Fail(vec![format!(
674            "statement type '{}' is not {TYPE_ACTION_V2}",
675            stmt.type_
676        )]);
677    }
678
679    let m = &stmt.mandate;
680
681    // signed_at is the gating instant. A receipt with an unparseable
682    // timestamp cannot have its mandate evaluated -- fail closed.
683    let signed_at = match parse_rfc3339_to_unix(&stmt.timestamp) {
684        Some(t) => t,
685        None => {
686            return MandateVerdict::Fail(vec![format!(
687                "timestamp '{}' is not RFC 3339",
688                stmt.timestamp
689            )])
690        }
691    };
692
693    // -- scope: the action must be positively authorized.
694    if m.scope.is_empty() {
695        fail.push("mandate.scope is empty: it authorizes no action".into());
696    } else if !action_in_scope(&stmt.action, &m.scope) {
697        fail.push(format!(
698            "action '{}' is not in mandate scope {:?}",
699            stmt.action, m.scope
700        ));
701    }
702
703    // -- audience: block cross-audience replay.
704    if m.audience.trim().is_empty() {
705        fail.push("mandate.audience is empty: the grant is not bound to an audience".into());
706    } else {
707        match &stmt.audience {
708            Some(a) if a == &m.audience => {}
709            Some(a) => fail.push(format!(
710                "action audience '{a}' does not match mandate audience '{}'",
711                m.audience
712            )),
713            None => unver
714                .push("action recorded no audience; cannot confirm it matched the mandate".into()),
715        }
716    }
717
718    // -- TTL: signed_at must be within [issued_at, expiry).
719    match (
720        parse_rfc3339_to_unix(&m.issued_at),
721        parse_rfc3339_to_unix(&m.expiry),
722    ) {
723        (Some(issued), Some(expiry)) => {
724            if expiry <= issued {
725                fail.push(format!(
726                    "mandate expiry '{}' is not after issued_at '{}'",
727                    m.expiry, m.issued_at
728                ));
729            }
730            if signed_at < issued {
731                fail.push(format!(
732                    "signed_at '{}' is before mandate issued_at '{}'",
733                    stmt.timestamp, m.issued_at
734                ));
735            }
736            if signed_at >= expiry {
737                fail.push(format!(
738                    "signed_at '{}' is at or after mandate expiry '{}'",
739                    stmt.timestamp, m.expiry
740                ));
741            }
742        }
743        _ => fail.push(format!(
744            "mandate issued_at '{}' / expiry '{}' are not both RFC 3339",
745            m.issued_at, m.expiry
746        )),
747    }
748
749    // -- revocation at signing time. The external source is the authority;
750    // the embedded revoked_at is never trusted to assert non-revocation.
751    match revocation.status(&m.grant_id, &m.revocation.path) {
752        RevocationStatus::NotRevoked => {}
753        RevocationStatus::RevokedAt(ts) => match parse_rfc3339_to_unix(&ts) {
754            Some(revoked_at) => {
755                if signed_at >= revoked_at {
756                    fail.push(format!(
757                        "grant was revoked at '{ts}'; signed_at '{}' is not before revocation",
758                        stmt.timestamp
759                    ));
760                }
761            }
762            None => unver.push(format!("revocation timestamp '{ts}' is not RFC 3339")),
763        },
764        RevocationStatus::Unknown(reason) => {
765            unver.push(format!("revocation could not be checked: {reason}"))
766        }
767    }
768
769    if !fail.is_empty() {
770        MandateVerdict::Fail(fail)
771    } else if !unver.is_empty() {
772        MandateVerdict::Unverified(unver)
773    } else {
774        MandateVerdict::Pass
775    }
776}
777
778// ---------------------------------------------------------------------------
779// Effect verdict + verifier (operational confidence)
780// ---------------------------------------------------------------------------
781
782/// Decides whether a bundled [`Witness`] is a trustworthy, independent
783/// corroboration of an effect. A real implementation MUST require all of:
784/// the witness `signature` verifies against `observer`'s key in the trust
785/// roots; `observer != actor` (a self-witness proves nothing); and
786/// `observation` matches the effect's own observed post-state. The default
787/// [`NoWitnessAuthority`] trusts nothing, so witnesses give zero evidence
788/// lift until an authority is wired in -- fail closed, exactly like
789/// [`NoRevocationSource`].
790pub trait WitnessAuthority {
791    fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
792}
793
794/// The default: no authority configured, so no witness is trusted and
795/// witnesses contribute no evidence.
796pub struct NoWitnessAuthority;
797
798impl WitnessAuthority for NoWitnessAuthority {
799    fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
800        false
801    }
802}
803
804/// The reconciled operational-confidence outcome for a v2 receipt's effect,
805/// kept deliberately separate from cryptographic validity (the DSSE
806/// signature, checked elsewhere). A perfectly-signed receipt can still carry
807/// an effect nobody independently confirmed; this verdict reports how much of
808/// the *effect* the evidence actually supports, never how well it was signed.
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct EffectVerdict {
811    /// The confidence the evidence supports after reconciliation. Equal to the
812    /// actor's claim for every honest (non-`Verified`) claim; a `Verified`
813    /// claim carrying no independent evidence is downgraded to `NotVerified`.
814    /// Never higher than the actor claimed, and never higher than the evidence
815    /// supports.
816    pub effective_confidence: EffectConfidence,
817    /// The actor's own claim, echoed for audit. `None` when the actor recorded
818    /// no `effect_confidence`.
819    pub claimed_confidence: Option<EffectConfidence>,
820    /// Count of bundled witnesses the [`WitnessAuthority`] vouched for.
821    pub trusted_witnesses: usize,
822    /// Audit notes: downgrades applied, and witnesses that were not trusted.
823    pub notes: Vec<String>,
824    /// The lifecycle stage the evidence supports. Equal to the actor's claim
825    /// except that an unbacked `Finalized` is downgraded to `Indeterminate`:
826    /// "the target told me it committed" is the actor's word, and the actor's
827    /// word is what this verifier exists to not take. `None` when the receipt
828    /// declared no finality at all.
829    pub effective_finality: Option<EffectFinality>,
830    /// The actor's own finality claim, echoed for audit.
831    pub claimed_finality: Option<EffectFinality>,
832}
833
834impl EffectVerdict {
835    /// True when the effect is independently confirmed at the strongest level.
836    pub fn is_verified(&self) -> bool {
837        self.effective_confidence == EffectConfidence::Verified
838    }
839}
840
841/// Reconcile a v2 receipt's effect claim against its evidence. Fails safe: the
842/// effective confidence is never higher than what independent, actor-unmintable
843/// evidence supports. Independent evidence is a `readback` the actor could not
844/// mint, or a witness the [`WitnessAuthority`] vouches for (signed by a trusted
845/// key that is not the actor, observing the same post-state).
846///
847/// Only a `Verified` claim asserts the effect definitely happened, so only it
848/// can be inflated and only it is capped. Lesser claims (`Partial`,
849/// `Ambiguous`, `Unknown`, `NotVerified`) are already admissions of incomplete
850/// confidence and pass through unchanged -- the verifier's job is to block
851/// inflation, not to erase an honest actor's own hedging.
852///
853/// Signature validity is a precondition checked elsewhere; this evaluates
854/// operational confidence over bytes assumed authentic.
855pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
856    let effect = match &stmt.effect {
857        Some(e) => e,
858        None => {
859            return EffectVerdict {
860                effective_confidence: EffectConfidence::NotVerified,
861                claimed_confidence: None,
862                trusted_witnesses: 0,
863                notes: vec!["receipt carries no effect block; effect is unverified".into()],
864                effective_finality: None,
865                claimed_finality: None,
866            }
867        }
868    };
869
870    let mut notes: Vec<String> = Vec::new();
871
872    let trusted_witnesses = effect
873        .witnesses
874        .iter()
875        .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
876        .count();
877    let untrusted = effect.witnesses.len() - trusted_witnesses;
878    if untrusted > 0 {
879        notes.push(format!(
880            "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
881            effect.witnesses.len()
882        ));
883    }
884
885    // The verify layer knows more than the pure-data ceiling: a witness the
886    // authority vouched for is also actor-unmintable evidence.
887    let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
888
889    let claimed = effect.effect_confidence;
890    let effective = match claimed {
891        None => {
892            notes.push("actor recorded no effect_confidence; effect is unverified".into());
893            EffectConfidence::NotVerified
894        }
895        Some(EffectConfidence::Verified) if !has_evidence => {
896            notes.push(
897                "actor claimed Verified but bundled no independent evidence \
898                 (no readback, no trusted witness); downgraded to NotVerified"
899                    .into(),
900            );
901            EffectConfidence::NotVerified
902        }
903        Some(c) => c,
904    };
905
906    // Finality is capped on the same principle as confidence, for the same
907    // reason: `Finalized` is the only lifecycle claim that asserts the change
908    // definitely landed, so it is the only one an actor can inflate. Lesser
909    // stages are admissions and pass through untouched.
910    //
911    // The downgrade target is `Indeterminate`, not `Initiated`: without
912    // evidence we do not know that the target even acknowledged the write, so
913    // asserting the weaker stage would be inventing a fact rather than
914    // withdrawing one.
915    let claimed_finality = effect.finality;
916    let effective_finality = match claimed_finality {
917        Some(EffectFinality::Finalized) if !has_evidence => {
918            notes.push(
919                "actor claimed the effect Finalized but bundled no independent evidence \
920                 (no readback, no trusted witness); downgraded to Indeterminate"
921                    .into(),
922            );
923            Some(EffectFinality::Indeterminate)
924        }
925        other => other,
926    };
927
928    EffectVerdict {
929        effective_confidence: effective,
930        claimed_confidence: claimed,
931        trusted_witnesses,
932        notes,
933        effective_finality,
934        claimed_finality,
935    }
936}
937
938// ---------------------------------------------------------------------------
939// First-class grant object + attenuation
940// ---------------------------------------------------------------------------
941
942/// A signed capability grant. Each delegation edge mints a narrower grant,
943/// signed by the grantor, so a verifier can walk grant -> parent-grant -> …
944/// -> root offline. Canonical binding mirrors the invitation statement: a
945/// pipe-delimited, version-prefixed line with variable-length fields folded
946/// into digests, so the canonical stays single-line and unambiguous.
947#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948pub struct Grant {
949    pub grant_id: String,
950    /// Ed25519 pubkey of the grantor, base64url-no-pad.
951    pub grantor: String,
952    #[serde(default)]
953    pub scope: Vec<String>,
954    pub audience: String,
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub parent_request_id: Option<String>,
957    #[serde(default)]
958    pub delegation_depth: u32,
959    pub issued_at: String,
960    pub expiry: String,
961    #[serde(default)]
962    pub max_delegation: u32,
963    #[serde(default, skip_serializing_if = "Option::is_none")]
964    pub objective_hash: Option<String>,
965
966    /// Grantor's detached signature over this grant's canonical bytes.
967    /// Deliberately absent from `canonical_for_signing` -- a signature cannot
968    /// cover itself. Required for any grant carried in a mandate chain;
969    /// `resolve_grant_chain` refuses unsigned ancestors.
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub issuer_sig: Option<String>,
972
973    /// Content id of the grant this one was delegated from. `None` marks a
974    /// root grant. Signed, so lineage cannot be re-parented after issuance --
975    /// and because ids are content-derived, this is a hash commitment to the
976    /// exact parent, not a reference to a name someone else could also claim.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub parent_grant_id: Option<String>,
979}
980
981impl Grant {
982    /// Canonical signing bytes. `v1|grant|` prefixed; the variable-length
983    /// `scope` is folded into a sorted-key JSON digest so the line stays
984    /// single-field-per-position. New fields go through a canonical-version
985    /// bump, never a silent extension.
986    pub fn canonical_for_signing(&self) -> String {
987        let scope_digest = canonical_json_digest(&self.scope);
988        // v2 differs from v1 in two ways: `parent_grant_id` is covered, and
989        // `grant_id` is NOT. The id is derived from these bytes (see
990        // `derive_grant_id`), so including it would be circular -- and it
991        // matches how artifact ids work elsewhere: derived from the signed
992        // bytes, never stored inside them.
993        format!(
994            "v2|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
995            self.grantor,
996            scope_digest,
997            self.audience,
998            self.parent_request_id.as_deref().unwrap_or(""),
999            self.parent_grant_id.as_deref().unwrap_or(""),
1000            self.delegation_depth,
1001            self.issued_at,
1002            self.expiry,
1003            self.max_delegation,
1004            self.objective_hash.as_deref().unwrap_or(""),
1005        )
1006    }
1007
1008    /// The grant's content id: `grn_` + first 16 hex of sha256 over the
1009    /// canonical bytes. Mirrors `artifact_id = "art_" + hex(sha256(PAE))[..16]`.
1010    ///
1011    /// Ids stop being claims and become facts: two grants collide only under a
1012    /// hash break, and a `parent_grant_id` therefore commits to one specific
1013    /// parent rather than to whatever grant happens to assert that name.
1014    pub fn derive_grant_id(&self) -> String {
1015        let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1016        format!("grn_{}", hex::encode(&digest[..8]))
1017    }
1018
1019    /// True when the declared `grant_id` matches the derived one. A mismatch
1020    /// means the id was chosen rather than computed, so nothing that
1021    /// references it by id can be trusted to reference *this* grant.
1022    pub fn id_is_consistent(&self) -> bool {
1023        self.grant_id == self.derive_grant_id()
1024    }
1025
1026    /// Sign the grant's canonical bytes; returns the base64url-no-pad
1027    /// signature. The `grantor` field must be `signer`'s public key for the
1028    /// grant to later verify.
1029    pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1030        let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1031        Ok(URL_SAFE_NO_PAD.encode(sig))
1032    }
1033
1034    /// Verify `signature_b64url` against `self.grantor` over the canonical
1035    /// bytes. Returns true only when the pubkey decodes AND the signature
1036    /// math checks out. Does not consult trust roots -- the caller decides
1037    /// whether `grantor` is a pinned issuer.
1038    pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1039        // A valid signature over content whose id was chosen by hand is still
1040        // unusable for chain walking: the parent pointer would resolve to a
1041        // name, not to these bytes. Fail closed before touching the crypto.
1042        if !self.id_is_consistent() {
1043            return false;
1044        }
1045        let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1046            Ok(b) if b.len() == 32 => b,
1047            _ => return false,
1048        };
1049        let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1050            Ok(b) if b.len() == 64 => b,
1051            _ => return false,
1052        };
1053        let mut pk = [0u8; 32];
1054        pk.copy_from_slice(&pk_bytes);
1055        let mut sig = [0u8; 64];
1056        sig.copy_from_slice(&sig_bytes);
1057        let vk = match VerifyingKey::from_bytes(&pk) {
1058            Ok(k) => k,
1059            Err(_) => return false,
1060        };
1061        vk.verify_strict(
1062            self.canonical_for_signing().as_bytes(),
1063            &Signature::from_bytes(&sig),
1064        )
1065        .is_ok()
1066    }
1067}
1068
1069/// Why a mandate's grant chain could not be resolved into a trustworthy order.
1070#[derive(Debug, Clone, PartialEq, Eq)]
1071pub enum ChainResolveError {
1072    /// A grant's declared id does not match its content.
1073    InconsistentId { grant_id: String },
1074    /// `mandate.grant_id` names a grant that is not in the carried chain.
1075    LeafMissing { grant_id: String },
1076    /// A `parent_grant_id` points at a grant that is not present.
1077    AncestorMissing { parent_grant_id: String },
1078    /// Following parent links revisited a grant: the links form a cycle.
1079    Cycle { grant_id: String },
1080    /// The carrier supplied grants that the walk never reached. Extra grants
1081    /// are refused rather than ignored -- silently dropping them would let a
1082    /// carrier stuff a chain with decoys.
1083    UnreachableExtras { count: usize },
1084    /// A grant carried no signature, so its content is unattested.
1085    Unsigned { grant_id: String },
1086    /// A grant's signature does not verify against its grantor.
1087    BadSignature { grant_id: String },
1088}
1089
1090impl std::fmt::Display for ChainResolveError {
1091    /// Operator-facing wording. The CLI prints these verbatim, so each names
1092    /// the grant it is talking about: "unresolvable" without a subject leaves
1093    /// a reader nothing to go and look at.
1094    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095        match self {
1096            Self::InconsistentId { grant_id } => {
1097                write!(f, "grant {grant_id} declares an id that does not match its content")
1098            }
1099            Self::LeafMissing { grant_id } => {
1100                write!(f, "the mandate names grant {grant_id}, which is not in the carried chain")
1101            }
1102            Self::AncestorMissing { parent_grant_id } => {
1103                write!(f, "parent grant {parent_grant_id} is missing from the chain")
1104            }
1105            Self::Cycle { grant_id } => {
1106                write!(f, "parent links revisit grant {grant_id}: the chain is a cycle")
1107            }
1108            Self::UnreachableExtras { count } => {
1109                write!(f, "{count} carried grant(s) are not reachable from the mandate")
1110            }
1111            Self::Unsigned { grant_id } => {
1112                write!(f, "grant {grant_id} carries no issuer signature")
1113            }
1114            Self::BadSignature { grant_id } => {
1115                write!(f, "grant {grant_id} has a signature that does not verify")
1116            }
1117        }
1118    }
1119}
1120
1121impl std::error::Error for ChainResolveError {}
1122
1123/// Reconstruct the delegation chain for a mandate, root-first, from the
1124/// grants it carries.
1125///
1126/// The carrier hands over a set; this function derives the *order* from the
1127/// signed `parent_grant_id` links rather than trusting the sequence it was
1128/// given. That is the whole point: `verify_grant_chain` checks attenuation
1129/// between adjacent pairs, so if an attacker picks the pairing, the check
1130/// establishes nothing. Here, reordering is a no-op, truncation shows up as a
1131/// missing ancestor, and splicing shows up as an unreachable extra.
1132///
1133/// Every grant must be signed by its own grantor and carry a content-consistent
1134/// id. Fails closed on anything it cannot establish.
1135pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1136    use std::collections::{HashMap, HashSet};
1137
1138    // Index by content id, checking each grant is self-consistent and attested.
1139    let mut by_id: HashMap<String, &Grant> = HashMap::new();
1140    for g in &mandate.chain {
1141        if !g.id_is_consistent() {
1142            return Err(ChainResolveError::InconsistentId {
1143                grant_id: g.grant_id.clone(),
1144            });
1145        }
1146        let sig = match g.issuer_sig.as_deref() {
1147            Some(s) if !s.is_empty() => s,
1148            _ => {
1149                return Err(ChainResolveError::Unsigned {
1150                    grant_id: g.grant_id.clone(),
1151                })
1152            }
1153        };
1154        if !g.verify_canonical(sig) {
1155            return Err(ChainResolveError::BadSignature {
1156                grant_id: g.grant_id.clone(),
1157            });
1158        }
1159        by_id.insert(g.grant_id.clone(), g);
1160    }
1161
1162    // Walk leaf -> root through signed parent links.
1163    let mut leaf_first: Vec<Grant> = Vec::new();
1164    let mut seen: HashSet<String> = HashSet::new();
1165    let mut cursor = Some(mandate.grant_id.clone());
1166
1167    while let Some(id) = cursor {
1168        if !seen.insert(id.clone()) {
1169            return Err(ChainResolveError::Cycle { grant_id: id });
1170        }
1171        let g = match by_id.get(&id) {
1172            Some(g) => *g,
1173            None => {
1174                return Err(if leaf_first.is_empty() {
1175                    ChainResolveError::LeafMissing { grant_id: id }
1176                } else {
1177                    ChainResolveError::AncestorMissing {
1178                        parent_grant_id: id,
1179                    }
1180                })
1181            }
1182        };
1183        leaf_first.push(g.clone());
1184        cursor = g.parent_grant_id.clone();
1185    }
1186
1187    // Anything the walk never reached is a decoy, not a spare.
1188    if seen.len() != by_id.len() {
1189        return Err(ChainResolveError::UnreachableExtras {
1190            count: by_id.len() - seen.len(),
1191        });
1192    }
1193
1194    leaf_first.reverse(); // verify_grant_chain wants root-first
1195    Ok(leaf_first)
1196}
1197
1198/// Why a grant chain failed its attenuation invariants.
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200pub enum GrantChainError {
1201    /// Chain was empty.
1202    Empty,
1203    /// A grant carried an unparseable `issued_at`/`expiry`.
1204    BadTimestamp { index: usize },
1205    /// child.scope is not a subset of parent.scope.
1206    ScopeWidened { parent: usize },
1207    /// child.expiry is later than parent.expiry.
1208    ExpiryWidened { parent: usize },
1209    /// child.delegation_depth is not exactly parent.delegation_depth + 1.
1210    DepthNotIncremented { parent: usize },
1211    /// child.delegation_depth exceeds parent.max_delegation.
1212    DepthExceedsMax { parent: usize },
1213    /// child.audience differs from parent.audience.
1214    AudienceChanged { parent: usize },
1215}
1216
1217impl std::fmt::Display for GrantChainError {
1218    /// `parent` is the index of the *parent* in the resolved root-first chain,
1219    /// so the violation is reported as the hop between it and its child --
1220    /// which is where an operator has to look to fix it.
1221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1222        match self {
1223            Self::Empty => write!(f, "the chain is empty"),
1224            Self::BadTimestamp { index } => {
1225                write!(f, "grant at hop {index} has an unparseable issued_at/expiry")
1226            }
1227            Self::ScopeWidened { parent } => {
1228                write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1229            }
1230            Self::ExpiryWidened { parent } => {
1231                write!(f, "expiry extends past the parent at hop {}->{}", parent, parent + 1)
1232            }
1233            Self::DepthNotIncremented { parent } => {
1234                write!(f, "delegation depth does not increment by one at hop {}->{}", parent, parent + 1)
1235            }
1236            Self::DepthExceedsMax { parent } => {
1237                write!(f, "delegation depth exceeds the parent's max_delegation at hop {}->{}", parent, parent + 1)
1238            }
1239            Self::AudienceChanged { parent } => {
1240                write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1241            }
1242        }
1243    }
1244}
1245
1246impl std::error::Error for GrantChainError {}
1247
1248/// Verify attenuation across an ordered grant chain (`chain[0]` is the root,
1249/// `chain[last]` is the leaf the action was minted from). Every adjacent pair
1250/// must satisfy: scope narrows (child ⊆ parent), expiry does not extend,
1251/// delegation depth increments by exactly one and stays within the parent's
1252/// `max_delegation`, and audience is preserved. All checks fail closed.
1253///
1254/// This validates the *shape* of the delegation. Signature verification of
1255/// each grant is a separate concern ([`Grant::verify_canonical`]); a full
1256/// verifier composes both.
1257pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1258    if chain.is_empty() {
1259        return Err(GrantChainError::Empty);
1260    }
1261
1262    // Every grant's timestamps must parse, or downstream comparisons would be
1263    // meaningless. Fail closed up front.
1264    for (i, g) in chain.iter().enumerate() {
1265        if parse_rfc3339_to_unix(&g.issued_at).is_none()
1266            || parse_rfc3339_to_unix(&g.expiry).is_none()
1267        {
1268            return Err(GrantChainError::BadTimestamp { index: i });
1269        }
1270    }
1271
1272    for (i, pair) in chain.windows(2).enumerate() {
1273        let parent = &pair[0];
1274        let child = &pair[1];
1275
1276        if !scope_subset(&child.scope, &parent.scope) {
1277            return Err(GrantChainError::ScopeWidened { parent: i });
1278        }
1279
1280        // Safe: timestamps validated above.
1281        let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1282        let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1283        if child_expiry > parent_expiry {
1284            return Err(GrantChainError::ExpiryWidened { parent: i });
1285        }
1286
1287        if child.delegation_depth != parent.delegation_depth + 1 {
1288            return Err(GrantChainError::DepthNotIncremented { parent: i });
1289        }
1290        if child.delegation_depth > parent.max_delegation {
1291            return Err(GrantChainError::DepthExceedsMax { parent: i });
1292        }
1293
1294        if child.audience != parent.audience {
1295            return Err(GrantChainError::AudienceChanged { parent: i });
1296        }
1297    }
1298
1299    Ok(())
1300}
1301
1302/// True iff every entry of `child` is covered by some entry of `parent`. An
1303/// exact label is covered by an equal label or by a parent family glob; a
1304/// child family glob is covered only by an equal-or-broader parent glob.
1305fn scope_subset(child: &[String], parent: &[String]) -> bool {
1306    child
1307        .iter()
1308        .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1309}
1310
1311fn scope_entry_covers(parent: &str, child: &str) -> bool {
1312    if parent == child {
1313        return true;
1314    }
1315    if let Some(parent_prefix) = parent.strip_suffix(".*") {
1316        // A parent glob `foo.*` covers `foo`, anything under `foo.`, and a
1317        // narrower child glob like `foo.bar.*` (compare on its prefix).
1318        let child_core = child.strip_suffix(".*").unwrap_or(child);
1319        child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1320    } else {
1321        false
1322    }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327    use super::*;
1328    use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1329
1330    #[test]
1331    fn effect_confidence_ceiling_gates_on_independent_evidence() {
1332        // A readback the actor could not mint lets the evidence support Verified.
1333        let with_evidence = Effect {
1334            readback: Some("sha256:observed".into()),
1335            effect_confidence: Some(EffectConfidence::Verified),
1336            ..Default::default()
1337        };
1338        assert!(with_evidence.has_independent_evidence());
1339        assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1340
1341        // No independent evidence: the honest ceiling is NotVerified, so a
1342        // `Verified` CLAIM here must be treated as inflated (ack != act).
1343        let claim_only = Effect {
1344            output_hash: Some("sha256:out".into()),
1345            effect_confidence: Some(EffectConfidence::Verified),
1346            ..Default::default()
1347        };
1348        assert!(!claim_only.has_independent_evidence());
1349        assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1350
1351        // An honest actor can downgrade below the ceiling with no evidence.
1352        let honest_downgrade = Effect {
1353            effect_confidence: Some(EffectConfidence::Unknown),
1354            ..Default::default()
1355        };
1356        assert_eq!(
1357            honest_downgrade.evidence_ceiling(),
1358            EffectConfidence::NotVerified
1359        );
1360    }
1361
1362    #[test]
1363    fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1364        let e = Effect {
1365            effect_confidence: Some(EffectConfidence::NotVerified),
1366            ..Default::default()
1367        };
1368        let j = serde_json::to_string(&e).unwrap();
1369        assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1370
1371        // Absent => omitted entirely (additive, backward-compatible over v1).
1372        let empty = Effect::default();
1373        assert!(!serde_json::to_string(&empty)
1374            .unwrap()
1375            .contains("effect_confidence"));
1376    }
1377
1378    /// A test authority that trusts any signed witness whose observer differs
1379    /// from the actor and whose observation matches the effect's readback.
1380    /// Stands in for the real trust-root + signature check.
1381    struct TrustingWitnessAuthority;
1382    impl WitnessAuthority for TrustingWitnessAuthority {
1383        fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1384            w.is_signed()
1385                && w.observer != actor
1386                && effect.readback.as_deref() == Some(w.observation.as_str())
1387        }
1388    }
1389
1390    #[test]
1391    fn verify_effect_downgrades_unbacked_verified_claim() {
1392        // Verified claim, no readback, no witness => downgraded to NotVerified.
1393        let mut s = good_stmt();
1394        s.actor = "agent://worker".into();
1395        s.effect = Some(Effect {
1396            output_hash: Some("sha256:out".into()),
1397            effect_confidence: Some(EffectConfidence::Verified),
1398            ..Default::default()
1399        });
1400        let v = verify_effect(&s, &NoWitnessAuthority);
1401        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1402        assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1403        assert!(!v.is_verified());
1404        assert!(
1405            v.notes.iter().any(|n| n.contains("downgraded")),
1406            "{:?}",
1407            v.notes
1408        );
1409    }
1410
1411    // ---- effect finality (lifecycle) vs confidence (evidence) ----
1412
1413    #[test]
1414    fn finality_and_confidence_are_independent_axes() {
1415        // The composite-claim bug in one assertion: a receipt can be honest
1416        // about its evidence and still overclaim completion. Weak evidence,
1417        // strong completion claim -- the verifier must judge them separately.
1418        let mut s = good_stmt();
1419        s.effect = Some(Effect {
1420            output_hash: Some("sha256:out".into()),
1421            effect_confidence: Some(EffectConfidence::Partial),
1422            finality: Some(EffectFinality::Finalized),
1423            ..Default::default()
1424        });
1425        let v = verify_effect(&s, &NoWitnessAuthority);
1426        // Partial is an admission, so it survives untouched...
1427        assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1428        // ...while the unbacked Finalized does not.
1429        assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1430        assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1431    }
1432
1433    #[test]
1434    fn unbacked_finalized_is_downgraded_to_indeterminate() {
1435        // The 56%-of-writes case: accepted, acknowledged, served back on read,
1436        // never committed. Downgrade must land on Indeterminate, not Initiated
1437        // -- without evidence we cannot assert the weaker stage either, and
1438        // inventing it would be a different false claim.
1439        let mut s = good_stmt();
1440        s.effect = Some(Effect {
1441            output_hash: Some("sha256:out".into()),
1442            finality: Some(EffectFinality::Finalized),
1443            ..Default::default()
1444        });
1445        let v = verify_effect(&s, &NoWitnessAuthority);
1446        assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1447        assert!(
1448            v.notes.iter().any(|n| n.contains("Finalized")),
1449            "the downgrade must be stated, not silent: {:?}",
1450            v.notes
1451        );
1452    }
1453
1454    #[test]
1455    fn finalized_backed_by_readback_survives() {
1456        let mut s = good_stmt();
1457        s.effect = Some(Effect {
1458            readback: Some("sha256:observed".into()),
1459            finality: Some(EffectFinality::Finalized),
1460            ..Default::default()
1461        });
1462        let v = verify_effect(&s, &NoWitnessAuthority);
1463        assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1464    }
1465
1466    #[test]
1467    fn lesser_finality_claims_pass_through_unchanged() {
1468        // Only the strongest claim can be inflated, so only it is capped.
1469        // Downgrading an actor's own hedge would punish honesty.
1470        for stage in [
1471            EffectFinality::NotAttempted,
1472            EffectFinality::Initiated,
1473            EffectFinality::Failed,
1474            EffectFinality::Indeterminate,
1475        ] {
1476            let mut s = good_stmt();
1477            s.effect = Some(Effect {
1478                finality: Some(stage),
1479                ..Default::default()
1480            });
1481            let v = verify_effect(&s, &NoWitnessAuthority);
1482            assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1483        }
1484    }
1485
1486    #[test]
1487    fn not_attempted_is_the_no_authority_moved_receipt() {
1488        // A timeout before the call. The input is bound so the negative is
1489        // about a specific request, and the stage is terminal so nothing is
1490        // still owed. This is what makes absence expressible instead of silent.
1491        let e = Effect {
1492            input_hash: Some("sha256:req".into()),
1493            finality: Some(EffectFinality::NotAttempted),
1494            ..Default::default()
1495        };
1496        assert!(EffectFinality::NotAttempted.is_resolved());
1497        assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
1498    }
1499
1500    // ---- resolution deadlines ----
1501
1502    fn open_effect(resolution: Option<Resolution>) -> Effect {
1503        Effect {
1504            finality: Some(EffectFinality::Initiated),
1505            resolution,
1506            ..Default::default()
1507        }
1508    }
1509
1510    #[test]
1511    fn unresolved_without_a_deadline_reports_indefinite() {
1512        // The 181-day pending row. Silence here is what made it survivable;
1513        // naming the state is the whole point of the field.
1514        assert_eq!(
1515            check_resolution(&open_effect(None), 1_800_000_000),
1516            ResolutionStatus::Indefinite
1517        );
1518    }
1519
1520    /// Derive the epoch from the same parser the check uses, rather than
1521    /// hand-computing one. A wrong literal here would make the test assert a
1522    /// fact about my arithmetic instead of about the function.
1523    const DEADLINE: &str = "2026-07-20T11:00:00Z";
1524    fn deadline_unix() -> i64 {
1525        parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1526    }
1527
1528    #[test]
1529    fn unresolved_past_its_deadline_reports_the_declared_event() {
1530        let e = open_effect(Some(Resolution {
1531            deadline: DEADLINE.into(),
1532            on_deadline: DeadlineEvent::Escalate,
1533        }));
1534        match check_resolution(&e, deadline_unix() + 90) {
1535            ResolutionStatus::Breached {
1536                on_deadline,
1537                seconds_overdue,
1538            } => {
1539                assert_eq!(on_deadline, DeadlineEvent::Escalate);
1540                assert_eq!(seconds_overdue, 90);
1541            }
1542            other => panic!("expected Breached, got {other:?}"),
1543        }
1544    }
1545
1546    #[test]
1547    fn unresolved_inside_its_window_is_pending() {
1548        let e = open_effect(Some(Resolution {
1549            deadline: DEADLINE.into(),
1550            on_deadline: DeadlineEvent::Timeout,
1551        }));
1552        match check_resolution(&e, deadline_unix() - 60) {
1553            ResolutionStatus::Pending { seconds_remaining } => {
1554                assert_eq!(seconds_remaining, 60)
1555            }
1556            other => panic!("expected Pending, got {other:?}"),
1557        }
1558    }
1559
1560    #[test]
1561    fn a_resolved_effect_cannot_breach() {
1562        // Finalized long before "now": the deadline is moot, not breached.
1563        let e = Effect {
1564            finality: Some(EffectFinality::Finalized),
1565            resolution: Some(Resolution {
1566                deadline: "2026-07-20T11:00:00Z".into(),
1567                on_deadline: DeadlineEvent::Tombstone,
1568            }),
1569            ..Default::default()
1570        };
1571        assert_eq!(check_resolution(&e, 4_000_000_000), ResolutionStatus::Resolved);
1572    }
1573
1574    #[test]
1575    fn unparseable_deadline_fails_toward_unknown() {
1576        // Never toward "in window": a deadline nobody can read must not be
1577        // treated as one that has not passed yet.
1578        let e = open_effect(Some(Resolution {
1579            deadline: "whenever".into(),
1580            on_deadline: DeadlineEvent::Timeout,
1581        }));
1582        assert_eq!(
1583            check_resolution(&e, 1_800_000_000),
1584            ResolutionStatus::BadDeadline
1585        );
1586    }
1587
1588    #[test]
1589    fn missing_finality_is_treated_as_unresolved() {
1590        // A receipt that never says where its state change got to has not told
1591        // us it landed. Conservative reading, stated explicitly.
1592        let e = Effect {
1593            output_hash: Some("sha256:out".into()),
1594            ..Default::default()
1595        };
1596        assert_eq!(
1597            check_resolution(&e, 1_800_000_000),
1598            ResolutionStatus::Indefinite
1599        );
1600    }
1601
1602    #[test]
1603    fn finality_and_resolution_are_omitted_when_absent() {
1604        // Existing v2 receipts must keep byte-identical canonical bytes.
1605        let json = serde_json::to_string(&Effect {
1606            output_hash: Some("sha256:out".into()),
1607            ..Default::default()
1608        })
1609        .unwrap();
1610        assert!(!json.contains("finality"), "{json}");
1611        assert!(!json.contains("resolution"), "{json}");
1612    }
1613
1614    #[test]
1615    fn verify_effect_honors_verified_backed_by_readback() {
1616        let mut s = good_stmt();
1617        s.effect = Some(Effect {
1618            readback: Some("sha256:observed".into()),
1619            effect_confidence: Some(EffectConfidence::Verified),
1620            ..Default::default()
1621        });
1622        let v = verify_effect(&s, &NoWitnessAuthority);
1623        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1624        assert!(v.is_verified());
1625    }
1626
1627    #[test]
1628    fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1629        // No readback, but an independent trusted witness observed the same
1630        // post-state the effect commits to: Verified stands.
1631        let mut s = good_stmt();
1632        s.actor = "agent://worker".into();
1633        s.effect = Some(Effect {
1634            readback: Some("sha256:state".into()),
1635            effect_confidence: Some(EffectConfidence::Verified),
1636            witnesses: vec![Witness {
1637                observer: "agent://auditor".into(),
1638                observation: "sha256:state".into(),
1639                observed_at: Some("2026-07-20T10:00:00Z".into()),
1640                signature: Some("ed25519:sig".into()),
1641            }],
1642            ..Default::default()
1643        });
1644        let v = verify_effect(&s, &TrustingWitnessAuthority);
1645        assert_eq!(v.trusted_witnesses, 1);
1646        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1647
1648        // A self-witness (observer == actor) is not trusted, even signed.
1649        let mut self_witness = s.clone();
1650        if let Some(e) = self_witness.effect.as_mut() {
1651            e.readback = None; // remove the readback so only the witness could lift it
1652            e.witnesses[0].observer = "agent://worker".into();
1653        }
1654        let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1655        assert_eq!(v2.trusted_witnesses, 0);
1656        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1657        assert!(v2
1658            .notes
1659            .iter()
1660            .any(|n| n.contains("not independently trusted")));
1661    }
1662
1663    #[test]
1664    fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1665        // Partial/Unknown are admissions, not inflations: no downgrade even
1666        // without independent evidence.
1667        for c in [
1668            EffectConfidence::Partial,
1669            EffectConfidence::Ambiguous,
1670            EffectConfidence::Unknown,
1671            EffectConfidence::NotVerified,
1672        ] {
1673            let mut s = good_stmt();
1674            s.effect = Some(Effect {
1675                effect_confidence: Some(c),
1676                ..Default::default()
1677            });
1678            let v = verify_effect(&s, &NoWitnessAuthority);
1679            assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1680        }
1681    }
1682
1683    #[test]
1684    fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1685        // No effect block at all.
1686        let s = good_stmt();
1687        assert!(s.effect.is_none());
1688        let v = verify_effect(&s, &NoWitnessAuthority);
1689        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1690        assert_eq!(v.claimed_confidence, None);
1691        assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1692
1693        // Effect present but no confidence claim.
1694        let mut s2 = good_stmt();
1695        s2.effect = Some(Effect {
1696            output_hash: Some("sha256:out".into()),
1697            ..Default::default()
1698        });
1699        let v2 = verify_effect(&s2, &NoWitnessAuthority);
1700        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1701        assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1702    }
1703
1704    #[test]
1705    fn witness_does_not_inflate_evidence_ceiling() {
1706        // Security invariant: a witness the actor bundled in -- even a signed
1707        // one -- must NOT lift evidence_ceiling at the data-model layer. Only
1708        // an actor-unmintable readback does that here; witness trust is
1709        // verify's job.
1710        let signed_witness = Witness {
1711            observer: "agent://auditor".into(),
1712            observation: "sha256:observed".into(),
1713            observed_at: Some("2026-07-20T10:00:00Z".into()),
1714            signature: Some("ed25519:sig".into()),
1715        };
1716        let e = Effect {
1717            witnesses: vec![signed_witness.clone()],
1718            effect_confidence: Some(EffectConfidence::Verified),
1719            ..Default::default()
1720        };
1721        assert!(!e.has_independent_evidence());
1722        assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1723        // The signature is visible for verify to check, but that's a
1724        // precondition, not evidence.
1725        assert!(signed_witness.is_signed());
1726        assert_eq!(e.signed_witnesses().count(), 1);
1727
1728        // An unsigned witness isn't even a candidate.
1729        let unsigned = Effect {
1730            witnesses: vec![Witness {
1731                observer: "agent://auditor".into(),
1732                observation: "sha256:observed".into(),
1733                ..Default::default()
1734            }],
1735            ..Default::default()
1736        };
1737        assert_eq!(unsigned.signed_witnesses().count(), 0);
1738    }
1739
1740    #[test]
1741    fn witnesses_serialize_and_omit_when_empty() {
1742        let empty = Effect::default();
1743        assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1744
1745        let e = Effect {
1746            witnesses: vec![Witness {
1747                observer: "key_9f2c".into(),
1748                observation: "sha256:obs".into(),
1749                observed_at: None,
1750                signature: Some("ed25519:sig".into()),
1751            }],
1752            ..Default::default()
1753        };
1754        let j = serde_json::to_string(&e).unwrap();
1755        assert!(j.contains("\"witnesses\":[{"), "{j}");
1756        assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1757        // observed_at absent => omitted, not null.
1758        assert!(!j.contains("observed_at"), "{j}");
1759        let back: Effect = serde_json::from_str(&j).unwrap();
1760        assert_eq!(back.witnesses.len(), 1);
1761        assert!(back.witnesses[0].is_signed());
1762    }
1763
1764    #[test]
1765    fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1766        assert!(RuntimeIdentity::default().is_unbound());
1767
1768        // Any single populated field means the binding attests something.
1769        let with_model = RuntimeIdentity {
1770            model: Some("claude-opus-4-8".into()),
1771            ..Default::default()
1772        };
1773        assert!(!with_model.is_unbound());
1774
1775        let with_prompt = RuntimeIdentity {
1776            system_prompt_hash: Some("sha256:sys".into()),
1777            ..Default::default()
1778        };
1779        assert!(!with_prompt.is_unbound());
1780    }
1781
1782    #[test]
1783    fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1784        let rt = RuntimeIdentity {
1785            provider: Some("anthropic".into()),
1786            model: Some("claude-opus-4-8".into()),
1787            tool_schema_hash: Some("sha256:tools".into()),
1788            system_prompt_hash: None,
1789        };
1790        let j = serde_json::to_string(&rt).unwrap();
1791        assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1792        assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1793        assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1794        // Absent field omitted, not null -- keeps the canonical stable.
1795        assert!(!j.contains("system_prompt_hash"), "{j}");
1796
1797        // All-absent runtime is an empty object, and roundtrips.
1798        let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1799        assert_eq!(empty, "{}");
1800        let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1801        assert!(back.is_unbound());
1802    }
1803
1804    #[test]
1805    fn runtime_is_omitted_from_statement_when_absent() {
1806        // A v2 statement with no runtime must not emit a `runtime` key, so
1807        // existing artifact_ids over runtime-less receipts are unaffected.
1808        let s = good_stmt();
1809        assert!(s.runtime.is_none());
1810        let j = serde_json::to_string(&s).unwrap();
1811        assert!(!j.contains("runtime"), "{j}");
1812
1813        // When present, it rides in the signed statement and roundtrips.
1814        let mut with_rt = good_stmt();
1815        with_rt.runtime = Some(RuntimeIdentity {
1816            model: Some("claude-opus-4-8".into()),
1817            ..Default::default()
1818        });
1819        let j2 = serde_json::to_string(&with_rt).unwrap();
1820        assert!(j2.contains("\"runtime\""), "{j2}");
1821        let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
1822        assert_eq!(
1823            back.runtime.unwrap().model.as_deref(),
1824            Some("claude-opus-4-8")
1825        );
1826    }
1827
1828    fn base_mandate() -> Mandate {
1829        Mandate {
1830            grant_id: "grant_9c2f".into(),
1831            grantor: "key_parent".into(),
1832            issuer_sig: None,
1833            objective_hash: Some("sha256:abc".into()),
1834            scope: vec!["payments.charge".into()],
1835            audience: "acme-payments-api".into(),
1836            parent_request_id: Some("req_7d3e".into()),
1837            delegation_depth: 2,
1838            issued_at: "2026-07-11T19:50:00Z".into(),
1839            expiry: "2026-07-11T20:50:00Z".into(),
1840            max_delegation: 3,
1841            revocation: Revocation {
1842                path: "hub://acme/revocations".into(),
1843                revoked_at: None,
1844            },
1845            chain: Vec::new(),
1846        }
1847    }
1848
1849    /// A statement whose signed_at sits inside the mandate window, action in
1850    /// scope, audience matching.
1851    fn good_stmt() -> ActionStatementV2 {
1852        let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
1853        s.timestamp = "2026-07-11T19:53:09Z".into();
1854        s.audience = Some("acme-payments-api".into());
1855        s
1856    }
1857
1858    struct StaticRevocation(RevocationStatus);
1859    impl RevocationSource for StaticRevocation {
1860        fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
1861            self.0.clone()
1862        }
1863    }
1864
1865    // ---- scope ----
1866
1867    #[test]
1868    fn scope_exact_and_glob() {
1869        assert!(action_in_scope(
1870            "payments.charge",
1871            &["payments.charge".into()]
1872        ));
1873        assert!(action_in_scope("payments.charge", &["payments.*".into()]));
1874        assert!(action_in_scope("payments", &["payments.*".into()]));
1875        assert!(!action_in_scope(
1876            "payments.refund",
1877            &["payments.charge".into()]
1878        ));
1879        assert!(!action_in_scope("email.send", &["payments.*".into()]));
1880        // A bare "*" is a literal, not a wildcard.
1881        assert!(!action_in_scope("anything", &["*".into()]));
1882        assert!(action_in_scope("*", &["*".into()]));
1883    }
1884
1885    #[test]
1886    fn empty_scope_authorizes_nothing() {
1887        let mut s = good_stmt();
1888        s.mandate.scope = vec![];
1889        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1890            MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
1891            v => panic!("empty scope must fail, got {v:?}"),
1892        }
1893    }
1894
1895    #[test]
1896    fn action_out_of_scope_fails() {
1897        let mut s = good_stmt();
1898        s.action = "payments.refund".into();
1899        assert!(matches!(
1900            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1901            MandateVerdict::Fail(_)
1902        ));
1903    }
1904
1905    // ---- audience ----
1906
1907    #[test]
1908    fn audience_match_passes_layer() {
1909        let s = good_stmt();
1910        assert_eq!(
1911            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1912            MandateVerdict::Pass
1913        );
1914    }
1915
1916    #[test]
1917    fn audience_mismatch_fails() {
1918        let mut s = good_stmt();
1919        s.audience = Some("evil-api".into());
1920        assert!(matches!(
1921            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1922            MandateVerdict::Fail(_)
1923        ));
1924    }
1925
1926    #[test]
1927    fn missing_action_audience_is_unverified_not_pass() {
1928        let mut s = good_stmt();
1929        s.audience = None;
1930        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
1931            MandateVerdict::Unverified(rs) => {
1932                assert!(rs.iter().any(|r| r.contains("recorded no audience")))
1933            }
1934            v => panic!("missing audience must be Unverified, got {v:?}"),
1935        }
1936    }
1937
1938    #[test]
1939    fn empty_mandate_audience_fails() {
1940        let mut s = good_stmt();
1941        s.mandate.audience = "".into();
1942        assert!(matches!(
1943            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1944            MandateVerdict::Fail(_)
1945        ));
1946    }
1947
1948    // ---- TTL (validity at signed_at) ----
1949
1950    #[test]
1951    fn signed_before_issued_fails() {
1952        let mut s = good_stmt();
1953        s.timestamp = "2026-07-11T19:49:59Z".into(); // one sec before issued
1954        assert!(matches!(
1955            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1956            MandateVerdict::Fail(_)
1957        ));
1958    }
1959
1960    #[test]
1961    fn signed_at_expiry_fails() {
1962        let mut s = good_stmt();
1963        s.timestamp = "2026-07-11T20:50:00Z".into(); // exactly expiry (exclusive)
1964        assert!(matches!(
1965            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1966            MandateVerdict::Fail(_)
1967        ));
1968    }
1969
1970    #[test]
1971    fn signed_within_window_passes() {
1972        let s = good_stmt(); // 19:53:09 within [19:50, 20:50)
1973        assert_eq!(
1974            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1975            MandateVerdict::Pass
1976        );
1977    }
1978
1979    #[test]
1980    fn malformed_timestamp_fails_closed() {
1981        let mut s = good_stmt();
1982        s.timestamp = "not-a-timestamp".into();
1983        assert!(matches!(
1984            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
1985            MandateVerdict::Fail(_)
1986        ));
1987    }
1988
1989    // ---- revocation at signing time ----
1990
1991    #[test]
1992    fn revoked_after_signing_still_passes() {
1993        // Grant revoked at 20:00; receipt signed at 19:53 -> still valid,
1994        // like a TLS cert whose later revocation is not retroactive.
1995        let s = good_stmt();
1996        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
1997        assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
1998    }
1999
2000    #[test]
2001    fn revoked_before_signing_fails() {
2002        let s = good_stmt(); // signed 19:53
2003        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2004        assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2005    }
2006
2007    #[test]
2008    fn revocation_unknown_is_unverified() {
2009        let s = good_stmt();
2010        match verify_mandate(&s, &NoRevocationSource) {
2011            MandateVerdict::Unverified(rs) => {
2012                assert!(rs
2013                    .iter()
2014                    .any(|r| r.contains("revocation could not be checked")))
2015            }
2016            v => panic!("no revocation source must be Unverified, got {v:?}"),
2017        }
2018    }
2019
2020    #[test]
2021    fn fail_takes_precedence_over_unverified() {
2022        // Out-of-scope action (fail) AND no revocation source (unverified):
2023        // the verdict must be Fail, never Unverified.
2024        let mut s = good_stmt();
2025        s.action = "payments.refund".into();
2026        assert!(matches!(
2027            verify_mandate(&s, &NoRevocationSource),
2028            MandateVerdict::Fail(_)
2029        ));
2030    }
2031
2032    #[test]
2033    fn wrong_type_fails() {
2034        let mut s = good_stmt();
2035        s.type_ = "treeship/action/v1".into();
2036        assert!(matches!(
2037            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2038            MandateVerdict::Fail(_)
2039        ));
2040    }
2041
2042    // ---- canonical binding: mandate/effect are in the signed bytes ----
2043
2044    #[test]
2045    fn mandate_is_bound_into_signature() {
2046        let signer = Ed25519Signer::generate("key_test").unwrap();
2047        let pt = payload_type_v2("action");
2048
2049        let a = good_stmt();
2050        let mut b = good_stmt();
2051        b.mandate.scope = vec!["payments.*".into()]; // differ only in mandate
2052
2053        let ra = sign(&pt, &a, &signer).unwrap();
2054        let rb = sign(&pt, &b, &signer).unwrap();
2055        assert_ne!(
2056            ra.artifact_id, rb.artifact_id,
2057            "changing mandate.scope must change the signed artifact id"
2058        );
2059    }
2060
2061    #[test]
2062    fn v2_sign_verify_roundtrip() {
2063        let signer = Ed25519Signer::generate("key_test").unwrap();
2064        let verifier = EnvVerifier::from_signer(&signer);
2065        let pt = payload_type_v2("action");
2066
2067        let mut s = good_stmt();
2068        s.effect = Some(Effect {
2069            output_hash: Some("sha256:out".into()),
2070            readback: Some("sha256:observed".into()),
2071            bytes_moved: Some(1_048_576),
2072            cost: Some(Cost {
2073                unit: "usd_micros".into(),
2074                amount: 4200,
2075            }),
2076            side_effects: vec!["db:users.update".into()],
2077            ..Default::default()
2078        });
2079
2080        let signed = sign(&pt, &s, &signer).unwrap();
2081        verifier.verify(&signed.envelope).unwrap();
2082
2083        let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2084        assert_eq!(decoded.type_, TYPE_ACTION_V2);
2085        assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2086        assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2087    }
2088
2089    #[test]
2090    fn v2_payload_type_differs_from_v1() {
2091        assert_eq!(
2092            payload_type_v2("action"),
2093            "application/vnd.treeship.action.v2+json"
2094        );
2095        assert_ne!(
2096            payload_type_v2("action"),
2097            super::super::payload_type("action")
2098        );
2099    }
2100
2101    // ---- grant object + chain attenuation ----
2102
2103    fn grant(
2104        id: &str,
2105        grantor: &str,
2106        scope: &[&str],
2107        depth: u32,
2108        expiry: &str,
2109        max_deleg: u32,
2110    ) -> Grant {
2111        Grant {
2112            grant_id: id.into(),
2113            grantor: grantor.into(),
2114            issuer_sig: None,
2115            scope: scope.iter().map(|s| (*s).into()).collect(),
2116            audience: "acme-payments-api".into(),
2117            parent_request_id: None,
2118            parent_grant_id: None,
2119            delegation_depth: depth,
2120            issued_at: "2026-07-11T19:00:00Z".into(),
2121            expiry: expiry.into(),
2122            max_delegation: max_deleg,
2123            objective_hash: None,
2124        }
2125    }
2126
2127    #[test]
2128    fn grant_sign_verify_roundtrip_and_tamper() {
2129        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2130        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2131        let mut g = grant(
2132            "grant_root",
2133            &grantor,
2134            &["payments.*"],
2135            0,
2136            "2026-07-11T21:00:00Z",
2137            3,
2138        );
2139        // Ids are content-derived now; verify_canonical refuses a hand-chosen
2140        // one, so compute it before signing.
2141        g.grant_id = g.derive_grant_id();
2142
2143        let sig = g.sign_canonical(&signer).unwrap();
2144        assert!(g.verify_canonical(&sig));
2145
2146        // Tamper after signing -> verification fails.
2147        g.scope.push("email.*".into());
2148        assert!(!g.verify_canonical(&sig));
2149    }
2150
2151    #[test]
2152    fn grant_verify_rejects_wrong_key() {
2153        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2154        let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2155        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2156        let g = grant(
2157            "grant_root",
2158            &grantor,
2159            &["payments.*"],
2160            0,
2161            "2026-07-11T21:00:00Z",
2162            3,
2163        );
2164        let sig = g.sign_canonical(&attacker).unwrap();
2165        assert!(!g.verify_canonical(&sig));
2166    }
2167
2168    #[test]
2169    fn valid_attenuating_chain_ok() {
2170        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2171        let child = grant(
2172            "g1",
2173            "k",
2174            &["payments.charge"],
2175            1,
2176            "2026-07-11T20:30:00Z",
2177            3,
2178        );
2179        assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2180    }
2181
2182    #[test]
2183    fn scope_widening_rejected() {
2184        let root = grant(
2185            "g0",
2186            "k",
2187            &["payments.charge"],
2188            0,
2189            "2026-07-11T21:00:00Z",
2190            3,
2191        );
2192        let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2193        assert_eq!(
2194            verify_grant_chain(&[root, child]),
2195            Err(GrantChainError::ScopeWidened { parent: 0 })
2196        );
2197    }
2198
2199    #[test]
2200    fn expiry_widening_rejected() {
2201        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2202        let child = grant(
2203            "g1",
2204            "k",
2205            &["payments.charge"],
2206            1,
2207            "2026-07-11T22:00:00Z",
2208            3,
2209        );
2210        assert_eq!(
2211            verify_grant_chain(&[root, child]),
2212            Err(GrantChainError::ExpiryWidened { parent: 0 })
2213        );
2214    }
2215
2216    #[test]
2217    fn depth_not_incremented_rejected() {
2218        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2219        let child = grant(
2220            "g1",
2221            "k",
2222            &["payments.charge"],
2223            2,
2224            "2026-07-11T21:00:00Z",
2225            3,
2226        );
2227        assert_eq!(
2228            verify_grant_chain(&[root, child]),
2229            Err(GrantChainError::DepthNotIncremented { parent: 0 })
2230        );
2231    }
2232
2233    #[test]
2234    fn depth_exceeds_max_rejected() {
2235        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2236        let child = grant(
2237            "g1",
2238            "k",
2239            &["payments.charge"],
2240            1,
2241            "2026-07-11T21:00:00Z",
2242            0,
2243        );
2244        assert_eq!(
2245            verify_grant_chain(&[root, child]),
2246            Err(GrantChainError::DepthExceedsMax { parent: 0 })
2247        );
2248    }
2249
2250    #[test]
2251    fn audience_change_rejected() {
2252        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2253        let mut child = grant(
2254            "g1",
2255            "k",
2256            &["payments.charge"],
2257            1,
2258            "2026-07-11T21:00:00Z",
2259            3,
2260        );
2261        child.audience = "other-api".into();
2262        assert_eq!(
2263            verify_grant_chain(&[root, child]),
2264            Err(GrantChainError::AudienceChanged { parent: 0 })
2265        );
2266    }
2267
2268    #[test]
2269    fn empty_chain_rejected() {
2270        assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2271    }
2272
2273    #[test]
2274    fn bad_timestamp_in_chain_rejected() {
2275        let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2276        root.expiry = "nope".into();
2277        assert_eq!(
2278            verify_grant_chain(&[root]),
2279            Err(GrantChainError::BadTimestamp { index: 0 })
2280        );
2281    }
2282
2283    #[test]
2284    fn single_grant_chain_ok() {
2285        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2286        assert_eq!(verify_grant_chain(&[root]), Ok(()));
2287    }
2288
2289    // ---- grant chain resolution ----
2290
2291
2292    /// Mint a signed grant with a content-consistent id.
2293    fn mk_grant(
2294        signer: &Ed25519Signer,
2295        grantor_pk: &str,
2296        scope: Vec<&str>,
2297        depth: u32,
2298        parent: Option<&str>,
2299    ) -> Grant {
2300        let mut g = Grant {
2301            grant_id: String::new(),
2302            grantor: grantor_pk.to_string(),
2303            issuer_sig: None,
2304            scope: scope.into_iter().map(String::from).collect(),
2305            audience: "acme".into(),
2306            parent_request_id: None,
2307            parent_grant_id: parent.map(String::from),
2308            delegation_depth: depth,
2309            issued_at: "2026-07-20T10:00:00Z".into(),
2310            expiry: "2026-07-20T11:00:00Z".into(),
2311            max_delegation: 3,
2312            objective_hash: None,
2313        };
2314        g.grant_id = g.derive_grant_id();
2315        g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2316        g
2317    }
2318
2319    fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2320        let signer = Ed25519Signer::generate("issuer").unwrap();
2321        let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2322        let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2323        let leaf = mk_grant(
2324            &signer,
2325            &pk,
2326            vec!["payments.charge"],
2327            1,
2328            Some(&root.grant_id),
2329        );
2330        (root, leaf, signer)
2331    }
2332
2333    fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2334        let mut m = base_mandate();
2335        m.grant_id = leaf.grant_id.clone();
2336        m.chain = chain;
2337        m
2338    }
2339
2340    #[test]
2341    fn grant_id_is_content_derived_and_stable() {
2342        let (root, _, _) = chain_fixture();
2343        assert!(root.grant_id.starts_with("grn_"));
2344        assert_eq!(root.grant_id, root.derive_grant_id());
2345        // Changing any covered field must move the id.
2346        let mut altered = root.clone();
2347        altered.scope = vec!["payments.refund".into()];
2348        assert_ne!(altered.derive_grant_id(), root.grant_id);
2349    }
2350
2351    #[test]
2352    fn hand_chosen_id_fails_verification() {
2353        let (mut root, _, _) = chain_fixture();
2354        let sig = root.issuer_sig.clone().unwrap();
2355        root.grant_id = "grn_deadbeefdeadbeef".into();
2356        assert!(
2357            !root.verify_canonical(&sig),
2358            "an id that was chosen rather than computed must not verify"
2359        );
2360    }
2361
2362    #[test]
2363    fn resolves_root_first_regardless_of_carrier_order() {
2364        let (root, leaf, _) = chain_fixture();
2365        // Carrier hands them over leaf-first; resolution must not care.
2366        let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2367        let resolved = resolve_grant_chain(&m).expect("resolves");
2368        assert_eq!(resolved.len(), 2);
2369        assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2370        assert_eq!(resolved[1].grant_id, leaf.grant_id);
2371    }
2372
2373    #[test]
2374    fn truncated_chain_is_rejected() {
2375        let (_, leaf, _) = chain_fixture();
2376        // Drop the root: the leaf still points at it, so the walk must fail
2377        // rather than treating the leaf as its own root.
2378        let m = mandate_with(&leaf, vec![leaf.clone()]);
2379        match resolve_grant_chain(&m) {
2380            Err(ChainResolveError::AncestorMissing { .. }) => {}
2381            other => panic!("truncation must be caught, got {other:?}"),
2382        }
2383    }
2384
2385    #[test]
2386    fn spliced_decoy_grant_is_rejected() {
2387        let (root, leaf, signer) = chain_fixture();
2388        let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2389        // A valid, signed, unrelated grant smuggled into the chain. It must
2390        // differ in content from root -- an identical grant has an identical
2391        // content id, which is the point of deriving ids from content.
2392        let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2393        assert_ne!(decoy.grant_id, root.grant_id);
2394        let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2395        match resolve_grant_chain(&m) {
2396            Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2397            other => panic!("unreachable extras must be refused, got {other:?}"),
2398        }
2399    }
2400
2401    #[test]
2402    fn unsigned_ancestor_is_rejected() {
2403        let (mut root, leaf, _) = chain_fixture();
2404        root.issuer_sig = None;
2405        let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2406        assert!(matches!(
2407            resolve_grant_chain(&m),
2408            Err(ChainResolveError::Unsigned { .. })
2409        ));
2410    }
2411
2412    #[test]
2413    fn resolved_chain_feeds_attenuation_check() {
2414        // End to end: resolution proves the shape, verify_grant_chain then
2415        // judges the attenuation. Only together do they mean anything.
2416        let (root, leaf, _) = chain_fixture();
2417        let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2418        let resolved = resolve_grant_chain(&m).expect("resolves");
2419        assert!(
2420            verify_grant_chain(&resolved).is_ok(),
2421            "narrowing scope at depth+1 must satisfy attenuation"
2422        );
2423    }
2424
2425    #[test]
2426    fn leaf_not_in_chain_is_rejected() {
2427        // The mandate names a grant the carrier did not supply. Distinct from
2428        // AncestorMissing: nothing has been walked yet, so the failure has to
2429        // point at the leaf rather than at a parent link.
2430        let (root, leaf, _) = chain_fixture();
2431        let mut m = mandate_with(&leaf, vec![root.clone()]);
2432        m.grant_id = leaf.grant_id.clone();
2433        match resolve_grant_chain(&m) {
2434            Err(ChainResolveError::LeafMissing { grant_id }) => {
2435                assert_eq!(grant_id, leaf.grant_id);
2436            }
2437            other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2438        }
2439    }
2440
2441    #[test]
2442    fn ancestor_signed_by_a_stranger_is_rejected() {
2443        // Content-consistent id, well-formed signature -- but produced by a key
2444        // that is not the declared grantor. The id check cannot catch this
2445        // because `grantor` is covered by the canonical: only the crypto can.
2446        let (root, leaf, _) = chain_fixture();
2447        let stranger = Ed25519Signer::generate("stranger").unwrap();
2448        let mut forged = root.clone();
2449        forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2450        assert_eq!(
2451            forged.grant_id, root.grant_id,
2452            "signing with another key must not change the content id"
2453        );
2454
2455        let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2456        match resolve_grant_chain(&m) {
2457            Err(ChainResolveError::BadSignature { grant_id }) => {
2458                assert_eq!(grant_id, root.grant_id);
2459            }
2460            other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2461        }
2462    }
2463
2464    #[test]
2465    fn inconsistent_id_is_caught_before_signature_check() {
2466        // A tampered id must be refused as InconsistentId, not surface later as
2467        // BadSignature -- the reason an operator is given should name the
2468        // actual defect.
2469        let (root, leaf, _) = chain_fixture();
2470        let mut tampered = root.clone();
2471        tampered.grant_id = "grn_0000000000000000".into();
2472        let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2473        assert!(matches!(
2474            resolve_grant_chain(&m),
2475            Err(ChainResolveError::InconsistentId { .. })
2476        ));
2477    }
2478}