277 lines
13 KiB
Markdown
277 lines
13 KiB
Markdown
# UUID-Rooted Metadata with CID Aliasing
|
|
|
|
## Status
|
|
|
|
**Status:** Design record. Implemented in 2026 alpha (clean-wipe gate);
|
|
**reframed by meta-core v2.0.0 (claims model)** — see
|
|
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
|
|
§4. This doc keeps the *why* behind UUID rids and CID symmetry; it is not
|
|
the schema reference. For what is stored, see
|
|
[metadata-storage-structure.md](./metadata-storage-structure.md) and
|
|
`METADATA_KEYS.md` §2/§14.13.
|
|
|
|
What still holds, unchanged since the original proposal:
|
|
|
|
- **No CID is privileged.** Any known CID resolves to its record; all hash
|
|
types are symmetric.
|
|
- **Rids are UUIDv7**, 26-char Crockford Base32, **minted locally** with no
|
|
central allocator.
|
|
- **`canonical_cid` is derived on read** by rank (`internal/cid/rank.go` →
|
|
`cid.Better`), never stored.
|
|
- **`cids` is a key-set of bare CIDs** (`cids/<bareCid>` = `"true"`), no
|
|
`<algo>:` prefix — a CIDv1's multicodec already names the hash function.
|
|
|
|
What changed in v2.0.0:
|
|
|
|
> 1. **The rid is a hidden local name for a group of cids.** Metadata is no
|
|
> longer stored under `file:<uuid>/<field>`. Every value is a signed claim
|
|
> about a **cid** (`claim(uid, cid, key) → value`); `cid_group(cid → rid)`
|
|
> says which cids form one record. The rid is **derived from cid groups,
|
|
> never signed and never on the wire** (swarm, gateway); each box mints its
|
|
> own.
|
|
> 2. **The `cid:<cid>` reverse index is gone**, replaced by the `cid_group`
|
|
> table (still a primary-key lookup, so still O(1)-class).
|
|
> 3. **meta-core no longer writes `cids/*` members.** They are signed votes
|
|
> from the writers that learn a cid (e.g. the full-hash plugin); a non-null
|
|
> `cids/<other>` vote links two groups. Only the watcher's midhash enters
|
|
> `cid_group` directly.
|
|
> 4. **`duplicates` / `dup:<uuid>/<path>` are gone.** Box-local paths live in
|
|
> the cache's `local_file(path → midhash, size, mtime)`; several paths with
|
|
> the same midhash are the duplicates.
|
|
>
|
|
> Earlier superseded points, kept for the record: the proposed
|
|
> `reconcileCanonicalCIDLocked` never shipped; `cids` never was a Redis SET
|
|
> of `<algo>:<value>` tokens; Redis itself went in v1.1.0 (2026-10). Text
|
|
> below marked *(proposal)* or *(v1.x)* describes those shapes, not v2.0.0.
|
|
|
|
## Motivation (history)
|
|
|
|
Before UUID rids, every metadata entry in Redis was keyed by a content hash,
|
|
specifically midhash256:
|
|
|
|
```
|
|
file:midhash256:abc.../filePath
|
|
file:midhash256:abc.../title
|
|
file:midhash256:abc.../tmdb/poster
|
|
```
|
|
|
|
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) lived as fields
|
|
*inside* that entry (`cid_sha256`, `cid_ipfs`). Two problems:
|
|
|
|
1. **midhash256 was privileged by accident.** It *was* the key, while every
|
|
other CID was a secondary field. Hash-algorithm changes, or new digest
|
|
types added by plugins, forced rewrites of every root. The privilege
|
|
itself was the issue, not which hash held it.
|
|
2. **CID lookup was O(n).** "Given any CID, return the metadata" scanned
|
|
every entry (`storage/client.go::LookupPathByCID`). Unacceptable for a
|
|
public API, especially when peers query by any CID variant.
|
|
|
|
The requirement: **any CID a client knows must resolve to that content's
|
|
metadata in O(1)**, and the storage layout must not bias toward any digest
|
|
type. The claims model adds a second one: **what is signed must name
|
|
something every peer agrees on** — a rid minted at random on one box does
|
|
not qualify, a cid does.
|
|
|
|
## Goal
|
|
|
|
```
|
|
GET /api/meta/<cid> → full (merged) metadata document for that content
|
|
```
|
|
|
|
`<cid>` may be any supported type (midhash, sha256, IPFS, btih, sha3, future
|
|
additions), all treated symmetrically.
|
|
|
|
## Design (v2.0.0)
|
|
|
|
### Votes name cids; a rid names a group
|
|
|
|
```
|
|
votes (signed, travel) derived (local, persistent: truth cid_group)
|
|
(A, M, title) = "The Matrix" M → R1
|
|
(A, M, cids/Q) = "true" ← alias vote Q → R1
|
|
(B, Q, title) = "Matrix, The"
|
|
(B, Q, sizeByte)= "734003200" record R1 = every vote on M or Q, merged per key
|
|
```
|
|
|
|
- **First-seen cid, no link** → new group, fresh UUIDv7 rid.
|
|
- **`cids/<other>` vote on X** → X and `<other>` join one group.
|
|
- **Link between two existing groups** → the group with the **older rid
|
|
survives**; the other's cids move to it; the losing rid gets a `del` event
|
|
for each of its keys, the survivor `set` events. No `rid_alias`: consumers
|
|
already handle deletes, and cids re-resolve.
|
|
- **Reading record R** = every vote on every cid of R's group, merged per key
|
|
(one signer counts once per key), precomputed into the cache's
|
|
`file:<rid>/<field>` rows (the v1 key layout, now derived).
|
|
- **Retracting a `cids/<other>` link does not split the group** (phase 1):
|
|
the tombstone drops out of the merge, `cid_group` keeps both cids under one
|
|
rid. In the record view, membership is synthesised from `cid_group`
|
|
(`cids/<cid> = "true"` for every cid of the group).
|
|
|
|
Why keep a rid at all, if votes never mention it: local consumers (meta-sort,
|
|
meta-fuse, meta-stremio) and the merged view need one stable name per record,
|
|
and a group's cid set grows over time. Why `cid_group` lives in **truth**
|
|
although it could be recomputed from `cids/*` votes: the rid names are minted
|
|
at random and consumers hold them, so a recomputation would rename every
|
|
record.
|
|
|
|
### Why UUIDv7 (still valid)
|
|
|
|
- **128 bits**: collision-free under distributed minting; no allocator. Each
|
|
box minting its own rids for the same content is now the normal case, not a
|
|
corner case — which is exactly why rids never travel.
|
|
- **Time-sortable prefix**: rids minted in a window sort together
|
|
(`k >= 'file:01JKR8' AND k < 'file:01JKR9'` on the cache's merged rows),
|
|
and "older rid
|
|
survives" on a group merge is a plain string comparison.
|
|
- **Monotonic-ish**: adjacent rids cluster in the cache's `file:<rid>/…`
|
|
key range.
|
|
|
|
Encoded as 26-char Crockford Base32 (ULID-compatible) for compactness and
|
|
case-insensitive matching. Migrated records kept their v1.x UUIDs as rids.
|
|
|
|
### The `cids` key-set
|
|
|
|
`cids/<bareCid>` = `"true"`, one claim per member, by whichever signer knows
|
|
the link. A key-set merges conflict-free: a member is present while at least
|
|
one vote supports it, and removal is a signed retraction. That is the reason
|
|
the proposal's Redis SET was reversed before shipping, and it is what lets
|
|
the claims model treat `cids` with no special merge code.
|
|
|
|
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are **not**
|
|
claims — meta-core cannot sign and the disk can always be rescanned. The
|
|
watcher records them in `local_file` and puts the midhash into `cid_group`;
|
|
the record view joins them in unsigned. When several local paths hold the
|
|
same content, `filePath` is the lexicographically first one.
|
|
|
|
### Canonical CID selection (unchanged)
|
|
|
|
The canonical CID is a label, not a key, so nothing external breaks when it
|
|
changes. It is computed on every read from the group's cids:
|
|
|
|
1. IPFS CID (any variant)
|
|
2. SHA-256 / SHA-3 family
|
|
3. BitTorrent infohash (v2 preferred over v1)
|
|
4. midhash256
|
|
|
|
Ranked by **utility for external retrieval**, not cryptographic strength:
|
|
midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
|
|
externally meaningful; btih ranks above midhash because it enables swarm
|
|
interop. The full ladder (including the custom locator codecs) lives in
|
|
`internal/cid/rank.go`, mirrored in meta-search / meta-share
|
|
(`scripts/check-cid-vectors.sh`).
|
|
|
|
## Lifecycles (v2.0.0)
|
|
|
|
### File arrival
|
|
|
|
```
|
|
1. Watcher sees /files/watch/foo.mkv, computes its midhash M.
|
|
2. local_file: /files/watch/foo.mkv → (M, size, mtime).
|
|
3. cid_group: M already grouped → nothing; otherwise M → fresh rid.
|
|
4. Plugins process the file by cid M (the rid is never disclosed to them)
|
|
and sign claims about M with their own keys.
|
|
5. The full-hash plugin computes sha256 S and signs (plugin, M, cids/S) = "true".
|
|
→ S joins M's group (or the two groups merge, older rid surviving).
|
|
→ The next read derives canonical_cid = S, because it outranks the midhash.
|
|
```
|
|
|
|
A second path with the same midhash is just another `local_file` row: same
|
|
cid, same group, same record; the record's `filePath` is the
|
|
lexicographically first of the paths.
|
|
|
|
### File deletion
|
|
|
|
The watcher removes the path's `local_file` row; the record view loses that
|
|
`filePath`. Claims are signed truth from their writers and are not deleted
|
|
by meta-core (the store is permanent and additive,
|
|
`docs/project-architecture/metacore-as-permanent-store.md`).
|
|
|
|
*(v1.x)* Deletion promoted a duplicate to `filePath`, or called `DeleteRoot`,
|
|
which removed every `cid:*` alias and every `file:<uuid>/*` key.
|
|
|
|
## Public API
|
|
|
|
```
|
|
GET /api/meta/<cid> → 200 merged document { "filePath", "canonical_cid", "cids": [...], ... }
|
|
(optional ?as=<uid>: the viewer's own votes win)
|
|
→ 404 if no such cid
|
|
GET /api/meta/<cid>/claims → {rid, cids, claims}: every live signed claim of the cid's
|
|
group, all signers (?key=, ?uid=); 404 if no such cid
|
|
GET /api/metadata/<rid>/claims → the same, addressed by rid (local consumers)
|
|
```
|
|
|
|
The rid is not part of the cid-addressed contract and never appears in a
|
|
claim. It is visible only to local consumers that list or address records by
|
|
rid (`/api/metadata/{rid}`, `/api/metadata/{rid}/claims`, `hash-ids`, SSE event
|
|
keys; the claims routes also echo it as `rid`), and may change on a
|
|
group merge (delete event, then re-resolve by cid).
|
|
|
|
Auth: `/api/meta/<cid>` bypasses the perimeter like `/api/file/<cid>` — it is
|
|
the metadata twin of the content endpoint.
|
|
|
|
## Cross-service impact (v2.0.0)
|
|
|
|
- **Writers** (plugins, self-publishing feeders, gateway core, meta-share,
|
|
meta-search): address claims by cid only and sign them; they never resolve
|
|
a rid.
|
|
- **meta-fuse, meta-stremio**: unchanged — merged reads and SSE keep their
|
|
shape; a group merge reaches them as ordinary `del` + `set` events.
|
|
- **meta-search / meta-share**: announce the derived canonical cid (each
|
|
mirrors the Go rank) and relay raw claims; every cid of the group travels as
|
|
`cids/*` claims, so a peer querying by any digest gets a hit.
|
|
|
|
## Migration (history)
|
|
|
|
> **The v1.0 sweep below was never run.** The alpha shipped behind a
|
|
> clean-wipe gate. The v1.x → v2.0.0 conversion of watch.nsl.sh is a
|
|
> one-off tool outside the service image, `packages/meta-core/tools/claims-migrate`
|
|
> (canonical cid by rank per record; `cid:` aliases → `cid_group`, rids keep
|
|
> their UUIDs, and the `cid:` index wins when a record's `cids/*` member
|
|
> disagrees; fields re-signed by a throwaway legacy key); every other box
|
|
> starts empty. Implementation basis §11.
|
|
|
|
*(proposal)* One-time sweep over `file:__index__`: mint a uuid, `AddAlias`
|
|
the old hashID and every `cid_*` field, `RENAME` every property under the new
|
|
root, reconcile canonical, swap the index entry; crash-recoverable by
|
|
detecting which step completed.
|
|
|
|
## Decisions recorded
|
|
|
|
| Question | Decision | Notes |
|
|
|---|---|---|
|
|
| When is the rid minted? | When a cid is first seen with no link to a known group | v1.x: at first hash of a watched file. Now any first-seen cid (watcher midhash or a claim about a new cid) mints one |
|
|
| Rid format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe |
|
|
| Rid visible externally? | No — never signed, never on the swarm/gateway wire | Local HTTP consumers may hold it |
|
|
| What do signed votes name? | A cid | Every peer agrees on a cid; rids differ per box |
|
|
| Record identity | A group of cids (`cid_group`, in truth) | Linked by `cids/<other>` votes |
|
|
| Group merge | Older rid survives; losing rid → `del` events | No `rid_alias` (out of scope for phase 1) |
|
|
| Duplicate paths | `local_file` rows sharing a midhash (cache) | v1.x: `duplicates` SET, then `dup:<uuid>/<path>` |
|
|
| Canonical CID rank | IPFS > sha-family > btih > midhash, derived on read | Utility-weighted; never stored |
|
|
| `cids` shape | Key-set of bare CIDs, one claim per member | Merges with the generic rule; a SET would not |
|
|
| Reverse index | `cid_group(cid PK → rid)` in truth, copied to cache `cid:<cid>` rows for reads | v1.x: flat `cid:<cid>` keys were the only copy |
|
|
| Unlinking | A retracted `cids/*` link does not split a group | Phase 1 |
|
|
|
|
## Open questions
|
|
|
|
- **Discovery-time rid minting.** Minting at discovery (before hashing)
|
|
would give two same-content paths two rids until hashing links them. The
|
|
claims model already handles that case — it is an ordinary group merge —
|
|
so this is now a policy choice, not a missing mechanism.
|
|
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` for
|
|
external IPFS clients? Adjacent, not part of this design.
|
|
- ~~**meta-share announce strategy.**~~ Settled: the canonical cid addresses
|
|
the record, every group cid travels as a `cids/*` claim.
|
|
- ~~**Keyspace-notification consumers.**~~ Obsolete since Redis left.
|
|
|
|
## Non-goals
|
|
|
|
- **Not** a content-replication or pinning system. A `cids/<ipfs-cid>` vote
|
|
pins nothing; it states "hashing these bytes with IPFS's chunker gives this
|
|
CID."
|
|
- **Not** a history. `ts` versions each `(uid, cid, key)` and the newest vote
|
|
replaces the older one; only tombstones are kept, to block replay.
|
|
- **Not** strongly consistent across the two databases: the cache is applied
|
|
after the truth commit. `POST /api/claims` waits for the apply before
|
|
answering, so the writer reads its own write; another reader may briefly
|
|
miss a just-accepted claim. It is behind, never wrong.
|