Design
ocid — design¶
ocid is a local-first, peer-to-peer alternative to centralized OCI registries: every node runs a loopback OCI registry that standard tooling (podman/docker/oras) talks to unchanged, while peers exchange signed, content-addressed releases directly.
The design borrows Radicle's model — keys are identities, signed records carry trust, replication follows a local policy — mapped onto OCI images:
| Radicle | ocid |
|---|---|
Ed25519 node id / did:key | same key: iroh EndpointId + publisher id |
repository (rad:z…) | image <publisher>/<name> |
| signed refs | signed release record per name:tag |
| git objects | OCI blobs (manifest, config, layers, referrers) |
git-remote-rad | embedded OCI v2 registry on localhost:5050 |
rad seed / rad follow | ocictl seed / follow / pin with a retention window |
| gossip announcements | iroh-gossip, one topic per publisher |
| fetch protocol | ocid/sync/1 + iroh-blobs |
rad node / rad | ocid (daemon) / ocictl (CLI) |
Components¶
flowchart LR
subgraph host["your machine"]
podman["podman / docker / oras"]
cli["ocictl"]
tui["ocitop (TUI)"]
ext["Podman Desktop<br/>extension"]
prom["Prometheus"]
subgraph node["ocid (daemon)"]
reg["OCI v2 registry<br/>127.0.0.1:5050/v2"]
ctl["control API & SSE<br/>/_ocid/*"]
met["/metrics<br/>OpenMetrics"]
core["Node<br/>identity · policy · windows · fetch · GC"]
store["Store<br/>iroh-blobs FsStore + JSON index"]
gossip["iroh-gossip<br/>swarm + publisher topics"]
sync["ocid/sync/1"]
blobs["iroh-blobs"]
mdns["mDNS lookup<br/>_ocid._udp"]
ep["iroh Endpoint<br/>(QUIC, NAT traversal)"]
end
end
swarm(("peers"))
podman -- "HTTP /v2" --> reg
cli -- "HTTP /_ocid" --> ctl
tui -- "HTTP /_ocid & SSE" --> ctl
ext -- "HTTP /_ocid & SSE (backend)" --> ctl
cli -. "offline: reads index" .-> store
prom --> met
reg --> core
ctl --> core
core --> store
core --> gossip
core --> sync
core --> blobs
gossip --> ep
sync --> ep
blobs --> ep
mdns --> ep
ep <--> swarm Crates: ocid-core (library: identity, config/policy, OCI types, release records, index, API DTOs, optional client) — ocid (daemon) — ocictl (CLI) — ocitop (TUI dashboard). The Podman Desktop extension lives in extensions/podman-desktop (just ext-* recipes; node toolchain also runs in a container).
Identity and naming¶
flowchart LR
sk["secret.key<br/>(ed25519)"] --> pk["public key"]
pk --> hex["hex (64)<br/>EndpointId<br/>used in registry paths"]
pk --> did["did:key:z6Mk…<br/>CLI / display"]
pk --> sig["signs releases"]
pk --> quic["authenticates QUIC<br/>connections"]
pk --> topic["derives the publisher's<br/>gossip topic"] Repository path resolution (hybrid scheme):
flowchart TD
in["/v2/<repo>/…"] --> q1{"first segment<br/>is 64-hex?"}
q1 -- yes --> explicit["publisher = hex<br/>name = rest"]
q1 -- no --> q0{"first segment is a<br/>domain with a verified<br/>_ocid TXT record?"}
q0 -- yes --> dns["publisher = record key<br/>(TOFU-pinned)<br/>name = rest"]
q0 -- no --> q2{"whole repo<br/>is an image alias?"}
q2 -- yes --> ialias["alias → publisher/name"]
q2 -- no --> q3{"first segment<br/>is a publisher alias?"}
q3 -- yes --> palias["publisher = alias target<br/>name = rest"]
q3 -- no --> me["publisher = me<br/>name = repo"] Only the key holder can write into a publisher namespace: pushes to <other>/… are refused with 403.
DNS publisher names¶
A domain name can stand in for the hex publisher id: whoever controls a DNS zone publishes a signed TXT record at _ocid.<zone> binding the zone to their publisher key:
1_ocid.images.example.com. 300 IN TXT "v=ocid1 k=<64-hex> ts=<unix> [relay=<url>] [seed=<64-hex>] sig=<128-hex>"
sig is an Ed25519 signature by the key it carries over the canonical payload ocid1 <zone> <k> <ts> [<relay>] [<seed>] — mutual consent: the zone admin points the name at the key, and only the key's owner could have signed it. Releases are signed by the same key, so once the name is trusted every artifact under it is self-verifying.
The optional hints make the name a bootstrap, not just an alias: relay= is a dial hint, seed= an introducer peer — ocictl connect<zone> (and ocid --peer <zone>) resolve the name and dial the publisher with them, no ticket exchange required. Hints are signed (DNS cannot invent or swap them) but can go stale; they are best-effort dial candidates, never a trust input — content verifies itself however the connection was found. Without hints, the bare key resolves via iroh's pkarr/DHT address lookup (pkarr = "off" disables both publishing and lookup).
DNS is an untrusted lookup; trust is anchored locally:
- The signature is always verified; the zone is part of the signed bytes (a record cannot be replayed across zones).
- Freshness: records older than
dns_max_age_secs(default 30 days) are rejected. - TOFU pinning: the first verified resolution pins the key in
$OCID_HOME/dns-pins.json; a later record for the same zone with a different key is rejected as a possible hijack. Rotation is explicit:ocictl dns-unpin <zone>, then the next resolve re-pins. - Positive and negative lookups are cached briefly (TTL-capped); errors are never cached, so every pull re-checks a bad record.
- Dotted image names (
my.app) pass the domain gate and fall through to the alias/self rules on a lookup miss — DNS adds a branch, it never breaks existing names. policy.tomlkeeps domain forms (follow images.example.com); the daemon records the resolved key in a[dns]mapping table so evaluation never needs DNS at policy-load time.
Produce a record with ocictl dns-record <zone>; inspect a name with ocictl resolve <zone>; the daemon exposes the same via GET /_ocid/dns/resolve and POST /_ocid/dns/unpin.
Data model¶
classDiagram
class Release {
ReleasePayload payload
hex signature
verify()
supersedes(other)
}
class ReleasePayload {
u8 version
PublisherId publisher
String name
String tag
BlobRef manifest
BlobRef[] blobs
u64 timestamp
Referrer[] referrers
}
class BlobRef {
Digest digest_sha256
Hash hash_blake3
u64 size
String media_type
}
class Referrer {
Digest digest
String media_type
u64 size
String? artifact_type
Map annotations
}
Release --> ReleasePayload
ReleasePayload --> BlobRef : manifest
ReleasePayload --> "*" BlobRef : blobs
ReleasePayload --> "*" Referrer : referrers - The signature covers the canonical JSON of the payload and is made by
payload.publisher; anyone can verify it offline. Records travel as the exact JSON bytes that were signed. blobslists everything the manifest references transitively (config, layers, nested manifests for indexes, referrer manifests and their blobs), each with both digests. sha256 is what OCI tooling expects; BLAKE3 is what iroh-blobs streams and verifies incrementally.- Newer
timestampfor the same(publisher, name, tag)supersedes. Timestamps are seconds; within one second the greater tag sorts newer. referrersare manifests whosesubjectis this release's manifest (signatures, SBOMs, attestations). Attaching one re-signs and republishes the release, so referrers replicate with the image they describe.
On disk:
1blobs/ iroh-blobs FsStore (BLAKE3)
2index/digests/<sha256>.json BlobRef (sha256 → blake3)
3index/releases/<pub>/<name>/<tag>.json Release
4index/referrers/<subject-hex>/<hex>.json Referrer (for the referrers API)
Publish¶
sequenceDiagram
participant P as podman
participant R as registry (A)
participant S as Store (A)
participant G as gossip topic(A)
P->>R: POST/PATCH/PUT blobs
R->>S: sha256 verify, import → blake3, index, pin
P->>R: PUT /v2/app/manifests/1.0
R->>R: resolve "app" → (me, app)
R->>S: check all referenced blobs present
R->>S: store manifest blob
R->>R: build ReleasePayload (+ known referrers), sign
R->>S: put release
R->>G: broadcast Announcement{publisher, name, tag, digest, ts}
R-->>P: 201 Created A manifest with a subject takes a side path: it is stored, recorded under index/referrers/<subject>, and every own release whose manifest is the subject is re-signed with the new referrer and re-announced.
Policy and retention windows¶
flowchart LR
subgraph rules["policy.toml"]
f["[[follow]] publisher, mode"]
s["[[seed]] ref, mode"]
st["[[seed]] ref:tag"]
p["pin = [ref:tag]"]
end
f --> w
s --> w
st --> w
p --> w
w["Window(publisher, name)<br/>full | last:N | tags"]
w --> sel["select(known releases)<br/>newest first, tag tie-break"]
sel --> keep["wanted set"] - A mode is
latest(=last:1),last:Norfull. Several rules for one image combine to the widest window; tagged seeds and pins add fixed tags. select_wantedis evaluated against everything the node knows about an image — local releases plus what peers' inventories and announcements report — so a node withlast:2fetches the two newest and nothing else.enforce_windowruns after every replication and on policy reload: releases outside the window are removed and their blobs unpinned. Own releases and pins are never touched.- The daemon is the single policy writer while it runs:
POST /_ocid/policy/{seed,unseed,follow,unfollow,pin,unpin}mutatepolicy.toml(alias-resolving the reference), persist it, resubscribe gossip topics and enforce windows in one step — the same effects a reload would have.ocictluses these endpoints when the daemon is running and falls back to editing the file directly when it is not. The reply is{changed, reference}with the canonical rule. - Everything the policy does not name is cache: on-demand pulls and leftovers of removed rules. GC keeps cache for
gc_grace_secsafter it was fetched, then drops it.ocictl gc --forceignores the grace period.
Gossip topics¶
flowchart TB
subgraph swarm["swarm topic blake3('ocid/swarm/v1')"]
A1[A]; B1[B]; C1[C]
end
subgraph ta["topic(A) blake3('ocid/publisher/v1/' ‖ A)"]
A2[A]; B2["B (follows A)"]
end
subgraph tc["topic(C)"]
C2[C]; B3["B (pins C/x:1)"]
end - Every node joins the swarm topic. It carries no announcements; it supplies neighbors (
NeighborUp→ inventory sync) and bootstrap peers. - A node subscribes to its own topic and to the topic of every publisher its policy follows, seeds or pins;
ocictlreloads the policy in the daemon, which joins/leaves topics accordingly. Announcements are broadcast on the publisher's topic only, so nodes receive exactly what they can act on. - A newly reachable peer (swarm neighbor, mDNS,
ocictl connect) is introduced to every subscribed topic; peers not on a topic ignore the join. - Announcements from a different publisher than the topic's owner are dropped.
Replicate (policy-driven)¶
sequenceDiagram
participant A as node A (publisher)
participant B as node B (follows A, last:2)
A-)B: gossip Announcement on topic(A)
B->>B: select_wanted(known ∪ announced) → wanted?
B->>A: sync GetRelease(publisher, name, tag)
A-->>B: Release (signed JSON bytes)
B->>B: verify signature
loop each BlobRef not present
B->>A: iroh-blobs download(blake3), protected from GC
A-->>B: verified stream (bao)
B->>B: verify sha256 + size, index, pin blob
end
B->>B: put release
B->>B: enforce_window → prune oldest, unpin blobs Same logic runs on NeighborUp, on mDNS discovery and on ocictl sync, starting from the peer's Inventory instead of a single announcement — this catches up after downtime.
Pull on demand¶
sequenceDiagram
participant P as podman
participant C as registry (C)
participant X as peers (neighbors, known, publisher)
P->>C: GET /v2/<A>/alpine/manifests/3
C->>C: local release complete? no
loop candidate peers, until found
C->>X: sync GetRelease
X-->>C: Release | None
end
C->>X: download blobs (shuffled providers)
C->>C: verify, index, store release (cache)
C-->>P: manifest (+ Docker-Content-Digest)
P->>C: GET blobs/sha256:… (Range supported)
C-->>P: streamed from store Any node that holds a complete release serves it (seeding): C above can fetch A's image from B if B replicated it, even if A is offline.
Deletion and garbage collection¶
flowchart TD
del["DELETE /v2/<name>/manifests/<ref><br/>ocictl rm"] --> rr["remove release record<br/>(local only, not propagated)"]
win["enforce_window"] --> rr
gc["gc (periodic / ocictl gc)"] --> ret["retained = own ∪ pinned ∪ in-window<br/>∪ cache younger than grace"]
ret --> rr
rr --> sweep["sweep: unpin blob:<sha256> tags no<br/>retained release references, drop index entries"]
sweep --> ib["iroh-blobs GC deletes unpinned data<br/>(blob_gc_interval_secs)"] - Blobs are pinned in iroh-blobs under
blob:<sha256>tags; in-flight downloads hold a temp tag so a sweep can't race them, and fetch vs. GC is serialised by an RwLock (fetch = read, GC/prune = write). DELETEon a blob is refused (405): only GC removes data.- A deleted own release simply stops being announced; peers that already replicated it keep it until their own policy drops it.
Wire protocols¶
| ALPN | purpose | encoding |
|---|---|---|
/iroh-gossip/1 | Announcement::Release(ReleaseSummary) on the publisher's topic; membership on the swarm topic | postcard |
ocid/sync/1 | Hello{addr}, Inventory, GetRelease, ListTags — one bi-stream per request; releases returned as their signed JSON bytes | postcard framing |
/iroh-bytes/… (iroh-blobs) | blob download by BLAKE3 hash with incremental verification | bao |
mDNS _ocid._udp.local | LAN discovery of other ocid endpoints (no tickets needed) | iroh-mdns-address-lookup |
| pkarr / mainline DHT | internet-wide address publishing + lookup by bare key (n0.computer relay re-publishes into the DHT; pkarr = "off" disables) | iroh presets::N0 |
Announcements are deliberately tiny (summary only) so they fit gossip limits; the signed record is always fetched and verified via sync.
Observability¶
GET /metrics on the registry port serves OpenMetrics text: ocid_* (HTTP requests by method/route/status, bytes served/received, releases published/replicated/failed, blobs fetched, announcements, sync requests by type, mDNS discoveries, gossip topics, GC runs/removals, gauges for neighbors/peers/releases) and iroh's endpoint and gossip metrics (iroh_*).
GET /_ocid/events serves a Server-Sent Events (SSE) stream of DaemonEvents (gossip announcements, releases saved, transfer progress and fetch failures, window prunes, peer connections, and HTTP requests) consumed in real time by ocitop.
Podman Desktop extension¶
extensions/podman-desktop is a thin dashboard over the same control API — a third consumer next to ocictl and ocitop, not a second implementation of the control plane. Rules that keep it thin:
- The backend owns all network I/O. The extension's main process runs the typed
/_ocidclient, a 2s poller (same model asocitop) and an SSE watcher feeding a ring buffer; the webview is a pure view talking over postMessage. (The daemon has no CORS headers, so the webview cannot fetch anyway — and the UI never depends on the SSE stream.) - Every action maps 1:1 to a
/_ocidendpoint. Policy actions go through the policy mutation endpoints; the daemon stays the single policy writer.src/types.tsmirrorsocid-core's API DTOs — that mirror is the contract to update when the control API changes. - UI: Svelte 5 webview built with
@podman-desktop/ui-svelteand Tailwind, colors via Podman Desktop's--pd-*theme variables. Status header, releases table (seed/pin/follow/remove), peers + ticket connect, live events, connection ticket copy + QR. - Distributed as a scratch OCI artifact (
Containerfile,/extensionlayout,io.podman-desktop.api.versionlabel) for the Podman Desktop catalog; local development loads the folder directly. - Phase 2 (tracked in #15): bundling per-platform daemon binaries, daemon lifecycle, and registering the local registry in podman. The registry part cannot use PD's settings dialog (an
auth.jsonlogin flow that assumes https and credentials) — it means writing aregistries.confdrop-in ([[registry]] insecure = true) on the host (Linux) or inside the podman machine withhost.containers.internal(macOS); see the research notes on #15. Update (phase 3): the daemon now serves TLS with a self-signed CA, so the drop-in is replaced by installing the CA into podman'scerts.d; the extension bundles the daemon as a container image (assembled from the release binaries) and runs it as a host-networked, quadlet-supervised pod — details inextensions/podman-desktop/README.md.
Deployment & supervision¶
The same daemon, supervised per channel — every packaged channel serves TLS on loopback and keeps its CA in <its OCID_HOME>/tls:
| channel | runs as | supervised by | state home |
|---|---|---|---|
.deb/.rpm | host service (DynamicUser, hardened) | systemd ocid.service — enable once with systemctl enable --now ocid | /var/lib/ocid |
| Homebrew | user agent | brew services start ocid (launchd on macOS, systemd user unit on Linux) | ~/.local/share/ocid |
| PD extension | rootless container (host network) | systemd quadlet, podman run --restart=always fallback | ~/.local/share/ocid |
| container image | container | runtime restart policy | volume (/data) |
| manual | foreground process | you | ~/.local/share/ocid |
The Homebrew formula template lives in packaging/brew/ocid.rb — the tap (safonas/tap) is a rendered copy kept in sync by just packaging brew and just publish-release.
Trust boundaries¶
- Publisher is trusted for what
name:tagmeans (they sign it). - DNS is not trusted: a
_ocidTXT record is only an unverified claim until its Ed25519 signature checks out and it agrees with the local TOFU pin. The pins ($OCID_HOME/dns-pins.json), not DNS, are the root for name→key mapping. - Providers (any peer) are not trusted: BLAKE3 verifies every chunk while streaming, sha256 is re-checked after download against the signed record, and podman verifies sha256 again on pull.
- Local registry has no auth; it binds to loopback. Anyone who can reach it can push as you — same trust model as the local podman socket. With
tls = "auto"it serves HTTPS with a self-signed CA under$OCID_HOME/tls(transport encryption and a CA clients can pin — not authentication). - Deletion is local. There is no signed tombstone; a publisher cannot recall a release from peers who already hold it.
Testing¶
just test— unit tests (policy parsing, windows, naming, records).just e2e—scripts/e2e.shstarts two daemons on the host and drives them with podman/curl/ocictl (and oras if present): publish, replicate, run a container from the replica, on-demand pull, Range/DELETE/metrics, follow/seed/pin windows with pruning and topic joins, aliases, GC, offlineocictl, DNS publisher names (dnsmasq zone: pull by domain, hijack rejection, stale rejection, pin persistence, rotation).
User documentation¶
- dns-publisher-names.md — DNS publisher names: record format, trust model, publisher/consumer workflows, configuration, HTTP API, troubleshooting.
Deferred¶
- delegation / multi-key publishers (Radicle "delegates"): a publisher is exactly one key
- signed tombstones (propagated deletion)
- registry authentication