Skip to main content

runx_core/policy/
authority_mint.rs

1// rust-style-allow: large-file because the mint primitive keeps its core fn, the
2// family-comparator trait, the generic scope-bounds comparator, and their unit tests
3// as one cohesive unit; splitting them would fracture the fail-closed minting path.
4//! Authority minting: derive a narrowed child authority from a parent charter
5//! and compute its subset proof, fail-closed.
6//!
7//! This is the one place that narrows and the one place that proves. The proof
8//! is computed here, never trusted from a model or skill input. Mint, prove,
9//! and the lifted [`ensure_subset_proof`] validator share this home and one
10//! comparison vocabulary, so the minted term, the proof it carries, and the
11//! validator that re-checks it cannot drift apart.
12//!
13//! The core holds zero family knowledge. The only family seam is the
14//! [`FamilySubsetComparator`]: the generic verb/capability/condition/approval/
15//! expiry checks live in [`authority_algebra`](super::authority_algebra), and a
16//! family supplies only its bounds comparison. The default
17//! [`ScopeBoundsComparator`] bridges string scopes to [`AuthorityBounds`] in one
18//! place; a domain path registers its own bounds comparator in a
19//! later phase without changing this trait.
20
21use runx_contracts::schema::{IsoDateTime, NonEmptyString};
22use runx_contracts::{
23    AuthorityBounds, AuthorityCapability, AuthorityResourceFamily, AuthoritySubsetComparison,
24    AuthoritySubsetProof, AuthoritySubsetRelation, AuthoritySubsetResult, AuthorityTerm,
25    AuthorityVerb, Reference, sha256_hex,
26};
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30use super::authority_algebra::{
31    items_subset, optional_ref_bound_subset, parent_items_preserved, same_reference_address,
32};
33
34/// The family seam for [`mint_attenuated`]. A comparator owns two
35/// responsibilities for one authority family, and nothing else:
36///
37/// - [`narrow_bounds`](FamilySubsetComparator::narrow_bounds): derive the child
38///   term's bounds from the parent and the requested bounds. The default is to
39///   take the requested bounds verbatim; a family clamps or normalizes here so
40///   the minted ceiling derives from a single source.
41/// - [`bounds_subset`](FamilySubsetComparator::bounds_subset): decide whether
42///   the child term's family-specific bounds are a subset of the parent's.
43///
44/// The generic dimensions (verbs, capabilities, conditions, approvals, expiry,
45/// resource address) are checked by [`mint_attenuated`] via the shared algebra
46/// and are deliberately NOT part of this trait, so a family cannot weaken them.
47/// Both methods receive the whole [`AuthorityTerm`] because a family's bounds
48/// rule may depend on the term's verbs (for example, a cost-class authority
49/// requiring an aggregate cap), exactly as a domain comparator does.
50pub trait FamilySubsetComparator {
51    /// The `comparison_algorithm` recorded in the emitted proof. A stable,
52    /// versioned identifier for the family's comparison vocabulary.
53    fn comparison_algorithm(&self) -> &str;
54
55    /// Derive the child term's bounds from the requested bounds, clamped to the
56    /// parent where the family demands it. The default takes the request
57    /// verbatim; the subsequent [`bounds_subset`](Self::bounds_subset) check is
58    /// the fail-closed guarantee, so an over-wide request is rejected, not
59    /// silently widened.
60    fn narrow_bounds(
61        &self,
62        parent: &AuthorityTerm,
63        requested: &AuthorityBounds,
64    ) -> AuthorityBounds {
65        let _ = parent;
66        requested.clone()
67    }
68
69    /// Decide whether the child term's family-specific bounds are a subset of
70    /// the parent's. Must be fail-closed: incomparable terms are not subsets.
71    fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool;
72}
73
74/// The default comparator any skill gets: verbs plus generic string-scope and
75/// numeric bounds subset over [`AuthorityBounds`], composed from the shared
76/// algebra. This is the single bridge from declared string scopes (repo globs,
77/// filesystem roots, network destinations, deployment environments, token
78/// audiences) to [`AuthorityBounds`]; the mint works on terms and bounds, never
79/// on a second string-scope vocabulary.
80///
81/// It does not inspect `effect_limits` or `effects`; those are family bounds and
82/// belong to a family comparator (for example a domain bounds comparator).
83#[derive(Clone, Debug, Default)]
84pub struct ScopeBoundsComparator;
85
86impl ScopeBoundsComparator {
87    /// The comparison algorithm identifier this comparator records in proofs.
88    pub const ALGORITHM: &'static str = "runx.scope-bounds-subset.v1";
89}
90
91impl FamilySubsetComparator for ScopeBoundsComparator {
92    fn comparison_algorithm(&self) -> &str {
93        Self::ALGORITHM
94    }
95
96    // rust-style-allow: long-function - the exhaustive destructure of both bounds
97    // sides plus the single subset conjunction is one trust-boundary comparison;
98    // splitting it would let a forgotten field fail open.
99    fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool {
100        // Exhaustively destructure both sides so growing `AuthorityBounds` breaks
101        // this function until the new field is classified as generic-narrowable
102        // (compared below) or family-owned (bound to `_` with a reason). Silent
103        // fail-open on a forgotten field is what the no-brittleness rule forbids,
104        // and this sits on the trust boundary.
105        let AuthorityBounds {
106            repo_path_globs: child_repo_path_globs,
107            branch_patterns: child_branch_patterns,
108            filesystem_roots: child_filesystem_roots,
109            network_destinations: child_network_destinations,
110            deployment_environments: child_deployment_environments,
111            token_audiences: child_token_audiences,
112            max_cost_units: _, // compared via max_cost_units_subset (non-Ord projection)
113            // Family bounds: the scope family does not govern effect ceilings or
114            // guards, so a term carrying them is incomparable here and denied
115            // below. Bound (not `_`-ignored) so they cannot be silently dropped.
116            effect_limits: child_effect_limits,
117            effects: child_effects,
118            max_runtime_ms: child_max_runtime_ms,
119            max_fanout: child_max_fanout,
120            max_child_depth: child_max_child_depth,
121        } = &child.bounds;
122        let AuthorityBounds {
123            repo_path_globs: parent_repo_path_globs,
124            branch_patterns: parent_branch_patterns,
125            filesystem_roots: parent_filesystem_roots,
126            network_destinations: parent_network_destinations,
127            deployment_environments: parent_deployment_environments,
128            token_audiences: parent_token_audiences,
129            max_cost_units: _, // compared via max_cost_units_subset
130            effect_limits: _,  // family bounds, see child denial above
131            effects: _,        // family bounds, see child denial above
132            max_runtime_ms: parent_max_runtime_ms,
133            max_fanout: parent_max_fanout,
134            max_child_depth: parent_max_child_depth,
135        } = &parent.bounds;
136
137        // Fail closed on bounds this comparator does not model: an effect-bearing
138        // term must be minted under the family comparator that governs it (for
139        // example a domain bounds comparator), never silently waved through here.
140        child_effect_limits.is_empty()
141            && child_effects.is_empty()
142            && items_subset(child_repo_path_globs, parent_repo_path_globs)
143            && items_subset(child_branch_patterns, parent_branch_patterns)
144            && items_subset(child_filesystem_roots, parent_filesystem_roots)
145            && items_subset(child_network_destinations, parent_network_destinations)
146            && items_subset(
147                child_deployment_environments,
148                parent_deployment_environments,
149            )
150            && items_subset(child_token_audiences, parent_token_audiences)
151            && max_cost_units_subset(&child.bounds, &parent.bounds)
152            && optional_ref_bound_subset(
153                child_max_runtime_ms.as_ref(),
154                parent_max_runtime_ms.as_ref(),
155            )
156            && optional_ref_bound_subset(child_max_fanout.as_ref(), parent_max_fanout.as_ref())
157            && optional_ref_bound_subset(
158                child_max_child_depth.as_ref(),
159                parent_max_child_depth.as_ref(),
160            )
161    }
162}
163
164/// `max_cost_units` is an optional [`JsonNumber`](runx_contracts::JsonNumber),
165/// which is not totally ordered (it carries non-finite `f64`). Compare on the
166/// finite `f64` projection and fail closed when either side is absent or
167/// non-finite while the parent constrains it.
168fn max_cost_units_subset(child: &AuthorityBounds, parent: &AuthorityBounds) -> bool {
169    match (
170        child.max_cost_units.as_ref().and_then(|n| n.as_f64()),
171        parent.max_cost_units.as_ref().and_then(|n| n.as_f64()),
172    ) {
173        // Both finite: the child bound must be no larger.
174        (Some(child), Some(parent)) => child <= parent,
175        // Parent caps cost (finite projection) but the child declares no usable
176        // bound: deny. The child's projection is None because its cap is absent
177        // OR present-but-non-finite; either way it cannot be proven no larger.
178        (None, Some(_)) => false,
179        // Parent's projection is None. Allow only when the parent genuinely has
180        // no cap; a present-but-non-finite parent cap is uncomparable, so deny.
181        (_, None) => parent.max_cost_units.is_none(),
182    }
183}
184
185/// The requested narrowing handed to [`mint_attenuated`].
186///
187/// It carries the child's identity (`principal_ref`, `resource_ref`,
188/// `resource_family`) and the requested ceiling (`verbs`, `capabilities`,
189/// `bounds`, optional `expires_at`). `bounds` is the typed [`AuthorityBounds`],
190/// which already expresses BOTH an agency narrowing (the string-scope lists: a
191/// member's needed repos, roots, destinations) AND a cost narrowing (the
192/// `effect_limits` caps and rails). There is no stringly map and no
193/// hand-maintained parallel list: the request is exactly the contract's own
194/// narrowable surface.
195///
196/// Conditions and approvals are intentionally absent: they are PRESERVED from
197/// the parent by [`mint_attenuated`] and cannot be requested away.
198#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct AttenuationRequest {
201    /// The principal the child authority is minted for.
202    pub principal_ref: Reference,
203    /// The resource the child authority addresses. Must share its address with
204    /// the parent's resource (same type and URI); mint fails closed otherwise.
205    pub resource_ref: Reference,
206    /// The child's resource family. Must equal the parent's family.
207    pub resource_family: AuthorityResourceFamily,
208    /// The verbs to keep. Must be a subset of the parent's verbs.
209    pub verbs: Vec<AuthorityVerb>,
210    /// The capabilities to keep. Must be a subset of the parent's capabilities.
211    pub capabilities: Vec<AuthorityCapability>,
212    /// The requested bounds (string scopes and/or family limits). The family
213    /// comparator may clamp these; the result must be a subset of the parent's.
214    pub bounds: AuthorityBounds,
215    /// An optional expiry. Must be no later than the parent's expiry; a child
216    /// may not outlive its parent.
217    pub expires_at: Option<IsoDateTime>,
218}
219
220/// Why a mint failed. Fail-closed: any failure yields NO proof.
221#[derive(Clone, Debug, Error, PartialEq, Eq)]
222pub enum AttenuationError {
223    /// The requested resource family does not match the parent's family.
224    #[error("requested resource family does not match the parent authority")]
225    ResourceFamilyMismatch,
226    /// The requested resource address does not match the parent's resource.
227    #[error("requested resource address does not match the parent authority")]
228    ResourceAddressMismatch,
229    /// The requested verbs are not a subset of the parent's verbs.
230    #[error("requested verbs are not a subset of the parent authority")]
231    VerbsNotSubset,
232    /// The requested capabilities are not a subset of the parent's.
233    #[error("requested capabilities are not a subset of the parent authority")]
234    CapabilitiesNotSubset,
235    /// The requested expiry is later than the parent's, or absent while the
236    /// parent bounds it.
237    #[error("requested expiry outlives the parent authority")]
238    ExpiryNotSubset,
239    /// The derived child term is not a verified subset of the parent under the
240    /// family comparator. The catch-all: the minted term widened some dimension
241    /// (most commonly a family bound) the comparator denies.
242    #[error("derived child authority is not a subset of the parent authority")]
243    ChildNotSubset,
244}
245
246/// Derive a narrowed child [`AuthorityTerm`] from `parent` per `request`, then
247/// prove it is a subset and emit the [`AuthoritySubsetProof`]. Pure and
248/// fail-closed: on any non-subset request it returns an [`AttenuationError`] and
249/// emits no proof, so the primitive can never produce a false attestation.
250///
251/// The child preserves the parent's conditions and approvals (they cannot be
252/// dropped), narrows verbs/capabilities to the request, clamps expiry to no
253/// later than the parent, derives bounds through the family `comparator`, and
254/// carries a deterministic `term_id` over its own content. The proof records
255/// the parent resource ref, the comparator's algorithm, `result: Subset`, the
256/// child/parent term ids with their relation, and `checked_at`.
257///
258/// # Errors
259///
260/// Returns the matching [`AttenuationError`] when the requested family, resource
261/// address, verbs, capabilities, or expiry widen the parent, or when the derived
262/// child fails the family subset check.
263// rust-style-allow: long-function - minting is one linear derive/compare/prove
264// sequence; splitting it would scatter the fail-closed checks across helpers.
265pub fn mint_attenuated(
266    parent: &AuthorityTerm,
267    request: &AttenuationRequest,
268    comparator: &dyn FamilySubsetComparator,
269    checked_at: IsoDateTime,
270) -> Result<(AuthorityTerm, AuthoritySubsetProof), AttenuationError> {
271    // Cheap generic guards first, so the error is specific. The derived-child
272    // subset check below is the authoritative fail-closed gate; these only make
273    // the common widening mistakes legible.
274    if request.resource_family != parent.resource_family {
275        return Err(AttenuationError::ResourceFamilyMismatch);
276    }
277    if !same_reference_address(&request.resource_ref, &parent.resource_ref) {
278        return Err(AttenuationError::ResourceAddressMismatch);
279    }
280    if !items_subset(&request.verbs, &parent.verbs) {
281        return Err(AttenuationError::VerbsNotSubset);
282    }
283    if !items_subset(&request.capabilities, &parent.capabilities) {
284        return Err(AttenuationError::CapabilitiesNotSubset);
285    }
286    if !optional_ref_bound_subset(request.expires_at.as_ref(), parent.expires_at.as_ref()) {
287        return Err(AttenuationError::ExpiryNotSubset);
288    }
289
290    // Derive the child. Conditions and approvals are PRESERVED verbatim from the
291    // parent: a narrowing cannot drop a parent obligation. Bounds derive through
292    // the family comparator from the requested bounds, so the minted ceiling has
293    // one source and cannot drift from the request.
294    let bounds = comparator.narrow_bounds(parent, &request.bounds);
295    let term_id = derive_term_id(parent, request, &bounds);
296    let child = AuthorityTerm {
297        term_id,
298        principal_ref: request.principal_ref.clone(),
299        resource_ref: request.resource_ref.clone(),
300        resource_family: request.resource_family.clone(),
301        verbs: request.verbs.clone(),
302        bounds,
303        conditions: parent.conditions.clone(),
304        approvals: parent.approvals.clone(),
305        capabilities: request.capabilities.clone(),
306        expires_at: request.expires_at.clone(),
307        issued_by_ref: parent.issued_by_ref.clone(),
308        credential_ref: parent.credential_ref.clone(),
309    };
310
311    // Authoritative gate: VERIFY the derived child is a subset of the parent.
312    // Generic dimensions via the shared algebra; family bounds via the
313    // comparator. No proof is emitted unless this holds.
314    if !is_authority_subset(&child, parent, comparator) {
315        return Err(AttenuationError::ChildNotSubset);
316    }
317
318    let proof = AuthoritySubsetProof {
319        parent_authority_ref: parent.resource_ref.clone(),
320        comparison_algorithm: NonEmptyString::from(comparator.comparison_algorithm().to_owned()),
321        result: AuthoritySubsetResult::Subset,
322        compared_terms: vec![AuthoritySubsetComparison {
323            child_term_id: child.term_id.clone(),
324            parent_term_id: parent.term_id.clone(),
325            relation: AuthoritySubsetRelation::Subset,
326        }],
327        proof_ref: None,
328        checked_at,
329    };
330
331    Ok((child, proof))
332}
333
334/// The generic subset check: `child` is no broader than `parent` under the
335/// shared algebra (resource address, verbs, capabilities, preserved conditions
336/// and approvals, expiry) plus the family `comparator`'s bounds rule. This is
337/// the one composition of the algebra that new families dispatch through. Until
338/// a later domain refactor lands, the domain subset function
339/// still carries its own copy of this prefix; that copy will be deleted and
340/// reimplemented as `is_authority_subset(child, parent, &DomainBounds)`, so the
341/// generic prefix ends up here exactly once.
342#[must_use]
343pub fn is_authority_subset(
344    child: &AuthorityTerm,
345    parent: &AuthorityTerm,
346    comparator: &dyn FamilySubsetComparator,
347) -> bool {
348    child.resource_family == parent.resource_family
349        && same_reference_address(&child.resource_ref, &parent.resource_ref)
350        && items_subset(&child.verbs, &parent.verbs)
351        && items_subset(&child.capabilities, &parent.capabilities)
352        && parent_items_preserved(&child.conditions, &parent.conditions)
353        && parent_items_preserved(&child.approvals, &parent.approvals)
354        && optional_ref_bound_subset(child.expires_at.as_ref(), parent.expires_at.as_ref())
355        && comparator.bounds_subset(child, parent)
356}
357
358/// A deterministic child `term_id`: `mint-` plus the SHA-256 hex of the child's
359/// identifying content (parent term, principal, resource, family, verbs,
360/// capabilities, bounds, expiry). Self-syncing by construction; the same parent
361/// and request always mint the same id, and any change to the narrowed content
362/// changes the id, so there is no hand-maintained parallel identifier.
363fn derive_term_id(
364    parent: &AuthorityTerm,
365    request: &AttenuationRequest,
366    bounds: &AuthorityBounds,
367) -> NonEmptyString {
368    let fingerprint = serde_json::json!({
369        "parent_term_id": parent.term_id.as_str(),
370        "principal_ref": request.principal_ref,
371        "resource_ref": request.resource_ref,
372        "resource_family": request.resource_family,
373        "verbs": request.verbs,
374        "capabilities": request.capabilities,
375        "bounds": bounds,
376        "expires_at": request.expires_at.as_ref().map(IsoDateTime::as_str),
377    });
378    let digest = sha256_hex(fingerprint.to_string().as_bytes());
379    NonEmptyString::from(format!("mint-{digest}"))
380}
381
382/// Validate that a supplied [`AuthoritySubsetProof`] attests `child` is a subset
383/// of `parent`. Lifted verbatim from the original domain path: it has zero family
384/// knowledge, so it is the one validator for any minted or input proof.
385///
386/// Checks that the proof exists, names a non-empty algorithm and `checked_at`,
387/// points at the parent's resource ref, asserts a `Subset` result, and carries a
388/// compared-terms entry binding the child and parent term ids with a `Subset` or
389/// `Equal` relation.
390///
391/// # Errors
392///
393/// Returns the matching [`SubsetProofError`] when the proof is absent or fails
394/// any of those structural checks.
395pub fn ensure_subset_proof(
396    proof: Option<&AuthoritySubsetProof>,
397    child: &AuthorityTerm,
398    parent: &AuthorityTerm,
399) -> Result<(), SubsetProofError> {
400    let Some(proof) = proof else {
401        return Err(SubsetProofError::Missing);
402    };
403    if proof.comparison_algorithm.trim().is_empty() || proof.checked_at.trim().is_empty() {
404        return Err(SubsetProofError::Invalid);
405    }
406    if proof.parent_authority_ref != parent.resource_ref {
407        return Err(SubsetProofError::Invalid);
408    }
409    if !matches!(proof.result, AuthoritySubsetResult::Subset) {
410        return Err(SubsetProofError::Invalid);
411    }
412    let compared_terms_match = proof.compared_terms.iter().any(|comparison| {
413        comparison.child_term_id == child.term_id
414            && comparison.parent_term_id == parent.term_id
415            && matches!(
416                comparison.relation,
417                AuthoritySubsetRelation::Subset | AuthoritySubsetRelation::Equal
418            )
419    });
420    if !compared_terms_match {
421        return Err(SubsetProofError::Invalid);
422    }
423    Ok(())
424}
425
426/// Why [`ensure_subset_proof`] rejected a proof.
427#[derive(Clone, Debug, Error, PartialEq, Eq)]
428pub enum SubsetProofError {
429    /// No subset proof was supplied where one is required.
430    #[error("authority attenuation requires a subset proof")]
431    Missing,
432    /// The subset proof is structurally invalid or does not attest this pair.
433    #[error("authority attenuation subset proof is invalid")]
434    Invalid,
435}
436
437#[cfg(test)]
438mod tests {
439    use super::{
440        AttenuationError, AttenuationRequest, FamilySubsetComparator, ScopeBoundsComparator,
441        SubsetProofError, ensure_subset_proof, is_authority_subset, mint_attenuated,
442    };
443    use runx_contracts::schema::IsoDateTime;
444    use runx_contracts::{
445        AuthorityApproval, AuthorityBounds, AuthorityCapability, AuthorityCondition,
446        AuthorityConditionPredicate, AuthorityEffectGuard, AuthorityEffectLimit,
447        AuthorityResourceFamily, AuthoritySubsetRelation, AuthorityTerm, AuthorityVerb, JsonNumber,
448        Reference, ReferenceType,
449    };
450
451    const CHECKED_AT: &str = "2026-06-23T00:00:00Z";
452
453    fn reference(reference_type: ReferenceType, uri: &str) -> Reference {
454        Reference::with_uri(reference_type, uri)
455    }
456
457    fn condition() -> AuthorityCondition {
458        AuthorityCondition {
459            condition_id: "condition_approval".into(),
460            predicate: AuthorityConditionPredicate::ApprovalPresent,
461            refs: Vec::new(),
462            parameters: None,
463        }
464    }
465
466    fn approval() -> AuthorityApproval {
467        AuthorityApproval {
468            approval_ref: reference(ReferenceType::Decision, "runx:decision:approval"),
469            approved_by_ref: None,
470            approved_at: None,
471            criterion_ids: Vec::new(),
472        }
473    }
474
475    /// A workspace parent charter: broad string scopes, two verbs, conditions
476    /// and approvals the child must preserve.
477    fn parent_charter() -> AuthorityTerm {
478        AuthorityTerm {
479            term_id: "charter".into(),
480            principal_ref: reference(ReferenceType::Principal, "runx:principal:agency"),
481            resource_ref: reference(ReferenceType::Repository, "runx:repository:monorepo"),
482            resource_family: AuthorityResourceFamily::Workspace,
483            verbs: vec![
484                AuthorityVerb::Read,
485                AuthorityVerb::Write,
486                AuthorityVerb::Review,
487            ],
488            bounds: AuthorityBounds {
489                repo_path_globs: vec!["apps/**".into(), "packages/**".into()],
490                network_destinations: vec!["api.internal".into(), "cdn.internal".into()],
491                max_fanout: Some(8),
492                max_child_depth: Some(3),
493                ..AuthorityBounds::default()
494            },
495            conditions: vec![condition()],
496            approvals: vec![approval()],
497            capabilities: vec![
498                AuthorityCapability::FilesystemRead,
499                AuthorityCapability::NetworkEgress,
500            ],
501            expires_at: Some("2026-12-31T00:00:00Z".into()),
502            issued_by_ref: reference(ReferenceType::Principal, "runx:principal:operator"),
503            credential_ref: None,
504        }
505    }
506
507    /// A valid member narrowing: a strict subset on every dimension.
508    fn member_request() -> AttenuationRequest {
509        AttenuationRequest {
510            principal_ref: reference(ReferenceType::Principal, "runx:principal:member"),
511            resource_ref: reference(ReferenceType::Repository, "runx:repository:monorepo"),
512            resource_family: AuthorityResourceFamily::Workspace,
513            verbs: vec![AuthorityVerb::Read],
514            capabilities: vec![AuthorityCapability::FilesystemRead],
515            bounds: AuthorityBounds {
516                repo_path_globs: vec!["apps/**".into()],
517                network_destinations: vec!["api.internal".into()],
518                max_fanout: Some(2),
519                max_child_depth: Some(1),
520                ..AuthorityBounds::default()
521            },
522            expires_at: Some("2026-06-30T00:00:00Z".into()),
523        }
524    }
525
526    fn checked_at() -> IsoDateTime {
527        CHECKED_AT.into()
528    }
529
530    #[test]
531    fn valid_narrowing_yields_child_and_proof_the_validator_accepts() -> Result<(), AttenuationError>
532    {
533        let parent = parent_charter();
534        let (child, proof) = mint_attenuated(
535            &parent,
536            &member_request(),
537            &ScopeBoundsComparator,
538            checked_at(),
539        )?;
540
541        // The minted child is the requested narrowing with parent obligations
542        // preserved.
543        assert_eq!(child.verbs, vec![AuthorityVerb::Read]);
544        assert_eq!(
545            child.capabilities,
546            vec![AuthorityCapability::FilesystemRead]
547        );
548        assert_eq!(child.conditions, parent.conditions);
549        assert_eq!(child.approvals, parent.approvals);
550        assert_eq!(child.resource_family, parent.resource_family);
551
552        // The proof attests this exact pair, and the lifted validator accepts it.
553        assert_eq!(proof.parent_authority_ref, parent.resource_ref);
554        assert_eq!(proof.comparison_algorithm, ScopeBoundsComparator::ALGORITHM);
555        assert_eq!(proof.compared_terms.len(), 1);
556        assert_eq!(proof.compared_terms[0].child_term_id, child.term_id);
557        assert_eq!(proof.compared_terms[0].parent_term_id, parent.term_id);
558        assert_eq!(
559            proof.compared_terms[0].relation,
560            AuthoritySubsetRelation::Subset
561        );
562        assert_eq!(ensure_subset_proof(Some(&proof), &child, &parent), Ok(()));
563        assert!(is_authority_subset(&child, &parent, &ScopeBoundsComparator));
564        Ok(())
565    }
566
567    #[test]
568    fn term_id_is_deterministic() -> Result<(), AttenuationError> {
569        let parent = parent_charter();
570        let first = mint_attenuated(
571            &parent,
572            &member_request(),
573            &ScopeBoundsComparator,
574            checked_at(),
575        )?
576        .0;
577        let second = mint_attenuated(
578            &parent,
579            &member_request(),
580            &ScopeBoundsComparator,
581            checked_at(),
582        )?
583        .0;
584
585        assert_eq!(first.term_id, second.term_id);
586        assert!(first.term_id.as_str().starts_with("mint-"));
587        Ok(())
588    }
589
590    #[test]
591    fn widening_a_verb_errors_fail_closed() {
592        let parent = parent_charter();
593        let mut request = member_request();
594        request.verbs = vec![AuthorityVerb::Delete];
595
596        assert_eq!(
597            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
598            Err(AttenuationError::VerbsNotSubset)
599        );
600    }
601
602    #[test]
603    fn widening_a_capability_errors_fail_closed() {
604        let parent = parent_charter();
605        let mut request = member_request();
606        request.capabilities = vec![AuthorityCapability::SecretRead];
607
608        assert_eq!(
609            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
610            Err(AttenuationError::CapabilitiesNotSubset)
611        );
612    }
613
614    #[test]
615    fn widening_a_string_bound_errors_fail_closed() {
616        let parent = parent_charter();
617        let mut request = member_request();
618        // A scope outside the charter's repo globs.
619        request.bounds.repo_path_globs = vec!["secrets/**".into()];
620
621        assert_eq!(
622            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
623            Err(AttenuationError::ChildNotSubset)
624        );
625    }
626
627    #[test]
628    fn widening_a_numeric_bound_errors_fail_closed() {
629        let parent = parent_charter();
630        let mut request = member_request();
631        request.bounds.max_fanout = Some(99);
632
633        assert_eq!(
634            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
635            Err(AttenuationError::ChildNotSubset)
636        );
637    }
638
639    #[test]
640    fn widening_max_cost_units_errors_fail_closed() {
641        let mut parent = parent_charter();
642        parent.bounds.max_cost_units = Some(JsonNumber::U64(100));
643        let mut request = member_request();
644        request.bounds.max_cost_units = Some(JsonNumber::U64(500));
645
646        assert_eq!(
647            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
648            Err(AttenuationError::ChildNotSubset)
649        );
650    }
651
652    #[test]
653    fn dropping_max_cost_units_when_parent_caps_it_errors_fail_closed() {
654        let mut parent = parent_charter();
655        parent.bounds.max_cost_units = Some(JsonNumber::U64(100));
656        let mut request = member_request();
657        request.bounds.max_cost_units = None;
658
659        assert_eq!(
660            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
661            Err(AttenuationError::ChildNotSubset)
662        );
663    }
664
665    #[test]
666    fn narrowing_max_cost_units_succeeds() -> Result<(), AttenuationError> {
667        let mut parent = parent_charter();
668        parent.bounds.max_cost_units = Some(JsonNumber::U64(500));
669        let mut request = member_request();
670        request.bounds.max_cost_units = Some(JsonNumber::U64(100));
671
672        let (child, _) = mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at())?;
673        assert!(is_authority_subset(&child, &parent, &ScopeBoundsComparator));
674        Ok(())
675    }
676
677    #[test]
678    fn effect_bearing_child_under_scope_comparator_errors_fail_closed() {
679        // The scope comparator does not govern effect ceilings or guards. A term
680        // carrying an effect_limit or effect guard is incomparable under it and
681        // must be denied, not silently waved through with a Subset proof.
682        let parent = parent_charter();
683
684        let mut limit_request = member_request();
685        limit_request.bounds.effect_limits = vec![AuthorityEffectLimit {
686            family: "deployment".into(),
687            unit: "USD".into(),
688            max_per_call_units: Some(1_000),
689            max_per_run_units: Some(5_000),
690            max_per_period_units: None,
691            period: None,
692            channels: vec!["stripe".into()],
693            realm: None,
694            peer: None,
695            operation: None,
696            preflight_ttl_ms: None,
697            approval_threshold_units: None,
698            authorization_form: None,
699            preflight_required: false,
700            commitment_required: false,
701            idempotency_required: false,
702            recovery_required: false,
703            receipt_before_success: false,
704            single_use_capability: false,
705        }];
706        assert_eq!(
707            mint_attenuated(
708                &parent,
709                &limit_request,
710                &ScopeBoundsComparator,
711                checked_at()
712            )
713            .map(|_| ()),
714            Err(AttenuationError::ChildNotSubset)
715        );
716
717        let mut guard_request = member_request();
718        guard_request.bounds.effects = vec![AuthorityEffectGuard {
719            family: "deployment".into(),
720            guard_kinds: Vec::new(),
721            proof_kinds: Vec::new(),
722        }];
723        assert_eq!(
724            mint_attenuated(
725                &parent,
726                &guard_request,
727                &ScopeBoundsComparator,
728                checked_at()
729            )
730            .map(|_| ()),
731            Err(AttenuationError::ChildNotSubset)
732        );
733    }
734
735    #[test]
736    fn widening_expiry_errors_fail_closed() {
737        let parent = parent_charter();
738        let mut request = member_request();
739        request.expires_at = Some("2027-01-01T00:00:00Z".into());
740
741        assert_eq!(
742            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
743            Err(AttenuationError::ExpiryNotSubset)
744        );
745    }
746
747    #[test]
748    fn dropping_expiry_when_parent_bounds_it_errors_fail_closed() {
749        let parent = parent_charter();
750        let mut request = member_request();
751        request.expires_at = None;
752
753        assert_eq!(
754            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
755            Err(AttenuationError::ExpiryNotSubset)
756        );
757    }
758
759    #[test]
760    fn mismatched_resource_family_errors_fail_closed() {
761        let parent = parent_charter();
762        let mut request = member_request();
763        request.resource_family = AuthorityResourceFamily::Effect;
764
765        assert_eq!(
766            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
767            Err(AttenuationError::ResourceFamilyMismatch)
768        );
769    }
770
771    #[test]
772    fn mismatched_resource_address_errors_fail_closed() {
773        let parent = parent_charter();
774        let mut request = member_request();
775        request.resource_ref = reference(ReferenceType::Repository, "runx:repository:other");
776
777        assert_eq!(
778            mint_attenuated(&parent, &request, &ScopeBoundsComparator, checked_at()).map(|_| ()),
779            Err(AttenuationError::ResourceAddressMismatch)
780        );
781    }
782
783    #[test]
784    fn conditions_and_approvals_cannot_be_dropped() -> Result<(), AttenuationError> {
785        // The request carries no conditions or approvals; the mint must still
786        // preserve the parent's. A child with them stripped is not a subset.
787        let parent = parent_charter();
788        let (mut child, _) = mint_attenuated(
789            &parent,
790            &member_request(),
791            &ScopeBoundsComparator,
792            checked_at(),
793        )?;
794
795        assert_eq!(child.conditions, parent.conditions);
796        assert_eq!(child.approvals, parent.approvals);
797
798        // Independently: a hand-stripped child fails the subset gate, proving
799        // the preservation is enforced, not incidental.
800        child.conditions = Vec::new();
801        child.approvals = Vec::new();
802        assert!(!is_authority_subset(
803            &child,
804            &parent,
805            &ScopeBoundsComparator
806        ));
807        Ok(())
808    }
809
810    #[test]
811    fn ensure_subset_proof_rejects_absent_proof() -> Result<(), AttenuationError> {
812        let parent = parent_charter();
813        let (child, _) = mint_attenuated(
814            &parent,
815            &member_request(),
816            &ScopeBoundsComparator,
817            checked_at(),
818        )?;
819
820        assert_eq!(
821            ensure_subset_proof(None, &child, &parent),
822            Err(SubsetProofError::Missing)
823        );
824        Ok(())
825    }
826
827    #[test]
828    fn ensure_subset_proof_rejects_mismatched_terms() -> Result<(), AttenuationError> {
829        let parent = parent_charter();
830        let (child, mut proof) = mint_attenuated(
831            &parent,
832            &member_request(),
833            &ScopeBoundsComparator,
834            checked_at(),
835        )?;
836        proof.compared_terms[0].child_term_id = "other".into();
837
838        assert_eq!(
839            ensure_subset_proof(Some(&proof), &child, &parent),
840            Err(SubsetProofError::Invalid)
841        );
842        Ok(())
843    }
844
845    /// A stub family comparator: bounds are a subset only when the child's
846    /// `token_audiences` is exactly the parent's. Proves the trait drives the
847    /// bounds decision and the algorithm name flows into the proof, with no
848    /// scope-comparator behavior leaking in.
849    struct ExactAudienceComparator;
850
851    impl FamilySubsetComparator for ExactAudienceComparator {
852        fn comparison_algorithm(&self) -> &str {
853            "test.exact-audience.v1"
854        }
855
856        fn bounds_subset(&self, child: &AuthorityTerm, parent: &AuthorityTerm) -> bool {
857            child.bounds.token_audiences == parent.bounds.token_audiences
858        }
859    }
860
861    #[test]
862    fn stub_family_comparator_drives_the_bounds_decision() -> Result<(), AttenuationError> {
863        let mut parent = parent_charter();
864        parent.bounds.token_audiences = vec!["aud:a".into()];
865        let mut request = member_request();
866        request.bounds.token_audiences = vec!["aud:a".into()];
867
868        let (child, proof) =
869            mint_attenuated(&parent, &request, &ExactAudienceComparator, checked_at())?;
870        assert_eq!(proof.comparison_algorithm, "test.exact-audience.v1");
871        assert!(is_authority_subset(
872            &child,
873            &parent,
874            &ExactAudienceComparator
875        ));
876
877        // A divergent audience fails the family bounds check.
878        let mut widened = member_request();
879        widened.bounds.token_audiences = vec!["aud:b".into()];
880        assert_eq!(
881            mint_attenuated(&parent, &widened, &ExactAudienceComparator, checked_at()).map(|_| ()),
882            Err(AttenuationError::ChildNotSubset)
883        );
884        Ok(())
885    }
886}