Skip to main content

treeship_core/statements/
action_v2.rs

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