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