RFC: Registry
Status: Draft Author: Segev Shmueli Date: 2026-06-30
Summary
A Registry is the host-side service that knows which apps a host has, at which versions, and how to load them. Where the App RFC describes a single unit of integration, this RFC describes the collection — how a host stores, retrieves, versions, and serves those units to its runtime.
The registry is transport-free and storage-free: it owns the semantics (register, get, list,
load, lifecycle) and depends on an injected DataStore for persistence. A host (e.g. the w6w
server) supplies the data store; the registry library hands back a ready-to-use registry instance.
Motivation
Every host that runs @w6w/runtime ends up reinventing the same four things: an “apps catalog”
table, an import pipeline (resolve → loadApp → describe → persist), a “load it again at invoke
time” path, and a small set of lifecycle operations (deprecate, hide, retire). That logic doesn’t
belong to the runtime (which only knows about one LoadedApp at a time) and it doesn’t belong to
the host (which would re-implement it per database). It belongs in its own primitive that:
- Is specified once and reused by every host (today: the w6w server; tomorrow: internal tools, OSS hosts, a hypothetical CLI).
- Pluggable on persistence — Postgres, SQLite, in-memory, JSON-on-disk, or a future federated upstream.
- First-class about version history, so a host can hold multiple versions of the same app and a workflow step can pin to one.
- Leaves room for federation (upstream pull, mirroring) without disturbing the local-host contract.
Goals
- A logical
RegisteredAppshape, format-agnostic like the App manifest. - A
DataStorecontract with explicit atomicity guarantees. - Idempotent registration keyed by a content digest — re-registering the same source is a no-op.
- Versioned by default — apps stored per
(id, version)with alatestpointer per id. - Lifecycle overlay — host policy (deprecate, hide) layered on top of the publisher’s manifest claim.
- Load is part of the registry —
registry.load(id, {version?})returns aLoadedAppready for@w6w/runtime.invoke. - Federation reserved — concept and field names baked in; mechanics deferred.
Non-Goals
- Defining a wire format / HTTP API for the registry (a transport wrapper is a separate concern).
- Defining marketplace UX (discovery, ratings, install flows).
- Defining the federated pull / mirror protocol (reserved for a future RFC).
- Mandating any specific storage engine.
Concept
A registry is a set of registered apps. Each registered app is a sequence of app versions,
each of which is one immutable, validated record produced by registering a source reference
(file:, github:, …) at a moment in time.
A registered app has, at any time, exactly one latest version — the one returned by an
unqualified get(id) or shown by list(). Older versions are preserved and reachable via
getVersion(id, version) / listVersions(id).
Identity is the App’s reverse-DNS id. Within an id, the manifest version (SemVer) is the version
key. Content identity is the registered version’s digest — a sha-256 over the canonicalized
{ manifest, actions, auth } produced by @w6w/runtime.describe(). Two registrations with the same
digest are the same; the registry deduplicates.
┌──────── Registry ─────────┐
register("hello") ─► │ apps["io.w6w.hello"] │
│ ├─ versions: │
│ │ ├─ 1.0.0 (digest X)
│ │ └─ 1.1.0 (digest Y) ← latest
│ └─ overlay: { maturity: "deprecated" }
└────────────────────────────┘
Shape
The registry surface is intentionally small. Below is a TypeScript rendering; the same logical shapes map 1:1 to JSON or any other serialization.
RegisteredApp — the public view of an id
interface RegisteredApp {
/** The App's reverse-DNS id. */
id: string;
/** The version currently marked latest. */
latest: AppVersion;
/** Total versions stored for this id. */
versionCount: number;
/** Host-side lifecycle overrides (see Lifecycle). */
overlay: LifecycleOverlay;
/** Effective classification = overlay ?? manifest.classification. */
effective: EffectiveClassification;
/** When `id` was first registered. */
registeredAt: string; // ISO-8601
/** When any version under this id was last touched. */
updatedAt: string; // ISO-8601
}
AppVersion — one stored version
interface AppVersion {
/** The App's reverse-DNS id. */
id: string;
/** The manifest's `version` field. SemVer. */
version: string;
/** Content digest: sha-256 of canonical({ manifest, actions, auth }). Hex. */
digest: string;
/** Source reference this version was registered from (e.g. file:..., github:owner/repo@ref). */
sourceRef: string;
/** Provenance of this version. */
origin: Origin;
/** The full manifest as returned by @w6w/runtime.describe(). */
manifest: AppManifest;
/** Action definitions exposed by this version. */
actions: Action[];
/** Auth methods declared by this version. */
auth: Auth[];
/** When this version was first written to the registry. */
registeredAt: string; // ISO-8601
}
Origin — where this version came from
type Origin =
| { kind: "local" } // resolved + loaded by this registry (v1)
| { kind: "federated"; upstream: string }; // reserved; not yet implemented
"federated" is reserved — registries MUST refuse to register an origin other than "local"
until the federation RFC lands.
LifecycleOverlay — host policy on top of the manifest
interface LifecycleOverlay {
maturity?: Maturity; // alpha | beta | stable | deprecated
visibility?: Visibility; // private | unlisted | public
successor?: string; // reverse-DNS id
}
The manifest’s classification is the publisher’s claim; the overlay is the host’s policy. Hosts
MAY want to mark an app deprecated for compliance reasons even if the publisher still ships it as
stable. Overlay is stored per id (it applies to all versions of that id).
EffectiveClassification — what the host should display
interface EffectiveClassification {
maturity: Maturity; // overlay.maturity ?? manifest.classification.maturity ?? "stable"
visibility: Visibility; // same precedence
successor?: string; // overlay.successor ?? manifest.classification.successor
}
ListQuery / Page<T> — pagination + filters
interface ListQuery {
/** Substring against id / name / displayName. */
q?: string;
/** Filter by manifest category (one of the controlled vocabulary). */
category?: string;
/** Filter by effective maturity. */
maturity?: Maturity;
/** Filter by effective visibility. Default: ["public", "unlisted"]. */
visibility?: Visibility[];
/** Page size. Default 50, max 200. */
limit?: number;
/** Opaque cursor from a previous Page. */
cursor?: Cursor;
}
type Cursor = string; // opaque to callers
interface Page<T> {
items: T[];
nextCursor?: Cursor; // omit when no more pages
}
Operations
| Operation | Semantics |
|---|---|
register(sourceRef) | Resolve → load → describe → upsert one AppVersion. Idempotent by (id, version, digest). Returns { version, registered } where registered=false when the existing row matched the digest. |
get(id) | Returns the RegisteredApp (latest + overlay + effective). undefined if unknown. |
getVersion(id, version) | Returns one specific AppVersion. undefined if unknown. |
list(query) | Pageable list of RegisteredApp. Filters apply to the effective classification. |
listVersions(id) | All AppVersions for an id, newest first. |
load(id, {version?}) | Re-resolve the stored sourceRef and return a LoadedApp (runnable). Defaults to latest version. |
unregister(id, {version?}) | Remove one version, or all versions (with { allVersions: true }). Refuses to remove the only/last version unless allVersions: true. |
setMaturity(id, maturity | null) | Set/clear the overlay’s maturity. |
setVisibility(id, visibility | null) | Set/clear the overlay’s visibility. |
setSuccessor(id, successor | null) | Set/clear the overlay’s successor. |
load() is part of the registry (not a host concern) because the registry is the only thing that
knows which sourceRef to re-resolve for a given (id, version). Hosts that want a different
resolver inject one at construct time.
DataStore contract
interface DataStore {
/**
* Insert a new (id, version) row and bind it to its action/auth set.
* Atomic: either every row lands or none do. If (id, version) already
* exists with a *different* digest → throw `version_conflict`. If it
* exists with the *same* digest → return `{ registered: false, ... }`.
*/
putVersion(input: PutVersionInput): Promise<{ version: AppVersion; registered: boolean }>;
/** Set/clear the latest pointer for an id. */
setLatest(id: string, version: string): Promise<void>;
/** Read latest version + overlay + counts. */
getLatest(id: string): Promise<RegisteredApp | undefined>;
/** Read one specific version. */
getVersion(id: string, version: string): Promise<AppVersion | undefined>;
/** List apps (paged, filtered, by *effective* classification). */
listLatest(query: ListQuery): Promise<Page<RegisteredApp>>;
/** All versions for an id, newest first. */
listVersions(id: string): Promise<AppVersion[]>;
/** Remove one or all versions. Returns the number of versions removed. */
remove(id: string, opts: { version?: string; allVersions?: boolean }): Promise<number>;
/** Read-modify-write of the lifecycle overlay. Atomic. */
patchOverlay(id: string, patch: Partial<LifecycleOverlay>): Promise<LifecycleOverlay>;
}
Atomicity guarantees the data store MUST provide:
putVersionis a single transactional unit: actions and the version row land together or not at all.removecascades toapp_actions(or the equivalent action rows).patchOverlayis a single read-modify-write — concurrent callers MUST NOT see a torn overlay.
What the data store MAY do freely: caching, indexing, eventual indexing of search fields. The registry never reaches around the data store.
Idempotency
register(sourceRef) is content-addressable:
- Resolve the ref → local dir; load + describe →
{ manifest, actions, auth }. - Compute
digest = sha-256(canonical_json({manifest, actions, auth})). - Call
store.putVersion(...). - The store keys on
(id, version, digest):- Same
(id, version), same digest → no-op insert,registered: false. - Same
(id, version), different digest →version_conflict(publisher SHOULD bumpversionto publish changes). - New
(id, version)→ inserted; latest pointer SHOULD advance if the new SemVer is greater than the current latest, else stays put.
- Same
Canonicalization for the digest is: keys sorted lexicographically at every object level; undefined
omitted; arrays preserved in source order; numbers in their JSON form; strings as-is.
Lifecycle
Lifecycle ops mutate the overlay, not the manifest. The manifest of every stored version stays
bit-identical to what @w6w/runtime.describe() produced. Effective values are computed at read
time:
effective.maturity = overlay.maturity ?? manifest.classification?.maturity ?? "stable"
effective.visibility = overlay.visibility ?? manifest.classification?.visibility ?? "public"
effective.successor = overlay.successor ?? manifest.classification?.successor
Overlay applies per id (all versions). This matches the semantic intent — “this app is deprecated” rarely means “this particular version is deprecated”; it means the publisher has moved on (or the host has). Per-version policy can be layered later if needed.
Federation (reserved)
The shape is designed so a future RFC can specify federated registries — a local registry that pulls from an upstream and serves the cached copy — without breaking the local-host contract.
Reserved field names hosts MUST NOT repurpose:
AppVersion.origin.kind === "federated"— provenance from an upstream.RegisteredApp.overlay— already specified; federation will add upstream-pin and mirror-policy fields under it in a future RFC.
V1 implementations:
- MUST write
origin: { kind: "local" }on every registered version. - MUST reject
register({ origin: "federated", ... })withunsupported_origin.
Reserved
signature/attestationsonAppVersion— provenance attestations. A later RFC will define their shape. The registry MUST preserve these fields on round-trip if present in the manifest, but MUST NOT interpret them.
Resolved questions
| Question | Resolution |
|---|---|
| Storage layout | Datastore-agnostic. Registry holds the contract; hosts implement persistence. |
| Versioning | (id, version) PK; latest pointer per id; full history retained. |
| Idempotency | Content digest over canonical { manifest, actions, auth }. Same digest → no-op; same version + different digest → conflict. |
| Lifecycle | Overlay on top of the manifest; effective values computed at read time. |
load() ownership | In the registry — it owns the sourceRef → LoadedApp pipeline because it owns the sourceRef. |
| Federation | Reserved (origin, future overlay fields). v1 is local-only. |
| Provenance | signature / attestations are reserved at the version level; manifest’s reservations carry through. |
Amendment: owner-scoped registration (2026-07-14)
Adds per-tenant and per-user private apps alongside the global catalog. Motivated by embedded-tenant onboarding (a partner installs its own apps tenant-wide; its users bring their own). Companion design + rollout:
.claude/docs/app-install-scoping.md.
Owner scope
A registered app gains an owner scope — the principal that installed it:
interface OwnerScope {
tenant?: string; // absent / "" → not tenant-owned
subject?: string; // absent / "" → not user-owned
}
Three tiers, by which fields are set:
- Global — neither set. Operator-published; the existing behavior. Available to every tenant (subject to the host’s tenant allowlist / entitlement policy, which is out of scope for this RFC).
- Tenant —
tenantset,subjectunset. Installed by a tenant admin; available to every subject in that tenant. - User — both set. Installed by one user; available only to that
(tenant, subject).
Identity
The reverse-DNS id stays globally unique — version identity remains (id, version), and owner
is a visibility/ownership attribute, not part of the key. An id therefore belongs to exactly one
owner scope. register(sourceRef, { owner }) MUST be rejected (id_taken) if that id already exists
under a different owner scope. This keeps a workflow’s app reference unambiguous and makes
shadowing structurally impossible — a private app can never reuse (and so never silently substitute) a
global one.
This global-uniqueness choice is deliberate: it avoids per-scope key surgery in datastores and their inbound foreign keys. A host MAY later relax
idto be unique only within an owner scope, at the cost of composite keys; that is out of scope here.
Registration & resolution
register(sourceRef, opts?: { owner?: OwnerScope })— installs intoopts.owner(default global). Idempotency (digest over{ manifest, actions, auth }) is evaluated within the owner scope.list(query, opts?: { caller?: OwnerScope })— returns the union for the caller: global ∪ tenant-owned (caller.tenant) ∪ user-owned (caller.tenant, caller.subject). Most-specific tier wins onid; absentcaller→ global only (back-compat).get(id, opts?: { caller?: OwnerScope })— resolvesidmost-specific-first (user → tenant → global) within the caller’s scope.
Hosts MUST resolve an executing step’s app against the caller’s owner scope, and MUST treat an app owned by the caller’s scope as implicitly authorized (a host’s tenant entitlement/allowlist governs global apps only — an owner can always run what it installed).
Trust
Privileged host capabilities (ctx.host.*, see the Hook-Runtime RFC) MUST NOT be granted to any
non-global (tenant/user-owned) app. Owner-scoped apps run in the same least-privilege sandbox as any
third-party app; ownership conveys availability, never elevated trust.
Back-compat
The owner scope is additive and defaults to global. A datastore that stores every app with an empty
owner scope, and a caller that passes none, reproduce the pre-amendment single-global-catalog behavior
exactly. origin/federation reservations are unchanged and orthogonal to ownership.
Amendment: id-set lookup (2026-10-04)
The original text above does not yet describe id-set lookup,
sort, orinterface— four spots are each incomplete on these points:registry.md:163-187(theListQuery/Page<T>block omitsids,sortandinterfaceentirely),:196(thelist(query)operations-table row),:230(listLatest(query)in theDataStorecontract — missing both theidsfield and theopts?argument), and:364(list(query, opts?)in the owner-scoped registration amendment — correct onopts?, silent onids). Everything else in those sections, and in the file as a whole, otherwise stands as written.
ListQuery gains an optional ids field, honored by every DataStore implementation
(listLatest) and by Registry#list, which forwards it unchanged:
interface ListQuery {
// … q / category / maturity / visibility / limit / cursor unchanged …
/** Ordering. Defaults to `"name"` — display name, A→Z. (Already shipped; was missing above.) */
sort?: AppSort; // "name" | "-name" | "recent" | "-recent"
/** Filter by declared Interface id. (Already shipped; was missing above.) */
interface?: string;
/**
* Resolve exactly this set of ids instead of a page. When present:
* - the result is the **union with the caller's owner scope** — the same
* rule `list`/`listLatest` already apply (global ∪ `caller.tenant`-owned
* ∪ `caller.tenant,caller.subject`-owned); an id outside that union, or
* one that does not exist, is silently omitted — never reported as an
* error, the same as a page simply not containing it.
* - the effective-visibility default is UNCHANGED (`["public",
* "unlisted"]` unless the caller passes its own `visibility`), and every
* other filter (`q`, `category`, `maturity`, `visibility`, `interface`)
* still ANDs with the set.
* - `limit`, `cursor` and `sort` are ignored: every visible member of the
* set returns in ONE page; `nextCursor` is never set.
* - an **empty array is itself a request** (`ids: []` → an empty page) —
* never treated as "no id filter."
* - capped at `MAX_IDS_PER_QUERY` (100) distinct ids, exported alongside
* `MAX_PAGE_SIZE`; a datastore MAY assume a caller already enforced the
* cap (the host enforces it at the wire, `GET /apps?ids=a,b,c` → 400
* `too_many_ids` over the limit) but MUST still behave correctly if
* handed more.
*/
ids?: string[];
}
listLatest/list both take an optional second argument, opts?: ReadOpts ({ caller?: OwnerScope; unscoped?: boolean }) — the owner-scope amendment above already defines its semantics
in full; the two earlier sections’ signatures (listLatest(query), list(query)) predate that
argument and are completed the same way here: listLatest(query, opts?), list(query, opts?).