Skip to content

core/tokens: replace hardcoded narrowing checks with a generic predicate-list evaluator (trilean-backed) #85

Description

@Mearman

Parent: #77

`mintCapabilityToken`/`verifyCapabilityToken` (`ts/packages/core/src/domain/tokens.ts:337-396`) hardcode each narrowing check as its own `if`: bearer match, expiry-within-parent, `scopeNarrows`, capability equality, `delegations-remaining` strictly-less-than-parent's. Anything not covered by those five falls back to the claims payload's open `* tstr => any` tail, interpreted however the reading domain happens to define it -- a second, informal mechanism sitting beside the first.

Fold both into one: `token-claims` gets a `conditions: [* PredicateNode]` field, evaluated by a single `evaluatePredicate(node, resolver)` function. The current hardcoded checks become a mandated core vocabulary of predicate ops (`bearer-is`, `scope-narrows`, `expires-at`, `depth-remaining`, `capability-is`) every conforming verifier must support, sitting in the exact same list as any domain-specific condition (a message TTL, a co-presented-credential check, the `authorization` field #84 sketches for delegated revoke). One evaluator replaces five separate hardcoded checks plus whatever ad hoc reading of the extension tail a given domain invents.

trilean (ExaDev-owned, Zod-based) is the right building block: its three-valued result (`{status: "definite", value}` or `{status: "indeterminate", reason}`) maps directly onto the verifier-obligations glossary's own existing "can't resolve it, refuse it" convention -- `indeterminate` fails the token, the same as an unrecognised `kind`/`type` discriminator already does elsewhere in the spec. Its resolver pattern (`resolveValue`/`resolveLookup`/`resolveCollection`) is the exact injection point for wire-mesh's own local evaluation context (`Clock`, `RevocationView`, the presented token's own claims) -- nothing to invent, just wiring existing ports into an already-typed contract.

Deliberately kept flat/AND-only (every condition in the list must hold) -- not full boolean composition with OR/NOT. Every condition examined so far (expiry, delegation depth, a proposed TTL, the proposed revoke-authorization) has been one independent threshold; a flat list already covers that completely. Full recursive composition is a separate, harder problem (compound-expression gaming has its own attack surface) that nothing here actually needs yet -- the same restraint already applied to causal-reference composition in the design's own section C, and independent of any refactor-cost argument.

No existing tokens or external consumers to migrate -- this can be the schema from the start rather than a breaking change layered on afterward.


Update: checked directly against trilean's actual source (not assumed) rather than treating the fit as speculative. Four of five integration questions raised during design turned out already solved:

  • Custom domain predicates (bearer-is, scope-narrows, depth-remaining, capability-is, the future revoke-authorization check) are already supported natively via the delegate node ({kind: "delegate", system: string, payload: JsonValue}, evaluator.ts:1425-1441) -- a consumer-supplied resolvers.resolveDelegate(system, payload, context) handles each named system. No trilean change needed; wire-mesh just registers its five ops this way.
  • Already async. resolveDelegate (and the evaluator generally) is awaited throughout -- wire-mesh's own RevocationCheck.isRevoked(): Promise<boolean> composes directly, no synchronous-context workaround needed.
  • Already fails closed, not throws, for an unhandled case. A delegate node naming a system with no registered handler returns indeterminate("wrong-type", ...); malformed treeReference resolutions go through .safeParse() rather than a throwing .parse(). Matches wire-mesh's own verifier-obligation convention with no wrapper needed for these cases.
  • Serialization is a wire-mesh-side concern, not a trilean one, and a straightforward one. PredicateNode/ExpressionNode (including delegate's own payload) are plain JSON-value-shaped Zod schemas, no embedded functions or closures -- canonical CBOR encoding for embedding inside a signed COSE_Sign1 payload is ordinary downstream work, not something trilean needs to change to support.

One real gap confirmed and filed upstream: ExaDev/trilean#43 -- MAX_TREE_REFERENCE_DEPTH only bounds a chain of treeReference hops, not the nesting depth or node count of a single self-contained tree, which matters here because wire-mesh's own predicate trees arrive embedded in tokens minted by not-fully-trusted peers (every other trilean consumer examined assumes a trusted tree author). Track that issue as a soft prerequisite -- usable without it, but worth having before evaluating genuinely adversarial input in production.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions