Swarf is a UCAN revocation service.
swarf serve --storage memoryBy default, Swarf uses PostgreSQL. Configure it with --postgres-dsn, a
config.yaml, or SWARF_STORAGE_POSTGRES_DSN. Configuration sections are
identity, server, log, and storage; environment variable names use the
SWARF_ prefix, for example SWARF_SERVER_PORT.
Publish a revocation with an issuer PEM key, the CID to revoke, and the delegation to revoke:
swarf revoke \
--issuer-key-file issuer.pem \
<revoke-cid> \
<delegation-or-container>delegation-or-container can be a file path or an encoded UCAN container
string. A file may contain either a CBOR-encoded delegation or a UCAN container
holding the delegation to revoke. When the issuer key issued the revoked
delegation, the delegation alone is enough; otherwise the container must also
include the delegation witnesses, from which Swarf builds the witness chain.
The service defaults to did:web:swarf.forgery.network at
https://swarf.forgery.network; override these with --service-id and
--service-url.
Retrieve a revocation’s DAG-JSON record with:
swarf get <revoke-cid>The command prints the service response and exits silently if the revocation is
not found. Override the service endpoint with --service-url.
Stream revocation DAG-JSON records as they arrive with:
swarf streamBy default, it starts at the head of the stream, so it prints only the records
that arrive from then on. Pass --after <cursor> to start after a
cursor, or --after 0 to stream every record. Press Ctrl+C to
stop streaming. Override the service endpoint with --service-url.
The UCAN RPC endpoint. It supports /ucan/revoke; the invocation arguments
identify the revoked delegation and its delegation
path witness. These
delegations must be included in the invocation metadata. For example:
type RevokeArguments struct {
revoke Link
path [Link]
}
path proves the revocation issuer's authority over a delegation issued by
someone else further down a chain the issuer is involved in, and may be empty
when the revocation issuer issued the revoked delegation directly. The revoked
delegation itself must always be included in the invocation metadata.
A request may carry several /ucan/revoke invocations. Each one is validated
on its own and gets its own receipt, and the valid ones are stored together
with one database write, so they share the same recorded_at. If that write
fails, every revocation of the request gets a short-lived error receipt;
retrying is safe.
Retrieves the most recent revocation for a delegation. It returns a DAG-JSON
record containing revoke (the delegation CID), cause (the CBOR-encoded
revocation invocation), and CBOR-encoded witness delegation blocks, or 404
when no revocation exists. For example:
{
"revoke": {"/": "bafyreiehytyi4q3t2amvf2abdlt5xnnqtaqkknf6yxhre4klpjnejlnsc4"},
"cause": {"/": {"bytes": "omF2AWNjYXBnL3VjYW4vcmV2b2tl"}},
"path": [
{"/": {"bytes": "omF2AWNjYXBsL3Rlc3QvaW52b2tl"}}
],
"recorded_at": "2026-07-17T09:00:00Z"
}A Server-Sent Events stream of compact DAG-JSON records, starting after a
cursor. Each event has revoke (the revoked delegation CID),
path (the witness delegation CIDs), cause (the revocation invocation CID),
and recorded_at (the time the record was recorded), and its id is the
record's cursor. Use 0 to stream every stored record. For example:
id: 1734
event: revocation
data: {"revoke":{"/":"bafyreiehytyi4q3t2amvf2abdlt5xnnqtaqkknf6yxhre4klpjnejlnsc4"},"path":[{"/":"bafyreiehytyi4q3t2amvf2abdlt5xnnqtaqkknf6yxhre4klpjnejlnsc4"}],"cause":{"/":"bafyreif5fzax7oygfafacvxq2ndhtkshz2av5m42hqeixea7giirdxe5dm"},"recorded_at":"2026-07-17T09:00:00Z"}Records reach the stream in the order Swarf stored them, which is not always
recorded_at order, so recorded_at is not a position in the stream.
Pass now instead of a cursor to start at the head of the stream as Swarf's
database sees it when the request arrives, rather than at a time on the
client's clock. A stream started at now first sends an event with only an
id, the cursor now stands for. It carries no data, so it dispatches
nothing, but it sets an SSE client's last event ID, so a client that
reconnects before the first record resumes from there.
Swarf answers 409 for a cursor ahead of the stream's head, which it cannot
have issued: one from another database, such as one restored from an older
backup, or from before an in-memory store restarted. Streaming after it would
skip every record up to it. A consumer that gets one has to start over: after
now, which skips every record since the last one it applied, or after a
cursor it stored from /revocations/head earlier, or
after 0, deduplicating by cause the records it receives again.
Responds with the cursor at the head of the stream as plain text: the cursor
of the last record stored, or 0 when there is none. A stream after it yields
every record stored from then on. The response is never cached.
A consumer that starts after now has no cursor of its own to store until its
first record arrives, and revocations are rare. If it restarts before then, it
starts after now again and misses every record that arrived while it was
down. A new consumer avoids that by fetching the head, storing it, and
streaming after it.
A cursor is a decimal number, sent as each event's id. Swarf numbers each
record as it stores it, and records reach the stream in that order. Swarf
stores one write at a time, so a write takes its numbers only after the one
before it has finished, and no record can appear behind one already streamed.
A consumer that stores the cursor of the last record it applied, and resumes
after it, misses nothing. The numbers can have gaps, so treat a cursor as a
position to pass back, not a count.
/revocations/after/ honors the standard
Last-Event-ID
request header, which an SSE client such as EventSource sends when it
reconnects. The stream resumes after that event, in place of the cursor or
now in the URL. A Last-Event-ID that is not a cursor is ignored, and the
stream starts where the URL says. One ahead of the head shows that the
stream's database changed since the stream began, and Swarf answers 409,
since neither the URL's position nor the head is a safe place to start.
Delivery is at least once: a consumer that stores its cursor less often than
it applies records, or resumes from an older one, receives some records again.
Deduplicate by cause, the CID of the revocation invocation, which is unique
to a record. Records repeat when a consumer restarts, so what it deduplicates
against has to survive its restarts too.
A cursor is the seq column of the revocation table, so cursors move with
the data. A pg_dump, taken once the old server has stopped taking writes, and
restored keeps every row's seq and the position of the identity sequence that
numbers new rows, so stored cursors keep working, and so does a stream left
open across the move. Upgrading in place with pg_upgrade, or failing over to
a physical replica that is caught up, keeps them too.
Logical replication copies every row's seq, but not the sequence's position.
Once the old server takes no more writes and replication has caught up, and
before the new server takes writes, set the sequence past the highest seq:
SELECT setval(pg_get_serial_sequence('revocation', 'seq'), coalesce(max(seq), 0) + 1, false)
FROM revocation;Otherwise the new server hands out numbers from the start again. A write that draws a number a copied record holds fails, and one that draws a free number lands behind records consumers have already streamed, so they never receive it.
Restoring a backup older than the last write loses the records written after
it, and consumers can hold cursors past the restored head. Swarf refuses those
with 409 until new records pass them, then accepts them and skips the
records in between. After such a restore, have every consumer start over as
after a 409.
Restarting Swarf with in-memory storage voids every position in the same way, besides losing every record.
Construct a client with the Swarf service DID and URL, and pass the issuer
revoking a delegation to each Publish call:
serviceURL, _ := url.Parse("https://swarf.example.com")
client, _ := swarfclient.New(serviceDID, *serviceURL)
// Revoke a delegation you issued directly.
err := client.Publish(ctx, revoker, revoked)
// Provide a witness path (root first) when revoking a delegation issued by
// someone else further down a chain you are involved in.
err = client.Publish(ctx, revoker, revoked, swarfclient.WithWitnessPath(path...))
record, err := client.Get(ctx, delegationCID)
// Before following the stream for the first time, store its head.
head, err := client.Head(ctx)
// Resume after a stored cursor, or start at the head of the stream with
// swarfclient.Now.
for event, err := range client.StreamAfter(ctx, head) {
// event.Revocation.Revoke, .Path, and .Cause are CIDs, and .RecordedAt
// is a time. Store event.Cursor once the revocation is applied, and pass
// it to StreamAfter to resume.
}Publish self-signs the revocation invocation with the passed revoker, which
must be the issuer of the revoked delegation or appear as an issuer in the
witness path provided with WithWitnessPath. Get returns a full
store.RevocationRecord. StreamAfter returns compact
api.FirehoseRevocation values as Events with their cursors, and reconnects
when the stream is interrupted, resuming after the last event received. A
409 ends the stream with an error wrapping ErrCursorRefused. Head
returns the head of the stream, a cursor to pass to StreamAfter.
A push to main publishes to GHCR from the Container workflow. The prod
target becomes ghcr.io/fil-forge/swarf:main, a stripped binary on a slim
Debian base. The dev target becomes ghcr.io/fil-forge/swarf:main-dev and
adds delve plus a handful of debugging tools. Both cover linux/amd64 and
linux/arm64, and both also carry a sha-<short-sha> tag, the dev image with a
-dev suffix.
The same run asks infra-central to deploy the prod image. It dispatches a
bump-deployed-image event carrying the manifest digest it just pushed, and
infra-central's Bump deployed image workflow opens a pull request
pinning that digest in terraform/envs/dev/apps/terraform.tfvars, with
auto-merge enabled. infra-central's Check and deploy workflow runs
tofu apply on dev/apps on every push to its main, so merging that pull
request is what deploys.
The dispatch runs as the fil-forge-bot GitHub App and needs the
FORGE_BOT_APP_ID variable and the FORGE_BOT_PRIVATE_KEY secret. Prod pins
are promoted by hand.