4.26.6 Capability Context
Import :std/ensemble/ucan/context for open-capability-context. It implements
the complete CapabilityContext interface using an internally
opened UCAN database and a supplied Keystore.
Only the constructor is exported; the concrete implementation remains private.
4.26.6.1 Construction
open-capability-context takes a database path and an open Keystore, and
returns a CapabilityContext. Optional keywords are:
| Keyword | Default | Purpose |
|---|---|---|
public-key-cache-size: |
1024 | Capacity of each of the public-key and DID-alias LRUs; must exceed one |
cleanup-interval: |
3600 | Positive interval in seconds for database expiration cleanup |
The path follows open-ucan-db semantics: a missing file is initialized, an
existing file must have the expected schema version, and ":memory:" creates
a transient policy database. The context owns this database and its cleanup
worker. It borrows the supplied keystore and never closes it, including when
construction fails. The caller must keep that keystore open while using the context.
The database is assumed to be manipulated only through a capability context. Insertion verifies anchors, and database opening removes expired rows. Opening does not reverify or repair stored anchors. Manual database modification bypasses this invariant and is unsupported; invalid credentials may fail when used.
Opening lists the keystore’s principal DIDs once and retains them in the private
implicit-roots slot. These principals are trusted roots for token verification,
without loading their private keys or writing root records into the database.
Keystore.list-keys guarantees canonical DIDs, so this snapshot needs no further
normalization.
SQLite support is required. The context does not take a passphrase: encryption and unlocking are responsibilities of the already-constructed keystore.
4.26.6.2 Private Keys
get-principal normalizes its DID and checks a canonical-keyed hash-table cache. On a miss it calls
Keystore.get-private-key and retains the resulting key until context close.
There is no eviction or expiration for private keys. Repeated signing with the
same DID requires neither keystore access nor DID decoding.
add-principal! stores the caller’s key through Keystore.put-private-key!
before publishing a cache entry, returning its canonical DID. If the DID is
not already cached, it retrieves a separate key object from the keystore.
It does not retain the caller’s native key wrapper, so the caller remains free
to release that wrapper after the method returns.
principals delegates to the keystore, not the cache. It includes stored
principals that have never been loaded or used for signing. The implicit-root
snapshot is captured at opening: keys added later, including via add-principal!,
become implicit roots when a new context opens. Use add-root! to establish
explicit trust immediately in an already open context.
4.26.6.3 Normalization And Public Keys
normalize-did(did: string) -> string strictly validates Ed25519 DIDs and returns
the canonical unpadded base64url spelling. Canonical u inputs take the strict
lexical validation path and are returned directly, without occupying an alias
cache entry. Valid z aliases are mapped to canonical strings in a bounded LRU;
hits return the identical cached string and refresh recency. Invalid inputs are
never inserted. This cache has its own entries but reuses public-key-cache-size:
as its capacity, independent of public-key eviction.
Input and returned strings are borrowed/read-only. Do not mutate them while retained by the context. Eviction and close drop references, not the validity of strings already returned to callers.
public-key uses :std/struct/lru. Hits update recency; misses decode the DID
with did->public-key and insert the resulting PubKey. The least recently
used entry is evicted when capacity is reached. Failed decoding is not cached.
The private-key cache uses canonical DIDs. The public-key cache uses the supplied DID string without normalization: equivalent spellings may occupy separate entries, avoiding normalization overhead on hits. Misses validate while decoding the key. Trust matching normalizes identities through the context; the context never rewrites identifiers inside signed tokens or changes their serialized bytes.
Cached private and public keys are shared, read-only objects. Callers must not mutate or explicitly release their native pointers. Public-key eviction drops only the cache reference; it does not invalidate a key held by an in-flight verifier or another caller. Native storage is reclaimed by normal foreign-object garbage collection after the last reference disappears.
4.26.6.4 Signing And Trust
sign! calls sign-token! with the context’s cached interface instance. It
neither chooses an output anchor nor saves the token automatically. Callers
construct the desired chain and explicitly call save-token! when needed.
The optional extension module adds explicit grant, delegation, and
output-anchor selection helpers without changing this base signing behavior.
verify first runs verify-token. Any signature, expiration, or delegation
failure is returned immediately. For a valid chain it then accepts either:
-
A configured root matching
token-rooted-at?. -
An opening-time principal DID in
implicit-rootsmatchingtoken-rooted-at?. -
An unexpired input anchor matching
token-anchored-at?.
The helper signatures are (token-rooted-at? token did ctx) and
(token-anchored-at? token anchor ctx), with the context last.
Otherwise it returns !AnchorVerificationError. Output anchors do not authorize
incoming tokens. Opening-time principals authorize both direct grants and valid
chains containing their issuer DID; signatures, expiration, and delegation checks
are never bypassed. Key lookup, decoding, and storage exceptions propagate normally.
roots, add-root!, and remove-root! manage only explicit database roots.
Removing an explicit root does not revoke the same DID’s implicit principal trust.
Implicit roots remain an immutable snapshot for the context’s lifetime.
The shared database add/remove boundary strictly normalizes explicit roots.
Aliases deduplicate and can remove the same canonical root; malformed roots are
rejected without changing policy. Root listings contain canonical strings.
Both add-input-anchor! and add-output-anchor! run verify-token before storing
the anchor. Invalid signatures, missing signatures/nonces, expired tokens, and
invalid delegation chains are rejected with ContractViolation; key lookup,
codec, and crypto exceptions propagate unchanged. Rejection leaves the stored
anchor sets unchanged. Validation runs outside the context mutex because public
key lookup reenters it. The database persists a serialized snapshot.
Insertion does not require existing root/input trust: installing an anchor is
itself an explicit policy decision authorized by the application. Input anchors
confer partial input trust, while output anchors only supply outgoing credentials.
Root and saved-token management still delegate directly to ucan-db, as do
expiration filtering and periodic cleanup. A verified anchor must remain unexpired
when used; insertion verification is not an indefinite authorization grant.
Verification does not infer an intended actor, recipient, or requested operation from the call. The surrounding operation must also check the token’s applicability to its particular subject and capability.
4.26.6.5 Concurrency And Close
A mutex protects lifecycle checks, the normalization LRU, and both key caches,
including misses. Normalization rejects unsupported prefixes and validates u
DIDs before any locking; only the z alias-cache path acquires the mutex.
Private-key lookup normalizes before entering its key-cache critical section;
public-key lookup uses the supplied string directly. Signing
and verification run outside it because their helpers reenter the key methods.
Database operations have their own mutex; user predicates run outside both locks
and can safely call back into the context. Callers must not concurrently mutate
tokens or DID strings passed to these operations.
Close marks the context closed, clears its key and normalization caches, and closes the owned
database outside the context mutex. It is idempotent. Subsequent stateful operations
raise Closed through raise-io-closed. Pure u normalization remains available
after close, and unsupported prefixes still raise a contract violation before
checking state. Key operations still reject closed contexts.
Operations racing close may finish or
raise Closed as their subsequent context/database accesses encounter closure.
Previously returned key objects remain valid while referenced. Clearing the
caches therefore does not promise immediate erasure of native private keys.
Close does not close the borrowed keystore or invalidate caller-owned keys
originally passed to add-principal!.