Skip to main content

triblespace_core/repo/
capability.rs

1//! Capability-based authorization for triblespace networks.
2//!
3//! Implements a chain-of-trust capability system where:
4//!
5//! - A team has a single immutable root keypair (the "team root"), generated
6//!   once at team creation and used to sign exactly one capability — the
7//!   founder's. The team root never operates online; it's the constitutional
8//!   document for the team's identity.
9//! - All other capabilities chain off the founder's via delegation. Any holder
10//!   of a capability can sign a sub-capability for someone else, as long as
11//!   the sub-cap's scope is a subset of their own. Verification walks the
12//!   chain back to the team root.
13//! - Each capability link is two blobs: a `cap` blob (the claim) and a `sig`
14//!   blob (the issuer's signature over the cap blob's bytes). For chains of
15//!   length > 1, each non-root cap embeds its parent's signature inline as a
16//!   sub-entity, which halves the cold-cache verification fetch count by
17//!   eliminating a separate round-trip per intermediate signature.
18//! - Signatures attest to the cap blob's canonical bytes (SimpleArchive's
19//!   serialization is already canonical), not to a hash of those bytes —
20//!   matching the existing commit-signing convention. This keeps signatures
21//!   hash-agnostic across any future Blake3 migration.
22//!
23//! Scope is encoded as tribles inside the cap blob, anchored at
24//! `cap_scope_root`. Permissions are tagged via `metadata::tag` linking
25//! to constants like `PERM_READ`, `PERM_WRITE`, `PERM_ADMIN`. Optional
26//! per-resource restrictions like `scope_branch` narrow a permission to a
27//! specific branch.
28//!
29//! (Names like `cap_scope_root`, `metadata::tag`, `scope_branch`, and
30//! `PERM_*` are spelled in plain code formatting rather than as
31//! intra-doc links because the macro-generated attribute items and
32//! the `id_hex!`-defined constants don't reliably resolve as
33//! rustdoc link targets from a `//!` block.)
34//!
35//! See `docs/sync_relay_auth_design.md` (or the `shared.pile` wiki fragment
36//! titled "Sync Relay Auth Design") for the full design rationale.
37
38use crate::id::Id;
39use crate::id_hex;
40
41/// Tag indicating a scope grants read access on the resources in scope.
42pub const PERM_READ: Id = id_hex!("A75EED8224A553DD8002576E2E8A6823");
43/// Tag indicating a scope grants write access on the resources in scope.
44pub const PERM_WRITE: Id = id_hex!("C56AAF4191DD4FBB9F197B79435B881D");
45/// Tag indicating a scope grants admin (delegation) authority.
46pub const PERM_ADMIN: Id = id_hex!("EC68A0CBF9EF421F59A0A69ED80FD79F");
47
48use crate::inline::encodings::ed25519 as ed;
49use crate::blob::encodings::simplearchive::SimpleArchive;
50use crate::inline::encodings::genid::GenId;
51use crate::inline::encodings::hash::Handle;
52
53triblespace_core_macros::attributes! {
54    // ── Cap blob ──────────────────────────────────────────────────────
55    /// The pubkey this capability authorizes. Must match the verified
56    /// peer identity at connection time (i.e. the connecting peer's
57    /// iroh `EndpointId`).
58    "1A8A6A9D8CA1DA67FACAB373DE21233B" as pub cap_subject: ed::ED25519PublicKey;
59    /// The pubkey of the entity that signed this capability. Must match
60    /// the `signed_by` field of the accompanying signature blob.
61    /// Recorded in the cap so verification can detect a sig-blob/cap
62    /// issuer mismatch without an extra fetch.
63    "2E9CD97ED0698FAF18EAEB74B5893685" as pub cap_issuer: ed::ED25519PublicKey;
64    /// Entity id within the cap blob anchoring the scope tribles. The
65    /// scope sub-graph hanging off this id encodes which permissions
66    /// (and optionally which resources) the capability grants.
67    "1A7DD2026BEFBE55A354CE10839CFDD6" as pub cap_scope_root: GenId;
68    // Note: chain references (cap_parent, embedded parent sig) live in
69    // the sig blob, not the cap blob. A cap blob is a pure declaration
70    // of (subject, issuer, scope, expiry) — independent of which
71    // authority chain endorses it. See sig_parent_cap below.
72
73    // ── Scope ─────────────────────────────────────────────────────────
74    /// Optional restriction of a permission to a specific branch.
75    /// Repeated when a permission applies to multiple branches; absent
76    /// when the permission is unrestricted (applies to every branch
77    /// the holder is otherwise authorised on).
78    "46246789D627C1B0F81B21418E179DFD" as pub scope_branch: GenId;
79
80    // ── Sig blob ──────────────────────────────────────────────────────
81    /// Handle of the cap blob this signature attests to. The signature
82    /// itself is over the cap blob's canonical bytes (i.e.
83    /// `cap_blob.bytes`), not over the handle. SimpleArchive is already
84    /// canonical, so the bytes the signer signs are exactly what the
85    /// hasher hashes.
86    "230E175A083E29155C860B38BD44F2F3" as pub sig_signs: Handle<SimpleArchive>;
87    /// Handle of the parent cap blob in the chain. Absent when this
88    /// entry's issuer is the team root (chain terminator). Present on
89    /// every other sig-blob outer entity and recursive sub-entity.
90    "ACF20EE95C6A4AE16B445590E88AB9BE" as pub sig_parent_cap: Handle<SimpleArchive>;
91    /// Entity id within the same sig blob holding the parent's proof
92    /// inline. The sub-entity carries `signed_by`, `signature_r`,
93    /// `signature_s`, and (if the chain continues) its own
94    /// `sig_parent_cap` + `sig_embedded_parent_proof`. Absent when
95    /// the issuer is the team root.
96    "8ED30E412129FB0A791BD335EACF2E82" as pub sig_embedded_parent_proof: GenId;
97    // Note: sig_signer + sig_value (r/s) reuse the existing
98    // `repo::signed_by`, `repo::signature_r`, `repo::signature_s`
99    // attributes — same convention as commit signatures, plus
100    // structural reuse (a sig blob has the same shape inside as the
101    // signature portion of a commit's metadata blob).
102}
103
104/// Tag identifying a blob as a capability claim.
105#[allow(dead_code)]
106pub const KIND_CAPABILITY: Id = id_hex!("B8D76786ACD20F344A4E5CBFC0F75772");
107/// Tag identifying a blob as a capability signature.
108#[allow(dead_code)]
109pub const KIND_CAPABILITY_SIG: Id = id_hex!("E6BB52CE6E02D51C3676ECE1EEA9094F");
110
111// ── Builder ──────────────────────────────────────────────────────────
112
113use ed25519::Signature;
114use ed25519_dalek::SigningKey;
115use ed25519_dalek::VerifyingKey;
116use ed25519::signature::Signer;
117
118use crate::blob::Blob;
119use crate::blob::IntoBlob;
120use crate::blob::TryFromBlob;
121use crate::blob::encodings::simplearchive::UnarchiveError;
122use crate::id::ExclusiveId;
123use crate::macros::entity;
124use crate::macros::pattern;
125use crate::query::find;
126use crate::trible::TribleSet;
127use crate::inline::Inline;
128use crate::inline::IntoInline;
129use crate::inline::encodings::time::NsTAIInterval;
130
131/// Errors returned by [`build_capability`].
132#[derive(Debug)]
133pub enum BuildError {
134    /// The provided parent signature blob could not be parsed as a valid
135    /// SimpleArchive.
136    ParseParentSig(UnarchiveError),
137    /// The provided parent signature blob did not contain exactly one
138    /// signature entity (i.e. exactly one entity carrying [`sig_signs`]).
139    ParentSigShape,
140}
141
142/// Build a capability link.
143///
144/// Returns the pair `(cap_blob, sig_blob)`:
145/// - `cap_blob` carries the claim (subject pubkey, scope, expiry, parent
146///   pointer, embedded parent signature). Its content-addressed handle is
147///   what the sig blob attests to.
148/// - `sig_blob` carries the issuer's signature over `cap_blob.bytes` plus
149///   the issuer's pubkey, alongside a `sig_signs` handle pointing at the
150///   cap blob.
151///
152/// `parent = None` constructs a root-issued capability: the issuer is
153/// expected to be the team root keypair, and the resulting cap has no
154/// `cap_parent` and no embedded parent signature. Verification terminates
155/// at this link when the issuer pubkey matches the team root.
156///
157/// `parent = Some((parent_cap, parent_sig))` constructs a delegated
158/// capability: the parent's signature is embedded inline in the new cap
159/// blob (via [`cap_embedded_parent_sig`] pointing at a sub-entity carrying
160/// `signed_by` + `signature_r` + `signature_s` reusing the existing
161/// commit-signature attribute conventions) so verifiers can walk one level
162/// up the chain without a separate fetch for the parent's signature.
163///
164/// `scope_facts` should be a TribleSet anchored at `scope_root` describing
165/// the capability's scope (permission tags via [`crate::metadata::tag`],
166/// optional resource restrictions via [`scope_branch`], etc.). The caller
167/// is responsible for producing a scope that's a subset of any parent
168/// scope; this builder does not enforce subsumption.
169///
170/// # Example
171///
172/// Mint a length-1 capability — team root signs the founder's cap
173/// directly. The returned `(cap_blob, sig_blob)` pair is what callers
174/// persist into the pile; the founder presents the sig blob's handle
175/// at connection time.
176///
177/// ```rust
178/// use ed25519_dalek::SigningKey;
179/// use triblespace_core::id::{ufoid, ExclusiveId};
180/// use triblespace_core::macros::entity;
181/// use triblespace_core::trible::TribleSet;
182/// use triblespace_core::inline::TryToInline;
183/// use triblespace_core::repo::capability::{build_capability, PERM_READ};
184/// use rand::rngs::OsRng;
185///
186/// let team_root = SigningKey::generate(&mut OsRng);
187/// let founder = SigningKey::generate(&mut OsRng);
188///
189/// // PERM_READ scope, no branch restriction (read-everything cap).
190/// let scope_root = ufoid();
191/// let scope_facts: TribleSet = entity! {
192///     ExclusiveId::force_ref(&scope_root) @
193///     triblespace_core::metadata::tag: PERM_READ,
194/// }
195/// .into();
196///
197/// let now = hifitime::Epoch::now().unwrap();
198/// let expiry = (now, now + hifitime::Duration::from_seconds(24.0 * 3600.0))
199///     .try_to_inline()
200///     .unwrap();
201///
202/// let (cap_blob, sig_blob) = build_capability(
203///     &team_root,
204///     founder.verifying_key(),
205///     None, // no parent — direct child of the team root
206///     *scope_root,
207///     scope_facts,
208///     expiry,
209/// )
210/// .expect("cap builds");
211///
212/// // Both blobs go into the pile. The founder's "credential" is the
213/// // sig blob's content-addressed handle.
214/// assert!(!cap_blob.bytes.is_empty());
215/// assert!(!sig_blob.bytes.is_empty());
216/// ```
217pub fn build_capability(
218    issuer: &SigningKey,
219    subject: VerifyingKey,
220    parent: Option<(Blob<SimpleArchive>, Blob<SimpleArchive>)>,
221    scope_root: crate::id::Id,
222    scope_facts: TribleSet,
223    expiry: Inline<NsTAIInterval>,
224) -> Result<(Blob<SimpleArchive>, Blob<SimpleArchive>), BuildError> {
225    let issuer_pubkey: VerifyingKey = issuer.verifying_key();
226
227    // Build the cap blob — pure declaration of (subject, issuer, scope,
228    // expiry) and any caller-supplied scope facts. NO chain references;
229    // those live in the sig blob.
230    let cap_fragment = entity! {
231        cap_subject: issuer_subject_value(subject),
232        cap_issuer: issuer_subject_value(issuer_pubkey),
233        cap_scope_root: scope_root,
234        crate::metadata::expires_at: expiry,
235    };
236
237    let mut cap_set = TribleSet::from(cap_fragment);
238    cap_set += scope_facts;
239
240    let cap_blob: Blob<SimpleArchive> = cap_set.to_blob();
241    let cap_handle: Inline<Handle<SimpleArchive>> = (&cap_blob).get_handle();
242
243    // Sign the cap blob's canonical bytes.
244    let signature: Signature = issuer.sign(&cap_blob.bytes);
245
246    // Build the sig blob. Outer entity carries the leaf sig over the
247    // cap, plus (if there's a parent) `sig_parent_cap` + the parent's
248    // entire proof. The parent's tribles are folded in under their
249    // existing entity ids; the parent's outer entity becomes our
250    // embedded proof sub-entity. We strip the parent's `sig_signs`
251    // attribute on its outer entity — that attribute marks the leaf
252    // entity of a sig blob, and once embedded as a sub-entity it's no
253    // longer a leaf.
254    let mut sig_set: TribleSet = TribleSet::from(entity! {
255        sig_signs: cap_handle,
256        crate::repo::signed_by: issuer_pubkey,
257        crate::repo::signature_r: signature,
258        crate::repo::signature_s: signature,
259    });
260    let leaf_outer_id: crate::id::Id = find!(
261        (s: crate::id::Id, _h: Inline<Handle<SimpleArchive>>),
262        pattern!(&sig_set, [{ ?s @ sig_signs: ?_h }])
263    )
264    .map(|(s, _)| s)
265    .next()
266    .expect("just inserted our own outer sig entity");
267
268    if let Some((parent_cap_blob, parent_sig_blob)) = parent {
269        let parent_cap_handle: Inline<Handle<SimpleArchive>> =
270            parent_cap_blob.get_handle();
271
272        let parent_sig_set: TribleSet =
273            TryFromBlob::<SimpleArchive>::try_from_blob(parent_sig_blob)
274                .map_err(BuildError::ParseParentSig)?;
275
276        // Locate the parent's outer leaf entity (the one with sig_signs).
277        let mut parent_outer_iter = find!(
278            (sig: crate::id::Id, _signed: Inline<Handle<SimpleArchive>>),
279            pattern!(&parent_sig_set, [{ ?sig @ sig_signs: ?_signed }])
280        )
281        .map(|(sig, _)| sig);
282        let parent_outer_id = match (
283            parent_outer_iter.next(),
284            parent_outer_iter.next(),
285        ) {
286            (Some(id), None) => id,
287            _ => return Err(BuildError::ParentSigShape),
288        };
289
290        // Pull every trible from the parent sig blob into our sig blob,
291        // dropping the parent's outer `sig_signs` trible (since that
292        // entity is no longer a leaf in the merged sig blob).
293        let sig_signs_attr_id = sig_signs.id();
294        for trible in parent_sig_set.iter() {
295            if *trible.e() == parent_outer_id && *trible.a() == sig_signs_attr_id {
296                continue;
297            }
298            sig_set.insert(trible);
299        }
300
301        // Attach the parent linkage to our own outer entity.
302        sig_set += TribleSet::from(entity! {
303            ExclusiveId::force_ref(&leaf_outer_id) @
304            sig_parent_cap: parent_cap_handle,
305            sig_embedded_parent_proof: parent_outer_id,
306        });
307    }
308
309    let sig_blob: Blob<SimpleArchive> = sig_set.to_blob();
310
311    Ok((cap_blob, sig_blob))
312}
313
314/// Convenience: convert a `VerifyingKey` to a `Inline<ED25519PublicKey>`.
315/// Inlined to avoid an explicit `IntoInline` import at the call sites in
316/// the builder above.
317fn issuer_subject_value(key: VerifyingKey) -> Inline<ed::ED25519PublicKey> {
318    key.to_inline()
319}
320
321// ── Scope subsumption ────────────────────────────────────────────────
322
323/// Collect the permission tag ids and branch restrictions from a scope
324/// sub-graph anchored at `scope_root`.
325fn collect_scope_facts(
326    set: &TribleSet,
327    scope_root: crate::id::Id,
328) -> (HashSet<crate::id::Id>, HashSet<crate::id::Id>) {
329    let perms: HashSet<crate::id::Id> = find!(
330        (perm: crate::id::Id),
331        pattern!(set, [{ scope_root @ crate::metadata::tag: ?perm }])
332    )
333    .map(|(p,)| p)
334    .collect();
335
336    let branches: HashSet<crate::id::Id> = find!(
337        (branch: crate::id::Id),
338        pattern!(set, [{ scope_root @ scope_branch: ?branch }])
339    )
340    .map(|(b,)| b)
341    .collect();
342
343    (perms, branches)
344}
345
346/// Check whether a parent scope authorises a child scope.
347///
348/// Rules:
349/// - If parent grants `PERM_ADMIN`, parent subsumes every child scope.
350/// - Otherwise: every permission tag in the child must be in the
351///   parent's set (with `PERM_WRITE` implying `PERM_READ` for upgrade
352///   compatibility, but an explicit `PERM_READ`-only parent does *not*
353///   imply `PERM_WRITE` for the child).
354/// - Branch restriction: an empty `scope_branch` set means "all
355///   branches"; a non-empty set restricts the scope to those branches.
356///   The child's restriction set must be a subset of the parent's
357///   (where empty parent = all branches allowed).
358///
359/// Unknown permission tags in the child cause subsumption to fail
360/// closed.
361pub fn scope_subsumes(
362    parent_set: &TribleSet,
363    parent_scope_root: crate::id::Id,
364    child_set: &TribleSet,
365    child_scope_root: crate::id::Id,
366) -> bool {
367    let (parent_perms, parent_branches) =
368        collect_scope_facts(parent_set, parent_scope_root);
369    let (child_perms, child_branches) =
370        collect_scope_facts(child_set, child_scope_root);
371
372    if parent_perms.contains(&PERM_ADMIN) {
373        return true;
374    }
375
376    for perm in &child_perms {
377        if *perm == PERM_READ {
378            if !parent_perms.contains(&PERM_READ)
379                && !parent_perms.contains(&PERM_WRITE)
380            {
381                return false;
382            }
383        } else if *perm == PERM_WRITE {
384            if !parent_perms.contains(&PERM_WRITE) {
385                return false;
386            }
387        } else if *perm == PERM_ADMIN {
388            // Parent isn't admin (already checked), so the child can't
389            // claim admin either.
390            return false;
391        } else {
392            // Unknown permission — fail closed.
393            return false;
394        }
395    }
396
397    // Branch restriction subsumption.
398    if !parent_branches.is_empty() {
399        if child_branches.is_empty() {
400            return false;
401        }
402        for b in &child_branches {
403            if !parent_branches.contains(b) {
404                return false;
405            }
406        }
407    }
408
409    true
410}
411
412
413// ── Verifier ──────────────────────────────────────────────────────────
414
415use ed25519_dalek::Verifier;
416use std::collections::HashSet;
417use crate::inline::TryFromInline;
418use hifitime::Epoch;
419
420/// Errors returned by [`verify_chain`].
421#[derive(Debug)]
422pub enum VerifyError {
423    /// The leaf or some intermediate sig/cap blob could not be parsed
424    /// as a valid SimpleArchive.
425    ParseBlob(UnarchiveError),
426    /// Fetching a referenced blob (cap or sig) from the caller-supplied
427    /// fetch function failed.
428    Fetch,
429    /// A signature failed to verify against the expected pubkey + cap
430    /// blob bytes.
431    BadSignature,
432    /// The leaf cap's subject did not match the expected (connecting)
433    /// peer pubkey.
434    SubjectMismatch,
435    /// A cap's `cap_issuer` did not match the accompanying sig's
436    /// `signed_by`.
437    IssuerMismatch,
438    /// A cap or one of its parent caps has expired.
439    Expired,
440    /// A child cap's scope was not a subset of its parent's scope.
441    /// (Enforcement deferred to the scope-subsumption module — for now
442    /// this variant is reserved for future use.)
443    ScopeNotSubset,
444    /// A cap blob is missing required attributes (e.g. cap_subject,
445    /// cap_issuer, cap_scope_root, expires_at) or has multiple
446    /// conflicting values.
447    MalformedCap,
448    /// A sig blob is missing required attributes or has multiple
449    /// conflicting values.
450    MalformedSig,
451    /// The leaf sig blob refers to a cap blob whose handle the verifier
452    /// could not retrieve.
453    LeafCapMissing,
454    /// A non-root sig-blob entity (one whose signer differs from the
455    /// team root) is missing either `sig_parent_cap` or
456    /// `sig_embedded_parent_proof`.
457    NonRootMissingParent,
458    /// The chain exceeded a sanity-bound depth without terminating at
459    /// the team root.
460    ChainTooDeep,
461}
462
463impl From<UnarchiveError> for VerifyError {
464    fn from(e: UnarchiveError) -> Self {
465        VerifyError::ParseBlob(e)
466    }
467}
468
469/// A successfully verified leaf capability.
470///
471/// Returned by [`verify_chain`] on a successful walk back to the
472/// configured `team_root`. Carries the leaf cap's full `TribleSet` so
473/// callers can ask:
474///
475/// - [`permissions`](Self::permissions) — which `PERM_*` tags are
476///   hung on the scope root
477/// - [`granted_branches`](Self::granted_branches) — `Some(set)` if the
478///   cap restricts itself to specific branches, or `None` if it's
479///   unrestricted within its permission set
480/// - [`grants_read`](Self::grants_read) — convenience for "any read-
481///   equivalent permission" (write/admin imply read)
482/// - [`grants_read_on`](Self::grants_read_on) — combines the two:
483///   read-permission AND (unrestricted OR branch-in-scope)
484///
485/// # Example
486///
487/// Build a `VerifiedCapability` directly (skipping `verify_chain` —
488/// the helpers operate on `cap_set` shape, not on the chain proof,
489/// so a hand-crafted instance suffices for testing scope predicates):
490///
491/// ```rust
492/// use std::collections::HashSet;
493/// use triblespace_core::id::{ufoid, ExclusiveId, Id};
494/// use triblespace_core::macros::entity;
495/// use triblespace_core::trible::TribleSet;
496/// use triblespace_core::repo::capability::{
497///     scope_branch, VerifiedCapability, PERM_READ,
498/// };
499/// use ed25519_dalek::SigningKey;
500/// use rand::rngs::OsRng;
501///
502/// let scope_root = ufoid();
503/// let allowed_branch = ufoid();
504/// // PERM_READ scope, restricted to one branch.
505/// let mut cap_set = TribleSet::new();
506/// cap_set += TribleSet::from(entity! {
507///     ExclusiveId::force_ref(&scope_root) @
508///     triblespace_core::metadata::tag: PERM_READ,
509/// });
510/// cap_set += TribleSet::from(entity! {
511///     ExclusiveId::force_ref(&scope_root) @
512///     scope_branch: *allowed_branch,
513/// });
514///
515/// let verified = VerifiedCapability {
516///     subject: SigningKey::generate(&mut OsRng).verifying_key(),
517///     scope_root: *scope_root,
518///     cap_set,
519/// };
520///
521/// // permissions() exposes the raw tag set.
522/// let perms = verified.permissions();
523/// assert_eq!(perms.len(), 1);
524/// assert!(perms.contains(&PERM_READ));
525///
526/// // granted_branches() returns Some(set) for restricted caps.
527/// let branches = verified.granted_branches().expect("restricted");
528/// assert!(branches.contains(&*allowed_branch));
529///
530/// // grants_read() short-circuits to "any read-equivalent perm".
531/// assert!(verified.grants_read());
532///
533/// // grants_read_on() composes both checks.
534/// assert!(verified.grants_read_on(&*allowed_branch));
535/// let other_branch: Id = *ufoid();
536/// assert!(!verified.grants_read_on(&other_branch));
537/// ```
538#[derive(Debug, Clone)]
539pub struct VerifiedCapability {
540    /// The subject pubkey the leaf cap authorizes.
541    pub subject: VerifyingKey,
542    /// The scope root entity id within the leaf cap blob.
543    pub scope_root: crate::id::Id,
544    /// The leaf cap's full TribleSet (caller can extract its scope by
545    /// querying tribles anchored at `scope_root`).
546    pub cap_set: TribleSet,
547}
548
549impl VerifiedCapability {
550    /// Returns the set of permissions tagged on this cap's scope root
551    /// (a subset of `{`[`PERM_READ`]`,`[`PERM_WRITE`]`,`[`PERM_ADMIN`]`}`).
552    pub fn permissions(&self) -> HashSet<crate::id::Id> {
553        let (perms, _) = collect_scope_facts(&self.cap_set, self.scope_root);
554        perms
555    }
556
557    /// Returns `Some(set)` if the cap restricts itself to a specific
558    /// non-empty set of branches, or `None` if the cap is unrestricted
559    /// (i.e. applies to every branch within the granted permission set).
560    pub fn granted_branches(&self) -> Option<HashSet<crate::id::Id>> {
561        let (_, branches) = collect_scope_facts(&self.cap_set, self.scope_root);
562        if branches.is_empty() { None } else { Some(branches) }
563    }
564
565    /// Returns `true` if the cap grants any read-equivalent permission
566    /// (read, write, or admin — write/admin imply read, matching the
567    /// subsumption rules in [`scope_subsumes`]).
568    pub fn grants_read(&self) -> bool {
569        let perms = self.permissions();
570        perms.contains(&PERM_READ)
571            || perms.contains(&PERM_WRITE)
572            || perms.contains(&PERM_ADMIN)
573    }
574
575    /// Returns `true` if the cap grants read-equivalent permission on
576    /// the given branch — i.e. the cap [`grants_read`](Self::grants_read)
577    /// AND either is unrestricted or its restriction set contains
578    /// `branch`.
579    pub fn grants_read_on(&self, branch: &crate::id::Id) -> bool {
580        if !self.grants_read() {
581            return false;
582        }
583        match self.granted_branches() {
584            None => true,
585            Some(set) => set.contains(branch),
586        }
587    }
588}
589
590/// Maximum chain depth the verifier will walk before giving up. Real
591/// chains are 1-3 deep typically; this is a sanity bound to refuse
592/// adversarial deep chains.
593pub const MAX_CHAIN_DEPTH: usize = 32;
594
595/// Verify a single signature blob's claim against a cap blob's bytes.
596///
597// The old `verify_sig_blob` helper was replaced by the
598// `extract_and_verify_sig_at` helper used by `verify_chain` — that one
599// works against an arbitrary entity inside a sig blob (outer leaf or
600// embedded sub-entity), which is what the new chain walk needs.
601
602/// Extract a cap blob's declared attributes: subject, issuer, scope
603/// root, expiry. Cap blobs are pure declarations now — chain
604/// references live in the sig blob, so this is just a four-field
605/// projection.
606fn extract_cap_fields(
607    cap_set: &TribleSet,
608) -> Result<CapFields, VerifyError> {
609    let mut iter = find!(
610        (cap: crate::id::Id,
611         subject: VerifyingKey,
612         issuer: VerifyingKey,
613         scope_root: crate::id::Id,
614         expiry: Inline<NsTAIInterval>),
615        pattern!(cap_set, [{
616            ?cap @
617            cap_subject: ?subject,
618            cap_issuer: ?issuer,
619            cap_scope_root: ?scope_root,
620            crate::metadata::expires_at: ?expiry,
621        }])
622    );
623    let (cap_id, subject, issuer, scope_root, expiry) = match (iter.next(), iter.next()) {
624        (Some(row), None) => row,
625        _ => return Err(VerifyError::MalformedCap),
626    };
627
628    Ok(CapFields {
629        cap_id,
630        subject,
631        issuer,
632        scope_root,
633        expiry,
634    })
635}
636
637#[derive(Debug, Clone)]
638struct CapFields {
639    #[allow(dead_code)]
640    cap_id: crate::id::Id,
641    subject: VerifyingKey,
642    issuer: VerifyingKey,
643    scope_root: crate::id::Id,
644    expiry: Inline<NsTAIInterval>,
645}
646
647/// Verify that a leaf signature blob plus its referenced cap blob form
648/// a valid capability chain rooted at `team_root`, authorising the
649/// `expected_subject` to act with the leaf cap's scope.
650///
651/// `fetch_blob` is called to retrieve any cap blob referenced by a
652/// `cap_parent` handle during chain walk. The leaf sig and leaf cap
653/// blobs are also looked up via `fetch_blob`, given the
654/// `leaf_sig_handle`.
655///
656/// Eviction in the descriptive-caps model is per-issuer non-renewal
657/// (the issuer's local retraction-policy pin), not a broadcast
658/// revocation blob. Verification therefore checks signatures and
659/// expiry only; a "revoked" peer's chain dies at its next natural
660/// expiry once the issuer stops renewing.
661///
662/// Returns the verified leaf capability on success.
663///
664/// # Example
665///
666/// End-to-end auth flow: team root mints a length-1 cap for a
667/// member, then verifies it.
668///
669/// ```rust
670/// use ed25519_dalek::SigningKey;
671/// use std::collections::HashMap;
672/// use triblespace_core::blob::Blob;
673/// use triblespace_core::blob::encodings::simplearchive::SimpleArchive;
674/// use triblespace_core::id::{ufoid, ExclusiveId};
675/// use triblespace_core::macros::entity;
676/// use triblespace_core::trible::TribleSet;
677/// use triblespace_core::inline::TryToInline;
678/// use triblespace_core::inline::Inline;
679/// use triblespace_core::inline::encodings::hash::Handle;
680/// use triblespace_core::repo::capability::{
681///     build_capability, verify_chain, PERM_READ,
682/// };
683/// use rand::rngs::OsRng;
684///
685/// // Team root mints itself; in a real deployment this happens
686/// // once at team creation and the secret is archived offline.
687/// let team_root = SigningKey::generate(&mut OsRng);
688/// let member = SigningKey::generate(&mut OsRng);
689///
690/// // Scope: a single anchor entity tagged with PERM_READ.
691/// let scope_root = ufoid();
692/// let scope_facts: TribleSet = entity! {
693///     ExclusiveId::force_ref(&scope_root) @
694///     triblespace_core::metadata::tag: PERM_READ,
695/// }
696/// .into();
697///
698/// // 24-hour expiry interval, anchored at "now".
699/// let now = hifitime::Epoch::now().unwrap();
700/// let expiry = (now, now + hifitime::Duration::from_seconds(24.0 * 3600.0))
701///     .try_to_inline()
702///     .unwrap();
703///
704/// // Length-1 chain: team root signs the member's cap directly.
705/// let (cap_blob, sig_blob) = build_capability(
706///     &team_root,
707///     member.verifying_key(),
708///     None, // No parent — directly off the root.
709///     *scope_root,
710///     scope_facts,
711///     expiry,
712/// )
713/// .unwrap();
714///
715/// // The peer presents the *sig* blob's handle on connection.
716/// let leaf_sig_handle: Inline<Handle<SimpleArchive>> =
717///     (&sig_blob).get_handle();
718///
719/// // The verifier needs both blobs available via the fetch closure.
720/// let cap_handle: Inline<Handle<SimpleArchive>> =
721///     (&cap_blob).get_handle();
722/// let mut blobs: HashMap<[u8; 32], Blob<SimpleArchive>> = HashMap::new();
723/// blobs.insert(cap_handle.raw, cap_blob);
724/// blobs.insert(leaf_sig_handle.raw, sig_blob);
725///
726/// let verified = verify_chain(
727///     team_root.verifying_key(),
728///     leaf_sig_handle,
729///     member.verifying_key(),
730///     |h| blobs.get(&h.raw).cloned(),
731/// )
732/// .expect("chain valid");
733///
734/// assert_eq!(verified.subject, member.verifying_key());
735/// assert!(verified.grants_read());
736/// ```
737pub fn verify_chain<F>(
738    team_root: VerifyingKey,
739    leaf_sig_handle: Inline<Handle<SimpleArchive>>,
740    expected_subject: VerifyingKey,
741    mut fetch_blob: F,
742) -> Result<VerifiedCapability, VerifyError>
743where
744    F: FnMut(Inline<Handle<SimpleArchive>>) -> Option<Blob<SimpleArchive>>,
745{
746    let now: Epoch = hifitime::Epoch::now().expect("system time");
747
748    // Helper: a cap is valid until the *upper bound* of its expiry
749    // interval. We compare that upper bound against `now`.
750    let is_expired = |expiry: &Inline<NsTAIInterval>| -> bool {
751        match <(Epoch, Epoch)>::try_from_inline(expiry) {
752            Ok((_lower, upper)) => upper < now,
753            // A malformed/inverted interval is treated as expired so
754            // adversarial caps can't fall through.
755            Err(_) => true,
756        }
757    };
758
759    // ── Leaf step ────────────────────────────────────────────────────
760    //
761    // The leaf sig blob carries: the leaf signature (over the leaf
762    // cap), the leaf cap handle (via sig_signs), and — if the chain
763    // extends beyond a single hop — the recursive chain proof
764    // (sig_parent_cap + sig_embedded_parent_proof, each linking to the
765    // next level's signer/signature/parent).
766    let leaf_sig_blob = fetch_blob(leaf_sig_handle).ok_or(VerifyError::Fetch)?;
767    let sig_set: TribleSet = TryFromBlob::try_from_blob(leaf_sig_blob)?;
768
769    // Find the leaf outer entity — the one carrying sig_signs.
770    let mut leaf_outer_iter = find!(
771        (sig: crate::id::Id, h: Inline<Handle<SimpleArchive>>),
772        pattern!(&sig_set, [{ ?sig @ sig_signs: ?h }])
773    );
774    let (mut current_outer_id, leaf_cap_handle) = match (
775        leaf_outer_iter.next(),
776        leaf_outer_iter.next(),
777    ) {
778        (Some(row), None) => row,
779        _ => return Err(VerifyError::MalformedSig),
780    };
781
782    // Fetch + decode the leaf cap.
783    let leaf_cap_blob = fetch_blob(leaf_cap_handle).ok_or(VerifyError::LeafCapMissing)?;
784    let leaf_cap_set: TribleSet = TryFromBlob::try_from_blob(leaf_cap_blob.clone())?;
785    let leaf_fields = extract_cap_fields(&leaf_cap_set)?;
786
787    // Subject must match the connecting peer.
788    if leaf_fields.subject != expected_subject {
789        return Err(VerifyError::SubjectMismatch);
790    }
791    if is_expired(&leaf_fields.expiry) {
792        return Err(VerifyError::Expired);
793    }
794
795    // Verify the outer signature attests to the leaf cap's bytes,
796    // signed by the leaf's claimed issuer.
797    let outer_signer = extract_and_verify_sig_at(
798        &sig_set,
799        current_outer_id,
800        &leaf_cap_blob,
801    )?;
802    if outer_signer != leaf_fields.issuer {
803        return Err(VerifyError::IssuerMismatch);
804    }
805
806    // ── Walk back to root ────────────────────────────────────────────
807    //
808    // Loop invariant:
809    //   - `current_outer_id`: the entity in `sig_set` whose signature
810    //     we have just verified (over `current_cap_set`'s blob bytes).
811    //   - `current_signer`: the pubkey that signed `current_cap_set`'s
812    //     blob (== current cap's issuer).
813    //   - `current_cap_set`: the decoded cap whose signature we've
814    //     verified.
815    let mut current_signer = outer_signer;
816    let mut current_cap_set = leaf_cap_set.clone();
817    let mut current_fields = leaf_fields.clone();
818    let mut depth = 0usize;
819
820    loop {
821        // Termination: the issuer of the current cap is the team root.
822        if current_signer == team_root {
823            return Ok(VerifiedCapability {
824                subject: leaf_fields.subject,
825                scope_root: leaf_fields.scope_root,
826                cap_set: leaf_cap_set,
827            });
828        }
829
830        depth += 1;
831        if depth > MAX_CHAIN_DEPTH {
832            return Err(VerifyError::ChainTooDeep);
833        }
834
835        // Non-root: the current outer entity must carry sig_parent_cap
836        // + sig_embedded_parent_proof pointing at the next sub-entity.
837        let mut parent_iter = find!(
838            (ph: Inline<Handle<SimpleArchive>>, pid: crate::id::Id),
839            pattern!(&sig_set, [{
840                current_outer_id @
841                sig_parent_cap: ?ph,
842                sig_embedded_parent_proof: ?pid,
843            }])
844        );
845        let (parent_cap_handle, parent_proof_id) = match (
846            parent_iter.next(),
847            parent_iter.next(),
848        ) {
849            (Some(row), None) => row,
850            _ => return Err(VerifyError::NonRootMissingParent),
851        };
852
853        // Fetch + decode the parent cap.
854        let parent_cap_blob = fetch_blob(parent_cap_handle).ok_or(VerifyError::Fetch)?;
855        let parent_cap_set: TribleSet =
856            TryFromBlob::try_from_blob(parent_cap_blob.clone())?;
857        let parent_fields = extract_cap_fields(&parent_cap_set)?;
858
859        // Verify the parent proof's sig attests to the parent cap's
860        // bytes, signed by some authority.
861        let parent_signer = extract_and_verify_sig_at(
862            &sig_set,
863            parent_proof_id,
864            &parent_cap_blob,
865        )?;
866        if parent_signer != parent_fields.issuer {
867            return Err(VerifyError::IssuerMismatch);
868        }
869        if is_expired(&parent_fields.expiry) {
870            return Err(VerifyError::Expired);
871        }
872        // Each child link's scope must be a subset of its parent's.
873        if !scope_subsumes(
874            &parent_cap_set,
875            parent_fields.scope_root,
876            &current_cap_set,
877            current_fields.scope_root,
878        ) {
879            return Err(VerifyError::ScopeNotSubset);
880        }
881
882        // Step.
883        current_outer_id = parent_proof_id;
884        current_signer = parent_signer;
885        current_cap_set = parent_cap_set;
886        current_fields = parent_fields;
887    }
888}
889
890/// Extract a `(signed_by, signature_r, signature_s)` from a specific
891/// entity inside a sig blob's TribleSet, verify it's a valid signature
892/// over `signed_blob.bytes`, and return the signer.
893fn extract_and_verify_sig_at(
894    sig_set: &TribleSet,
895    entity: crate::id::Id,
896    signed_blob: &Blob<SimpleArchive>,
897) -> Result<VerifyingKey, VerifyError> {
898    let mut iter = find!(
899        (signer: VerifyingKey, r, s),
900        pattern!(sig_set, [{
901            entity @
902            crate::repo::signed_by: ?signer,
903            crate::repo::signature_r: ?r,
904            crate::repo::signature_s: ?s,
905        }])
906    );
907    let (signer, r, s) = match (iter.next(), iter.next()) {
908        (Some(row), None) => row,
909        _ => return Err(VerifyError::MalformedSig),
910    };
911    let signature = Signature::from_components(r, s);
912    signer
913        .verify(&signed_blob.bytes, &signature)
914        .map_err(|_| VerifyError::BadSignature)?;
915    Ok(signer)
916}
917
918#[cfg(test)]
919mod tests {
920    //! Tests for the descriptive-caps shape: cap blobs are pure
921    //! declarations; sig blobs carry the chain proof as recursive
922    //! embedded sub-entities. See decide#5ed64e57.
923    use super::*;
924    use crate::inline::TryToInline;
925    use ed25519_dalek::SigningKey;
926    use hifitime::Epoch;
927    use rand::rngs::OsRng;
928    use std::collections::HashMap;
929
930    fn key() -> SigningKey {
931        SigningKey::generate(&mut OsRng)
932    }
933
934    fn interval(seconds_from_now: f64) -> Inline<NsTAIInterval> {
935        let now = Epoch::now().expect("system time");
936        let later = now + hifitime::Duration::from_seconds(seconds_from_now);
937        (now, later).try_to_inline().expect("valid interval")
938    }
939
940    fn expired_interval() -> Inline<NsTAIInterval> {
941        let now = Epoch::now().expect("system time");
942        let past_start = now - hifitime::Duration::from_seconds(7200.0);
943        let past_end = now - hifitime::Duration::from_seconds(3600.0);
944        (past_start, past_end).try_to_inline().expect("valid interval")
945    }
946
947    fn empty_scope() -> (Id, TribleSet) {
948        let scope_root = crate::id::ufoid();
949        let facts = TribleSet::from(entity! { ExclusiveId::force_ref(&scope_root) @
950            crate::metadata::tag: PERM_READ,
951        });
952        (*scope_root, facts)
953    }
954
955    /// Build a fetch_blob closure backed by an in-memory map.
956    fn fetch_from(
957        blobs: &[Blob<SimpleArchive>],
958    ) -> impl FnMut(Inline<Handle<SimpleArchive>>) -> Option<Blob<SimpleArchive>> + '_ {
959        let map: HashMap<_, _> = blobs
960            .iter()
961            .map(|b| {
962                let h: Inline<Handle<SimpleArchive>> = b.get_handle();
963                (h.raw, b.clone())
964            })
965            .collect();
966        move |h| map.get(&h.raw).cloned()
967    }
968
969    // ── Length-1 chain ────────────────────────────────────────────────
970
971    #[test]
972    fn length_one_chain_round_trips() {
973        let team_root = key();
974        let (scope_root, scope_facts) = empty_scope();
975
976        let (cap_blob, sig_blob) = build_capability(
977            &team_root,
978            team_root.verifying_key(),
979            None,
980            scope_root,
981            scope_facts,
982            interval(3600.0),
983        )
984        .expect("build");
985
986        let sig_handle: Inline<Handle<SimpleArchive>> = (&sig_blob).get_handle();
987        let blobs = [cap_blob.clone(), sig_blob.clone()];
988
989        let verified = verify_chain(
990            team_root.verifying_key(),
991            sig_handle,
992            team_root.verifying_key(),
993            fetch_from(&blobs),
994        )
995        .expect("verify");
996
997        assert_eq!(verified.subject, team_root.verifying_key());
998        assert_eq!(verified.scope_root, scope_root);
999    }
1000
1001    // ── Length-N chain ────────────────────────────────────────────────
1002
1003    fn three_level_chain()
1004    -> (SigningKey, SigningKey, SigningKey, Vec<Blob<SimpleArchive>>, Inline<Handle<SimpleArchive>>) {
1005        let team_root = key();
1006        let a = key();
1007        let b = key();
1008
1009        // Level 1: team_root → A (subject = A)
1010        let (scope1_root, scope1_facts) = empty_scope();
1011        let (cap_a, sig_a) = build_capability(
1012            &team_root,
1013            a.verifying_key(),
1014            None,
1015            scope1_root,
1016            scope1_facts,
1017            interval(3600.0),
1018        )
1019        .expect("build level-1");
1020
1021        // Level 2: A → B (subject = B)
1022        let (scope2_root, scope2_facts) = empty_scope();
1023        let (cap_b, sig_b) = build_capability(
1024            &a,
1025            b.verifying_key(),
1026            Some((cap_a.clone(), sig_a.clone())),
1027            scope2_root,
1028            scope2_facts,
1029            interval(3600.0),
1030        )
1031        .expect("build level-2");
1032
1033        let leaf_sig_handle: Inline<Handle<SimpleArchive>> = (&sig_b).get_handle();
1034        let blobs = vec![cap_a, sig_a, cap_b, sig_b];
1035        (team_root, a, b, blobs, leaf_sig_handle)
1036    }
1037
1038    #[test]
1039    fn length_three_chain_round_trips() {
1040        let (team_root, _a, b, blobs, leaf_sig_handle) = three_level_chain();
1041
1042        let verified = verify_chain(
1043            team_root.verifying_key(),
1044            leaf_sig_handle,
1045            b.verifying_key(),
1046            fetch_from(&blobs),
1047        )
1048        .expect("verify");
1049
1050        assert_eq!(verified.subject, b.verifying_key());
1051    }
1052
1053    #[test]
1054    fn rejects_subject_mismatch() {
1055        let (team_root, _a, _b, blobs, leaf_sig_handle) = three_level_chain();
1056        let imposter = key();
1057
1058        let err = verify_chain(
1059            team_root.verifying_key(),
1060            leaf_sig_handle,
1061            imposter.verifying_key(),
1062            fetch_from(&blobs),
1063        )
1064        .expect_err("must reject subject mismatch");
1065
1066        assert!(matches!(err, VerifyError::SubjectMismatch));
1067    }
1068
1069    #[test]
1070    fn rejects_wrong_team_root() {
1071        let (_real_team_root, _a, b, blobs, leaf_sig_handle) = three_level_chain();
1072        let wrong_root = key();
1073
1074        // With a wrong team root, the chain walk never finds a sig
1075        // signed by it — climbs to the actual root, finds no
1076        // sig_parent_cap there, errors with NonRootMissingParent
1077        // (current_signer != wrong_team_root && no parent linkage).
1078        let err = verify_chain(
1079            wrong_root.verifying_key(),
1080            leaf_sig_handle,
1081            b.verifying_key(),
1082            fetch_from(&blobs),
1083        )
1084        .expect_err("must reject wrong team root");
1085
1086        assert!(matches!(err, VerifyError::NonRootMissingParent));
1087    }
1088
1089    #[test]
1090    fn rejects_expired_leaf() {
1091        let team_root = key();
1092        let (scope_root, scope_facts) = empty_scope();
1093
1094        let (cap_blob, sig_blob) = build_capability(
1095            &team_root,
1096            team_root.verifying_key(),
1097            None,
1098            scope_root,
1099            scope_facts,
1100            expired_interval(),
1101        )
1102        .expect("build");
1103
1104        let sig_handle: Inline<Handle<SimpleArchive>> = (&sig_blob).get_handle();
1105        let blobs = [cap_blob, sig_blob];
1106
1107        let err = verify_chain(
1108            team_root.verifying_key(),
1109            sig_handle,
1110            team_root.verifying_key(),
1111            fetch_from(&blobs),
1112        )
1113        .expect_err("must reject expired");
1114
1115        assert!(matches!(err, VerifyError::Expired));
1116    }
1117
1118    #[test]
1119    fn rejects_expired_intermediate() {
1120        // Length-2 chain where team_root's cap to A has expired,
1121        // but A's cap to B has not. verify must reject.
1122        let team_root = key();
1123        let a = key();
1124        let b = key();
1125
1126        let (scope1_root, scope1_facts) = empty_scope();
1127        let (cap_a, sig_a) = build_capability(
1128            &team_root,
1129            a.verifying_key(),
1130            None,
1131            scope1_root,
1132            scope1_facts,
1133            expired_interval(),
1134        )
1135        .expect("build level-1");
1136
1137        let (scope2_root, scope2_facts) = empty_scope();
1138        let (cap_b, sig_b) = build_capability(
1139            &a,
1140            b.verifying_key(),
1141            Some((cap_a.clone(), sig_a.clone())),
1142            scope2_root,
1143            scope2_facts,
1144            interval(3600.0),
1145        )
1146        .expect("build level-2");
1147
1148        let leaf_sig_handle: Inline<Handle<SimpleArchive>> = (&sig_b).get_handle();
1149        let blobs = [cap_a, sig_a, cap_b, sig_b];
1150
1151        let err = verify_chain(
1152            team_root.verifying_key(),
1153            leaf_sig_handle,
1154            b.verifying_key(),
1155            fetch_from(&blobs),
1156        )
1157        .expect_err("must reject expired intermediate");
1158
1159        assert!(matches!(err, VerifyError::Expired));
1160    }
1161
1162    // ── Structural checks ─────────────────────────────────────────────
1163
1164    #[test]
1165    fn cap_blob_carries_no_chain_attributes() {
1166        // The whole point of the refactor: cap blobs are pure
1167        // declarations. Verify that even at depth > 1, the inner cap
1168        // blobs don't contain sig_parent_cap / sig_embedded_parent_proof
1169        // or any other chain reference.
1170        let (_team_root, _a, _b, blobs, _leaf_sig_handle) = three_level_chain();
1171
1172        for blob in &blobs {
1173            let set: TribleSet = match TryFromBlob::try_from_blob(blob.clone()) {
1174                Ok(s) => s,
1175                Err(_) => continue, // not a SimpleArchive blob; skip
1176            };
1177            // If this set contains cap_subject, it's a cap blob —
1178            // those must NOT carry sig-blob-only attributes.
1179            let is_cap = find!(
1180                (e: Id, s: VerifyingKey),
1181                pattern!(&set, [{ ?e @ cap_subject: ?s }])
1182            )
1183            .next()
1184            .is_some();
1185            if !is_cap {
1186                continue;
1187            }
1188
1189            let has_parent_link = find!(
1190                (e: Id, h: Inline<Handle<SimpleArchive>>),
1191                pattern!(&set, [{ ?e @ sig_parent_cap: ?h }])
1192            )
1193            .next()
1194            .is_some();
1195            assert!(
1196                !has_parent_link,
1197                "cap blob unexpectedly carries sig_parent_cap"
1198            );
1199        }
1200    }
1201
1202    #[test]
1203    fn leaf_sig_blob_carries_full_chain() {
1204        // The leaf sig blob should carry every cap's handle in its
1205        // recursive embedded proof structure. Walk the structure and
1206        // confirm we see N entries for an N-deep chain.
1207        let (_team_root, _a, _b, blobs, leaf_sig_handle) = three_level_chain();
1208
1209        let leaf_sig_blob = fetch_from(&blobs)(leaf_sig_handle).expect("fetch leaf sig");
1210        let sig_set: TribleSet = TryFromBlob::try_from_blob(leaf_sig_blob).expect("parse sig");
1211
1212        // Count entities with signed_by — should be 2 (A signed cap_b,
1213        // team_root signed cap_a). Each level of the chain contributes
1214        // exactly one signed_by trible.
1215        let signed_by_entities: HashSet<Id> = find!(
1216            (e: Id, s: VerifyingKey),
1217            pattern!(&sig_set, [{ ?e @ crate::repo::signed_by: ?s }])
1218        )
1219        .map(|(e, _)| e)
1220        .collect();
1221        assert_eq!(
1222            signed_by_entities.len(),
1223            2,
1224            "expected 2 signed_by entities (one per chain level); got {}",
1225            signed_by_entities.len()
1226        );
1227
1228        // Count entities with sig_parent_cap — should be 1 (the leaf's
1229        // outer entity points at A's cap; the embedded proof for A's
1230        // signature is itself the root level and has no further
1231        // sig_parent_cap).
1232        let parent_links: HashSet<Id> = find!(
1233            (e: Id, h: Inline<Handle<SimpleArchive>>),
1234            pattern!(&sig_set, [{ ?e @ sig_parent_cap: ?h }])
1235        )
1236        .map(|(e, _)| e)
1237        .collect();
1238        assert_eq!(
1239            parent_links.len(),
1240            1,
1241            "expected 1 sig_parent_cap entry for length-2 chain"
1242        );
1243    }
1244}