The first useful content-store API fit on two lines, which felt like a small miracle:
PUT /blobs/<digest>
GET /blobs/<digest>
For a while, it felt wonderfully complete.
A user could upload bytes, verify that their digest matched the address, and retrieve the same immutable object later. Deduplication fell out naturally. Replication became easier because every recipient could verify what it received.
Then we tried to describe an actual release.
A release is not one blob. It has a manifest, dependencies, signatures, attestations, mutable human names, and a moment when all of those related objects should become visible together. Two namespaces may store the same immutable bytes while disagreeing about which tag points to them or which signatures they trust. Garbage collection has to understand reachability. Replicas need to compare state without replaying every write since the beginning of time.
The blob API remained useful. It had never been the whole registry.
That is why I think of kappa-registry as a graph service built over a content store—not a bucket with an increasingly anxious metadata table beside it.
The same bytes can mean different things in different namespaces
An immutable content object can be global.
If two namespaces store identical bytes under the same hash axis, the underlying blob should only need to exist once. The namespaces may still attach different names, edges, policies, and signatures to it.
flowchart TB
B[Global content-addressed blobs]
N1[Namespace alpha] --> T1[tag latest]
N1 --> E1[typed edges]
N1 --> R1[signed root]
N2[Namespace beta] --> T2[tag stable]
N2 --> E2[different edges]
N2 --> R2[signed root]
T1 --> B
E1 --> B
T2 --> B
E2 --> B
The blob makes one narrow statement:
These bytes derive this address.
The namespace makes a contextual statement:
In this namespace, this mutable name or typed relationship currently refers to that object.
Those statements have different authorities and different update rules. Blending them makes a mutable tag part of immutable identity—or makes an immutable object unexpectedly change when a namespace is edited.
Keeping them separate lets global content remain stable while local meaning evolves.
A tag is useful because it can move
People do not want to type a long content address every time they refer to a release. They want names such as:
latest
production
model-small
release-2026-08
Those names are valuable precisely because they are mutable.
A content address is valuable precisely because it is not.
flowchart LR
T[Mutable tag: production] --> A1[Object κ1]
T -. update .-> A2[Object κ2]
A1 --> B1[Immutable bytes]
A2 --> B2[Immutable bytes]
The mutable operation needs concurrency semantics. A publisher should be able to say:
Move
productionfrom κ1 to κ2 only if it still points to κ1.
That is compare-and-swap over namespace state. Without it, two publishers can race, each believing it advanced the same tag from the version it observed.
The immutable store remains append-only. The contested part is small and explicit: which namespace record points where now?
That is a much easier problem to reason about than pretending the friendly name is the object’s identity.
The object becomes useful through its relationships
A model artifact may include weight shards, a tokenizer, configuration, an evaluation report, a license, an attestation, and a compiled graph. An application may depend on several archives and runtime objects.
The registry needs to preserve those relationships as typed edges:
flowchart LR
M[Model manifest κ] -->|weights| W[Weights κ]
M -->|tokenizer| T[Tokenizer κ]
M -->|config| C[Config κ]
M -->|evaluated-by| E[Evaluation report κ]
M -->|compiled-as| H[.holo archive κ]
H -->|requires| W
The type is not decoration. “Depends on,” “was derived from,” “was signed by,” “was evaluated by,” and “supersedes” answer different questions.
Once edges are first-class, the registry can walk an application’s dependency closure, find attestations referring to an artifact, discover every compiled graph derived from a particular weight set, compare namespace graphs, and determine which objects are still reachable.
A bucket can store a JSON document containing edges. A registry makes the graph part of its query and consistency model.
That difference becomes important the moment storage safety depends on the relationships.
A namespace needs one verifiable state
Tags and edges are mutable. So are namespace schemas, filters, pinned roots, and transaction results.
The registry can derive a deterministic root from the canonical namespace state. Let be its ordered records:
A real implementation may use a tree or range structure so replicas can compare portions efficiently. The important property is that the same logical namespace derives the same root.
flowchart TD
T[Tags] --> C[Canonical namespace state]
E[Edges] --> C
S[Schemas and filters] --> C
P[Pins] --> C
C --> R[Deterministic root]
R --> G[Optional signature]
A signature over that root does not make every referenced object trustworthy. It makes the namespace state attributable and tamper-evident.
A user can verify that the tag and edge graph it received belongs to the state a particular key endorsed.
The root gives the mutable part of the registry an identity without pretending that it is immutable forever.
Publication needs one visible moment
Suppose an application release requires three blobs, a manifest, dependency edges, and a tag update.
Publishing those records one at a time exposes partial states. A reader may observe the manifest before all of its dependencies, or the tag may move before the new graph is complete.
The registry therefore needs a bounded transaction and a promotion boundary:
sequenceDiagram
participant U as User
participant S as Staging area
participant R as Registry namespace
U->>S: begin bounded transaction
U->>S: upload blobs and manifest
U->>S: add edges and tag update
U->>S: validate complete set
alt all checks pass
S->>R: atomic promotion
R-->>U: new namespace root
else limit, timeout, or validation failure
S-->>C: abort
end
The word bounded matters.
A staging area without limits becomes a way to reserve storage indefinitely. Transactions need maximum concurrent counts, byte ceilings, expiration, validation, and cleanup after abandonment.
Atomicity is useful only when the unfinished work cannot consume the service forever.
Large uploads remain untrusted until finalization
Artifact transfers fail midway. A useful upload protocol has to support recovery without weakening object integrity.
An upload session can accept ordered chunks and retain bounded progress state. The object does not enter the immutable store until the final digest is computed and compared with the expected address.
stateDiagram-v2
[*] --> Open
Open --> Receiving: PATCH ordered chunk
Receiving --> Receiving: more chunks
Receiving --> Finalizing: PUT with expected address
Finalizing --> Complete: computed address matches
Finalizing --> Rejected: mismatch
Open --> Expired
Receiving --> Expired
Until finalization, the bytes are staging data. They may be incomplete, malformed, or maliciously mislabeled.
After the digest matches, they become the addressed object.
That last transition is what turns “some uploaded bytes” into content whose name can verify it everywhere else in the system.
Replicas should compare what they have, not recount every event
Two registries need a way to discover disagreement.
Replaying every historical operation can work, but it makes the operational log the only path to convergence. Deterministic state roots allow another approach.
If two namespace roots match, the logical states match under the root construction:
subject to the collision bound and canonical state model.
When the roots differ, range roots or subtrees can narrow the mismatch:
flowchart TD
A[Replica A root] --> Q{Equal to B root?}
B[Replica B root] --> Q
Q -- yes --> D[No reconciliation needed]
Q -- no --> R[Compare ranges / subroots]
R --> M[Identify missing records]
M --> F[Fetch blobs and namespace updates]
F --> V[Verify and recompute root]
Immutable blobs remain straightforward to replicate because the address verifies them on receipt. Namespace state needs the additional comparison and update protocol because its meaning is allowed to change.
The root tells replicas where they disagree before they transfer the entire namespace to find out.
Garbage collection is where the graph stops being optional
It is unsafe to delete every blob that lacks a direct pin.
A pinned manifest may refer to an unpinned weight shard. A namespace root may retain objects through typed edges. A delta bundle may depend on a base. An active transaction may still be assembling a reachable graph.
The live set is the closure of retained roots:
flowchart LR
P[Pins] --> W[Reachability walk]
N[Namespace roots] --> W
X[Active leases / transactions] --> W
W --> L[Live object set]
A[All stored objects] --> D[Set difference]
L --> D
D --> G[Garbage-collection candidates]
An object is collectible only when it is outside that closure and no retention rule or active operation protects it.
At this point the graph is clearly not “metadata.” It determines whether deleting a blob is safe.
The same closure model defines portable bundles. A full bundle carries selected roots and the objects reachable from them. A delta carries only what the receiver lacks relative to a known root or fingerprint. Every object remains independently verifiable, and the namespace state can be checked against its deterministic root.
That makes the format useful for offline transfer, browser import, air-gapped deployment, and peer synchronization.
Verification and admission are separate questions
A blob whose bytes match its address is globally valid as content.
A particular namespace may still refuse to reference it.
The namespace may require a schema, license, signature, object type, size limit, or policy condition:
flowchart LR
U[Uploaded object] --> H{Address verifies?}
H -- no --> X[Reject]
H -- yes --> S{Schema / filter passes?}
S -- no --> Y[Keep out of namespace]
S -- yes --> N[Admit reference and update root]
The hash answers whether these bytes match this address. Admission answers whether this namespace is willing to attach meaning to them.
Keeping those decisions apart allows the same immutable object to be acceptable in one context and rejected in another without changing its identity.
A single-node implementation can still define the hard contract
The current kappa-registry scope is intentionally explicit: it is filesystem-backed and single-node. Upload sessions and rate-limiter state are process-local. The authorization hook is not yet a complete external identity system.
That does not make the reference implementation disposable.
A single node can establish the object-verification rules, namespace separation, transaction semantics, graph queries, root derivation, upload protocol, bundle format, reconciliation model, garbage-collection safety, and conformance tests.
Ask the two-line API one more question
Take the next release you need to publish and list everything the two blob calls cannot say:
- which human name should move?
- which dependencies must arrive with it?
- which signatures and attestations apply?
- when should readers see the whole release?
- how can another namespace compare its state?
Those answers are the beginning of the registry. The blob store can remain exactly as simple as it was.
Distribution can then be added around a state model the project already understands.
The more dangerous order is to build a cluster first and use production failures to discover what the replicas were supposed to agree on.
The blob was the easy, beautiful part
The original two-line API remains the foundation. Stable content identity simplifies storage, transfer, verification, and deduplication enormously.
It does not eliminate mutable state.
It moves mutable state into smaller, explicit structures: tags, typed edges, namespace roots, leases, policies, and atomic promotion.
Those structures are exactly where the registry has to become deliberate.
A bucket stores bytes under keys. A registry has to preserve the object graph, the changing human names around it, and enough verifiable state to move that graph between systems without losing track of what any of it means. The bucket was not wrong; I had just been asking it to answer a bigger question than it was built for.