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    // -- the mandate must not claim more than the grant it names gave it.
777    //
778    // Every check above judges the action against the mandate's OWN fields.
779    // Those fields are signed -- by the actor, in the action envelope. The
780    // grantor's signature covers the `Grant`, which is a different object.
781    // Nothing tied the two together, so an actor could carry a legitimately
782    // signed, correctly attenuated chain whose leaf granted `payments.charge`,
783    // declare `mandate.scope = ["payments.*"]`, and have `payments.refund`
784    // verify as Pass. The chain was resolved, attenuation-checked, and then
785    // never consulted: real, verified, and decorative.
786    //
787    // Restating a constraint under the signature of the party it constrains is
788    // not a constraint. This reconciles the restatement with the source.
789    // Only when a chain is carried. A chainless mandate's terms are
790    // self-asserted here too, and arguably that should read `Unverified` --
791    // the same way "no revocation source" does a few lines up. But that would
792    // change the verdict of every chainless receipt, which is a product
793    // decision, not a bug fix, and bundling it here would make a security
794    // change hard to review on its own merits. Tracked separately; the design
795    // does support resolving the grant out of band by `grant_id`, so a
796    // verifier that does so can still check what this one cannot.
797    if !m.chain.is_empty() {
798        match resolve_grant_chain(m) {
799            Err(e) => fail.push(format!("grant chain does not resolve: {e}")),
800            Ok(chain) => {
801                if let Err(e) = verify_grant_chain(&chain) {
802                    fail.push(format!("grant chain attenuation: {e}"));
803                } else if let Some(leaf) = chain.last() {
804                    if !scope_subset(&m.scope, &leaf.scope) {
805                        fail.push(format!(
806                            "mandate scope {:?} exceeds the leaf grant's scope {:?}: the \
807                             mandate claims authority the grantor did not give",
808                            m.scope, leaf.scope
809                        ));
810                    }
811                    if m.audience != leaf.audience {
812                        fail.push(format!(
813                            "mandate audience '{}' does not match the leaf grant's '{}'",
814                            m.audience, leaf.audience
815                        ));
816                    }
817                    // Same attenuation rule as between grants: adding an
818                    // objective narrows and is fine; changing or dropping one
819                    // spends authority minted for one task on another.
820                    match (&leaf.objective_hash, &m.objective_hash) {
821                        (Some(g), Some(mm)) if g != mm => fail.push(format!(
822                            "mandate objective '{mm}' does not match the leaf grant's '{g}'"
823                        )),
824                        (Some(g), None) => fail.push(format!(
825                            "leaf grant is bound to objective '{g}' and the mandate declares \
826                             none: dropping the binding removes the constraint"
827                        )),
828                        _ => {}
829                    }
830                }
831            }
832        }
833    }
834
835    // -- holder binding. A mandate with no `grantee` came from a bearer grant:
836    // authentic, in scope, in window, and spendable by anyone who obtained the
837    // grant bytes. The signature proves who issued the grant and who signed
838    // this receipt; it never proves the two were related.
839    //
840    // Unverified rather than Fail: bearer is a legitimate mode, and the honest
841    // report is that entitlement is a layer we could not check, not that a
842    // violation was found. Silence here would let "correctly signed" read as
843    // "the right party did this".
844    if m.grantee.as_deref().unwrap_or("").is_empty() {
845        unver.push(
846            "grant names no grantee (bearer): any holder of the grant could have produced this"
847                .into(),
848        );
849    }
850
851    if !fail.is_empty() {
852        MandateVerdict::Fail(fail)
853    } else if !unver.is_empty() {
854        MandateVerdict::Unverified(unver)
855    } else {
856        MandateVerdict::Pass
857    }
858}
859
860// ---------------------------------------------------------------------------
861// Effect verdict + verifier (operational confidence)
862// ---------------------------------------------------------------------------
863
864/// Decides whether a bundled [`Witness`] is a trustworthy, independent
865/// corroboration of an effect. A real implementation MUST require all of:
866/// the witness `signature` verifies against `observer`'s key in the trust
867/// roots; `observer != actor` (a self-witness proves nothing); and
868/// `observation` matches the effect's own observed post-state. The default
869/// [`NoWitnessAuthority`] trusts nothing, so witnesses give zero evidence
870/// lift until an authority is wired in -- fail closed, exactly like
871/// [`NoRevocationSource`].
872pub trait WitnessAuthority {
873    fn is_trusted(&self, actor: &str, effect: &Effect, witness: &Witness) -> bool;
874}
875
876/// The default: no authority configured, so no witness is trusted and
877/// witnesses contribute no evidence.
878pub struct NoWitnessAuthority;
879
880impl WitnessAuthority for NoWitnessAuthority {
881    fn is_trusted(&self, _actor: &str, _effect: &Effect, _witness: &Witness) -> bool {
882        false
883    }
884}
885
886/// The reconciled operational-confidence outcome for a v2 receipt's effect,
887/// kept deliberately separate from cryptographic validity (the DSSE
888/// signature, checked elsewhere). A perfectly-signed receipt can still carry
889/// an effect nobody independently confirmed; this verdict reports how much of
890/// the *effect* the evidence actually supports, never how well it was signed.
891#[derive(Debug, Clone, PartialEq, Eq)]
892pub struct EffectVerdict {
893    /// The confidence the evidence supports after reconciliation. Equal to the
894    /// actor's claim for every honest (non-`Verified`) claim; a `Verified`
895    /// claim carrying no independent evidence is downgraded to `NotVerified`.
896    /// Never higher than the actor claimed, and never higher than the evidence
897    /// supports.
898    pub effective_confidence: EffectConfidence,
899    /// The actor's own claim, echoed for audit. `None` when the actor recorded
900    /// no `effect_confidence`.
901    pub claimed_confidence: Option<EffectConfidence>,
902    /// Count of bundled witnesses the [`WitnessAuthority`] vouched for.
903    pub trusted_witnesses: usize,
904    /// Audit notes: downgrades applied, and witnesses that were not trusted.
905    pub notes: Vec<String>,
906    /// The lifecycle stage the evidence supports. Equal to the actor's claim
907    /// except that an unbacked `Finalized` is downgraded to `Indeterminate`:
908    /// "the target told me it committed" is the actor's word, and the actor's
909    /// word is what this verifier exists to not take. `None` when the receipt
910    /// declared no finality at all.
911    pub effective_finality: Option<EffectFinality>,
912    /// The actor's own finality claim, echoed for audit.
913    pub claimed_finality: Option<EffectFinality>,
914}
915
916impl EffectVerdict {
917    /// True when the effect is independently confirmed at the strongest level.
918    pub fn is_verified(&self) -> bool {
919        self.effective_confidence == EffectConfidence::Verified
920    }
921}
922
923/// Reconcile a v2 receipt's effect claim against its evidence. Fails safe: the
924/// effective confidence is never higher than what independent, actor-unmintable
925/// evidence supports. Independent evidence is a `readback` the actor could not
926/// mint, or a witness the [`WitnessAuthority`] vouches for (signed by a trusted
927/// key that is not the actor, observing the same post-state).
928///
929/// Only a `Verified` claim asserts the effect definitely happened, so only it
930/// can be inflated and only it is capped. Lesser claims (`Partial`,
931/// `Ambiguous`, `Unknown`, `NotVerified`) are already admissions of incomplete
932/// confidence and pass through unchanged -- the verifier's job is to block
933/// inflation, not to erase an honest actor's own hedging.
934///
935/// Signature validity is a precondition checked elsewhere; this evaluates
936/// operational confidence over bytes assumed authentic.
937pub fn verify_effect(stmt: &ActionStatementV2, witnesses: &dyn WitnessAuthority) -> EffectVerdict {
938    let effect = match &stmt.effect {
939        Some(e) => e,
940        None => {
941            return EffectVerdict {
942                effective_confidence: EffectConfidence::NotVerified,
943                claimed_confidence: None,
944                trusted_witnesses: 0,
945                notes: vec!["receipt carries no effect block; effect is unverified".into()],
946                effective_finality: None,
947                claimed_finality: None,
948            }
949        }
950    };
951
952    let mut notes: Vec<String> = Vec::new();
953
954    let trusted_witnesses = effect
955        .witnesses
956        .iter()
957        .filter(|w| witnesses.is_trusted(&stmt.actor, effect, w))
958        .count();
959    let untrusted = effect.witnesses.len() - trusted_witnesses;
960    if untrusted > 0 {
961        notes.push(format!(
962            "{untrusted} of {} bundled witness(es) not independently trusted; they add no evidence",
963            effect.witnesses.len()
964        ));
965    }
966
967    // The verify layer knows more than the pure-data ceiling: a witness the
968    // authority vouched for is also actor-unmintable evidence.
969    let has_evidence = effect.has_independent_evidence() || trusted_witnesses > 0;
970
971    let claimed = effect.effect_confidence;
972    let effective = match claimed {
973        None => {
974            notes.push("actor recorded no effect_confidence; effect is unverified".into());
975            EffectConfidence::NotVerified
976        }
977        Some(EffectConfidence::Verified) if !has_evidence => {
978            notes.push(
979                "actor claimed Verified but bundled no independent evidence \
980                 (no readback, no trusted witness); downgraded to NotVerified"
981                    .into(),
982            );
983            EffectConfidence::NotVerified
984        }
985        Some(c) => c,
986    };
987
988    // Finality is capped on the same principle as confidence, for the same
989    // reason: `Finalized` is the only lifecycle claim that asserts the change
990    // definitely landed, so it is the only one an actor can inflate. Lesser
991    // stages are admissions and pass through untouched.
992    //
993    // The downgrade target is `Indeterminate`, not `Initiated`: without
994    // evidence we do not know that the target even acknowledged the write, so
995    // asserting the weaker stage would be inventing a fact rather than
996    // withdrawing one.
997    let claimed_finality = effect.finality;
998    let effective_finality = match claimed_finality {
999        Some(EffectFinality::Finalized) if !has_evidence => {
1000            notes.push(
1001                "actor claimed the effect Finalized but bundled no independent evidence \
1002                 (no readback, no trusted witness); downgraded to Indeterminate"
1003                    .into(),
1004            );
1005            Some(EffectFinality::Indeterminate)
1006        }
1007        other => other,
1008    };
1009
1010    EffectVerdict {
1011        effective_confidence: effective,
1012        claimed_confidence: claimed,
1013        trusted_witnesses,
1014        notes,
1015        effective_finality,
1016        claimed_finality,
1017    }
1018}
1019
1020// ---------------------------------------------------------------------------
1021// First-class grant object + attenuation
1022// ---------------------------------------------------------------------------
1023
1024/// A signed capability grant. Each delegation edge mints a narrower grant,
1025/// signed by the grantor, so a verifier can walk grant -> parent-grant -> …
1026/// -> root offline. Canonical binding mirrors the invitation statement: a
1027/// pipe-delimited, version-prefixed line with variable-length fields folded
1028/// into digests, so the canonical stays single-line and unambiguous.
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030pub struct Grant {
1031    pub grant_id: String,
1032    /// Ed25519 pubkey of the grantor, base64url-no-pad.
1033    pub grantor: String,
1034    #[serde(default)]
1035    pub scope: Vec<String>,
1036    pub audience: String,
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub parent_request_id: Option<String>,
1039    #[serde(default)]
1040    pub delegation_depth: u32,
1041    pub issued_at: String,
1042    pub expiry: String,
1043    #[serde(default)]
1044    pub max_delegation: u32,
1045    #[serde(default, skip_serializing_if = "Option::is_none")]
1046    pub objective_hash: Option<String>,
1047
1048    /// Grantor's detached signature over this grant's canonical bytes.
1049    /// Deliberately absent from `canonical_for_signing` -- a signature cannot
1050    /// cover itself. Required for any grant carried in a mandate chain;
1051    /// `resolve_grant_chain` refuses unsigned ancestors.
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub issuer_sig: Option<String>,
1054
1055    /// Content id of the grant this one was delegated from. `None` marks a
1056    /// root grant. Signed, so lineage cannot be re-parented after issuance --
1057    /// and because ids are content-derived, this is a hash commitment to the
1058    /// exact parent, not a reference to a name someone else could also claim.
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub parent_grant_id: Option<String>,
1061
1062    /// Base64url-no-pad Ed25519 public key of the agent entitled to exercise
1063    /// this grant. Signed, so a holder cannot rebind it to themselves.
1064    ///
1065    /// `None` makes the grant a **bearer credential**: whoever holds the bytes
1066    /// holds the authority. That was the only mode before this field existed,
1067    /// and it is why a grant file copied into another workspace let a different
1068    /// key emit a receipt claiming its authority with nothing to object --
1069    /// `grantor` says who issued it and `audience` says which system it acts
1070    /// against, but nothing said who was allowed to spend it.
1071    ///
1072    /// Verification treats absence as a disclosed weakness, not a pass: see
1073    /// [`Grant::binds_holder`].
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub grantee: Option<String>,
1076}
1077
1078impl Grant {
1079    /// Canonical signing bytes. `v1|grant|` prefixed; the variable-length
1080    /// `scope` is folded into a sorted-key JSON digest so the line stays
1081    /// single-field-per-position. New fields go through a canonical-version
1082    /// bump, never a silent extension.
1083    pub fn canonical_for_signing(&self) -> String {
1084        let scope_digest = canonical_json_digest(&self.scope);
1085        // v2 differs from v1 in two ways: `parent_grant_id` is covered, and
1086        // `grant_id` is NOT. The id is derived from these bytes (see
1087        // `derive_grant_id`), so including it would be circular -- and it
1088        // matches how artifact ids work elsewhere: derived from the signed
1089        // bytes, never stored inside them.
1090        // v3 adds `grantee`. It has to be inside the signed bytes: a holder who
1091        // could add or strip it would be choosing who the grant is for, which
1092        // is the grantor's decision alone. Bumping the version rather than
1093        // appending silently means a v2 line can never be read as a v3 line
1094        // with an empty grantee.
1095        format!(
1096            "v3|grant|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
1097            self.grantor,
1098            scope_digest,
1099            self.audience,
1100            self.parent_request_id.as_deref().unwrap_or(""),
1101            self.parent_grant_id.as_deref().unwrap_or(""),
1102            self.grantee.as_deref().unwrap_or(""),
1103            self.delegation_depth,
1104            self.issued_at,
1105            self.expiry,
1106            self.max_delegation,
1107            self.objective_hash.as_deref().unwrap_or(""),
1108        )
1109    }
1110
1111    /// Whether this grant names the key allowed to exercise it.
1112    ///
1113    /// `false` means bearer: authentic, verifiable, and spendable by anyone who
1114    /// obtains the bytes. Callers must surface that rather than treat a valid
1115    /// signature as sufficient -- the signature proves who *issued* the grant,
1116    /// never who is entitled to use it.
1117    pub fn binds_holder(&self) -> bool {
1118        self.grantee.as_deref().is_some_and(|g| !g.is_empty())
1119    }
1120
1121    /// Whether `holder_pubkey` (base64url-no-pad Ed25519) may exercise this
1122    /// grant. A bearer grant returns `true` for every key, which is exactly the
1123    /// property [`Grant::binds_holder`] exists to let callers warn about.
1124    pub fn exercisable_by(&self, holder_pubkey: &str) -> bool {
1125        match self.grantee.as_deref() {
1126            Some(g) if !g.is_empty() => g == holder_pubkey,
1127            _ => true,
1128        }
1129    }
1130
1131    /// The grant's content id: `grn_` + first 16 hex of sha256 over the
1132    /// canonical bytes. Mirrors `artifact_id = "art_" + hex(sha256(PAE))[..16]`.
1133    ///
1134    /// Ids stop being claims and become facts: two grants collide only under a
1135    /// hash break, and a `parent_grant_id` therefore commits to one specific
1136    /// parent rather than to whatever grant happens to assert that name.
1137    pub fn derive_grant_id(&self) -> String {
1138        let digest = Sha256::digest(self.canonical_for_signing().as_bytes());
1139        format!("grn_{}", hex::encode(&digest[..8]))
1140    }
1141
1142    /// True when the declared `grant_id` matches the derived one. A mismatch
1143    /// means the id was chosen rather than computed, so nothing that
1144    /// references it by id can be trusted to reference *this* grant.
1145    pub fn id_is_consistent(&self) -> bool {
1146        self.grant_id == self.derive_grant_id()
1147    }
1148
1149    /// Sign the grant's canonical bytes; returns the base64url-no-pad
1150    /// signature. The `grantor` field must be `signer`'s public key for the
1151    /// grant to later verify.
1152    pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
1153        let sig = signer.sign(self.canonical_for_signing().as_bytes())?;
1154        Ok(URL_SAFE_NO_PAD.encode(sig))
1155    }
1156
1157    /// Verify `signature_b64url` against `self.grantor` over the canonical
1158    /// bytes. Returns true only when the pubkey decodes AND the signature
1159    /// math checks out. Does not consult trust roots -- the caller decides
1160    /// whether `grantor` is a pinned issuer.
1161    pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
1162        // A valid signature over content whose id was chosen by hand is still
1163        // unusable for chain walking: the parent pointer would resolve to a
1164        // name, not to these bytes. Fail closed before touching the crypto.
1165        if !self.id_is_consistent() {
1166            return false;
1167        }
1168        let pk_bytes = match URL_SAFE_NO_PAD.decode(self.grantor.as_bytes()) {
1169            Ok(b) if b.len() == 32 => b,
1170            _ => return false,
1171        };
1172        let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
1173            Ok(b) if b.len() == 64 => b,
1174            _ => return false,
1175        };
1176        let mut pk = [0u8; 32];
1177        pk.copy_from_slice(&pk_bytes);
1178        let mut sig = [0u8; 64];
1179        sig.copy_from_slice(&sig_bytes);
1180        let vk = match VerifyingKey::from_bytes(&pk) {
1181            Ok(k) => k,
1182            Err(_) => return false,
1183        };
1184        vk.verify_strict(
1185            self.canonical_for_signing().as_bytes(),
1186            &Signature::from_bytes(&sig),
1187        )
1188        .is_ok()
1189    }
1190}
1191
1192/// Why a mandate's grant chain could not be resolved into a trustworthy order.
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub enum ChainResolveError {
1195    /// A grant's declared id does not match its content.
1196    InconsistentId { grant_id: String },
1197    /// `mandate.grant_id` names a grant that is not in the carried chain.
1198    LeafMissing { grant_id: String },
1199    /// A `parent_grant_id` points at a grant that is not present.
1200    AncestorMissing { parent_grant_id: String },
1201    /// Following parent links revisited a grant: the links form a cycle.
1202    Cycle { grant_id: String },
1203    /// The carrier supplied grants that the walk never reached. Extra grants
1204    /// are refused rather than ignored -- silently dropping them would let a
1205    /// carrier stuff a chain with decoys.
1206    UnreachableExtras { count: usize },
1207    /// A grant carried no signature, so its content is unattested.
1208    Unsigned { grant_id: String },
1209    /// A grant's signature does not verify against its grantor.
1210    BadSignature { grant_id: String },
1211}
1212
1213impl std::fmt::Display for ChainResolveError {
1214    /// Operator-facing wording. The CLI prints these verbatim, so each names
1215    /// the grant it is talking about: "unresolvable" without a subject leaves
1216    /// a reader nothing to go and look at.
1217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1218        match self {
1219            Self::InconsistentId { grant_id } => {
1220                write!(
1221                    f,
1222                    "grant {grant_id} declares an id that does not match its content"
1223                )
1224            }
1225            Self::LeafMissing { grant_id } => {
1226                write!(
1227                    f,
1228                    "the mandate names grant {grant_id}, which is not in the carried chain"
1229                )
1230            }
1231            Self::AncestorMissing { parent_grant_id } => {
1232                write!(
1233                    f,
1234                    "parent grant {parent_grant_id} is missing from the chain"
1235                )
1236            }
1237            Self::Cycle { grant_id } => {
1238                write!(
1239                    f,
1240                    "parent links revisit grant {grant_id}: the chain is a cycle"
1241                )
1242            }
1243            Self::UnreachableExtras { count } => {
1244                write!(
1245                    f,
1246                    "{count} carried grant(s) are not reachable from the mandate"
1247                )
1248            }
1249            Self::Unsigned { grant_id } => {
1250                write!(f, "grant {grant_id} carries no issuer signature")
1251            }
1252            Self::BadSignature { grant_id } => {
1253                write!(f, "grant {grant_id} has a signature that does not verify")
1254            }
1255        }
1256    }
1257}
1258
1259impl std::error::Error for ChainResolveError {}
1260
1261/// Reconstruct the delegation chain for a mandate, root-first, from the
1262/// grants it carries.
1263///
1264/// The carrier hands over a set; this function derives the *order* from the
1265/// signed `parent_grant_id` links rather than trusting the sequence it was
1266/// given. That is the whole point: `verify_grant_chain` checks attenuation
1267/// between adjacent pairs, so if an attacker picks the pairing, the check
1268/// establishes nothing. Here, reordering is a no-op, truncation shows up as a
1269/// missing ancestor, and splicing shows up as an unreachable extra.
1270///
1271/// Every grant must be signed by its own grantor and carry a content-consistent
1272/// id. Fails closed on anything it cannot establish.
1273pub fn resolve_grant_chain(mandate: &Mandate) -> Result<Vec<Grant>, ChainResolveError> {
1274    use std::collections::{HashMap, HashSet};
1275
1276    // Index by content id, checking each grant is self-consistent and attested.
1277    let mut by_id: HashMap<String, &Grant> = HashMap::new();
1278    for g in &mandate.chain {
1279        if !g.id_is_consistent() {
1280            return Err(ChainResolveError::InconsistentId {
1281                grant_id: g.grant_id.clone(),
1282            });
1283        }
1284        let sig = match g.issuer_sig.as_deref() {
1285            Some(s) if !s.is_empty() => s,
1286            _ => {
1287                return Err(ChainResolveError::Unsigned {
1288                    grant_id: g.grant_id.clone(),
1289                })
1290            }
1291        };
1292        if !g.verify_canonical(sig) {
1293            return Err(ChainResolveError::BadSignature {
1294                grant_id: g.grant_id.clone(),
1295            });
1296        }
1297        by_id.insert(g.grant_id.clone(), g);
1298    }
1299
1300    // Walk leaf -> root through signed parent links.
1301    let mut leaf_first: Vec<Grant> = Vec::new();
1302    let mut seen: HashSet<String> = HashSet::new();
1303    let mut cursor = Some(mandate.grant_id.clone());
1304
1305    while let Some(id) = cursor {
1306        if !seen.insert(id.clone()) {
1307            return Err(ChainResolveError::Cycle { grant_id: id });
1308        }
1309        let g = match by_id.get(&id) {
1310            Some(g) => *g,
1311            None => {
1312                return Err(if leaf_first.is_empty() {
1313                    ChainResolveError::LeafMissing { grant_id: id }
1314                } else {
1315                    ChainResolveError::AncestorMissing {
1316                        parent_grant_id: id,
1317                    }
1318                })
1319            }
1320        };
1321        leaf_first.push(g.clone());
1322        cursor = g.parent_grant_id.clone();
1323    }
1324
1325    // Anything the walk never reached is a decoy, not a spare.
1326    if seen.len() != by_id.len() {
1327        return Err(ChainResolveError::UnreachableExtras {
1328            count: by_id.len() - seen.len(),
1329        });
1330    }
1331
1332    leaf_first.reverse(); // verify_grant_chain wants root-first
1333    Ok(leaf_first)
1334}
1335
1336/// Why a grant chain failed its attenuation invariants.
1337#[derive(Debug, Clone, PartialEq, Eq)]
1338pub enum GrantChainError {
1339    /// Chain was empty.
1340    Empty,
1341    /// A grant carried an unparseable `issued_at`/`expiry`.
1342    BadTimestamp { index: usize },
1343    /// child.scope is not a subset of parent.scope.
1344    ScopeWidened { parent: usize },
1345    /// child.expiry is later than parent.expiry.
1346    ExpiryWidened { parent: usize },
1347    /// child.delegation_depth is not exactly parent.delegation_depth + 1.
1348    DepthNotIncremented { parent: usize },
1349    /// child.delegation_depth exceeds parent.max_delegation.
1350    DepthExceedsMax { parent: usize },
1351    /// child.audience differs from parent.audience.
1352    AudienceChanged { parent: usize },
1353    /// The declared objective changed, or was dropped, across a delegation.
1354    ///
1355    /// `objective_hash` commits to *what task* a grant was issued to serve.
1356    /// Scope says which actions are permitted; the objective says what they
1357    /// were permitted **for**. A delegation that keeps the scope and swaps the
1358    /// objective is authority minted for one task being spent on another --
1359    /// every existing check passes, because none of them look at why.
1360    ///
1361    /// Dropping it is the same violation. A child with no objective is
1362    /// unconstrained by one, so removing it widens authority exactly as
1363    /// extending an expiry does.
1364    ObjectiveChanged { parent: usize },
1365}
1366
1367impl std::fmt::Display for GrantChainError {
1368    /// `parent` is the index of the *parent* in the resolved root-first chain,
1369    /// so the violation is reported as the hop between it and its child --
1370    /// which is where an operator has to look to fix it.
1371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1372        match self {
1373            Self::Empty => write!(f, "the chain is empty"),
1374            Self::BadTimestamp { index } => {
1375                write!(
1376                    f,
1377                    "grant at hop {index} has an unparseable issued_at/expiry"
1378                )
1379            }
1380            Self::ScopeWidened { parent } => {
1381                write!(f, "scope widens at hop {}->{}", parent, parent + 1)
1382            }
1383            Self::ExpiryWidened { parent } => {
1384                write!(
1385                    f,
1386                    "expiry extends past the parent at hop {}->{}",
1387                    parent,
1388                    parent + 1
1389                )
1390            }
1391            Self::DepthNotIncremented { parent } => {
1392                write!(
1393                    f,
1394                    "delegation depth does not increment by one at hop {}->{}",
1395                    parent,
1396                    parent + 1
1397                )
1398            }
1399            Self::DepthExceedsMax { parent } => {
1400                write!(
1401                    f,
1402                    "delegation depth exceeds the parent's max_delegation at hop {}->{}",
1403                    parent,
1404                    parent + 1
1405                )
1406            }
1407            Self::ObjectiveChanged { parent } => write!(
1408                f,
1409                "objective changed or was dropped at hop {}->{}: authority granted for one \
1410                 task cannot be spent on another",
1411                parent,
1412                parent + 1
1413            ),
1414            Self::AudienceChanged { parent } => {
1415                write!(f, "audience changes at hop {}->{}", parent, parent + 1)
1416            }
1417        }
1418    }
1419}
1420
1421impl std::error::Error for GrantChainError {}
1422
1423/// Verify attenuation across an ordered grant chain (`chain[0]` is the root,
1424/// `chain[last]` is the leaf the action was minted from). Every adjacent pair
1425/// must satisfy: scope narrows (child ⊆ parent), expiry does not extend,
1426/// delegation depth increments by exactly one and stays within the parent's
1427/// `max_delegation`, and audience is preserved. All checks fail closed.
1428///
1429/// This validates the *shape* of the delegation. Signature verification of
1430/// each grant is a separate concern ([`Grant::verify_canonical`]); a full
1431/// verifier composes both.
1432pub fn verify_grant_chain(chain: &[Grant]) -> Result<(), GrantChainError> {
1433    if chain.is_empty() {
1434        return Err(GrantChainError::Empty);
1435    }
1436
1437    // Every grant's timestamps must parse, or downstream comparisons would be
1438    // meaningless. Fail closed up front.
1439    for (i, g) in chain.iter().enumerate() {
1440        if parse_rfc3339_to_unix(&g.issued_at).is_none()
1441            || parse_rfc3339_to_unix(&g.expiry).is_none()
1442        {
1443            return Err(GrantChainError::BadTimestamp { index: i });
1444        }
1445    }
1446
1447    for (i, pair) in chain.windows(2).enumerate() {
1448        let parent = &pair[0];
1449        let child = &pair[1];
1450
1451        if !scope_subset(&child.scope, &parent.scope) {
1452            return Err(GrantChainError::ScopeWidened { parent: i });
1453        }
1454
1455        // Safe: timestamps validated above.
1456        let parent_expiry = parse_rfc3339_to_unix(&parent.expiry).unwrap();
1457        let child_expiry = parse_rfc3339_to_unix(&child.expiry).unwrap();
1458        if child_expiry > parent_expiry {
1459            return Err(GrantChainError::ExpiryWidened { parent: i });
1460        }
1461
1462        if child.delegation_depth != parent.delegation_depth + 1 {
1463            return Err(GrantChainError::DepthNotIncremented { parent: i });
1464        }
1465        if child.delegation_depth > parent.max_delegation {
1466            return Err(GrantChainError::DepthExceedsMax { parent: i });
1467        }
1468
1469        if child.audience != parent.audience {
1470            return Err(GrantChainError::AudienceChanged { parent: i });
1471        }
1472
1473        // Objective attenuation. A child may ADD an objective the parent did
1474        // not declare -- that narrows. It may not change one, and it may not
1475        // drop one, because both let a grant issued for task A be spent on
1476        // task B with every other check still passing.
1477        match (&parent.objective_hash, &child.objective_hash) {
1478            (Some(p), Some(c)) if p != c => {
1479                return Err(GrantChainError::ObjectiveChanged { parent: i });
1480            }
1481            (Some(_), None) => {
1482                return Err(GrantChainError::ObjectiveChanged { parent: i });
1483            }
1484            _ => {}
1485        }
1486    }
1487
1488    Ok(())
1489}
1490
1491/// True iff every entry of `child` is covered by some entry of `parent`. An
1492/// exact label is covered by an equal label or by a parent family glob; a
1493/// child family glob is covered only by an equal-or-broader parent glob.
1494fn scope_subset(child: &[String], parent: &[String]) -> bool {
1495    child
1496        .iter()
1497        .all(|c| parent.iter().any(|p| scope_entry_covers(p, c)))
1498}
1499
1500fn scope_entry_covers(parent: &str, child: &str) -> bool {
1501    if parent == child {
1502        return true;
1503    }
1504    if let Some(parent_prefix) = parent.strip_suffix(".*") {
1505        // A parent glob `foo.*` covers `foo`, anything under `foo.`, and a
1506        // narrower child glob like `foo.bar.*` (compare on its prefix).
1507        let child_core = child.strip_suffix(".*").unwrap_or(child);
1508        child_core == parent_prefix || child_core.starts_with(&format!("{parent_prefix}."))
1509    } else {
1510        false
1511    }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516    use super::*;
1517    use crate::attestation::{sign, Ed25519Signer, Verifier as EnvVerifier};
1518
1519    #[test]
1520    fn effect_confidence_ceiling_gates_on_independent_evidence() {
1521        // A readback the actor could not mint lets the evidence support Verified.
1522        let with_evidence = Effect {
1523            readback: Some("sha256:observed".into()),
1524            effect_confidence: Some(EffectConfidence::Verified),
1525            ..Default::default()
1526        };
1527        assert!(with_evidence.has_independent_evidence());
1528        assert_eq!(with_evidence.evidence_ceiling(), EffectConfidence::Verified);
1529
1530        // No independent evidence: the honest ceiling is NotVerified, so a
1531        // `Verified` CLAIM here must be treated as inflated (ack != act).
1532        let claim_only = Effect {
1533            output_hash: Some("sha256:out".into()),
1534            effect_confidence: Some(EffectConfidence::Verified),
1535            ..Default::default()
1536        };
1537        assert!(!claim_only.has_independent_evidence());
1538        assert_eq!(claim_only.evidence_ceiling(), EffectConfidence::NotVerified);
1539
1540        // An honest actor can downgrade below the ceiling with no evidence.
1541        let honest_downgrade = Effect {
1542            effect_confidence: Some(EffectConfidence::Unknown),
1543            ..Default::default()
1544        };
1545        assert_eq!(
1546            honest_downgrade.evidence_ceiling(),
1547            EffectConfidence::NotVerified
1548        );
1549    }
1550
1551    #[test]
1552    fn effect_confidence_serializes_snake_case_and_is_omitted_when_absent() {
1553        let e = Effect {
1554            effect_confidence: Some(EffectConfidence::NotVerified),
1555            ..Default::default()
1556        };
1557        let j = serde_json::to_string(&e).unwrap();
1558        assert!(j.contains("\"effect_confidence\":\"not_verified\""), "{j}");
1559
1560        // Absent => omitted entirely (additive, backward-compatible over v1).
1561        let empty = Effect::default();
1562        assert!(!serde_json::to_string(&empty)
1563            .unwrap()
1564            .contains("effect_confidence"));
1565    }
1566
1567    /// A test authority that trusts any signed witness whose observer differs
1568    /// from the actor and whose observation matches the effect's readback.
1569    /// Stands in for the real trust-root + signature check.
1570    struct TrustingWitnessAuthority;
1571    impl WitnessAuthority for TrustingWitnessAuthority {
1572        fn is_trusted(&self, actor: &str, effect: &Effect, w: &Witness) -> bool {
1573            w.is_signed()
1574                && w.observer != actor
1575                && effect.readback.as_deref() == Some(w.observation.as_str())
1576        }
1577    }
1578
1579    #[test]
1580    fn verify_effect_downgrades_unbacked_verified_claim() {
1581        // Verified claim, no readback, no witness => downgraded to NotVerified.
1582        let mut s = good_stmt();
1583        s.actor = "agent://worker".into();
1584        s.effect = Some(Effect {
1585            output_hash: Some("sha256:out".into()),
1586            effect_confidence: Some(EffectConfidence::Verified),
1587            ..Default::default()
1588        });
1589        let v = verify_effect(&s, &NoWitnessAuthority);
1590        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1591        assert_eq!(v.claimed_confidence, Some(EffectConfidence::Verified));
1592        assert!(!v.is_verified());
1593        assert!(
1594            v.notes.iter().any(|n| n.contains("downgraded")),
1595            "{:?}",
1596            v.notes
1597        );
1598    }
1599
1600    // ---- effect finality (lifecycle) vs confidence (evidence) ----
1601
1602    #[test]
1603    fn finality_and_confidence_are_independent_axes() {
1604        // The composite-claim bug in one assertion: a receipt can be honest
1605        // about its evidence and still overclaim completion. Weak evidence,
1606        // strong completion claim -- the verifier must judge them separately.
1607        let mut s = good_stmt();
1608        s.effect = Some(Effect {
1609            output_hash: Some("sha256:out".into()),
1610            effect_confidence: Some(EffectConfidence::Partial),
1611            finality: Some(EffectFinality::Finalized),
1612            ..Default::default()
1613        });
1614        let v = verify_effect(&s, &NoWitnessAuthority);
1615        // Partial is an admission, so it survives untouched...
1616        assert_eq!(v.effective_confidence, EffectConfidence::Partial);
1617        // ...while the unbacked Finalized does not.
1618        assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1619        assert_eq!(v.claimed_finality, Some(EffectFinality::Finalized));
1620    }
1621
1622    #[test]
1623    fn unbacked_finalized_is_downgraded_to_indeterminate() {
1624        // The 56%-of-writes case: accepted, acknowledged, served back on read,
1625        // never committed. Downgrade must land on Indeterminate, not Initiated
1626        // -- without evidence we cannot assert the weaker stage either, and
1627        // inventing it would be a different false claim.
1628        let mut s = good_stmt();
1629        s.effect = Some(Effect {
1630            output_hash: Some("sha256:out".into()),
1631            finality: Some(EffectFinality::Finalized),
1632            ..Default::default()
1633        });
1634        let v = verify_effect(&s, &NoWitnessAuthority);
1635        assert_eq!(v.effective_finality, Some(EffectFinality::Indeterminate));
1636        assert!(
1637            v.notes.iter().any(|n| n.contains("Finalized")),
1638            "the downgrade must be stated, not silent: {:?}",
1639            v.notes
1640        );
1641    }
1642
1643    #[test]
1644    fn finalized_backed_by_readback_survives() {
1645        let mut s = good_stmt();
1646        s.effect = Some(Effect {
1647            readback: Some("sha256:observed".into()),
1648            finality: Some(EffectFinality::Finalized),
1649            ..Default::default()
1650        });
1651        let v = verify_effect(&s, &NoWitnessAuthority);
1652        assert_eq!(v.effective_finality, Some(EffectFinality::Finalized));
1653    }
1654
1655    #[test]
1656    fn lesser_finality_claims_pass_through_unchanged() {
1657        // Only the strongest claim can be inflated, so only it is capped.
1658        // Downgrading an actor's own hedge would punish honesty.
1659        for stage in [
1660            EffectFinality::NotAttempted,
1661            EffectFinality::Initiated,
1662            EffectFinality::Failed,
1663            EffectFinality::Indeterminate,
1664        ] {
1665            let mut s = good_stmt();
1666            s.effect = Some(Effect {
1667                finality: Some(stage),
1668                ..Default::default()
1669            });
1670            let v = verify_effect(&s, &NoWitnessAuthority);
1671            assert_eq!(v.effective_finality, Some(stage), "{stage:?} was altered");
1672        }
1673    }
1674
1675    #[test]
1676    fn not_attempted_is_the_no_authority_moved_receipt() {
1677        // A timeout before the call. The input is bound so the negative is
1678        // about a specific request, and the stage is terminal so nothing is
1679        // still owed. This is what makes absence expressible instead of silent.
1680        let e = Effect {
1681            input_hash: Some("sha256:req".into()),
1682            finality: Some(EffectFinality::NotAttempted),
1683            ..Default::default()
1684        };
1685        assert!(EffectFinality::NotAttempted.is_resolved());
1686        assert_eq!(
1687            check_resolution(&e, 4_000_000_000),
1688            ResolutionStatus::Resolved
1689        );
1690    }
1691
1692    // ---- resolution deadlines ----
1693
1694    fn open_effect(resolution: Option<Resolution>) -> Effect {
1695        Effect {
1696            finality: Some(EffectFinality::Initiated),
1697            resolution,
1698            ..Default::default()
1699        }
1700    }
1701
1702    #[test]
1703    fn unresolved_without_a_deadline_reports_indefinite() {
1704        // The 181-day pending row. Silence here is what made it survivable;
1705        // naming the state is the whole point of the field.
1706        assert_eq!(
1707            check_resolution(&open_effect(None), 1_800_000_000),
1708            ResolutionStatus::Indefinite
1709        );
1710    }
1711
1712    /// Derive the epoch from the same parser the check uses, rather than
1713    /// hand-computing one. A wrong literal here would make the test assert a
1714    /// fact about my arithmetic instead of about the function.
1715    const DEADLINE: &str = "2026-07-20T11:00:00Z";
1716    fn deadline_unix() -> i64 {
1717        parse_rfc3339_to_unix(DEADLINE).expect("fixture deadline parses") as i64
1718    }
1719
1720    #[test]
1721    fn unresolved_past_its_deadline_reports_the_declared_event() {
1722        let e = open_effect(Some(Resolution {
1723            deadline: DEADLINE.into(),
1724            on_deadline: DeadlineEvent::Escalate,
1725        }));
1726        match check_resolution(&e, deadline_unix() + 90) {
1727            ResolutionStatus::Breached {
1728                on_deadline,
1729                seconds_overdue,
1730            } => {
1731                assert_eq!(on_deadline, DeadlineEvent::Escalate);
1732                assert_eq!(seconds_overdue, 90);
1733            }
1734            other => panic!("expected Breached, got {other:?}"),
1735        }
1736    }
1737
1738    #[test]
1739    fn unresolved_inside_its_window_is_pending() {
1740        let e = open_effect(Some(Resolution {
1741            deadline: DEADLINE.into(),
1742            on_deadline: DeadlineEvent::Timeout,
1743        }));
1744        match check_resolution(&e, deadline_unix() - 60) {
1745            ResolutionStatus::Pending { seconds_remaining } => {
1746                assert_eq!(seconds_remaining, 60)
1747            }
1748            other => panic!("expected Pending, got {other:?}"),
1749        }
1750    }
1751
1752    #[test]
1753    fn a_resolved_effect_cannot_breach() {
1754        // Finalized long before "now": the deadline is moot, not breached.
1755        let e = Effect {
1756            finality: Some(EffectFinality::Finalized),
1757            resolution: Some(Resolution {
1758                deadline: "2026-07-20T11:00:00Z".into(),
1759                on_deadline: DeadlineEvent::Tombstone,
1760            }),
1761            ..Default::default()
1762        };
1763        assert_eq!(
1764            check_resolution(&e, 4_000_000_000),
1765            ResolutionStatus::Resolved
1766        );
1767    }
1768
1769    #[test]
1770    fn unparseable_deadline_fails_toward_unknown() {
1771        // Never toward "in window": a deadline nobody can read must not be
1772        // treated as one that has not passed yet.
1773        let e = open_effect(Some(Resolution {
1774            deadline: "whenever".into(),
1775            on_deadline: DeadlineEvent::Timeout,
1776        }));
1777        assert_eq!(
1778            check_resolution(&e, 1_800_000_000),
1779            ResolutionStatus::BadDeadline
1780        );
1781    }
1782
1783    #[test]
1784    fn missing_finality_is_treated_as_unresolved() {
1785        // A receipt that never says where its state change got to has not told
1786        // us it landed. Conservative reading, stated explicitly.
1787        let e = Effect {
1788            output_hash: Some("sha256:out".into()),
1789            ..Default::default()
1790        };
1791        assert_eq!(
1792            check_resolution(&e, 1_800_000_000),
1793            ResolutionStatus::Indefinite
1794        );
1795    }
1796
1797    #[test]
1798    fn finality_and_resolution_are_omitted_when_absent() {
1799        // Existing v2 receipts must keep byte-identical canonical bytes.
1800        let json = serde_json::to_string(&Effect {
1801            output_hash: Some("sha256:out".into()),
1802            ..Default::default()
1803        })
1804        .unwrap();
1805        assert!(!json.contains("finality"), "{json}");
1806        assert!(!json.contains("resolution"), "{json}");
1807    }
1808
1809    #[test]
1810    fn verify_effect_honors_verified_backed_by_readback() {
1811        let mut s = good_stmt();
1812        s.effect = Some(Effect {
1813            readback: Some("sha256:observed".into()),
1814            effect_confidence: Some(EffectConfidence::Verified),
1815            ..Default::default()
1816        });
1817        let v = verify_effect(&s, &NoWitnessAuthority);
1818        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1819        assert!(v.is_verified());
1820    }
1821
1822    #[test]
1823    fn verify_effect_trusts_a_vouched_witness_over_no_readback() {
1824        // No readback, but an independent trusted witness observed the same
1825        // post-state the effect commits to: Verified stands.
1826        let mut s = good_stmt();
1827        s.actor = "agent://worker".into();
1828        s.effect = Some(Effect {
1829            readback: Some("sha256:state".into()),
1830            effect_confidence: Some(EffectConfidence::Verified),
1831            witnesses: vec![Witness {
1832                observer: "agent://auditor".into(),
1833                observation: "sha256:state".into(),
1834                observed_at: Some("2026-07-20T10:00:00Z".into()),
1835                signature: Some("ed25519:sig".into()),
1836            }],
1837            ..Default::default()
1838        });
1839        let v = verify_effect(&s, &TrustingWitnessAuthority);
1840        assert_eq!(v.trusted_witnesses, 1);
1841        assert_eq!(v.effective_confidence, EffectConfidence::Verified);
1842
1843        // A self-witness (observer == actor) is not trusted, even signed.
1844        let mut self_witness = s.clone();
1845        if let Some(e) = self_witness.effect.as_mut() {
1846            e.readback = None; // remove the readback so only the witness could lift it
1847            e.witnesses[0].observer = "agent://worker".into();
1848        }
1849        let v2 = verify_effect(&self_witness, &TrustingWitnessAuthority);
1850        assert_eq!(v2.trusted_witnesses, 0);
1851        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1852        assert!(v2
1853            .notes
1854            .iter()
1855            .any(|n| n.contains("not independently trusted")));
1856    }
1857
1858    #[test]
1859    fn verify_effect_passes_honest_lesser_claims_through_unchanged() {
1860        // Partial/Unknown are admissions, not inflations: no downgrade even
1861        // without independent evidence.
1862        for c in [
1863            EffectConfidence::Partial,
1864            EffectConfidence::Ambiguous,
1865            EffectConfidence::Unknown,
1866            EffectConfidence::NotVerified,
1867        ] {
1868            let mut s = good_stmt();
1869            s.effect = Some(Effect {
1870                effect_confidence: Some(c),
1871                ..Default::default()
1872            });
1873            let v = verify_effect(&s, &NoWitnessAuthority);
1874            assert_eq!(v.effective_confidence, c, "claim {c:?} should pass through");
1875        }
1876    }
1877
1878    #[test]
1879    fn verify_effect_reports_unverified_when_no_effect_or_no_claim() {
1880        // No effect block at all.
1881        let s = good_stmt();
1882        assert!(s.effect.is_none());
1883        let v = verify_effect(&s, &NoWitnessAuthority);
1884        assert_eq!(v.effective_confidence, EffectConfidence::NotVerified);
1885        assert_eq!(v.claimed_confidence, None);
1886        assert!(v.notes.iter().any(|n| n.contains("no effect block")));
1887
1888        // Effect present but no confidence claim.
1889        let mut s2 = good_stmt();
1890        s2.effect = Some(Effect {
1891            output_hash: Some("sha256:out".into()),
1892            ..Default::default()
1893        });
1894        let v2 = verify_effect(&s2, &NoWitnessAuthority);
1895        assert_eq!(v2.effective_confidence, EffectConfidence::NotVerified);
1896        assert!(v2.notes.iter().any(|n| n.contains("no effect_confidence")));
1897    }
1898
1899    #[test]
1900    fn witness_does_not_inflate_evidence_ceiling() {
1901        // Security invariant: a witness the actor bundled in -- even a signed
1902        // one -- must NOT lift evidence_ceiling at the data-model layer. Only
1903        // an actor-unmintable readback does that here; witness trust is
1904        // verify's job.
1905        let signed_witness = Witness {
1906            observer: "agent://auditor".into(),
1907            observation: "sha256:observed".into(),
1908            observed_at: Some("2026-07-20T10:00:00Z".into()),
1909            signature: Some("ed25519:sig".into()),
1910        };
1911        let e = Effect {
1912            witnesses: vec![signed_witness.clone()],
1913            effect_confidence: Some(EffectConfidence::Verified),
1914            ..Default::default()
1915        };
1916        assert!(!e.has_independent_evidence());
1917        assert_eq!(e.evidence_ceiling(), EffectConfidence::NotVerified);
1918        // The signature is visible for verify to check, but that's a
1919        // precondition, not evidence.
1920        assert!(signed_witness.is_signed());
1921        assert_eq!(e.signed_witnesses().count(), 1);
1922
1923        // An unsigned witness isn't even a candidate.
1924        let unsigned = Effect {
1925            witnesses: vec![Witness {
1926                observer: "agent://auditor".into(),
1927                observation: "sha256:observed".into(),
1928                ..Default::default()
1929            }],
1930            ..Default::default()
1931        };
1932        assert_eq!(unsigned.signed_witnesses().count(), 0);
1933    }
1934
1935    #[test]
1936    fn witnesses_serialize_and_omit_when_empty() {
1937        let empty = Effect::default();
1938        assert!(!serde_json::to_string(&empty).unwrap().contains("witnesses"));
1939
1940        let e = Effect {
1941            witnesses: vec![Witness {
1942                observer: "key_9f2c".into(),
1943                observation: "sha256:obs".into(),
1944                observed_at: None,
1945                signature: Some("ed25519:sig".into()),
1946            }],
1947            ..Default::default()
1948        };
1949        let j = serde_json::to_string(&e).unwrap();
1950        assert!(j.contains("\"witnesses\":[{"), "{j}");
1951        assert!(j.contains("\"observer\":\"key_9f2c\""), "{j}");
1952        // observed_at absent => omitted, not null.
1953        assert!(!j.contains("observed_at"), "{j}");
1954        let back: Effect = serde_json::from_str(&j).unwrap();
1955        assert_eq!(back.witnesses.len(), 1);
1956        assert!(back.witnesses[0].is_signed());
1957    }
1958
1959    #[test]
1960    fn runtime_identity_is_unbound_only_when_all_fields_absent() {
1961        assert!(RuntimeIdentity::default().is_unbound());
1962
1963        // Any single populated field means the binding attests something.
1964        let with_model = RuntimeIdentity {
1965            model: Some("claude-opus-4-8".into()),
1966            ..Default::default()
1967        };
1968        assert!(!with_model.is_unbound());
1969
1970        let with_prompt = RuntimeIdentity {
1971            system_prompt_hash: Some("sha256:sys".into()),
1972            ..Default::default()
1973        };
1974        assert!(!with_prompt.is_unbound());
1975    }
1976
1977    #[test]
1978    fn runtime_identity_serializes_snake_case_and_omits_absent_fields() {
1979        let rt = RuntimeIdentity {
1980            provider: Some("anthropic".into()),
1981            model: Some("claude-opus-4-8".into()),
1982            tool_schema_hash: Some("sha256:tools".into()),
1983            system_prompt_hash: None,
1984        };
1985        let j = serde_json::to_string(&rt).unwrap();
1986        assert!(j.contains("\"provider\":\"anthropic\""), "{j}");
1987        assert!(j.contains("\"model\":\"claude-opus-4-8\""), "{j}");
1988        assert!(j.contains("\"tool_schema_hash\":\"sha256:tools\""), "{j}");
1989        // Absent field omitted, not null -- keeps the canonical stable.
1990        assert!(!j.contains("system_prompt_hash"), "{j}");
1991
1992        // All-absent runtime is an empty object, and roundtrips.
1993        let empty = serde_json::to_string(&RuntimeIdentity::default()).unwrap();
1994        assert_eq!(empty, "{}");
1995        let back: RuntimeIdentity = serde_json::from_str(&empty).unwrap();
1996        assert!(back.is_unbound());
1997    }
1998
1999    #[test]
2000    fn runtime_is_omitted_from_statement_when_absent() {
2001        // A v2 statement with no runtime must not emit a `runtime` key, so
2002        // existing artifact_ids over runtime-less receipts are unaffected.
2003        let s = good_stmt();
2004        assert!(s.runtime.is_none());
2005        let j = serde_json::to_string(&s).unwrap();
2006        assert!(!j.contains("runtime"), "{j}");
2007
2008        // When present, it rides in the signed statement and roundtrips.
2009        let mut with_rt = good_stmt();
2010        with_rt.runtime = Some(RuntimeIdentity {
2011            model: Some("claude-opus-4-8".into()),
2012            ..Default::default()
2013        });
2014        let j2 = serde_json::to_string(&with_rt).unwrap();
2015        assert!(j2.contains("\"runtime\""), "{j2}");
2016        let back: ActionStatementV2 = serde_json::from_str(&j2).unwrap();
2017        assert_eq!(
2018            back.runtime.unwrap().model.as_deref(),
2019            Some("claude-opus-4-8")
2020        );
2021    }
2022
2023    fn base_mandate() -> Mandate {
2024        Mandate {
2025            grant_id: "grant_9c2f".into(),
2026            grantor: "key_parent".into(),
2027            // Bound, so tests of the scope / audience / window / revocation
2028            // layers are not perpetually Unverified on the holder layer. The
2029            // bearer case has its own test.
2030            grantee: Some("key_holder".into()),
2031            issuer_sig: None,
2032            objective_hash: Some("sha256:abc".into()),
2033            scope: vec!["payments.charge".into()],
2034            audience: "acme-payments-api".into(),
2035            parent_request_id: Some("req_7d3e".into()),
2036            delegation_depth: 2,
2037            issued_at: "2026-07-11T19:50:00Z".into(),
2038            expiry: "2026-07-11T20:50:00Z".into(),
2039            max_delegation: 3,
2040            revocation: Revocation {
2041                path: "hub://acme/revocations".into(),
2042                revoked_at: None,
2043            },
2044            chain: Vec::new(),
2045        }
2046    }
2047
2048    /// A statement whose signed_at sits inside the mandate window, action in
2049    /// scope, audience matching.
2050    fn good_stmt() -> ActionStatementV2 {
2051        let mut s = ActionStatementV2::new("ship://ship_f9ba", "payments.charge", base_mandate());
2052        s.timestamp = "2026-07-11T19:53:09Z".into();
2053        s.audience = Some("acme-payments-api".into());
2054        s
2055    }
2056
2057    struct StaticRevocation(RevocationStatus);
2058    impl RevocationSource for StaticRevocation {
2059        fn status(&self, _g: &str, _p: &str) -> RevocationStatus {
2060            self.0.clone()
2061        }
2062    }
2063
2064    // ---- scope ----
2065
2066    #[test]
2067    fn scope_exact_and_glob() {
2068        assert!(action_in_scope(
2069            "payments.charge",
2070            &["payments.charge".into()]
2071        ));
2072        assert!(action_in_scope("payments.charge", &["payments.*".into()]));
2073        assert!(action_in_scope("payments", &["payments.*".into()]));
2074        assert!(!action_in_scope(
2075            "payments.refund",
2076            &["payments.charge".into()]
2077        ));
2078        assert!(!action_in_scope("email.send", &["payments.*".into()]));
2079        // A bare "*" is a literal, not a wildcard.
2080        assert!(!action_in_scope("anything", &["*".into()]));
2081        assert!(action_in_scope("*", &["*".into()]));
2082    }
2083
2084    #[test]
2085    fn empty_scope_authorizes_nothing() {
2086        let mut s = good_stmt();
2087        s.mandate.scope = vec![];
2088        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2089            MandateVerdict::Fail(rs) => assert!(rs.iter().any(|r| r.contains("scope is empty"))),
2090            v => panic!("empty scope must fail, got {v:?}"),
2091        }
2092    }
2093
2094    #[test]
2095    fn action_out_of_scope_fails() {
2096        let mut s = good_stmt();
2097        s.action = "payments.refund".into();
2098        assert!(matches!(
2099            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2100            MandateVerdict::Fail(_)
2101        ));
2102    }
2103
2104    // ---- audience ----
2105
2106    #[test]
2107    fn audience_match_passes_layer() {
2108        let s = good_stmt();
2109        assert_eq!(
2110            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2111            MandateVerdict::Pass
2112        );
2113    }
2114
2115    #[test]
2116    fn audience_mismatch_fails() {
2117        let mut s = good_stmt();
2118        s.audience = Some("evil-api".into());
2119        assert!(matches!(
2120            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2121            MandateVerdict::Fail(_)
2122        ));
2123    }
2124
2125    #[test]
2126    fn missing_action_audience_is_unverified_not_pass() {
2127        let mut s = good_stmt();
2128        s.audience = None;
2129        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2130            MandateVerdict::Unverified(rs) => {
2131                assert!(rs.iter().any(|r| r.contains("recorded no audience")))
2132            }
2133            v => panic!("missing audience must be Unverified, got {v:?}"),
2134        }
2135    }
2136
2137    #[test]
2138    fn empty_mandate_audience_fails() {
2139        let mut s = good_stmt();
2140        s.mandate.audience = "".into();
2141        assert!(matches!(
2142            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2143            MandateVerdict::Fail(_)
2144        ));
2145    }
2146
2147    // ---- TTL (validity at signed_at) ----
2148
2149    #[test]
2150    fn signed_before_issued_fails() {
2151        let mut s = good_stmt();
2152        s.timestamp = "2026-07-11T19:49:59Z".into(); // one sec before issued
2153        assert!(matches!(
2154            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2155            MandateVerdict::Fail(_)
2156        ));
2157    }
2158
2159    #[test]
2160    fn signed_at_expiry_fails() {
2161        let mut s = good_stmt();
2162        s.timestamp = "2026-07-11T20:50:00Z".into(); // exactly expiry (exclusive)
2163        assert!(matches!(
2164            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2165            MandateVerdict::Fail(_)
2166        ));
2167    }
2168
2169    #[test]
2170    fn signed_within_window_passes() {
2171        let s = good_stmt(); // 19:53:09 within [19:50, 20:50)
2172        assert_eq!(
2173            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2174            MandateVerdict::Pass
2175        );
2176    }
2177
2178    #[test]
2179    fn malformed_timestamp_fails_closed() {
2180        let mut s = good_stmt();
2181        s.timestamp = "not-a-timestamp".into();
2182        assert!(matches!(
2183            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2184            MandateVerdict::Fail(_)
2185        ));
2186    }
2187
2188    // ---- revocation at signing time ----
2189
2190    #[test]
2191    fn revoked_after_signing_still_passes() {
2192        // Grant revoked at 20:00; receipt signed at 19:53 -> still valid,
2193        // like a TLS cert whose later revocation is not retroactive.
2194        let s = good_stmt();
2195        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T20:00:00Z".into()));
2196        assert_eq!(verify_mandate(&s, &src), MandateVerdict::Pass);
2197    }
2198
2199    #[test]
2200    fn revoked_before_signing_fails() {
2201        let s = good_stmt(); // signed 19:53
2202        let src = StaticRevocation(RevocationStatus::RevokedAt("2026-07-11T19:52:00Z".into()));
2203        assert!(matches!(verify_mandate(&s, &src), MandateVerdict::Fail(_)));
2204    }
2205
2206    #[test]
2207    fn revocation_unknown_is_unverified() {
2208        let s = good_stmt();
2209        match verify_mandate(&s, &NoRevocationSource) {
2210            MandateVerdict::Unverified(rs) => {
2211                assert!(rs
2212                    .iter()
2213                    .any(|r| r.contains("revocation could not be checked")))
2214            }
2215            v => panic!("no revocation source must be Unverified, got {v:?}"),
2216        }
2217    }
2218
2219    #[test]
2220    fn fail_takes_precedence_over_unverified() {
2221        // Out-of-scope action (fail) AND no revocation source (unverified):
2222        // the verdict must be Fail, never Unverified.
2223        let mut s = good_stmt();
2224        s.action = "payments.refund".into();
2225        assert!(matches!(
2226            verify_mandate(&s, &NoRevocationSource),
2227            MandateVerdict::Fail(_)
2228        ));
2229    }
2230
2231    #[test]
2232    fn wrong_type_fails() {
2233        let mut s = good_stmt();
2234        s.type_ = "treeship/action/v1".into();
2235        assert!(matches!(
2236            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2237            MandateVerdict::Fail(_)
2238        ));
2239    }
2240
2241    // ---- canonical binding: mandate/effect are in the signed bytes ----
2242
2243    #[test]
2244    fn mandate_is_bound_into_signature() {
2245        let signer = Ed25519Signer::generate("key_test").unwrap();
2246        let pt = payload_type_v2("action");
2247
2248        let a = good_stmt();
2249        let mut b = good_stmt();
2250        b.mandate.scope = vec!["payments.*".into()]; // differ only in mandate
2251
2252        let ra = sign(&pt, &a, &signer).unwrap();
2253        let rb = sign(&pt, &b, &signer).unwrap();
2254        assert_ne!(
2255            ra.artifact_id, rb.artifact_id,
2256            "changing mandate.scope must change the signed artifact id"
2257        );
2258    }
2259
2260    #[test]
2261    fn v2_sign_verify_roundtrip() {
2262        let signer = Ed25519Signer::generate("key_test").unwrap();
2263        let verifier = EnvVerifier::from_signer(&signer);
2264        let pt = payload_type_v2("action");
2265
2266        let mut s = good_stmt();
2267        s.effect = Some(Effect {
2268            output_hash: Some("sha256:out".into()),
2269            readback: Some("sha256:observed".into()),
2270            bytes_moved: Some(1_048_576),
2271            cost: Some(Cost {
2272                unit: "usd_micros".into(),
2273                amount: 4200,
2274            }),
2275            side_effects: vec!["db:users.update".into()],
2276            ..Default::default()
2277        });
2278
2279        let signed = sign(&pt, &s, &signer).unwrap();
2280        verifier.verify(&signed.envelope).unwrap();
2281
2282        let decoded: ActionStatementV2 = signed.envelope.unmarshal_statement().unwrap();
2283        assert_eq!(decoded.type_, TYPE_ACTION_V2);
2284        assert_eq!(decoded.mandate.grant_id, "grant_9c2f");
2285        assert_eq!(decoded.effect.unwrap().cost.unwrap().amount, 4200);
2286    }
2287
2288    #[test]
2289    fn v2_payload_type_differs_from_v1() {
2290        assert_eq!(
2291            payload_type_v2("action"),
2292            "application/vnd.treeship.action.v2+json"
2293        );
2294        assert_ne!(
2295            payload_type_v2("action"),
2296            super::super::payload_type("action")
2297        );
2298    }
2299
2300    // ---- holder binding ----
2301
2302    #[test]
2303    fn a_bearer_mandate_is_reported_not_passed() {
2304        // No grantee means anyone holding the grant bytes could have produced
2305        // this receipt. The signature proves who issued the grant and who
2306        // signed the receipt; it never proves the two were related.
2307        let mut s = good_stmt();
2308        s.mandate.grantee = None;
2309        match verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)) {
2310            MandateVerdict::Unverified(r) => assert!(
2311                r.iter().any(|x| x.contains("bearer")),
2312                "the reason must name it: {r:?}"
2313            ),
2314            other => panic!("bearer must not pass silently, got {other:?}"),
2315        }
2316    }
2317
2318    #[test]
2319    fn a_bound_mandate_clears_the_holder_layer() {
2320        let s = good_stmt();
2321        assert_eq!(
2322            verify_mandate(&s, &StaticRevocation(RevocationStatus::NotRevoked)),
2323            MandateVerdict::Pass
2324        );
2325    }
2326
2327    #[test]
2328    fn exercisable_by_is_exact_and_bearer_admits_everyone() {
2329        let mut g = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2330        g.grantee = Some("holder-key".into());
2331        assert!(g.binds_holder());
2332        assert!(g.exercisable_by("holder-key"));
2333        assert!(!g.exercisable_by("someone-else"));
2334
2335        // Bearer: true for every key, which is exactly what `binds_holder`
2336        // exists to let a caller warn about.
2337        g.grantee = None;
2338        assert!(!g.binds_holder());
2339        assert!(g.exercisable_by("anyone-at-all"));
2340    }
2341
2342    #[test]
2343    fn grantee_is_covered_by_the_signed_bytes() {
2344        // A holder who could add or strip the grantee would be choosing who the
2345        // grant is for -- the grantor's decision alone.
2346        let mut a = grant("g", "k", &["a"], 0, "2026-07-11T21:00:00Z", 3);
2347        let mut b = a.clone();
2348        a.grantee = Some("alice".into());
2349        b.grantee = Some("bob".into());
2350        assert_ne!(a.canonical_for_signing(), b.canonical_for_signing());
2351        assert_ne!(a.derive_grant_id(), b.derive_grant_id());
2352    }
2353
2354    // ---- grant object + chain attenuation ----
2355
2356    fn grant(
2357        id: &str,
2358        grantor: &str,
2359        scope: &[&str],
2360        depth: u32,
2361        expiry: &str,
2362        max_deleg: u32,
2363    ) -> Grant {
2364        Grant {
2365            grant_id: id.into(),
2366            grantor: grantor.into(),
2367            grantee: None,
2368            issuer_sig: None,
2369            scope: scope.iter().map(|s| (*s).into()).collect(),
2370            audience: "acme-payments-api".into(),
2371            parent_request_id: None,
2372            parent_grant_id: None,
2373            delegation_depth: depth,
2374            issued_at: "2026-07-11T19:00:00Z".into(),
2375            expiry: expiry.into(),
2376            max_delegation: max_deleg,
2377            objective_hash: None,
2378        }
2379    }
2380
2381    #[test]
2382    fn grant_sign_verify_roundtrip_and_tamper() {
2383        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2384        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2385        let mut g = grant(
2386            "grant_root",
2387            &grantor,
2388            &["payments.*"],
2389            0,
2390            "2026-07-11T21:00:00Z",
2391            3,
2392        );
2393        // Ids are content-derived now; verify_canonical refuses a hand-chosen
2394        // one, so compute it before signing.
2395        g.grant_id = g.derive_grant_id();
2396
2397        let sig = g.sign_canonical(&signer).unwrap();
2398        assert!(g.verify_canonical(&sig));
2399
2400        // Tamper after signing -> verification fails.
2401        g.scope.push("email.*".into());
2402        assert!(!g.verify_canonical(&sig));
2403    }
2404
2405    #[test]
2406    fn grant_verify_rejects_wrong_key() {
2407        let signer = Ed25519Signer::from_bytes("g", &[9u8; 32]).unwrap();
2408        let attacker = Ed25519Signer::from_bytes("a", &[3u8; 32]).unwrap();
2409        let grantor = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2410        let g = grant(
2411            "grant_root",
2412            &grantor,
2413            &["payments.*"],
2414            0,
2415            "2026-07-11T21:00:00Z",
2416            3,
2417        );
2418        let sig = g.sign_canonical(&attacker).unwrap();
2419        assert!(!g.verify_canonical(&sig));
2420    }
2421
2422    #[test]
2423    fn valid_attenuating_chain_ok() {
2424        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2425        let child = grant(
2426            "g1",
2427            "k",
2428            &["payments.charge"],
2429            1,
2430            "2026-07-11T20:30:00Z",
2431            3,
2432        );
2433        assert_eq!(verify_grant_chain(&[root, child]), Ok(()));
2434    }
2435
2436    #[test]
2437    fn scope_widening_rejected() {
2438        let root = grant(
2439            "g0",
2440            "k",
2441            &["payments.charge"],
2442            0,
2443            "2026-07-11T21:00:00Z",
2444            3,
2445        );
2446        let child = grant("g1", "k", &["payments.*"], 1, "2026-07-11T21:00:00Z", 3);
2447        assert_eq!(
2448            verify_grant_chain(&[root, child]),
2449            Err(GrantChainError::ScopeWidened { parent: 0 })
2450        );
2451    }
2452
2453    #[test]
2454    fn expiry_widening_rejected() {
2455        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2456        let child = grant(
2457            "g1",
2458            "k",
2459            &["payments.charge"],
2460            1,
2461            "2026-07-11T22:00:00Z",
2462            3,
2463        );
2464        assert_eq!(
2465            verify_grant_chain(&[root, child]),
2466            Err(GrantChainError::ExpiryWidened { parent: 0 })
2467        );
2468    }
2469
2470    #[test]
2471    fn depth_not_incremented_rejected() {
2472        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2473        let child = grant(
2474            "g1",
2475            "k",
2476            &["payments.charge"],
2477            2,
2478            "2026-07-11T21:00:00Z",
2479            3,
2480        );
2481        assert_eq!(
2482            verify_grant_chain(&[root, child]),
2483            Err(GrantChainError::DepthNotIncremented { parent: 0 })
2484        );
2485    }
2486
2487    #[test]
2488    fn depth_exceeds_max_rejected() {
2489        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 0);
2490        let child = grant(
2491            "g1",
2492            "k",
2493            &["payments.charge"],
2494            1,
2495            "2026-07-11T21:00:00Z",
2496            0,
2497        );
2498        assert_eq!(
2499            verify_grant_chain(&[root, child]),
2500            Err(GrantChainError::DepthExceedsMax { parent: 0 })
2501        );
2502    }
2503
2504    #[test]
2505    fn audience_change_rejected() {
2506        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2507        let mut child = grant(
2508            "g1",
2509            "k",
2510            &["payments.charge"],
2511            1,
2512            "2026-07-11T21:00:00Z",
2513            3,
2514        );
2515        child.audience = "other-api".into();
2516        assert_eq!(
2517            verify_grant_chain(&[root, child]),
2518            Err(GrantChainError::AudienceChanged { parent: 0 })
2519        );
2520    }
2521
2522    #[test]
2523    fn empty_chain_rejected() {
2524        assert_eq!(verify_grant_chain(&[]), Err(GrantChainError::Empty));
2525    }
2526
2527    #[test]
2528    fn bad_timestamp_in_chain_rejected() {
2529        let mut root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2530        root.expiry = "nope".into();
2531        assert_eq!(
2532            verify_grant_chain(&[root]),
2533            Err(GrantChainError::BadTimestamp { index: 0 })
2534        );
2535    }
2536
2537    #[test]
2538    fn single_grant_chain_ok() {
2539        let root = grant("g0", "k", &["payments.*"], 0, "2026-07-11T21:00:00Z", 3);
2540        assert_eq!(verify_grant_chain(&[root]), Ok(()));
2541    }
2542
2543    // ---- grant chain resolution ----
2544
2545    /// Mint a signed grant with a content-consistent id.
2546    fn mk_grant(
2547        signer: &Ed25519Signer,
2548        grantor_pk: &str,
2549        scope: Vec<&str>,
2550        depth: u32,
2551        parent: Option<&str>,
2552    ) -> Grant {
2553        let mut g = Grant {
2554            grant_id: String::new(),
2555            grantor: grantor_pk.to_string(),
2556            grantee: None,
2557            issuer_sig: None,
2558            scope: scope.into_iter().map(String::from).collect(),
2559            audience: "acme".into(),
2560            parent_request_id: None,
2561            parent_grant_id: parent.map(String::from),
2562            delegation_depth: depth,
2563            issued_at: "2026-07-20T10:00:00Z".into(),
2564            expiry: "2026-07-20T11:00:00Z".into(),
2565            max_delegation: 3,
2566            objective_hash: None,
2567        };
2568        g.grant_id = g.derive_grant_id();
2569        g.issuer_sig = Some(g.sign_canonical(signer).unwrap());
2570        g
2571    }
2572
2573    fn chain_fixture() -> (Grant, Grant, Ed25519Signer) {
2574        let signer = Ed25519Signer::generate("issuer").unwrap();
2575        let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2576        let root = mk_grant(&signer, &pk, vec!["payments.*"], 0, None);
2577        let leaf = mk_grant(
2578            &signer,
2579            &pk,
2580            vec!["payments.charge"],
2581            1,
2582            Some(&root.grant_id),
2583        );
2584        (root, leaf, signer)
2585    }
2586
2587    fn mandate_with(leaf: &Grant, chain: Vec<Grant>) -> Mandate {
2588        let mut m = base_mandate();
2589        m.grant_id = leaf.grant_id.clone();
2590        m.chain = chain;
2591        m
2592    }
2593
2594    #[test]
2595    fn grant_id_is_content_derived_and_stable() {
2596        let (root, _, _) = chain_fixture();
2597        assert!(root.grant_id.starts_with("grn_"));
2598        assert_eq!(root.grant_id, root.derive_grant_id());
2599        // Changing any covered field must move the id.
2600        let mut altered = root.clone();
2601        altered.scope = vec!["payments.refund".into()];
2602        assert_ne!(altered.derive_grant_id(), root.grant_id);
2603    }
2604
2605    #[test]
2606    fn hand_chosen_id_fails_verification() {
2607        let (mut root, _, _) = chain_fixture();
2608        let sig = root.issuer_sig.clone().unwrap();
2609        root.grant_id = "grn_deadbeefdeadbeef".into();
2610        assert!(
2611            !root.verify_canonical(&sig),
2612            "an id that was chosen rather than computed must not verify"
2613        );
2614    }
2615
2616    #[test]
2617    fn resolves_root_first_regardless_of_carrier_order() {
2618        let (root, leaf, _) = chain_fixture();
2619        // Carrier hands them over leaf-first; resolution must not care.
2620        let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2621        let resolved = resolve_grant_chain(&m).expect("resolves");
2622        assert_eq!(resolved.len(), 2);
2623        assert_eq!(resolved[0].grant_id, root.grant_id, "root must come first");
2624        assert_eq!(resolved[1].grant_id, leaf.grant_id);
2625    }
2626
2627    #[test]
2628    fn truncated_chain_is_rejected() {
2629        let (_, leaf, _) = chain_fixture();
2630        // Drop the root: the leaf still points at it, so the walk must fail
2631        // rather than treating the leaf as its own root.
2632        let m = mandate_with(&leaf, vec![leaf.clone()]);
2633        match resolve_grant_chain(&m) {
2634            Err(ChainResolveError::AncestorMissing { .. }) => {}
2635            other => panic!("truncation must be caught, got {other:?}"),
2636        }
2637    }
2638
2639    #[test]
2640    fn spliced_decoy_grant_is_rejected() {
2641        let (root, leaf, signer) = chain_fixture();
2642        let pk = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
2643        // A valid, signed, unrelated grant smuggled into the chain. It must
2644        // differ in content from root -- an identical grant has an identical
2645        // content id, which is the point of deriving ids from content.
2646        let decoy = mk_grant(&signer, &pk, vec!["email.send"], 0, None);
2647        assert_ne!(decoy.grant_id, root.grant_id);
2648        let m = mandate_with(&leaf, vec![root.clone(), leaf.clone(), decoy]);
2649        match resolve_grant_chain(&m) {
2650            Err(ChainResolveError::UnreachableExtras { count }) => assert_eq!(count, 1),
2651            other => panic!("unreachable extras must be refused, got {other:?}"),
2652        }
2653    }
2654
2655    #[test]
2656    fn unsigned_ancestor_is_rejected() {
2657        let (mut root, leaf, _) = chain_fixture();
2658        root.issuer_sig = None;
2659        let m = mandate_with(&leaf, vec![root, leaf.clone()]);
2660        assert!(matches!(
2661            resolve_grant_chain(&m),
2662            Err(ChainResolveError::Unsigned { .. })
2663        ));
2664    }
2665
2666    #[test]
2667    fn resolved_chain_feeds_attenuation_check() {
2668        // End to end: resolution proves the shape, verify_grant_chain then
2669        // judges the attenuation. Only together do they mean anything.
2670        let (root, leaf, _) = chain_fixture();
2671        let m = mandate_with(&leaf, vec![leaf.clone(), root.clone()]);
2672        let resolved = resolve_grant_chain(&m).expect("resolves");
2673        assert!(
2674            verify_grant_chain(&resolved).is_ok(),
2675            "narrowing scope at depth+1 must satisfy attenuation"
2676        );
2677    }
2678
2679    #[test]
2680    fn leaf_not_in_chain_is_rejected() {
2681        // The mandate names a grant the carrier did not supply. Distinct from
2682        // AncestorMissing: nothing has been walked yet, so the failure has to
2683        // point at the leaf rather than at a parent link.
2684        let (root, leaf, _) = chain_fixture();
2685        let mut m = mandate_with(&leaf, vec![root.clone()]);
2686        m.grant_id = leaf.grant_id.clone();
2687        match resolve_grant_chain(&m) {
2688            Err(ChainResolveError::LeafMissing { grant_id }) => {
2689                assert_eq!(grant_id, leaf.grant_id);
2690            }
2691            other => panic!("a mandate naming an absent leaf must fail, got {other:?}"),
2692        }
2693    }
2694
2695    #[test]
2696    fn ancestor_signed_by_a_stranger_is_rejected() {
2697        // Content-consistent id, well-formed signature -- but produced by a key
2698        // that is not the declared grantor. The id check cannot catch this
2699        // because `grantor` is covered by the canonical: only the crypto can.
2700        let (root, leaf, _) = chain_fixture();
2701        let stranger = Ed25519Signer::generate("stranger").unwrap();
2702        let mut forged = root.clone();
2703        forged.issuer_sig = Some(forged.sign_canonical(&stranger).unwrap());
2704        assert_eq!(
2705            forged.grant_id, root.grant_id,
2706            "signing with another key must not change the content id"
2707        );
2708
2709        let m = mandate_with(&leaf, vec![forged, leaf.clone()]);
2710        match resolve_grant_chain(&m) {
2711            Err(ChainResolveError::BadSignature { grant_id }) => {
2712                assert_eq!(grant_id, root.grant_id);
2713            }
2714            other => panic!("a grant signed by a non-grantor must fail, got {other:?}"),
2715        }
2716    }
2717
2718    #[test]
2719    fn inconsistent_id_is_caught_before_signature_check() {
2720        // A tampered id must be refused as InconsistentId, not surface later as
2721        // BadSignature -- the reason an operator is given should name the
2722        // actual defect.
2723        let (root, leaf, _) = chain_fixture();
2724        let mut tampered = root.clone();
2725        tampered.grant_id = "grn_0000000000000000".into();
2726        let m = mandate_with(&leaf, vec![tampered, leaf.clone()]);
2727        assert!(matches!(
2728            resolve_grant_chain(&m),
2729            Err(ChainResolveError::InconsistentId { .. })
2730        ));
2731    }
2732    /// The bypass this reconciliation closes, kept as the shape it had.
2733    ///
2734    /// A legitimately signed, correctly attenuated chain whose leaf grants
2735    /// only `payments.charge`, carried by a mandate that declares
2736    /// `payments.*`, previously verified `payments.refund` as **Pass**. The
2737    /// chain resolved, attenuation checked, and then nothing compared the
2738    /// mandate against it: real, verified, and decorative.
2739    ///
2740    /// The root cause is worth naming -- the mandate restates the grant's
2741    /// terms, and the restatement is signed by the party being constrained.
2742    /// A constraint you author about yourself is not a constraint.
2743    #[test]
2744    fn mandate_cannot_claim_more_than_the_grant_it_names() {
2745        let (root, leaf, _) = chain_fixture();
2746        assert_eq!(leaf.scope, vec!["payments.charge".to_string()]);
2747
2748        let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2749        m.scope = vec!["payments.*".into()];
2750        m.audience = leaf.audience.clone();
2751
2752        // The chain itself is genuine: it resolves and it attenuates.
2753        let chain = resolve_grant_chain(&m).expect("chain resolves");
2754        assert_eq!(verify_grant_chain(&chain), Ok(()));
2755
2756        let mut stmt = good_stmt();
2757        stmt.action = "payments.refund".into();
2758        stmt.audience = Some(leaf.audience.clone());
2759        stmt.mandate = m;
2760
2761        match verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)) {
2762            MandateVerdict::Fail(reasons) => assert!(
2763                reasons.iter().any(|r| r.contains("exceeds the leaf grant")),
2764                "failed for the wrong reason: {reasons:?}"
2765            ),
2766            other => panic!("an action outside the leaf grant must FAIL, got {other:?}"),
2767        }
2768    }
2769
2770    /// The honest case must still pass, or the check closes the hole and the
2771    /// feature together.
2772    #[test]
2773    fn a_mandate_within_its_grant_still_passes() {
2774        let (root, leaf, _) = chain_fixture();
2775        let mut m = mandate_with(&leaf, vec![root.clone(), leaf.clone()]);
2776        m.scope = leaf.scope.clone();
2777        m.audience = leaf.audience.clone();
2778
2779        let mut stmt = good_stmt();
2780        stmt.action = "payments.charge".into();
2781        stmt.audience = Some(leaf.audience.clone());
2782        stmt.mandate = m;
2783
2784        assert_eq!(
2785            verify_mandate(&stmt, &StaticRevocation(RevocationStatus::NotRevoked)),
2786            MandateVerdict::Pass
2787        );
2788    }
2789
2790    /// Objective attenuation between grants: authority minted for one task
2791    /// must not be spent on another, and dropping the binding removes the
2792    /// constraint exactly as changing it does.
2793    #[test]
2794    fn objective_cannot_change_or_be_dropped_across_a_delegation() {
2795        let (root, leaf, _) = chain_fixture();
2796
2797        let mut p = root.clone();
2798        p.objective_hash = Some("sha256:task-a".into());
2799
2800        let mut swapped = leaf.clone();
2801        swapped.objective_hash = Some("sha256:task-b".into());
2802        assert_eq!(
2803            verify_grant_chain(&[p.clone(), swapped]),
2804            Err(GrantChainError::ObjectiveChanged { parent: 0 })
2805        );
2806
2807        let mut dropped = leaf.clone();
2808        dropped.objective_hash = None;
2809        assert_eq!(
2810            verify_grant_chain(&[p.clone(), dropped]),
2811            Err(GrantChainError::ObjectiveChanged { parent: 0 })
2812        );
2813
2814        // Adding an objective the parent did not declare narrows, and is fine.
2815        let mut added = leaf.clone();
2816        added.objective_hash = Some("sha256:task-a".into());
2817        assert_eq!(verify_grant_chain(&[root.clone(), added]), Ok(()));
2818
2819        // Matching objectives are fine.
2820        let mut same = leaf.clone();
2821        same.objective_hash = Some("sha256:task-a".into());
2822        assert_eq!(verify_grant_chain(&[p, same]), Ok(()));
2823    }
2824}