Durable Discovery¶
Document type: Normative.
Scope: The control catalog, discover_worlds, open_world_readonly, cold subset queries. Issue #272 (v0.3.0 slice A1-read).
1. The problem this solves¶
Every store keeps a process-local registry of the worlds and archetype signatures it has seen. That registry dies with the process. The data does not: archetype tables are append-only and survive on disk or in object storage. Before durable discovery, a fresh process pointed at existing storage could read tables only if it already knew — from Python code — which worlds and component classes had been written there.
Durable discovery closes that gap. A world written by one process is discoverable and queryable from any later process pointed at the same storage identity, with no shared memory and no live world object.
2. The control catalog¶
Each storage identity gets one control catalog — local SQLite by default,
or the remote Durable Objects catalog when
ARCHETYPE_CONTROL_CATALOG_URL and
ARCHETYPE_CONTROL_CATALOG_TOKEN are configured (issue #281), which
lifts the single-host limit. The catalog is
authoritative for world identity and advisory for progress:
| Recorded | Authority |
|---|---|
World identity (world_id, name, run_id, parent_world_id) |
Authoritative. Registration failure fails create_world/fork_world. |
World status (active, destroyed) |
Authoritative for catalog state; destroyed worlds stay discoverable (their rows are still queryable; append-only). |
Immutable writer mode (resumable, cleanup_only) |
Authoritative for mutable reconstruction. Legacy rows default to resumable; cleanup-only and unknown future modes remain discoverable and queryable but fail closed before mutable resume opens storage or fences a writer. |
| Tick head | Advisory until A2 manifests land. Post-step updates log loudly on failure but never fail a tick whose data-plane writes succeeded. |
| Archetype signatures (component names, schema descriptor, fingerprint) | Authoritative descriptor for cold reads; guarded by fingerprint check (section 5). |
Catalog location is a pure function of the storage URI¶
The same StorageConfig always resolves to the same catalog file, so
restarts and crashes converge on one catalog with no coordination:
- Local URIs:
<uri>/<namespace>/.archetype-catalog-<backend>.db, beside the data it describes. - Remote URIs (e.g.
s3://):~/.archetype/catalogs/<fingerprint>.db, where the fingerprint hashes the normalized storage identity. Override the directory withARCHETYPE_CATALOG_DIR.
The storage identity is uri + namespace + backend — the same key
StorageService pools stores by. Two configs that resolve to different
stores (LanceDB vs Iceberg on the same uri and namespace) never share a
catalog, so one backend cannot discover descriptors whose rows live in the
other.
The catalog opens with WAL journaling, synchronous=FULL, a busy timeout,
and BEGIN IMMEDIATE transactions. Concurrent processes racing to register
the same world converge on exactly one row: identical re-registration is a
no-op; a differing immutable identity, including writer mode, for the same
world_id raises
CatalogConflictError.
Remote cleanup-only registration is a versioned deployment handshake, not an
ordinary Directory write. The public v8 Worker route is rewritten to a
Directory-internal host and route that no older outer Worker or older resident
Directory recognizes, so either direction of rollout skew rejects before the
Directory mutates SQL. After the Directory write, the outer Worker MUST mirror
the status into the per-world control authority before returning separate
catalog-v8 and gateway-v8 confirmations. A client accepts the record only when
the Directory and per-world authority both confirm status="active", both
protocol confirmations are present, and the exact
writer_mode="cleanup_only" marker is present.
Once the registration POST is issued, every uncertain outcome is treated as
possibly committed. This includes a non-success response, transport failure,
unparsable or incomplete response, and caller cancellation. The client MUST
finish cancellation-resistant reconciliation and exact retirement before
propagating the original outcome. Retirement uses the v8 exact-world route and
carries the complete WorldRecord: the immutable identity above, requested
status="destroyed", and the exact tick head.
The Directory applies retirement as one identity-checked transaction:
- an absent row becomes a destroyed tombstone for the supplied identity;
- the exact active row becomes destroyed;
- the exact destroyed row is an idempotent success; and
- a different immutable identity conflicts without mutation.
The destroyed state is monotonic in both the Directory and per-world authority. The Worker mirrors the Directory result and returns success only when both authorities confirm destroyed. Therefore an absent reconciliation read cannot open a race with a delayed registration write: the tombstone prevents that write from resurrecting the cleanup-only writer. This exact retirement contract is part of protocol v8 and introduces no later protocol version or additional data migration.
3. Governed discovery operations¶
Two exact read operations share trusted and actor-aware dispatcher entry:
worlds = await dispatcher.apply_as(
ctx,
DiscoverWorlds(storage_config=storage_config),
)
info = await dispatcher.apply_as(
ctx,
OpenWorldReadonly(
storage_config=storage_config,
world_id=world_id,
),
)
discover_worldsrequires the registereddiscover_worldspermission. Unlikelist_worlds(live process registry), it answers from the catalog: a fresh process sees every world ever registered against that storage identity, including destroyed ones.open_world_readonlyrequires the registeredopen_world_readonlypermission. It returns the world's durable descriptor as the existingWorldInfoboundary type and raisesKeyErrorfor unrecorded worlds. It never constructs a live mutable world — that isResumeWorld(registered with theresume_worldpermission; see World Lifecycle § Resume).- Both discovery operations retain the resolved world-to-storage coordinates for dependent read services. In particular, audit projection can discover and drain pre-restart command-outbox events without requiring new command activity in the current process.
Both operations respect the info-class downgrade: callers get WorldInfo,
never a world handle.
4. Cold queries and signature discovery¶
query_components unions two sources:
- The live querier path (unchanged): signatures registered in this process.
- Catalog-discovered tables: signature records whose component sets cover the request and whose tables are not already live.
Catalog tables are read through a dedicated store seam —
get_existing_table_schema / get_existing_table_df — that opens and
never creates. Read paths cannot allocate tables, on either backend
(LanceDB, Iceberg). Projection uses the durable schema descriptor, so a cold
process needs Python classes only for the components it is asking about, not
for every component the writing process defined.
# Process B, sharing nothing with the writer but the storage config:
async with ArchetypeRuntime() as runtime:
infos = await runtime.discover(storage_config)
info = infos[0]
assert info.run_id is not None
cold = runtime.attach(info.world_id, storage=storage_config)
df = await cold.query(Score)
list_signatures uses the same authority split: it unions the store's
process-local cache with every durable catalog record. Listing a complete
Python signature requires the corresponding component classes to be imported;
records are matched to those classes by full schema fingerprint, never by name
alone, and the recomputed table identity must equal the durable table_id.
Missing, drifted, or identity-mismatched historical records are skipped with a
warning so one unrelated old world cannot block storage-wide discovery. Mutable
world resume remains strict because it resolves only the target world's live
entity signatures. When both sources know a table, the exact process-local
class identity takes precedence over an ambiguous catalog reconstruction. If
the catalog itself is unavailable, discovery returns the process-local subset
and logs the degradation; commit-visibility checks remain fail-closed.
The frozen operation inventory keeps the two authority scopes explicit.
ListSignatures is application-scoped discovery for an explicit or default
storage identity. ListWorldSignatures is durable-world-scoped discovery: its
world_id selects the retained world-to-storage coordinates before running
the same storage-wide union. The API all-state read uses the world-scoped
operation, so authorization and storage selection share one exact world key;
neither operation changes the returned signature set for the selected store.
5. Fail-closed schema check¶
Before reading a catalog-discovered table, the physical table schema is
fingerprinted and compared to the catalog record. A mismatch raises
CatalogSchemaMismatchError — the read fails closed rather than returning
rows whose shape the descriptor no longer describes.
The fingerprint hashes the schema's logical shape: field names and
normalized logical types, in order. It deliberately excludes nullability and
physical encoding variants (large_string vs string), because backends
legitimately normalize those on round-trip — Iceberg stores string as
large_string and forces fields nullable. Renames, reorders, added or
removed columns, and retypes all mismatch.
Schemas never evolve in place: a changed component set is a new archetype signature and a new table. The stored descriptor for an existing table is therefore immutable, and a fingerprint mismatch means corruption or out-of-band interference, not drift.
6. What is and is not guaranteed (A1-read)¶
Guaranteed:
- Worlds registered at create/fork time are discoverable from any process,
for the life of the storage, including after
destroy_world. - Exactly one catalog row per world identity under concurrent registration.
- Cold reads never create tables and fail closed on descriptor mismatch.
- Catalog unavailability degrades reads to live-registry behavior (logged); it never corrupts the data plane.
Not guaranteed until A2 (issue #273):
- The catalog tick head is advisory. The durable, atomic tick-visibility boundary (commit tokens, writer epochs) is A2's contract.
- Crashed physical executions are queryable, not resumable (the ledger can hide partial rows; it cannot un-step external physics).