Skip to content

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/&lt;repo&gt;/…"] --> 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.&lt;zone&gt; 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.toml keeps 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.
  • blobs lists 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 timestamp for the same (publisher, name, tag) supersedes. Timestamps are seconds; within one second the greater tag sorts newer.
  • referrers are manifests whose subject is 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:N or full. Several rules for one image combine to the widest window; tagged seeds and pins add fixed tags.
  • select_wanted is evaluated against everything the node knows about an image — local releases plus what peers' inventories and announcements report — so a node with last:2 fetches the two newest and nothing else.
  • enforce_window runs 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} mutate policy.toml (alias-resolving the reference), persist it, resubscribe gossip topics and enforce windows in one step — the same effects a reload would have. ocictl uses 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_secs after it was fetched, then drops it. ocictl gc --force ignores 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; ocictl reloads 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/&lt;A&gt;/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/&lt;name&gt;/manifests/&lt;ref&gt;<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:&lt;sha256&gt; 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).
  • DELETE on 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 /_ocid client, a 2s poller (same model as ocitop) 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 /_ocid endpoint. Policy actions go through the policy mutation endpoints; the daemon stays the single policy writer. src/types.ts mirrors ocid-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-svelte and 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, /extension layout, io.podman-desktop.api.version label) 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.json login flow that assumes https and credentials) — it means writing a registries.conf drop-in ([[registry]] insecure = true) on the host (Linux) or inside the podman machine with host.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's certs.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 in extensions/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:tag means (they sign it).
  • DNS is not trusted: a _ocid TXT 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.sh starts 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, offline ocictl, 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