4.26.5 UCAN Database
:std/ensemble/ucan/db is the internal SQLite backend for the
capability context. It stores saved tokens, roots, input
anchors, and output anchors. It does not implement an interface, store private
keys, sign tokens, or decide whether a token is trusted.
4.26.5.1 Procedures
| Procedure | Arguments | Result |
|---|---|---|
open-ucan-db |
database path; optional cleanup-interval: |
ucan-db |
close-ucan-db! |
database | Void |
ucan-db-save-token! |
database, token | Void |
ucan-db-list-tokens |
database, predicate | List of tokens |
ucan-db-add-root! |
database, DID string | Void |
ucan-db-remove-root! |
database, DID string | Void |
ucan-db-roots |
database | Sorted list of DID strings |
ucan-db-add-input-anchor! |
database, token | Void |
ucan-db-remove-input-anchor! |
database, token | Void |
ucan-db-input-anchors |
database, predicate | List of tokens |
ucan-db-add-output-anchor! |
database, token | Void |
ucan-db-remove-output-anchor! |
database, token | Void |
ucan-db-output-anchors |
database, predicate | List of tokens |
Opening checks whether the path exists before opening SQLite, creates the
schema only for a missing database, deletes expired token rows, and then loads
the remaining records into memory. cleanup-interval: is a positive number
of seconds, defaulting to 3600; fractional values permit short test intervals.
Use a dedicated SQLite file, or ":memory:" for a transient database. An
unsupported schema version or malformed stored token causes opening to fail
and releases the connection. Closing wakes and joins the cleanup worker,
releases the statement cache and connection, and is idempotent. Subsequent
operations raise Closed through raise-io-closed.
4.26.5.2 Storage And Identity
db.sql contains the schema, included at compile time. Initialization uses
exec-schema! from :std/db/schema to split it on
semicolons, remove full-line -- comments, and execute each nonempty statement
separately within the initialization transaction. Schema
comments must not contain semicolons. PRAGMA user_version records schema
version 1. Existing databases
must have that exact version, including rejection of version 0; they are never
initialized or migrated implicitly. ":memory:" always creates a fresh schema.
ucan_roots stores canonical Ed25519 DID strings as primary keys. Both
ucan-db-add-root!(database, did: string) and
ucan-db-remove-root!(database, did: string) return void and strictly normalize
the supplied DID at this shared boundary, under the database mutex after checking
closed state. Valid base58btc (z) aliases and canonical unpadded base64url (u)
DIDs therefore deduplicate and remove the same identity. Invalid roots raise a
contract violation before SQL or cache mutation. Listings return sorted canonical
strings. Root strings are borrowed/read-only and must not be mutated.
This is the initial, unreleased schema policy: no migration or opening-time repair of previously noncanonical roots is provided. Writes through this boundary ensure that reopened databases retain canonical roots.
ucan_tokens uses a composite primary key of category and serialized token.
Category 0 stores saved tokens, 1 input anchors, and 2 output anchors. The same
token may occur independently in all three categories. The indexed expire
column stores the token’s expiration in Unix seconds, allowing SQLite to
delete expired entries without decoding their payloads.
Tokens are serialized through marshal-token and loaded through
unmarshal-token, preserving the token graph and rejecting cycles. Equality
for insertion/removal is exact serialized content, not Scheme object identity.
DID normalization applies to root identities, never to serialized token fields;
tokens containing signed alias spellings retain their exact bytes.
Duplicate insertions and removal of missing roots or anchors are no-ops.
Expiration is retained in the serialized token and copied to the SQLite
integer column. Stored expiration values must fit SQLite’s signed 64-bit range.
The database is not encrypted. Place it in an appropriately protected directory. It contains no private keys, but can contain sensitive capability tokens and trust policy. Signature and authorization checks remain the context’s responsibility.
4.26.5.3 Caching And Concurrency
All roots and decoded token records are cached at open. There is one mutex
around the SQLite connection, its StatementCache, and cache access. SQL
queries are named constants and values are bound as parameters. Statements are
borrowed with statement-cache-get and returned with statement-cache-put,
which resets execution and clears bindings even after a failed operation.
Writes complete in SQLite before the cache is updated. A failed SQL operation does not change the cached records. Other handles or processes must not modify the same database while this owner is active; there is no external invalidation.
Listings read only the cache. Token and anchor listings exclude tokens whose expiration is at or before the current time, then apply the supplied predicate. Their order is unspecified. Expired records are purged on opening and by a background worker at the configured interval, so listing does not need to wait for the next purge to hide an expired token.
The worker waits using thread-receive with the interval as its timeout and
#f as the timeout value. A timeout triggers deletion of expired rows and
matching cache entries under the mutex, using one timestamp for both. Roots
are not subject to expiration. Failed cleanup is reported and retried at the
next interval without changing the caches when the SQL delete fails.
Closing sends the worker a 'close message. The worker checks closed state
under the mutex before touching the database; joining it happens outside the
mutex to avoid deadlock. Callers must close the database explicitly, since the
worker retains its database object while running.
The cache owns serialized/decoded token snapshots rather than the caller’s mutable token. Listings return those cached tokens directly, without decoding them again. Callers and predicates must treat the returned tokens as read-only. Predicates run outside the mutex, so they can call back into the module without deadlocking. Mutating the original input token does not alter the stored snapshot. To remove an anchor, supply a token with the same serialized contents as that snapshot.
The module is built only when SQLite support is enabled. Its build specification
tracks db.sql as an extra input so schema edits trigger recompilation.