Storage layout: which store holds each table, and why
Each Loom table belongs in Postgres or ClickHouse by how it is read and written; this page places every table, with the wave that moves it. Postgres holds state read by key, changed in place or locked, and ClickHouse what is appended and scanned (storage authority). The page sets out:
- the rule that places a table by its access pattern, and the schema rules of each store;
- the shared Postgres structures that replace queues, claims and leases built on ClickHouse, and how statements that cross the two stores are rewritten;
- a row for every ClickHouse and Postgres table, with its store, key, kind and erasure coverage, and for a ClickHouse table up to two modules whose statement, table-name literal or table constant names it;
- the waves, how a table moves, and what a restore keeps.
A table's Today column is where it is now, until its wave ships. A table added later follows the same rule, and the change that adds it adds its row here.
The placement rule
A table goes to the store whose guarantees its readers depend on.
| A table goes to Postgres when it is | A table goes to ClickHouse when it is |
|---|---|
| read by key, and a reader needs the value as of the last commit (a revocation, a grant, a policy, a lock, a head) | appended and scanned: vectors, terms, chunks, statistics, the graph projections, the code index |
| changed in place: a status, a lease, a cursor, a counter two replicas must agree on exactly | history: version rows, append-only and changed only by erasure and, for memory_versions, redaction |
| claimed or locked: a queue, an outbox, a lease, a claim, a compare-and-set | a log or a rollup: events, traces, audit, usage, cost, exposures, SLIs |
| small per tenant: registrations, configuration, user-written state outside the search corpus | a cache with a TTL: provider answers, search memos |
State that search joins with the memories inside ClickHouse (operator-authored
categories and the memory_categories links, server/memory/domain/search_sql.py)
stays in ClickHouse even when it is small and per tenant. Its by-key reads need no
Postgres commit: the import's insert-if-absent read of the links (link_states in
server/memory/store/categories.py) runs with sequential consistency, and a duplicate
link collapses on the ReplacingMergeTree key; the registry read of
memory.assign_category and memory.import (registered_ids) runs without it today,
so on a lagging replica a category registered a moment before answers
CATEGORY_NOT_FOUND or skips the import's link, and wave 3 reads it with sequential
consistency too.
Each test comes from a cost ClickHouse pays today for state it does not fit:
FINALon point reads. AReplacingMergeTreeread by key underFINALmerges the key's parts at read time, so its latency depends on how far merges are behind.- Merge-dependent correctness. Without
FINAL, a read sees every version until the merge; with a cache in front, the cache must fail closed when the read fails (revoked_tokens,namespace_grants). - No row locks. A queue on
ReplacingMergeTreeclaims an item by inserting a version and reading back who won (memory_extraction_queue), or records an item's state by a mutation (memory_projection_failures), and keys of the KeeperMap tableloom._migration_lockstand in for compare-and-set and for leases (server/core/leases.py). A KeeperMap key is not in a transaction with the rows it guards, and it gives its holder no fencing token. - No transactions. A change that must touch two tables together (a task and its lease, a job and its cancel) is written as two inserts with reconciliation code between them.
A table that needs both a current value and a scan splits, as memory does: the head in Postgres, and in ClickHouse a projection of it, published from the commit, that the scans, joins and history views read.
Each fact has one owner
Every table is one of five kinds. Each store is backed up whole (a ClickHouse backup of
the loom and loom_telemetry databases, a Postgres backup of the cluster), so every
table is in its store's backup. A ClickHouse backup copies each table at a slightly
different moment, so a ledger that says how far a table is built may come back ahead of
or behind it. The ledger rule, which every other section points to, sets each after a
ClickHouse restore:
- A ledger whose output is idempotent to re-run is reset to no later than its output (a
ledger keyed by a commit,
code_index_state, forces a full re-diff). - A ledger that keeps output from being written twice (
usage_acted_credits, whose outputmemory_usage_eventsdoes not collapse duplicates) is set no earlier than its output, by rebuilding it from that output, which wave 4 makes possible by putting the pair digest on eachactedevent. - A Derived table fed by a view from its source (
entity_degree,memory_usage_totals, the term and document-length folds) is reconciled or rebuilt from its source, except a table that is SoR past its source's retention (subject_last_seen,agent_cost_rollup_daily,loom_telemetry.sli_minute), which is only refolded over the source's retained window, never truncated or rebuilt whole.conversation_embeddings, written directly and SoR past its messages' retention, is re-embedded only for retained messages and never truncated.
The kind says what is lost, and what a rebuild needs, when a backup is not there:
| Kind | Meaning | Without a backup |
|---|---|---|
| SoR | system of record: the only copy of the fact | lost |
| Log | an append-only system of record, often with a retention TTL (in the schema, or set by retention_reconcile): events, traces, audit, usage |
history is lost, not correctness |
| Derived | rebuilt from a named source; the row says from what, and whether the rebuild calls a model | rebuilt from its source, at a model's cost where it calls one |
| Cache | expendable: provider answers, memos, published assets | nothing; it refills |
| Coord | coordination state: leases, claims, acknowledgements, change counters; meaningful only while processes run | nothing; a restore starts with none held |
A row's kind may be qualified: "SoR (head)" is a Postgres head with a ClickHouse projection; "SoR, then Derived" is a table that becomes derivable once its wave ships; "Derived, SoR past its source's retention" and "Derived (SoR for …)" name the rows only that table holds; "SoR, Coord" and "Cache; SoR (head)" give the kinds of a row's tables or roles in order.
A Postgres row with a ClickHouse projection is owned by Postgres; the projection is
Derived, rebuilt by republishing the heads (except datasets, documents and
knowledge_ingestions, whose rows ClickHouse alone holds for some keys, so each row
gives its rebuild and its system-of-record rows), and a split table's row gives the kind
of the Postgres half. Postgres stores no vector, so a lost vector table is rebuilt only
by re-embedding every row at provider cost. memory_versions is a system of record too:
it holds history written before a deployment's journal existed, and it is the only copy
of a superseded version whose journal payload was cleared
(payload clearing). The revision metadata in
memory_revisions (key, version, hash, status, seq) is kept until an erasure deletes
it: a ClickHouse restore's reconcile anti-joins the restored history against it.
loom.memory keeps the current text of each key in ClickHouse. It is the source of
memory_versions_mv, one of the tables insights.query grants, and read by statements
that join it inside ClickHouse; the lexical leg reads
memory_embeddings.content_excerpt (server/memory/app/retrieval/lexical.py). On a
synthetic namespace of 250k keys, system.parts (bytes on disk over rows) measured
about 91 B per row for memory (the harness writes one to four version rows per key)
against about 12.9 KiB per row for memory_embeddings (one per key; the harness's key
vector is a constant that compresses to almost nothing, so the real figure is larger):
the cost lever is the vectors (vectors). To reproduce it,
load, into a ClickHouse of your own, with
PYTHONPATH=$PWD .venv/bin/python bench/scale_load.py load-ch --shape single --rows 250000
and run:
SELECT table, sum(bytes_on_disk) / sum(rows) FROM system.parts
WHERE active AND database = 'loom' AND table IN ('memory', 'memory_embeddings')
GROUP BY table
Cells
A cell is one whole Loom deployment (cells): its replicas and workers,
one Postgres unit, one ClickHouse service and one document store (a bucket prefix, or
document_blobs in its ClickHouse where no bucket is configured). Every table on this
page lives in a cell, and a cell shares no store, key, lease or worker with another. Its
signing keys, revoked tokens, service accounts, machine identities, SSO, SCIM and OAuth
state, grants, namespace registry, collections, blacklist (the global org = '*'
patterns included), process registry, configuration and secrets are its own, and their
waves move them to its own Postgres beside its memory (each table's Target column). Only
the router's directory is shared, in the router's own Postgres; cells places
it, with each cell's copy of its own row (loom.cell_self). An org lives in one cell
and never leaves it, so this page has no org move, and no fact is split between two
Postgres databases: a check over Postgres tables reads one database, and a grant or
blacklist change commits in the database whose memory commits it fences. (The memory,
knowledge and code planes of the rest of the docs are surfaces of every cell.)
Once they move, security reads (tokens, grants, the blacklist, the registry) go through
caches invalidated by cache generations that fail closed. Sign-in,
sign-up, OIDC-start and workload-token attempt counts (auth_attempts) become a
bucketed counter in Postgres, one row per kind, hashed key and window, so admitting a
sign-in reads no ClickHouse. Deployment-wide workers (configuration, the provider
rebuild) run under the cell's own leases. Every org-keyed Postgres table a wave adds
carries the tenancy trigger of cells in the migration that creates it
(exempting '', '-' and '*'), and enters the schema-generated org list that attach
and loom doctor read.
Statements that cross the stores
A table that leaves ClickHouse breaks every ClickHouse statement that names it. Each wave's pull request finds them all and rewrites each in one of four ways. It finds:
- every statement by
grep -rnw '<table>'over the whole repository but the generatedschemas/schema.sql, by the bare name, since some statements name tables withoutloom.; - the materialized views in
schemas/schema.sqlwhose source or target is the table; - code that finds tables at run time through
system.tablesorsystem.columns, such as_STORED_SQLinserver/core/rmt_carry.py.
The rewrites:
| Rewrite | When | Example |
|---|---|---|
| Bind a parameter | the moved table narrows a ClickHouse read to a small set of keys | switched-off orgs, open runs, candidate ids |
| Keep a projection or mirror | the moved table is joined with large ClickHouse tables or feeds a view | memory_records with memory_record_fields (the record side leg), agent_runs in the mentor's scans, namespaces in worker scans |
| Split per store | one check reads a table in each store while its tables move in different waves | the sign-up check that refuses a name an existing principal holds (_refuse_empowered in server/auth/self_signup.py): each part reads its store, under the claim that guards it today, until every table it reads is in Postgres |
| Move together | two tables are always read together | session_briefs with its revisions |
The nested claims that guard grant, group, collection and repository changes today
(grant_change, admins_change and the other claims in server/auth/grant_claims.py)
become row locks, taken in today's order, once the rows they guard are in Postgres; a
claim that guards a set (the admins of a namespace, the shape of a collection tree)
locks a named parent row (the namespace's registry row, the org's collection root), so
two changes to the set cannot each see the other's row unchanged. Namespace
auto-registration (ensure_registered in
server/identity/app/namespace_autoregister.py) decides the owner of the first write
into an unregistered namespace from the namespace census (a ClickHouse read, outside the
transaction) and inserts its namespaces row with ON CONFLICT. An owned row wins over
an ownerless one and never over an explicit registration, as ReplacingMergeTree
ordering decides today. It stays best effort and never fails the write it rides on. A
split check keeps its semantics: the sign-up check still refuses every holder
_refuse_empowered checks today (server/auth/self_signup.py), a grant, a namespace, a
token, an inbox, a poster rule, a task, and the names a setting grants a role.
tests/unit/test_package_contracts.py keeps the store modules separate; each rewrite
lives in the store module of the table that moved.
Shared Postgres structures
These structures hold the queues, claims, leases, cursors, change counters and the per-feature state that moves with them; each new one is a table with one library every caller uses, and the deliveries keep their two existing tables and loops. This page fixes what each is for, its key, and the requirements its design must meet; the wave that builds it designs it against them and is reviewed on them.
Cache generations
scope_generations (org, scope, generation) is unchanged. A namespace's row allocates
its revision seq under its row lock; that generation bounds the published watermark
that pg_memory.capture computes from memory_deliveries. * is the org's policy
scope, and ('', '*') the global one.
The change counters that process caches poll get their own table,
cache_generations (org, kind, generation). Its kinds:
grants,groupsandcollections, which today'sacl_versionscounts apart (server/core/grant_cache.py);blacklist,revocationandconfig;namespaces, the registry, whose narrowing changes answer only once their bump has committed, as a grant change does;- the
table_movesrecords (requirement 3's re-read).
Requirements:
- a change bumps its row in its own commit and sends
NOTIFY loom_generation; a memory commit never notifies (aNOTIFYtakes a database-wide lock at commit); - a cache listens on a session connection to the primary that bypasses any transaction pooler, re-reads on a short TTL when the listener is down, and fails closed past it;
- a cache compares
(restore generation, generation), so a counter that climbs back to a cached value after a restore still reads as a change; - the restore generation is a column of the singleton
authorityrow, kept apart from the paired authority epoch that boot checks (server/core/pg.py) and that a restore must not change; - every restore of the cell's Postgres raises it above every value an earlier restore set, taken from outside the restored database (the restore's clock in microseconds, never the restored value plus one);
- every reader on this page that orders by a restore generation reads this one.
Unauthenticated writes to Postgres are bounded per window, as the KeeperMap key limit bounds sign-in state today:
- attempt counts in
auth_attemptsfor new keys fold into a shared overflow bucket with a strict limit, so reaching the bound never refuses a key that already holds a row; - sign-in state in
oauth_signin_state(sign-in and device starts), a value read back per key, refuses a new key with 429 once its bound is reached, existing keys unaffected; - a client registration in
oauth_stateis admitted per address and refused with 429 past its bound; /v1/healthreports each bound reached.
The policy fence. A memory commit holds its policy scopes shared and starts its
verdict over when one moved (server/memory/store/pg_memory.py). Every change that
bumps the policy scope today keeps bumping it (the org's * row, ('', '*') for a
global pattern) in its own commit, in the same database, found by grep for
pg.bump_policy, grant_cache.changed and grant_cache.announced (a grant, group or
collection change through server/core/grant_cache.py, a namespace registered,
deactivated, restricted or opted out, an SSO link, a sign-up, a claim release, a
blacklist change), so a verdict judged under the old policy starts over as it does
today. Authorization and blacklist checks read caches, so a request a replica checked
before its cache heard of a change can still commit within the cache's invalidation
bound, today's bound with a poll replaced by a notification.
Jobs
jobs (org, id) in the cell's Postgres replaces the queues built on ClickHouse
(memory_extraction_queue, memory_projection_failures, memory_webhook_outbox,
code_scip_batches, delete_version_checks), one row per job with a kind, a typed
state machine (ready, running, done, failed, cancelled), a lease and a fencing
token, and the kind's arguments in an opaque jsonb payload. Knowledge ingestion is not
a jobs kind: it keeps its own knowledge_jobs, a named exception: it already holds
its jobs in Postgres with a fencing token and a cancel column, knowledge_ingestions is
its ClickHouse projection for reports, and folding it into jobs would be a
Postgres-to-Postgres move this page does not need. Requirements:
- Claim.
FOR UPDATE SKIP LOCKEDon due rows of a kind, one org per statement, orgs taken in turn so one backlog does not starve the rest, and inside an org the kind's fairness key in turn (the webhook forwebhookjobs, as delivery round-robins webhooks today,due_eventsinserver/memory/store/webhooks.py), a webhook's events still going out in order, its id their serial key; a claim raises the token and sets the lease from the database clock; an expiredrunningrow is reclaimed in place. - Serialization. A kind that runs one at a time per key (extraction per namespace,
user and enqueuer while a request is held, as
claim_batchinserver/memory/app/extraction_queue.pyrules today;knowledge_jobskeeps its one-at-a-time per source) has that key and its held state as typed columns, enforced by a partial unique index, with an advisory-lock pre-filter so claims do not wait on each other. - Dedup. One ready job per
(org, kind, dedup_key); work enqueued while a job runs is a ready job of its own, never folded into a run that already read its inputs. - Fencing. Every save and the completion name the token; a job whose effect is a
Postgres commit locks its row with its token in that commit; effects outside Postgres
are bounded by the lease, as publications are (
server/core/pg_lease.py). - Transactional outbox. A job a commit causes (a webhook event, a delete's follow-up) is inserted in that commit.
- Erasure and redaction.
job_users (org, user_id, job_id)andjob_memories (org, namespace, memory_id, job_id), eachON DELETE CASCADE, index the users and memories a job carries. A user's erasure deletes a job that names only that user and, from one that names several, removes the user and scrubs the user's content (an extraction job's messages) from its payload; a redaction scrubs the redacted text from the jobs that name the memory, matched by namespace and id, since a memory id is not unique within an org. A scrub raises the token, and a delete or scrub returns the jobs'lease_untilfor the saga's ClickHouse steps to wait out. Jobs a memory commit causes are deleted in the memory step's own transaction under the scope's lock; the jobs step keeps today's exclusion of the saga's ownmemory.erasedevents (server/erasure/store/census.py). - Retention. A kind keeps what its table keeps today: a finished job keeps its row
for its kind's status op, then the sweep for its kind deletes it on its table's TTL
today (an extraction job 30 days after enqueue, a
projection_repairjob 30 days after it is resolved, one that failed kept until resolved); each attempt is also aneventsrow.
memory_code_link_queue is not merged: the journal already orders every write, so the
code-link worker reads memory_revisions from its cursor.
Deliveries
What a memory or knowledge commit owes ClickHouse stays in memory_deliveries and
knowledge_deliveries. Those rows are the one publication path: the committing writer
publishes its own delivery under the commit's token (an admission publishes a
namespace's backlog), and the cell's pg_delivery and knowledge_pg_delivery loops
publish what is left, all under the delivery fencing. Nothing else writes the rows a
commit owes ClickHouse but the datasets exception below; rewrites of derived rows in
those tables (a pending-embed row's re-embed, the conflict resolver's version rows, the
provider rebuild's table renamed in whole with writers drained) publish no commit. No
staging table or queue is in ClickHouse. Work a revision owes ClickHouse is a target bit
on its delivery row.
Journaled heads. The shared and records heads join the memory journal: their
revisions are memory_revisions rows with a typed head column (memory, shared,
record, with typed schema_name and record_id for a record) that every journal
reader matches on, so record history (memory_record_versions_mv) is fed once per
revision and a ClickHouse restore reconciles both like memory; their erasure keeps
today's rules: shared is matched by the users it was promoted from, so a shared
revision's sources are a typed index, revision_sources (org, user_id, namespace, seq),
through which a pg: census row and the provenance step reach shared_current and its
revisions.
Redaction. Every table that holds a copy of a version's text is scrubbed by
redaction or its row names why not. In ClickHouse, the rows a model wrote from a
redacted version are matched and retired: the entity_vectors rows of the redacted key
for the entities the version's entity_edge_rows and entity_triple_rows name, matched
by (entity_text, memory_key) and rebuilt by the entity projection from the redacted
memory; the entity_summaries of those entities, retired and not rebuilt, since no
module writes that table today; the session_summaries whose derived_from names the
key (any version of it, since the column holds keys), retired and not rebuilt, since a
digest is written from the session's turns; and the derived_facts of the version,
matched by the memory version that wave 3 adds to them (a fact written before the
column, with no version, is retired with any redaction of its key), retired and not
rebuilt (no column tells a stated operand from an extracted one, and extraction reads
the messages, not the version). These rules are requirements of wave 3, the next wave
that touches redaction. A redaction scrubs a memory version's text wherever a copy of it
lives in Postgres: the version's journal row, memory_current (the key's own head when
it holds the text, and the profile heads folded from the memory, as pg_memory.redact
does today), shared_current when a promotion copied the text, and job payloads
(above); a redaction that reaches shared_current also scrubs or republishes the
ClickHouse shared rows and the shared-scope memory_embeddings rows of that version.
A replay answer in idempotency that quotes the text is deleted. Redaction finds it
through idempotency_revisions (org, key_hash, namespace, seq), a typed index of the
revisions each op committed; today they are the revisions jsonb
(server/memory/store/pg_memory.py). A trigger on INSERT OR UPDATE OF revisions fills
the index whatever the build, and the backfill step fills it for existing rows. Until
that backfill is recorded, redaction also matches the revisions jsonb.
Other heads. The other heads with a projection (agent_runs, operator_edges) are
republished by a publish job their commit inserts, and a task transition owes its
task_events row the same way. agent_runs' projection keeps today's nanosecond
version, strictly increasing per head, so rows written before the flip never outrank a
later commit and the readers that compare version with the clock (idle_session_runs
in server/tasks/store/runs.py) keep working.
pg_delivery itself claims publish jobs under the delivery fencing
(server/core/pg_lease.py), so each cell has one publication path per journal.
pg_delivery publishes the memory journal and every head outside the knowledge plane
that is not journaled. knowledge_pg_delivery publishes the knowledge commits and the
knowledge_ingestions projection of knowledge_jobs; wave 2 moves that projection's
inline insert there.
The datasets exception. The datasets projection of knowledge_datasets is a
named exception: its writer inserts it inline after the catalog commit (insert_catalog
and project in server/knowledge/store/datasets.py), as today. The reasons:
- The projection exists for a build that predates the table and a rollback to one (storage authority).
- A catalog write (a publish, an approval, a carry) commits no document version a delivery row could carry.
- A projection that lands after a delete of its source is harmless. A dataset is served
only while the source's live document row has its version, and the next delete of the
source removes it (
_deleteinserver/knowledge/app/writes.py). One that lands after an erasure'sdocuments.tombstoneis found by the residue census, which runs the saga again. - An approval deletes the snapshot it replaced only once the projection has landed
(
_switch,server/knowledge/app/datasets.py), since until thenloom.datasetsstill names it for N-1 and a rollback; through a delivery that delete would wait on the pass. - N needs no projection row of its own: a read takes Postgres's rows and only the newer
rows ClickHouse alone holds (
datasets_repo.at_version), while N's reports andknowledge.inspectreadknowledge_ingestions, which therefore moves to a delivery.
The inline insert of rows Postgres holds retires by rolling-upgrades rule 8 once no
build that predates knowledge_datasets can serve; loom.datasets then holds only the
rows Postgres cannot store, and the approval's wait on the projection goes with it. The
projection is never rebuilt whole from Postgres, since a row only ClickHouse holds is
its system of record. After a Postgres restore a newer projection row is kept, served
and reported, never deleted. After a ClickHouse restore loom reconcile names each
source to ingest again (backup and recovery).
Task events. For task_events the tasks wave meets these requirements: a task's
events land in the order its transitions committed, each lands once (by an event_id
column), it is visible before a watcher's cursor can pass it, and seq stays a
microsecond stamp (rolling-upgrades rule 4).
Mirrors. The namespaces mirror, a code_repos mirror without credentials and the
sql_access mirror, in the cell's ClickHouse, serve the ClickHouse statements that join
them (worker scans, and the security-definer SQL surface views, which leave out
restricted namespaces by them). Each is published by pg_delivery as a publish job
its head's commit inserts, and is ReplacingMergeTree(version) on the head's bigint
commit version, ordered with the restore generation of the cell's Postgres, so the order
rows arrive in does not matter; republishing from the heads rebuilds a mirror after a
restore, and the wave that moves each head builds its mirror before any view reads it
(wave 1 for namespaces and sql_access, wave 3 for code_repos). Two kinds of write
answer only once their rows have landed, else 503 PUBLICATION_PENDING with
committed: true, as a memory write does, and their replay answers the same until they
land:
- a change that narrows what a scan or a view may take (a namespace deactivated, opted out or restricted, or registered restricted, a repository likewise, an SQL-access revoke), on its mirror rows, so no row of it is written before the views can leave it out;
- a write that is a synchronous mutation today, so that the next read sees it (a conversation thread delete, a compaction tombstone, a save and an unsave), on its projection rows.
A rebuild swaps in a complete table and never empties the live one. Every projection of
a Postgres head that is not journaled (the mirrors, agent_runs, operator_edges,
conversation_states, knowledge_ingestions) is likewise ReplacingMergeTree on the
head's commit version; the journal's projections (memory, shared, memory_records)
keep their key's own version and are reconciled after a restore by its anti-join against
memory_revisions, and documents is reconciled from knowledge_heads, a document
with no head kept as restored. sql_access grants, its wildcards (namespace = '*' for
one org, org = '*' for every org) included, reach only the cell's ClickHouse, whose
user a SQL user is.
An erasure saga's steps that change identity tables (grants.revoke,
groups.remove_member, personal.retire, and collections.forget_owner) commit in the
cell's Postgres, idempotently, and owe the same cache bumps and mirror rows the change
would owe if an admin made it.
Conversation state
Messages are an append log read by scans, but today thread delete and compaction
tombstone them, and a save marks them, by mutation (is_saved, saved_at, and the
saver as user_id, server/knowledge/store/conversations.py). Wave 3 moves both, a
message's tombstone and its saved state, to Postgres
conversation_states (org, namespace, agent, thread_id, msg_id). Requirements:
- The saver. The state row's typed
user_idis the saver, written only for a message whose row'suser_idis'-',''or the saver, byINSERT … ON CONFLICT DO NOTHINGand a read-back, so the first saver of an unowned message takes it, asmark_message_savedclaims today. A message's user is its row'suser_id(the writer) or its state row's (the saver). - Readers. Every statement that reads a moved column (
tombstoned_at,is_saved,saved_at, oruser_idas the message's user: a filter, a selected or grouping column as insql_v1_conversations, or an export) onconversationsis rewritten onto the state rows, found by grep, its user filters matching either user (among themsearch_substring, the erasure exports inserver/erasure/store/exports.py, the thread check ofserver/memory/app/evidence_reads.py, the user-scope census predicate and theconversations.tombstoneuser step). - The projection. A ClickHouse projection,
ReplacingMergeTree(version)and published bypg_delivery, serves every live read, the saved-only scans and the census's live predicate. It has no TTL of its own: its rows leave when their state row is deleted and the delete is published. - Retention. A state row is deleted only once no row it governs is left: a message's
row once its message row is gone from ClickHouse (the sweep takes rows past the
message's retention expression,
tswith the globalretention_conversations_daysand the per-namespaceretention_daysthat_per_namespaceinserver/storage/retention_overrides.pyapplies, and deletes only those whose message a ClickHouse read no longer finds). The removingconversation.unsave_messagedeletes its state row in the same change. - Erasure. The split
conversations.tombstoneruns its ClickHouse step before its Postgres step. The Postgres step records the(org, namespace, agent, thread_id, msg_id)of every message it matched by the saver in Postgreserased_messages(ids only, swept with the erasure'serasure_ledgerrow), and the ClickHouse step's replay after a ClickHouse restore matches those ids as well as the row's ownuser_id, with a replay test of a saved'-'message in both restore directions. - Embeddings.
conversation_embeddingshas no TTL and outlives its messages, so its saved state and saver stay on its own rows. Wave 3 replaces today's mutations (mark_message_saved, andunsave_message's flag clear and removing tombstone) by version-row inserts carryingis_saved,saved_at,tombstoned_atand the saver asuser_id(ReplacingMergeTree(ts), the later insert winning atstie, with today's agent-less hand-off rule). The saver's erasure, the census andconversation_searchkeep matching the row's ownuser_id. - Threads.
conversation_threadsdoes not move: its tombstones stay mutations, a thread delete's and an erasure's, which settombstoned_aton every version of the row. A write that reads the row before a tombstone and inserts after it (conversation.update, the titler) undoes it; that race is today's code, and no wave changes it. - No mutations. Wave 3 rewrites
conversations.tombstone(server/erasure/store/steps_ledgers.py) so no statement onconversationsorconversation_embeddingsis anALTER … UPDATEbefore the flip's revoke: it deletes the messages with their state rows, and tombstonesconversation_embeddingsby version-row inserts (ReplacingMergeTree). TheconversationsUPDATEentry of_EXPECTED_MUTATIONS(tests/unit/test_clickhouse_mutation_inventory.py; the message tombstones oftombstone_threadandtombstone_messages) retires, and itsconversation_threadsentry stays;tombstone_scoped(whoseconversation_threadsstatement stays),mark_message_savedandunsave_messagename their table through a variable the inventory does not match, so the wave finds them by grep.
Write marks
write_marks (org, namespace, surface) holds the last insert per namespace and surface
(memory, chunk, evidence, record, summary). It serves the namespace sweep and
read-your-writes alike.
- Every write, memory included, sets its surface's mark before it answers, by an
autocommit
GREATESTupsert skipped while the mark is within its granularity, so concurrent writes coalesce instead of queueing on the row. - The memory surface's read-your-writes check alone compares the namespace's
scope_generationsgeneration with its published watermark. - A restore leaves no mark, and a missing mark counts as a write at the restore time, so the sweep never purges on its absence.
Context offloads
context.offload stores the only copy of replaced context (handle_context_offload in
server/memory/app/context_offload.py, up to context_offload_max_bytes).
- It becomes a Postgres head,
context_offloads, of metadata and a pointer. Its bytes go to the cell's bucket or, where none is configured, tooffload_bytesrows the head owns, deleted in the head's own transaction (document_blobs, shared by every document of the same bytes, is not). - The head carries its writer's expiry (
ttl_secondstoday) asexpires_at; a sweep deletes the head, its object and its rows. - Its erasure step deletes the objects and rows its heads point to, and the census counts them.
Leases
leases (org, name) takes the lease keys of loom._migration_lock (every lease caller
of server/core/leases.py, and its direct writers,
server/insights/store/provider_lock.py and server/core/schema/migrate_lease.py); the
table's claim keys follow the claims below, its probe and nonce keys stay (how a table
moves), and the KeeperMap task_leases and agent_run_claims
become columns and keys of the rows they guard (merges and
deletions). Requirements:
- a take or renewal is one upsert on the database clock; a take draws its token from a sequence, so no holder ever reuses one; a renewal keeps it, and a renewal that returns another token is a lost lease, whose fresh row the holder releases at once;
- a release deletes only
WHERE holder AND token; a sweep deletes rows a grace period pastuntil; - a lease bounds its holder by time, as today; work that must be fenced against a later holder runs as a job and names the job's token;
- a claim that guards one row's change becomes a row lock, a claim that guards a set
(the admins of a namespace, a collection tree) locks a named parent row, and a claim
over a key that may not exist yet (a create:
group_change,service_account_change,bot_change, the sign-up name and email claims,personal-ns:) becomes the insert's primary-key conflict where its check reads only that table, and otherwise apg_advisory_xact_lockon the claim key, taken in today's order inside the change's transaction (the sign-up check,trusts_change); - a Postgres restore runs with every process stopped, starts with no lease held (it
empties
leases, as for every Coord table) and pairs every token with the database's restore generation, so no token issued before the restore, held or lost, compares equal to one issued after it.
Cursors
cursors (org, consumer, scope) holds where a consumer stands whose output is not in
ClickHouse: the task webhook's delivery cursor per inbox, with its retry state as typed
columns, and from wave 3 each knowledge source's sync state (its changed-after stamp and
mined revisions, today knowledge_source:<name> rows of code_index_state), beside the
knowledge_sources registry and the knowledge_heads it feeds, so a Postgres restore
rewinds both together. A consumer writes its idempotent output, then advances its
cursor. A cursor that records how far a ClickHouse table has been built stays in that
ClickHouse beside the table, in the same backup, so a ClickHouse restore rewinds both,
each ledger set by the ledger rule: the code index's build
state (code_index_state), the code-link worker's cursor into memory_revisions (kept
in code_index_state, one per (org, namespace) since seq is numbered per namespace,
holding a seq and never a memory id), the worker resume cursors in worker_status,
the tracker's rollup ledger (qa_rollup_days) and the usage fold's credits
(usage_acted_credits). A cursor into the Postgres journal also records the Postgres
restore generation it was advanced under, and resets (the worker relinks from the heads)
when a Postgres restore has raised it, since a restored journal hands out its seq
values again. The provider rebuild's ledger (its table index and cursor, today the
progress column of provider_configuration) is a named exception: it is in its job's
Postgres row, since its targets are scratch tables of the run, and a ClickHouse restore
fails the job, so the rebuild starts again from its snapshot.
Schema rules
Postgres
- Typed columns for what is read. Every column a predicate, an index, a guard, a
constraint or a join reads is a typed column.
jsonbholds only an opaque, sparse remainder the database never filters on (a job's arguments). Ajsonbfield that a predicate starts reading moves to a column in that change. The exception is the full rows that heads, journals and deliveries keep so every row builder stays unchanged (storage authority):memory_current.row,memory_revisions.rowand.audit, and therowofknowledge_heads,knowledge_deliveries,knowledge_jobsandknowledge_datasets. A field of them that a predicate reads (row->>'memory_type'in the profile index, the originals a knowledge delivery owes) becomes a typed column in the wave that next touches its table: wave 3, which addsmemory_revisions.head, ownsmemory_currentandmemory_revisionsand types the head fields today's request-path predicates read (tombstoned_at,redacted_at,sensitivity,expires_at,status,memory_type, andcreated_at, anORDER BYkey;server/memory/store/pg_memory.py). Each such column on an existing table:- is filled by a trigger from
rowon every write, whatever the build; - is read by N's predicates through
coalesce(<col>, row->><field>)until a batched backfill has set it on every row; - on a table that grows with content, is indexed
CONCURRENTLYby the backfill step after it has set every row, never by a boot migration file; the step drops and rebuilds an indexpg_index.indisvalidshows invalid, and no N reader depends on the index until it is valid. A content-free or small table may take the boot path migrations names.
- is filled by a trigger from
redacted_ts. On the journal,redacted_atistexttoday, so it moves to a typed column over three builds (rolling-upgrades rules 8 and 1: a type change is not additive):- N adds
redacted_ts timestamptz, filled by the revision-metadata trigger fromcoalesce(NEW.redacted_at, NEW.row->>'redacted_at')(the column a redaction sets whenrowis NULL) onINSERT OR UPDATE OF row, redacted_at, and reads it throughcoalesce(redacted_ts, <text>::timestamp AT TIME ZONE 'UTC'), the text beingcoalesce(redacted_at, row->>'redacted_at'), aspg_memory._metafalls back today, payload included, until the backfill has set it on every revision where that text is not NULL. - N+1, once the backfill is recorded complete, reads
redacted_tsalone and neither writes nor readsredacted_at, except that its trigger still setsredacted_tsfromNEW.redacted_at, so a redaction by N during the window is kept. - N+2 or later drops
redacted_atand that clause together.
- N adds
- The backfill step is a leased worker pass that records its completion per column,
index and check in Postgres
backfill_stepsand never holds the boot's migration advisory lock (Postgres migrations); a later file'sSET NOT NULL, a build that reads a typed column alone, and a drop each refuse to boot until that record exists, as requirement 9 of how a table moves does for flips. A boot migration that waits on a lock held by the backfill step's session cancels that session (pg_cancel_backend, found by itsapplication_name), and the step records itself not done, drops any indexindisvalidshows invalid, and retries after the boot; the step holds no transaction open between statements (eachCREATE INDEX CONCURRENTLY,VALIDATE CONSTRAINTand batch is a statement of its own), so a cancel releases every lock it holds. -
Stated relations. Wave 3 keeps the relations a write states (
memory.set'srelations, amemory.bulk_setentry's, and the facet triples of files, concepts and topics) in a typedrevision_relations (org, namespace, seq, ord)table:- one row per stated relation, its subject, relation and object as plain columns, both
as written and as entity resolution resolved them
(
server/memory/app/entity_resolution.py), so a rebuild in revision order is deterministic, with a hash index where a lookup by triple needs one; - kept as long as the revision's metadata and cascading with it, with a census row
(
pg:loom.revision_relations, owned bypg_memory.delete), reached by erasure; - reached by redaction too, which also scrubs or retires the revision's triples in
ClickHouse, found through
entity_triple_rowsrather than in the payload, which may be NULL; - backfilled from
entity_triple_rowswhere a row maps to a revision (wave 4's row-link backfill backfills it too, for the values it links).
The triple tables are then derived from it for every relation a revision maps to, rebuilt without a model call, and stay the system of record for relations no revision maps to.
- one row per stated relation, its subject, relation and object as plain columns, both
as written and as entity resolution resolved them
(
- Keys lead with
orgin every tenant table. Coordination and job tables carry the identity they act on (org,namespace, a job's users injob_users, and in its payload the rest), not the full tuple. The exceptions are the maintenance indexes of the sweeps and claim scans and the cross-org principal index ofnamespace_grants((lower(subject), org), read across orgs by SCIM deprovisioning; the sign-up check uses it within its org), which are local to one database, and the tables of the cell rather than a tenant, each keyed by what identifies its row there:signing_keysbykid,sso_linksby(email, org, namespace),instancesby process,revoked_tokensby(kind, key),oauth_stateby(record_type, id),oauth_signin_stateby its claim key,move_acksby(move, process), the singleton rows,auth_attemptsby kind, key and window,scim_usersby its id,machine_identitiesby name with an index onorgfor the per-org reads,carry_acksby process,table_movesby move, the configuration tables by their ClickHouse key (deployment_configby name,runtime_overridesby(kind, key),provider_configurationbyconfig_id,embedder_calibrationby model),analysis_secretsandassistant_serviceby id,backfill_stepsby what it sets,pg_migrationsby file, andcell_probeby name. - Identifiers.
uuidfor ids made by writers (UUIDv7 where order helps an index),bigintfor sequences and tokens. Text keys stay where they are user-chosen names (memory_key,namespace), withCOLLATE "C"so ordering and equality match ClickHouse byte order. Postgrestextcannot hold a NUL byte, so a ClickHouse key that uses one as a separator becomes typed columns. - Checks in the schema. States, kinds, roles and non-negative counters are
CHECKconstraints where the check is cheap; a state column without one today (idempotency.state,knowledge_jobs.state,memory_revisions.status) gets it in the wave that next touches its table. A check on an existing table is addedNOT VALID(new rows are checked at once) and validated withVALIDATE CONSTRAINTby the backfill step once it has set every row (never by a boot migration file on a table that grows with content), which takes onlySHARE UPDATE EXCLUSIVE; a later file runsSET NOT NULLonly once that check is validated. ANOT NULLon an existing table is added the same way asCHECK (col IS NOT NULL) NOT VALID; once that is validated,SET NOT NULLuses it instead of scanning the table, and the check is then dropped (NOT NULL … NOT VALIDexists only from Postgres 18). So no migration scans a large table under an exclusive lock. A new value follows rolling-upgrades rule 3: N's migration widens the check, N's readers accept it, N+1 writes it. - Hot tables. A table updated in place on the request path or by every pass of a
worker (
scope_generations,leases,jobs,auth_attempts,idempotency,write_marks,graph_inflight,instances,cursors, the deliveries,memory_current) sets, or gets in its wave,fillfactor70 to 90 and keeps the columns it updates out of its indexes where it can, so updates stay heap-only; a claim index that readslease_until(the deliveries',jobs') makes those updates not heap-only, which is the price of a partial claim index. A queue or counter setsautovacuum_vacuum_scale_factor = 0with an absolute threshold, so vacuum runs by dead rows, not by table size; a table that grows with content (memory_current) gets a small scale factor instead, in wave 3.scope_generationsandidempotencytake afillfactorin the first wave that touches them. - TTLs become sweeps. A table that moves with a TTL keeps it as a sweep on the same
expression, run under a lease, unless its row names another retention; a KeeperMap
expiry (
oauth_signin_state) becomes anexpires_atcolumn the same sweep reads. - Partitioning only when measured. No Postgres table is partitioned until a
measurement shows vacuum, a per-org dump or a per-org erasure growing badly; the
candidates are
memory_revisionsandjobs, hash-partitioned byorg, which keeps every org-led key and index; the cross-org maintenance indexes become per-partition. - Foreign keys where one row's life ends another's (a delivery and its revision, a
supporter and its candidate), with
ON DELETE CASCADE, and an index on the referencing columns,orgfirst, wherever the key does not lead with them (job_users,job_memorieson(org, job_id);revision_sourceson(org, namespace, seq), which is also the redaction lookup).idempotency_revisionscascades with itsidempotencyrow, whose key its own leads with, and has an(org, namespace, seq)index for the redaction lookup.
ClickHouse
- The sort key leads with the identity tuple
(org, namespace, …), and the narrowest prefix every hot read filters on, for every table added or rebuilt; a table that stays keeps its key and its engine (the rollups written directly,memory_served_daily,query_nodes,code_unit_signal, among them). The deliberate exceptions: tables of a cell rather than a tenant (assets by path,worker_statusby worker), the provider caches (tenant rows keyed by model, which keep their key),audit_checkpointsby window,loom_telemetry.sli_minuteby SLO class, and thesql_accessmirror by user, which its readers filter bycurrentUser(). - Narrowest types.
LowCardinality(String)for org, namespace, agent, kinds and models;Enum8for statuses;DateTimeunless milliseconds are read;CODEC(ZSTD(3))on text over a few hundred bytes;Delta, ZSTDon monotone integers where a measurement shows it pays. - Engines by kind. Append logs are
MergeTree, with a TTL where retention applies; rollups areAggregatingMergeTreeorSummingMergeTreefed by materialized views; projections of Postgres state and versioned derived rows areReplacingMergeTree(version), withis_deletedonly where tombstones are a material share of bytes and the erasure census still holds. - Graph validity. The graph tables are also a named exception to the in-place rule
today: a close or reopen of a triple's or an edge's validity is an
ALTER TABLE … UPDATE(close_triples,reopen_triplesandend_edgesinserver/memory/app/projections.py); wave 3 moves validity to versioned interval rows of their own (entity_validity,ReplacingMergeTree(version), new tables), while an edge's co-occurrence stays an additive log thatentity_degree_mvsums once per write and interval rows never feed, so a rebuild fromrevision_relationsand revision order reproduces validity; an offline open-facts parity check, which wave 3 builds (bench/graph_eval.pycalls a model, so it is not one), proves results identical, and the change ships default-off where it does not. - No queues, leases, claims or staging tables. Anything with a state machine or an
owner is a Postgres row. A pending-embed row (a vector table's stub with
embed_attemptsandembed_failed_at, which a worker re-embeds) is not a queue: it has no claim or owner, and the re-embed is an idempotent overwrite of a derived row. The conflict resolver's mark (memory_embeddings.conflict_checked_at,server/memory/store/conflict_resolver.py) is not a queue either: it rides the row its write already emits, no row has a claim or owner, one resolver runs under a lease, and a judgement is a new version row of a derived table.erasure_requestsis a named exception: it is ClickHouse's own erasure record, saga checkpoints as version rows that the recovery worker resumes without a claim, kept beside the ClickHouse tables it erases so that each store keeps its own record (erasure_ledgerin Postgres) forloom reconcile(restores). A SCIP upload's parts, which its apply worker reads once a batch is complete, are bytes the batch'scode_scipjob owns. They go to the cell's bucket keyed by the job, or, where no bucket is configured (a single-node deployment), to Postgresjob_parts (org, job_id, seq):bytearows of at mostcode_scip_max_part_bytesthat cascade with the job, the upload refusing a batch larger than a per-job byte cap there. A job's parts are deleted when its batch is applied, and otherwise by a sweep 7 days aftercreated_at, today's TTL, apart from the job row's 30-day retention. The sweep finds a part's bucket object by its age under the jobs' prefix, so an object whose job is gone is still removed. The provider rebuild's snapshot, target, publish and backup tables (server/insights/store/provider_staging.py) are a named exception to "no staging tables in ClickHouse": each is built by one run under an exclusive lease with writers drained, no row waits on a per-row publisher, and the publish table is renamed into place whole (RENAME TABLE,server/insights/store/provider_publish.py). A rebuilt mirror swapped in whole is the same kind of table. - Skip indexes only where a
system.query_logmeasurement shows the reads they save. - Partitioning on parts and erasure grounds. A partition key exists only where a TTL drops whole partitions or an erasure replaces them. Tables that stay keep their layout here; changing a sort or partition key is a rebuild (migrations), done in its own measured change.
Vectors are the cost lever
A memory carries two 3,072-dimension BFloat16 vectors in memory_embeddings
(embedding and memory_key_embedding), 12 KiB of raw values. The codes-first codec
(Quantized('rabitq', …)) speeds the scan; it does not shrink the table. Three levers,
each measured with the offline ranking-neutral checks before it ships, and default-off
when it changes ranking:
- Whether
memory_key_embeddingearns its bytes. It serves the key axis of the fact leg (server/memory/app/retrieval/fact_sql.py) and the goal similarity ofprocedure.match(server/memory/app/procedure.py), and the offline checks cover both:bench/retrieval_determinism.py --savebefore and--againstafter covers the fact leg throughmemory.search, on a fixed namespace, identical keys and order (and scores within tolerance, which the first wave that uses it adds), and that wave adds the same before-and-after comparison forprocedure.match(fixed namespace, identical procedure ids and order). Run it with the axis off; if the results are identical, a setting that defaults to writing the column can stop it (half the vector bytes). Turning it back on re-embeds the keys at provider cost, so the default stays on. - The dimension. An embedder whose vectors keep their order when truncated can
store 1,536 dimensions. The width is in each vector column's codec
(
Quantized('rabitq', 3072, 0)), so it is a column and codec rebuild, from the stored values truncated and renormalised where they are the full-precision prefix (a re-embed only where they are not), gated by a setting that defaults to the full width, since it changes ranking. - The codec. Store the codes and a narrower rescoring copy; measure the codec's bytes per row and the rescore's recall on the fixtures.
Merges and deletions
Every ClickHouse table that merges into another or is deleted, and the KeeperMap tables
that become shared Postgres structures. Each table's row in the ClickHouse
tables is authoritative; a row whose Target is a merge or a
deletion names its table here too (tests/unit/test_storage_layout_rows.py).
| Today | Becomes | Condition |
|---|---|---|
memory_term_freq, chunk_term_freq, code_term_freq |
ClickHouse term_df (org, namespace, corpus, term), uniqExact over a UInt64 row hash, with the views that feed them (chunk_term_freq_mv, chunk_context_term_freq_mv, code_term_freq_mv, memory_term_freq_refresh) and code_term_df_refresh rewritten onto it |
bench/retrieval_determinism.py --save/--against shows identical keys and order, and scores within the tolerance the first wave that uses it adds |
memory_doc_stats, code_doc_stats |
ClickHouse doc_lengths (org, namespace, corpus, …), with memory_doc_stats_refresh and code_doc_stats_mv |
as above |
entity_edge_memories, entity_triple_memories |
entity_edge_rows, entity_triple_rows |
a backfill links the values written before row links; an offline open-facts parity check, built with the merge, shows the same open facts; user erasure (delete_entity_graph_by_user, server/erasure/store/steps_graph.py) reads ownership from the row links in the same change, proved by the census |
memory_extraction_queue, memory_webhook_outbox, memory_projection_failures, code_scip_batches, delete_version_checks |
Postgres jobs |
none |
memory_webhook_attempts |
Postgres jobs columns, and each attempt an events row |
none |
keyring_readers |
Postgres instances.keyring_refresh_s |
none |
cell_heartbeats (KeeperMap) |
Postgres instances columns cell, tenancy_mode, fingerprint |
none |
_provider_participants |
Postgres instances |
none |
memory_schema_fields |
Postgres record_schemas.fields |
none |
blob_uploads |
Postgres document_uploaders |
none |
task_webhook_cursor |
Postgres cursors |
none |
code_scip_parts |
the code_scip job's bytes (bucket, or Postgres job_parts) |
none |
acl_versions, blacklist_version |
Postgres cache_generations |
none |
namespace_write_keys (KeeperMap), namespace_write_marks |
Postgres write_marks (org, namespace, surface) |
none |
graph_projections (KeeperMap) |
Postgres graph_inflight |
none |
KeeperMap task_leases, agent_run_claims, and the keys of _migration_lock but the cell probe and nonce keys |
leases, the claims where leases places them, and columns and keys of the rows they guard |
none |
ClickHouse idempotency |
deleted: retires with the ClickHouse-authority path; its census row goes and its ClickHouse delete leaves idempotency.delete with the table; the step keeps deleting Postgres idempotency and ClickHouse embedding_cache |
retires by rolling-upgrades rule 8 |
memory_code_link_queue |
deleted: the link worker reads the journal | retires by rolling-upgrades rule 8 |
knowledge_ingest_cancels |
deleted: replaced by knowledge_jobs.cancel_requested_at |
retires by rolling-upgrades rule 8 |
_migration_lease |
deleted: no module names it | none |
task_claims, grant_claims, oauth_claims |
deleted: nothing writes them | none |
Checked and kept: memory against memory_embeddings (memory is about 91 B per row
measured and serves the ClickHouse-side reads; the vectors are apart so a text read
never touches them); code_term_df (an hourly snapshot the lexical leg reads in one
small statement, kept unless the merged term_df read meets its budget);
code_symbol_ids and code_symbol_names (re-keyings of code_symbols that table
projections could replace, which ReplacingMergeTree supports only with
deduplicate_merge_projection_mode; measured before any change); subject_last_seen (a
refreshable view's rollup of agent_events; namespace_visits, written by
namespace.mark_seen, moves apart); the conversation, evidence and chunk tables (one
fact each).
New ClickHouse tables the waves add, each with its kind and erasure, which its wave's pull request adds to the rows below:
| Table | Wave | Kind | Erasure |
|---|---|---|---|
entity_validity (the graph's versioned interval rows, ReplacingMergeTree(version)) |
3 | Derived, from revision_relations and revision order |
census and entity_graph.delete, user scope too; redaction through entity_triple_rows |
blacklist_hits (org, namespace, pattern_id): blacklist.list's all-time hit_count and last_hit_at, a rollup of the hit events that the flip seeds with each pattern's counts |
1 | SoR past the retention of events |
no content; deleted with its pattern |
term_df, doc_lengths |
4 | Derived | as the tables they merge: term_aggregates.delete for the memory and chunk corpora, code_index.purge for the code corpus |
the move markers paired with table_moves |
1 | Coord | no content |
The ClickHouse tables
Every ClickHouse table, by family: those of schemas/schema.sql and, last, the tables
whose names start with _ that a migration creates, which scripts/ops/dump_schema.py
leaves out of it, as it leaves out every _ name. Today is the engine (RMT
ReplacingMergeTree, MT MergeTree, AMT AggregatingMergeTree, SMT
SummingMergeTree), the sort key, the partition key and whether a TTL applies.
Target is where it goes: "stays", a Postgres table, a merge, or a deletion.
Kind, from each fact has one owner, is the table's kind
in the target layout. Erasure is its entry in server/erasure/store/census.py: a
census row and its namespace step, a saga step without a census row, or no memory
content; the _ tables are outside the census. Wave is the wave that moves the
table, or the part of it its Target names, or the precondition a table retires on
(waves); a table whose Target is "stays" has none
(tests/unit/test_storage_layout_rows.py), and its last column names each wave that
changes it in place. The last column names why, and after "named in" up to two modules
where a statement, a table-name literal or a module-level …TABLE constant names the
table, docstrings and logger names aside; for a table in both stores, only a module with
no pg part in its file name. _names_table in
tests/unit/test_storage_layout_rows.py holds the exact forms and checks every
citation. A citation names a module, not a symbol, so the test can check it, and
grep -rnw finds the other code that names the table (statements that cross the
stores). The migrator creates loom._migration_lock
itself, before any migration file runs; leases places its keys. Tables an
operator command creates for its own run (the provider rebuild's snapshot, target,
publish and backup tables, server/insights/store/provider_staging.py) are scratch
copies of tables listed here, owned and removed by that run, and erasure reaches them
(server/erasure/store/provider_copies.py).
Identity, tokens and access
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
revoked_tokens |
RMT(version) jti, TTL |
Postgres revoked_tokens |
SoR | no content | 1 | read on every request through a cache that must fail closed; keyed (kind, key), kind subject_or_jti, cutoff or scim (today's jti string holds a token id or a subject, either with an issued_before that stays a column, plus subject cutoffs and SCIM keys); named in server/auth/revocation.py |
signing_keys |
RMT(version) kid |
Postgres signing_keys |
SoR | no content | 1 | few rows, read by kid on every verify; rotation is a compare-and-set on activate_at; named in server/auth/keyring.py, server/cli/secrets.py |
keyring_readers |
MT (seen_at, process), TTL |
merge into Postgres instances.keyring_refresh_s |
Coord | no content | 1 | one value per live process; the instance row already exists; named in server/auth/keyring.py |
service_accounts |
RMT(version) (org, name), by org |
Postgres service_accounts |
SoR | no content | 1 | point read by (org, name) on token mint and on every delegated request; named in server/auth/service_accounts.py, server/identity/app/me_service_accounts.py |
machine_identities |
RMT(version) name |
Postgres machine_identities |
SoR | no content | 1 | workload trusts, read by name on token exchange; named in server/auth/machines.py, server/observability/otel_probes.py |
scim_users |
RMT(version) id |
Postgres scim_users |
SoR | no content | 1 | deprovisioning revokes tokens: a stale read keeps access; named in server/auth/scim.py |
sso_links |
RMT(version) (email, org, namespace) |
Postgres sso_links |
SoR | no content | 1 | account ownership decided at sign-in by (email, org, namespace); named in server/auth/sso_links.py, server/identity/store/namespace_census.py |
principal_groups |
RMT(version) (org, group_id) |
Postgres principal_groups |
SoR | step groups.remove_member |
1 | group membership expands grants at check time; named in server/identity/app/scim_groups.py, server/erasure/store/steps_principals.py |
oauth_state |
RMT(version) (record_type, id), by record_type, TTL |
Postgres oauth_state |
SoR | no content | 1 | registered clients, refresh tokens' grants (written before the token is handed out) and owed revocations (revocation_owed); what a sign-in spends once is in oauth_signin_state; today's 90-day TTL becomes a sweep; named in server/auth/oauth_store.py, server/auth/revocation.py |
oauth_claims |
KeeperMap claim_key |
delete | Coord | no content | 1 | no code path writes it (its census entry says so); no module outside the census names the table |
oauth_signin_state |
KeeperMap claim_key |
Postgres oauth_signin_state |
SoR | no content | 1 | strict compare-and-set per key with an expiry: an UPDATE … WHERE value = $old; the KeeperMap expiry becomes expires_at, read by a sweep; named in server/auth/claims.py, server/cli/secrets.py |
grant_claims |
KeeperMap claim_key |
delete | Coord | no content | 1 | no code path writes it (its census entry says so); no module outside the census names the table (server/auth/grant_claims.py is the claim module, whose keys are in _migration_lock) |
namespace_grants |
RMT(version) (org, namespace, subject), by org |
Postgres namespace_grants |
SoR | step grants.revoke |
1 | every request's role check; a revoke takes effect within the grant cache's invalidation bound; named in server/identity/store/grants.py, server/auth/self_signup.py |
namespaces |
RMT(updated_at) (org, namespace), by org |
Postgres namespaces + ClickHouse mirror |
SoR (head) | step personal.retire; step collections.forget_owner |
1 | the registry with its per-namespace settings, read by grants, sign-in and request paths through a cache; the mirror serves ClickHouse worker scans and the SQL surface views; named in server/auth/self_signup.py, server/cli/hygiene.py |
collections |
RMT(version) (org, namespace) |
Postgres collections |
SoR | step collections.forget_owner |
1 | the collection tree grants inherit through; named in server/identity/store/collections.py, server/identity/store/namespace_census.py |
acl_versions |
SMT(changes) (org, kind) |
merge into Postgres cache_generations (kinds grants, groups, collections) |
Coord | no content | 1 | a change counter caches poll; a generation row plus NOTIFY replaces it; named in server/core/grant_cache.py |
auth_attempts |
MT (kind, key_sha256, at), TTL |
Postgres auth_attempts |
Coord | no content | 1 | attempt counts per kind and hashed key (sign-in, sign-up, OIDC start, workload-token exchange; server/auth/attempts.py), read before any org is known, so one count per cell, not per org; it admits no more per key than today's sliding window and keeps the releases of successful attempts; named in server/auth/attempts.py, server/cli/doctor.py |
org_model_calls |
RMT(version) org |
Postgres org_model_calls |
SoR | no content | 1 | the org's switch for every model call, read at model-call time; one per org; named in server/identity/store/model_calls.py, server/llm/customer.py |
user_state |
RMT(version) (org, subject), by org |
Postgres user_state |
SoR | no content | 3 | per-user dashboard state, read and written by key; named in server/identity/app/me_routes.py, server/identity/store/user_state.py |
namespace_visits |
RMT(version) (org, subject, namespace), by org |
Postgres namespace_visits |
SoR | no content | 3 | written by namespace.mark_seen and read by key, like user_state; named in server/identity/app/namespace_membership.py |
subject_last_seen |
AMT (org, subject) |
stays | Derived, SoR past its source's retention | step subject_last_seen.delete |
— | max aggregate per subject, filled by a refreshable view over agent_events, without a model; past the source's retention the only copy; named in server/tasks/store/repo.py, server/erasure/store/steps_memory.py |
Deployment configuration and secrets
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
deployment_config |
RMT(version) name |
Postgres deployment_config |
SoR | no content | 1 | the settings snapshot the leader publishes and every process checks its own against; named in server/core/deployment_config.py |
runtime_overrides |
RMT(version) (kind, key) |
Postgres runtime_overrides |
SoR | no content | 1 | rate limits, budgets and live settings every replica applies; named in server/core/runtime_overrides.py |
provider_configuration |
RMT(version) config_id |
Postgres provider_configuration + rebuild jobs |
SoR | no content | 1 | sealed provider envelopes and activation state; a rebuild is a jobs row with its ledger (cursors); named in server/insights/store/provider_configuration.py, server/cli/secrets.py |
embedder_calibration |
RMT(version) embedder_model |
Postgres embedder_calibration |
Derived | no content | 1 | one row per embedder model, adopted by every replica; rebuilt by a recalibration, at embedding cost; named in server/memory/store/calibration.py |
analysis_secrets |
RMT(version) id |
Postgres analysis_secrets |
SoR | no content | 1 | sealed insights.query scope key and analyst password, written as literals into its ClickHouse's row policies (server/insights/domain/analysis.py); named in server/insights/store/analysis.py, server/cli/secrets.py |
assistant_service |
RMT(version) id |
Postgres assistant_service |
SoR | no content | 1 | the sealed password and API key the assistant service logs in with (server/assistant/app/service.py), written step by step under a lease: one secret per cell with no org, embedded in no ClickHouse object; named in server/assistant/store/service.py, server/assistant/app/service.py |
sql_access |
RMT(updated_at) (user_name, org, namespace) |
Postgres sql_access + ClickHouse mirror |
SoR (head) | no content | 1 | which SQL user may read which org and namespace through the scoped sql_v1_* views: a grant, so its head and its revocations are in Postgres; the views read the mirror by currentUser() inside ClickHouse, and a revoke answers once its mirror rows have landed; its wildcard grants (namespace = '*', org = '*') are rows like any other; named in server/insights/store/sql_access.py, server/identity/store/namespace_census.py |
analysis_canary |
RMT id |
stays | SoR | no content | — | must stay in ClickHouse: insights.query reads it through the same row policies as real data; named in server/insights/domain/analysis.py |
storage_authority |
RMT(epoch) tuple() |
stays | SoR | no content | — | ClickHouse's half of the pairing with Postgres's authority row (the deployment id); named in server/core/pg_profile.py |
frontend_assets |
RMT(published_at) path, TTL |
stays | Cache | no content | — | write-once per build and path, read by path, 30-day TTL; each cell's replicas publish their own; named in server/storage/frontend_assets.py |
instances |
KeeperMap instance_id |
Postgres instances |
Coord | no content | 1 | heartbeat registry the rate limiter, the workers lease and key rotation read; one row per process; named in server/core/instances.py |
cell_heartbeats |
KeeperMap instance_id |
merge into Postgres instances (cell, tenancy_mode, fingerprint columns) |
Coord | no content | 1 | each process's LOOM_CELL, tenancy mode and configuration fingerprint, which the loom cell commands (prepare, attach, update) compare across a cell's live processes; a second KeeperMap table only because a KeeperMap table takes no new column, so its fields become columns of the process's instances row (cells); named in server/core/instances.py |
carry_acks |
RMT(at) process, TTL |
Postgres carry_acks |
Coord | no content | 2 | per-process acknowledgement of the cell's ClickHouse column lists; a boot waits on it; named in server/core/rmt_carry.py |
worker_status |
RMT(version) (worker, instance_label, pass_started), TTL |
stays | Log | no content | — | one row per worker pass, 30-day TTL, scanned by status pages; its resume-cursor rows are a build ledger of the ClickHouse output they describe, which the TTL can expire, so a worker idle past it restarts from the beginning; named in server/workers/status_store.py, server/cli/restore.py |
Memory
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
memory |
RMT(version) (org, namespace, agent, user_id, session_id, memory_key), by toYYYYMM(created_at) |
stays | Derived | census, memory.tombstone, user memory.retire; step consolidated.tombstone |
— | the current rows in ClickHouse: the source of memory_versions_mv, granted to insights.query, read by ClickHouse-side joins; published from memory_current and rebuilt by republishing it; named in server/memory/store/history.py, server/memory/store/inserts.py |
memory_embeddings |
RMT(version) (org, namespace, agent, user_id, session_id, scope, embedding_model, memory_key), by toYYYYMM(created_at) |
stays | Derived | census, memory_embeddings.tombstone, user scope too; step shared_embeddings.tombstone; step consolidated.tombstone |
— | the vectors of the memory heads (memory_current) and the shared heads (shared, and from wave 3 shared_current); Postgres stores none (re-embedding costs provider calls); wave 4's vector levers change its columns and codec; named in server/memory/store/inserts.py, server/memory/store/shared.py |
memory_versions |
RMT (org, namespace, agent, user_id, session_id, memory_key, version), by toYYYYMM(created_at) |
stays | SoR | census, memory_versions.delete, user scope too |
— | version history, fed by memory_versions_mv: the system of record for history written before a deployment's journal existed, derived from memory_revisions for later rows; for a version superseded with its journal payload cleared, the only copy of its content; named in server/memory/store/conflict_resolver.py, server/memory/store/history.py |
shared |
RMT(version) (org, namespace, memory_key), by (org, namespace) |
Postgres shared_current + ClickHouse shared |
SoR (head) | census, shared.tombstone |
3 | promoted namespace-wide memory: a head per (org, namespace, memory_key) on the memory journal (its revisions in memory_revisions, its deliveries in memory_deliveries); the ClickHouse table stays its search projection; a namespace erasure tombstones it and a user erasure matches it by provenance; named in server/memory/store/shared.py, server/erasure/store/steps_memory.py |
working_memory |
RMT(version) (org, namespace, agent, user_id, session_id, run_id, block_key), by (org, namespace) |
Postgres working_memory |
SoR | census, working_memory.tombstone, user scope too |
3 | keyed blocks read and replaced by key, never ranked; named in server/memory/app/working_memory.py, server/erasure/store/steps_memory.py |
memory_reinforcement |
RMT(version) (org, namespace, agent, user_id, memory_key) |
stays | Derived | census, memory_reinforcement.delete, user scope too |
— | folded usage counters, rebuildable from memory_usage_events; named in server/memory/app/reinforcement.py, server/erasure/store/steps_memory.py |
memory_term_freq |
AMT (org, namespace, term), by toYYYYMM(df_added_at) |
merge into ClickHouse term_df |
Derived | step term_aggregates.delete |
4 | document frequency per term, folded by memory_term_freq_refresh from memory_embeddings without a model; one table for memory, chunks and code with a corpus column; named in server/memory/store/term_freq.py, server/memory/store/bm25_stats.py |
chunk_term_freq |
AMT (org, namespace, term), by toYYYYMM(df_added_at) |
merge into ClickHouse term_df |
Derived | step term_aggregates.delete |
4 | document frequency per term of the chunks, fed by chunk_term_freq_mv and chunk_context_term_freq_mv from chunks; named in server/memory/store/term_freq.py, server/erasure/store/steps_memory.py |
code_term_freq |
AMT (org, namespace, term) |
merge into ClickHouse term_df |
Derived | step code_index.purge |
4 | as above; named in server/code/store/index/search.py |
memory_doc_stats |
RMT(version) (org, namespace, agent, user_id, session_id, scope, embedding_model, memory_key) |
merge into ClickHouse doc_lengths |
Derived | step term_aggregates.delete |
4 | document length per searchable row, folded by memory_doc_stats_refresh from memory_embeddings; one table with code_doc_stats; named in server/memory/store/bm25_stats.py, server/erasure/store/steps_memory.py |
code_doc_stats |
RMT(version) (org, namespace, chunk_id) |
merge into ClickHouse doc_lengths |
Derived | step code_index.purge |
4 | as above; named in server/code/store/index/search.py |
code_term_df |
MT (org, namespace, term) |
stays | Derived | step code_index.purge |
— | an hourly snapshot of code_term_freq the lexical leg reads in one small statement; kept unless the merged read meets its budget; wave 4 rewrites its feed, code_term_df_refresh, onto term_df; named in server/code/store/index/search.py |
memory_evidence |
RMT(created_at) (org, namespace, agent, user_id, session_id, observed_at, evidence_id), by toYYYYMM(created_at) |
stays | SoR | census, raw_evidence.delete, user scope too |
— | raw evidence the extraction reads, append-only; named in server/memory/app/evidence.py, server/erasure/store/mutations.py |
memory_chunks |
RMT(created_at) (org, namespace, session_id, chunk_index, content_sha256), by toYYYYMM(created_at) |
stays | Derived (SoR for chunks memory.import_evidence writes) |
census, memory_chunks.tombstone, user scope too |
— | chunked evidence and chunks of long memory.set values, with vectors (server/memory/app/set_chunks.py); rebuilt from memory_evidence and the heads at embedding cost, except the chunks memory.import_evidence writes with no memory_evidence row (server/memory/app/evidence_transfer.py), held only here; named in server/memory/app/set_chunks.py, server/memory/app/chunk_projection.py |
memory_chunk_sources |
RMT(created_at) (org, namespace, session_id, chunk_index, memory_key, evidence_id), by toYYYYMM(created_at) |
stays | SoR | census, source_links.tombstone, user scope too |
— | chunk-to-memory links written by the extraction handler after a run and by memory.import_evidence (server/memory/app/evidence_transfer.py), which takes links a caller states; held only here, so lost without a backup; named in server/memory/app/chunk_links.py, server/erasure/store/steps_graph.py |
memory_belief_sources |
RMT(created_at) (org, namespace, memory_key, evidence_id) |
stays | SoR | census, source_links.tombstone, user scope too |
— | evidence links of a belief, written by the extraction handler after a run (server/memory/app/extract/handler.py), by consolidation linking merged rows to their summary (server/memory/app/consolidate.py) and by period digests (server/memory/app/period_digest.py); held only here, so lost without a backup; named in server/memory/app/evidence.py, server/erasure/store/steps_graph.py |
session_summaries |
RMT(version) (org, namespace, agent, user_id, session_id, embedding_model), by toYYYYMM(created_at) |
stays | Derived (SoR for digests memory.import_evidence writes) |
census, summaries.tombstone, user scope too |
— | session digests with vectors, written by a model from the session's turns (server/memory/app/session_summary.py) and rebuildable at model cost from those turns in memory_evidence; the digests memory.import_evidence writes are held only here; wave 3 retires a digest whose derived_from names a redacted key (deliveries); named in server/memory/app/session_summary.py, server/memory/app/evidence_transfer.py |
derived_facts |
MT (org, namespace, subject, predicate, category), by toYYYYMM(created_at) |
stays | Derived (SoR for operands a memory.bulk_set entry states) |
census, derived_facts.delete, user scope too |
— | per-occurrence operands for multi-hop answers, append-only, written by memory.bulk_set from each entry's derived list, which a caller states or extraction from messages fills; a stated operand is held only here, so a rebuild, by re-running extraction over the messages, restores only the extracted ones; wave 3 adds the fact's memory version and retires the facts of a redacted version (deliveries); named in server/memory/app/triple_projections.py, server/erasure/store/steps_memory.py |
memory_records |
RMT(version) (org, namespace, schema_name, memory_key, record_id) |
Postgres records + ClickHouse projection |
SoR (head) | census, records.tombstone, user scope too |
3 | a record's current fields, read by key and compared on write; Postgres records keeps each field's state and evidence id with its value, which only memory_record_fields holds today; the projection stays for the record side leg and record query, which join it with memory_record_fields, and feeds memory_record_versions_mv; named in server/memory/store/records.py, server/memory/app/records.py |
memory_record_versions |
RMT (org, namespace, schema_name, memory_key, record_id, version), by toYYYYMM(created_at) |
stays | SoR | census, records.tombstone, user scope too |
— | record history, fed by memory_record_versions_mv from memory_records; from wave 3, one row per delivered revision, reconciled after a restore by the journal replay; named in server/erasure/store/steps_graph.py, server/memory/app/records.py |
memory_record_fields |
RMT(version) (org, namespace, schema_name, field_name, record_id) |
stays | SoR, then Derived | census, records.tombstone, user scope too |
— | field-level projection record_query scans; each field's state and evidence id are held only here today (memory_records keeps the values), so it is rebuildable only from wave 3, from Postgres records; named in server/memory/app/records.py, server/memory/store/record_query.py |
memory_record_conflicts |
RMT(version) (org, namespace, schema_name, memory_key, field_name, incoming_record_id) |
stays | SoR, then Derived | census, records.tombstone, user scope too |
— | the conflicts a record commit found; from wave 3 they ride the record's revision, so the journal replay restores them after a ClickHouse restore and they are derived from it; its name is one of the tables insights.query grants (server/insights/domain/analysis.py); named in server/memory/app/records.py, server/erasure/store/steps_graph.py |
memory_schemas |
RMT(version) (org, namespace, name) |
Postgres record_schemas |
SoR | no content | 3 | per-namespace schema definitions, read by name on every record write; named in server/memory/app/records.py |
memory_schema_fields |
RMT(version) (org, namespace, schema_id, field_name) |
merge into Postgres record_schemas.fields |
SoR | no content | 3 | the fields of one schema are read and written with it; named in server/memory/app/records.py |
profile_schemas |
RMT(version) (org, namespace) |
Postgres profile_schemas |
SoR | no content | 3 | one JSON Schema per namespace, read on every profile build; named in server/memory/store/profile_schemas.py |
categories |
RMT(version) (org, namespace, name) |
stays | SoR | no content | — | operator-authored categories with vectors; it stays because search joins it with the memories inside ClickHouse, and its by-key reads (registered_ids, extraction's category fetch) need no Postgres commit; wave 3 reads registered_ids with sequential consistency; named in server/cli/import_clickgap.py, server/identity/app/namespace_catalog.py |
memory_categories |
RMT(created_at) (org, namespace, memory_key, category_id) |
stays | SoR | census, curation.tombstone |
— | the category links of each memory, written by memory.assign_category, memory.import and a CLI importer, and held nowhere else; it stays because search joins it with the memories inside ClickHouse, and the import's insert-if-absent read runs with sequential consistency, a duplicate collapsing on the key; named in server/memory/store/categories.py, server/cli/import_clickgap.py |
memory_extraction_queue |
RMT(version) (org, namespace, agent, user_id, session_id, queue_id), by toYYYYMM(enqueued_at), TTL |
merge into Postgres jobs (kind extraction) |
SoR | census, raw_evidence.delete, user scope too |
2 | a queue with leases and statuses on an engine without row locks; retention: the jobs sweep for its kind (jobs); named in server/memory/app/extraction_queue.py, server/erasure/store/steps_memory.py |
learnings_queue |
RMT(version) (org, namespace, candidate_id), by toYYYYMM(proposed_at) |
Postgres learning_candidates |
SoR | census, learnings_queue.delete, user scope too |
3 | candidates a human promotes or rejects: a status a decision changes once; named in server/memory/store/learnings.py, server/erasure/store/steps_ledgers.py |
learnings_supporters |
MT (org, namespace, candidate_id, user_id, session_id) |
Postgres learning_supporters |
SoR | census, learnings_queue.delete, user scope too |
3 | each supporter's bounded quotes, deleted with the user; foreign key to its candidate; named in server/memory/store/learnings.py, server/erasure/store/predicates.py |
memory_code_link_queue |
MT queued_at, by toDate(queued_at), TTL |
delete; the link worker reads memory_revisions from its cursor in code_index_state |
Derived | census, code_links.delete, user scope too |
2 | the journal is already the ordered list of every write; memory_code_link_queue_mv fills it until N+1; named in server/memory/store/code_links.py, server/erasure/store/steps_memory.py |
memory_code_links |
RMT(version) (org, code_namespace, path, qualified_name, namespace, memory_id) |
stays | Derived (SoR for anchors) | census, code_links.delete, user scope too; step consolidated.tombstone |
— | memory-to-code links built by the link worker; a rebuild anchors each link at the current head, so the anchor, baseline and former identities that say what changed since a link was made are held only here; their suggestion comes from a model (server/memory/app/code_link_judge.py); named in server/memory/store/code_links.py, server/memory/store/redaction.py |
memory_webhooks |
RMT(version) (org, namespace, webhook_id) |
Postgres webhooks |
SoR | no content | 2 | registrations read on every delivery pass; named in server/memory/store/webhooks.py, server/identity/store/namespace_census.py |
memory_webhook_outbox |
MT (org, namespace, webhook_id, occurred_at, event_id), by toDate(occurred_at), TTL |
merge into Postgres jobs (kind webhook) |
SoR | census, memory_webhook_outbox.delete, user scope too |
2 | a transactional outbox: the event row commits with the revision that caused it; named in server/memory/store/webhooks.py, server/erasure/store/steps_ledgers.py |
memory_webhook_attempts |
MT (event_id, version), by toDate(recorded_at), TTL |
merge into Postgres jobs columns + ClickHouse events |
Log | no content | 2 | the job row keeps attempts and last status; each attempt is also an events row; named in server/memory/store/webhooks.py |
memory_projection_failures |
MT (org, namespace, projection, created_at, failure_id), TTL |
merge into Postgres jobs (kind projection_repair) |
SoR | census, projection_failures.delete, user scope too |
2 | a retry queue of failed projections; retention: the jobs sweep for its kind (jobs); named in server/memory/app/projection_failures.py, server/erasure/store/steps_ledgers.py |
delete_version_checks |
RMT(updated_at) (org, namespace, agent, user_id, session_id, memory_key, tombstone), TTL |
merge into Postgres jobs (kind delete_followup) |
SoR | census, delete_version_checks.delete, user scope too |
2 | the Postgres commit already checks the head, but a delete still owes derived retirement and a webhook after its tombstone; that is a job its commit inserts; named in server/memory/store/delete_checks.py, server/erasure/store/steps_ledgers.py |
memory_drops |
MT (org, namespace, stage, reason, ts), TTL |
stays | Log | census, memory_drops.delete |
— | why a write was dropped, 30-day TTL; named in server/memory/app/drops.py, server/erasure/store/steps_ledgers.py |
memory_ask_precedents |
RMT(version) (org, namespace, precedent_key), TTL |
stays | Cache | census, ask_precedents.delete, user scope too |
— | memory.ask answers keyed by question, 30-day TTL; named in server/memory/store/ask_precedents.py, server/memory/store/redaction.py |
conflict_allowlist |
RMT(allowed_at) (org, namespace, memory_key_a, memory_key_b) |
Postgres conflict_allowlist |
SoR | census, curation.tombstone |
3 | a user's decision that two keys do not conflict, read on every conflict check; named in server/memory/store/conflicts.py, server/erasure/store/steps_ledgers.py |
extraction_policies |
RMT(version) (org, namespace) |
Postgres extraction_policies |
SoR | no content | 1 | rules every extraction call must carry; read by (org, namespace); named in server/memory/store/extraction_policy.py |
namespace_calibration_result |
RMT(version) (org, namespace) |
Postgres namespace_calibration |
Derived | no content | 3 | one row per namespace a calibration writes and searches read; rebuilt by a recalibration, at embedding cost; named in server/memory/app/embedder_calibration.py |
namespace_policies |
RMT(version) (org, namespace, policy_id) |
Postgres namespace_policies |
SoR | no content | 1 | policy rules the gate reads on every guarded op; named in server/identity/store/policy.py |
namespace_types |
RMT(updated_at) (org, namespace, memory_type), by org |
Postgres namespace_types |
SoR | no content | 3 | memory types a namespace declares, read on write; named in server/identity/app/namespace.py, server/identity/app/namespace_snapshot.py |
namespace_ontology |
RMT(version) (org, namespace, kind, name), by org |
Postgres namespace_ontology |
SoR | no content | 3 | entity kinds and relations a namespace declares; named in server/identity/app/namespace_ontology.py |
blacklist |
RMT(created_at) (org, namespace, pattern_id) |
Postgres blacklist |
SoR | no content | 1 | refusal patterns checked on every write, global (org = '*') and per org, read through a cache; a change bumps the policy scope in its commit; hit counts move to events and the blacklist_hits rollup (new tables); named in server/memory/store/blacklist.py, server/memory/app/blacklist_guard.py |
blacklist_version |
RMT(updated_at) singleton |
merge into Postgres cache_generations (kind blacklist) |
Coord | no content | 1 | a change stamp caches poll; named in server/memory/store/blacklist.py, server/core/namespace_write_marks.py |
consolidation_runs |
MT (org, namespace, ran_at, run_id), by toYYYYMM(ran_at), TTL |
stays | Log | census, consolidation_runs.delete, user scope too |
— | one row per consolidation pass, 180-day TTL; named in server/memory/app/consolidate.py, server/erasure/store/steps_ledgers.py |
context_blobs |
RMT(version) (org, namespace, blob_key), by toYYYYMM(expires_at), TTL |
stays (search pages) + Postgres context_offloads (offloaded context) |
Cache; SoR (head) | census, context_blobs.tombstone, user scope too |
3 | two writers: memory.search page handles past top_k (server/memory/app/search_pages.py), a cache whose loss answers page_expired, which stays here; and context.offload's replaced context, the only copy, which becomes a Postgres head (context offloads); named in server/memory/app/context_offload.py, server/memory/app/search_pages.py |
operator_edges |
RMT(created_at) (org, namespace, from_key, to_key, relation), by toYYYYMM(created_at) |
Postgres operator_edges + ClickHouse projection |
SoR (head) | census, curation.tombstone |
3 | links an op writes and unwrites between keys, changed in place: a Postgres head ordered by its commit, the projection kept for the graph statements that join it inside ClickHouse; the projection is rebuilt as ReplacingMergeTree(version) on the head's version in that wave; named in server/memory/app/digest_chain.py, server/memory/app/ops.py |
session_briefs |
RMT(version) (org, namespace, brief_key) |
Postgres session_briefs |
SoR | census, session_briefs.delete, user scope too |
3 | a brief's head with a compare-and-set on its revision; read by its store module and its erasure step; named in server/memory/store/session_briefs.py, server/erasure/store/steps_ledgers.py |
session_brief_revisions |
MT (org, namespace, brief_key, revision, version) |
Postgres session_brief_revisions |
SoR | census, session_briefs.delete, user scope too |
3 | brief history; small, and owned with the head's compare-and-set, so it moves with it and session_brief_revisions_mv goes; named in server/memory/store/session_briefs.py, server/erasure/store/steps_ledgers.py |
namespace_guides |
MT (org, namespace, revision) |
Postgres namespace_guides |
SoR | census, namespace_guides.delete |
3 | revisioned text whose writer names the revision it read: a compare-and-set; named in server/identity/store/namespace_guides.py, server/erasure/store/steps_ledgers.py |
Knowledge and conversations
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
documents |
RMT(version) (org, namespace, source_uri), by toYYYYMM(ingested_at) |
stays | Derived (SoR for documents with no head: written before their namespace had heads, or with a source URI or writer Postgres cannot store) | census, documents.tombstone, user scope too |
— | the searchable document rows, published from Postgres knowledge_heads; a document with no head is kept as restored; named in server/knowledge/store/documents.py, server/insights/store/mentor.py |
document_blobs |
RMT(created_at) (org, namespace, sha256) |
Postgres document_catalog + bytes in the document store |
SoR | census, documents.tombstone |
3 | the originals' catalog, read by key and changed in place (a re-upload restamps it, switch_to and unwrite rewrite its location, server/knowledge/store/blobs.py), with sweeping decided by an age heuristic rather than a lock: the catalog moves beside knowledge_heads, and the bytes stay in the bucket, or in document_blobs where no bucket is configured, written once and deleted by the catalog's owner; named in server/knowledge/store/blobs.py, server/erasure/store/steps_memory.py |
blob_uploads |
MT (org, user_id, namespace, sha256) |
merge into Postgres document_uploaders |
SoR | census, documents.tombstone, user scope too |
3 | who uploaded each original, read by a user's erasure to find the user's originals; it moves with the catalog, document_uploaders becoming its one owner, with the census row it has today; named in server/knowledge/store/blobs.py, server/erasure/store/steps_memory.py |
chunks |
MT (org, namespace, embedding_model, source_uri, chunk_idx), by toYYYYMM(created_at) |
stays | Derived | census, chunks.supersede, user scope too |
— | document chunks with vectors; rebuildable from the originals at embedding cost; named in server/knowledge/store/chunks.py, server/knowledge/store/documents.py |
chunk_supersessions |
RMT(created_at) (org, namespace, embedding_model, source_uri, doc_version) |
stays | Derived | no content | — | which chunk versions a newer document version retired, rebuilt from documents versions; named in server/knowledge/domain/live_chunks.py, server/knowledge/store/inspect.py |
datasets |
RMT(version) (org, namespace, source_uri, dataset_id, doc_version) |
stays | Derived (SoR for rows only ClickHouse holds) | census, documents.tombstone, user scope too |
— | tables extracted from documents, versioned with the document; under the Postgres authority a best-effort projection of Postgres knowledge_datasets that its writer inserts inline (the datasets exception); named in server/knowledge/store/datasets.py, server/erasure/store/steps_memory.py |
dataset_rows |
MT (org, namespace, dataset_id, snapshot_id, row_id) |
stays | Derived | census, documents.tombstone, user scope too |
— | typed slots of each extracted table row, written once per snapshot; rebuilt only by ingesting the source again; named in server/knowledge/store/datasets.py, server/erasure/store/steps_memory.py |
knowledge_ingestions |
RMT(version) (org, namespace, ingestion_id), TTL |
stays | Derived (SoR for jobs Postgres cannot hold, within its 30-day TTL) | census, documents.tombstone, user scope too |
— | the ClickHouse projection of Postgres knowledge_jobs, which holds ingestion jobs under the Postgres authority with a fencing token and a cancel column; kept for reports and knowledge.inspect; rebuilt in wave 2 as ReplacingMergeTree on the job's commit version; named in server/knowledge/store/ingestions.py, server/erasure/store/steps_memory.py |
knowledge_ingest_cancels |
MT (org, namespace, ingestion_id), TTL |
delete; knowledge_jobs.cancel_requested_at |
SoR | census, documents.tombstone, user scope too |
2 | a user's cancel request, counted by user in the census, kept so a build that predates knowledge_jobs reads it; retires by rolling-upgrades rule 8; named in server/erasure/store/steps_memory.py, server/knowledge/store/ingestions.py |
knowledge_uploads |
MT (org, namespace, upload_id), TTL |
Postgres knowledge_uploads |
SoR | census, documents.tombstone, user scope too |
2 | an upload's scoped identity, checked at ingest, expiring; today's TTL, 7 days past expires_at, becomes a sweep; named in server/knowledge/store/ingestions.py, server/erasure/store/steps_memory.py |
knowledge_sources |
RMT(version) (org, namespace, name) |
Postgres knowledge_sources |
SoR | step knowledge_sources.retire |
3 | registrations with sealed credentials, read by name; named in server/knowledge/store/sources.py, server/identity/store/namespace_census.py |
conversations |
MT (org, namespace, thread_id, ts, msg_id), by toYYYYMM(ts) |
stays + Postgres conversation_states |
SoR | census, conversations.tombstone, user scope too |
3 | messages, an append log read by scans; their tombstones and saved state move to Postgres conversation_states in wave 3 (conversation state); named in server/knowledge/store/conversations.py, server/erasure/store/exports.py |
conversation_threads |
RMT(updated_at) (org, namespace, thread_id), by toYYYYMM(created_at) |
stays | SoR | census, conversations.tombstone, user scope too |
— | threads: the title, metadata and run_id a caller sets with conversation.create and conversation.update, and a model title for an untitled thread, with no TTL; it stays because its tombstones must reach every version of the row, as today's mutations do, and no reader needs a Postgres commit of it; an update merges onto the row it reads, so a write racing another write can lose a field, and one racing a thread delete or an erasure brings the thread back, a race of today's code that no wave changes (conversation state); named in server/knowledge/store/conversations.py, server/erasure/store/exports.py |
conversation_embeddings |
RMT(ts) (org, namespace, embedding_model, thread_id, msg_id), by toYYYYMM(ts) |
stays | Derived, SoR past its source's retention | census, conversations.tombstone, user scope too |
— | message vectors, rebuildable at embedding cost while their messages are retained; it has no TTL, so past conversations retention its excerpt, vector and saved state are the only copy; its saved state and saver stay on its own rows as version-row inserts from wave 3 (conversation state); named in server/knowledge/store/conversations.py, server/erasure/store/mutations.py |
Entity graph
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
entity_edges |
MT (org, namespace, entity_a, entity_b), by toYYYYMM(last_seen) |
stays | Derived | census, entity_graph.delete |
— | pair edges with validity, from entity resolution and extraction over the memories, which need a model call to recompute; wave 3 writes their validity as entity_validity rows; named in server/memory/app/graph_undo.py, server/memory/app/projections.py |
entity_degree |
SMT (org, namespace, entity) |
stays | Derived | census, entity_graph.delete |
— | SummingMergeTree fed by entity_degree_mv from entity_edges inserts; named in server/memory/store/fact_graph.py, server/erasure/store/steps_graph.py |
entity_triples |
MT (org, namespace, subject, relation, object), by toYYYYMM(last_seen) |
stays | SoR, then Derived | census, entity_graph.delete |
— | subject-relation-object facts with validity; the system of record for relations no revision maps to, derived from Postgres revision_relations from wave 3, without a model call, for the rest; wave 3 writes their validity as entity_validity rows; named in server/memory/app/graph_undo.py, server/memory/app/triple_projections.py |
entity_edge_rows |
RMT (org, namespace, memory_id, user_id, content_sha256, entity_a, entity_b) |
stays | Derived | census, graph_links.delete, user scope too |
— | which row and value named each pair, rebuilt by the entity projection from the memories, without a model; wave 4 backfills it from entity_edge_memories; named in server/memory/app/edge_links.py, server/memory/app/projections.py |
entity_triple_rows |
RMT (org, namespace, memory_id, user_id, content_sha256, subject, relation, object, inferred) |
stays | SoR, then Derived | census, graph_links.delete, user scope too |
— | which row and value stated each triple; the system of record for relations no revision maps to, derived from Postgres revision_relations from wave 3, without a model call, for the rest; wave 4 backfills it from entity_triple_memories; named in server/memory/app/triple_projections.py, server/memory/app/projections.py |
entity_edge_memories |
RMT(seen_at) (org, namespace, entity_a, entity_b, memory_key), by toYYYYMM(seen_at) |
merge into ClickHouse entity_edge_rows |
Derived | census, graph_links.delete, user scope too |
4 | key links kept for values written before row links; a backfill of row links retires them; named in server/memory/app/projections.py, server/erasure/store/predicates.py |
entity_triple_memories |
RMT(seen_at) (org, namespace, subject, relation, object, memory_key), by toYYYYMM(seen_at) |
merge into ClickHouse entity_triple_rows |
SoR, then Derived | census, graph_links.delete, user scope too |
4 | as above; the system of record for relations no revision maps to, derived from Postgres revision_relations from wave 3, without a model call, for the rest; named in server/memory/app/triple_projections.py, server/erasure/store/steps_graph.py |
entity_vectors |
RMT(created_at) (org, namespace, agent, user_id, entity_text, memory_key), by toYYYYMM(created_at) |
stays | Derived | census, entity_vectors.delete, user scope too |
— | entity mention vectors, one row per entity and memory, embedded from the memories by the entity projection; wave 3 retires a redacted key's rows for the entities its version's graph rows name, and the projection rebuilds them (deliveries); named in server/memory/store/embeddings.py, server/cli/import_clickgap.py |
entity_summaries |
RMT(version) (org, namespace, agent, user_id, entity, embedding_model), by toYYYYMM(created_at) |
stays | SoR | census, summaries.tombstone, user scope too |
— | entity summaries with vectors, read by search; no module writes them today (the provider rebuild re-embeds their text), so its rows are the only copy; wave 3 retires the summaries of the entities a redacted version's graph rows name, not rebuilt (deliveries); named in server/memory/store/rows.py, server/erasure/store/steps_graph.py |
entity_canonical_map |
RMT(version) (org, namespace, mention_text), by org |
stays | Derived | step entity_canonical_map.tombstone |
— | mention-to-canonical resolution, rebuilt by the entity projection from the memories, without a model but order-dependent; named in server/memory/app/entity_resolution.py, server/erasure/store/steps_memory.py |
entity_communities |
RMT(version) (org, namespace, level, entity) |
stays | Derived | census, entity_communities.delete, user graph_links.delete |
— | community detection over entity_edges, rebuilt by its worker without a model; named in server/memory/app/entity_communities.py, server/erasure/store/steps_graph.py |
graph_projections |
KeeperMap token |
Postgres graph_inflight |
Coord | no content | 2 | up to SLOTS in-flight projection slots (server/memory/store/graph_projections.py) per namespace that graph walks wait on, plus the namespace's last graph write; named in server/memory/store/graph_projections.py, server/core/leases.py |
namespace_write_keys |
KeeperMap key |
merge into Postgres write_marks |
Coord | no content | 2 | a namespace's last ClickHouse write for read-your-writes on any replica (written_within); one table with the per-surface marks; named in server/core/namespace_write_marks.py |
namespace_write_marks |
AMT (org, namespace, plane) |
merge into Postgres write_marks |
Coord | no content | 2 | the last insert per namespace and surface, for the namespace sweep and read-your-writes (write marks); named in server/core/namespace_write_marks.py, server/identity/store/namespace_census.py |
query_nodes |
AMT (org, namespace, query_hash) |
stays | Log | step query_nodes.delete |
— | query text and its embedding, written directly by search telemetry behind its own setting (server/memory/app/search/telemetry.py); nothing rebuilds it; named in server/erasure/store/steps_memory.py, server/memory/app/search/telemetry.py |
Code index
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
code_repos |
RMT(version) (org, namespace) |
Postgres code_repos + ClickHouse mirror |
SoR (head) | no content | 3 | registered repositories with sealed credentials, read by namespace; a mirror without credentials stays in ClickHouse for the SQL surface views that join it; named in server/code/store/index/code_repos.py, server/identity/store/namespace_census.py |
code_index_state |
RMT(version) (org, namespace, state_key) |
stays + Postgres cursors (its knowledge_source:<name> rows) |
Derived | step code_index.purge |
3 | how far the code tables are built (git_head, pr_cursor, scip_commit, _FINGERPRINT_KEYS in server/code/store/index/status.py), and, once wave 2 retires the link queue, the link worker's per-namespace cursor into memory_revisions: a build ledger kept beside the tables it describes, so a restore brings both back together; the link cursor holds a seq, never a memory id, and resets on a Postgres restore; its knowledge_source:<name> rows, each source's sync state, move to Postgres cursors with knowledge_sources in wave 3 (cursors); named in server/code/store/index/base.py, server/knowledge/store/sources.py |
code_coverage_watches |
RMT(version) (org, namespace, commit_sha, inbox) |
Postgres code_coverage_watches |
SoR | step code_index.purge |
3 | subscriptions a notify worker reads by commit; named in server/code/store/coverage_watches.py |
code_scip_batches |
RMT(version) (org, namespace, batch_id), by toYYYYMM(created_at), TTL |
merge into Postgres jobs (kind code_scip) |
SoR | step code_index.purge |
2 | upload batches with a state machine; retention: the jobs sweep for its kind (jobs); named in server/code/store/index/scip_staging.py |
code_scip_parts |
MT (org, namespace, batch_id, seq), by toYYYYMM(created_at), TTL |
merge into the code_scip job's bytes (bucket, or Postgres job_parts) |
SoR | step code_index.purge |
2 | a SCIP upload's parts, held until its batch is applied (server/code/store/index/scip_staging.py); owned by the batch's job and leaving with it; deleted when its batch is applied, else by a sweep 7 days after created_at, today's TTL, apart from the job row's retention; named in server/code/store/index/scip_staging.py |
code_search_memo |
RMT(created_at) (org, namespace, key), TTL |
stays | Cache | step code_index.purge |
— | search answers by key, 60-minute TTL; named in server/code/store/index/search_memo.py |
code_search_trace |
MT (org, namespace, ts), by toYYYYMM(ts), TTL |
stays | Log | census, code_search_trace.delete, user scope too |
— | search traces, 180-day TTL; named in server/code/app/common.py, server/code/store/index/search.py |
code_search_outcome |
MT (org, namespace, trace_run_id, ts), by toYYYYMM(ts), TTL |
stays | Log | census, code_search_trace.delete |
— | search outcomes, 180-day TTL; named in server/code/app/outcome.py, server/code/store/index/search.py |
code_symbol_ids |
RMT (org, namespace, symbol_id, tier, qualified_name) |
stays | Derived | step code_index.purge |
— | code_symbols re-keyed by id through code_symbol_ids_mv; named in server/code/store/index/symbols.py |
code_symbol_names |
RMT (org, namespace, name, tier, qualified_name, symbol_id) |
stays | Derived | step code_index.purge |
— | code_symbols re-keyed by short name through code_symbol_names_mv; named in server/code/store/index/symbols.py |
code_answers |
RMT(version) (org, namespace, embedding_model, question_id) |
stays | Cache | census, code_answers.delete, user scope too |
— | cached model answers that may quote memory, so erasure reaches them; named in server/code/store/index/answers.py, server/memory/store/redaction.py |
code_unit_signal |
SMT((positive, negative)) (org, namespace, unit_id) |
stays | SoR | step code_index.purge |
— | feedback increments per unit, summed; named in server/code/store/index/search.py |
code_gh_items |
RMT(version) (org, namespace, number) |
stays | Derived | step code_index.purge |
— | issues and pull requests mirrored from the repository host, refetched by code_pr_history back to code_gh_items_backfill_days; named in server/code/store/index/gh_items.py |
code_pr_history |
RMT(version) (org, namespace, pr_number) |
stays | Derived | step code_index.purge |
— | merged pull requests and their files mirrored from the repository host, refetched by code_pr_history; named in server/code/store/index/pr_history.py, server/code/store/index/reviews.py |
code_pr_reviews |
RMT(version) (org, namespace, path, pr_number, comment_id) |
stays | Derived | step code_index.purge |
— | pull request review comments mirrored from the repository host, refetched by code_pr_history; named in server/code/store/index/reviews.py, server/cli/code_prs.py |
code_chunks |
RMT(version) (org, namespace, embedding_model, path, chunk_id), by (org, namespace) |
stays | Derived | census, code_index.purge |
— | code chunks with vectors, rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry) at embedding cost; named in server/code/app/index/compaction.py, server/core/vectors/code_tables.py |
code_summaries |
RMT(version) (org, namespace, embedding_model, scope_type, scope_path) |
stays | Derived | census, code_index.purge |
— | code summaries with vectors, rebuilt by code_summary at model cost; named in server/code/store/index/cards.py, server/code/store/index/graph_stats.py |
code_backport_gaps |
RMT(version) (org, code_namespace, branch_namespace, pr_number) |
stays | Derived | no content | — | backport verdicts per release branch, rebuilt by code_backport_gaps from the pull request ledger and tracker reads over the last code_backport_gaps_days; named in server/code/store/backport_gaps.py |
code_coverage_files |
RMT(version) (org, namespace, path) |
stays | Derived | step code_index.purge |
— | executed line ranges per file, rebuilt by code_coverage_import from the CI source's newest run (server/code/app/index/coverage_import.py); named in server/code/store/index/coverage.py |
code_symbol_stats |
RMT(version) (org, namespace, symbol_id) |
stays | Derived | step code_index.purge |
— | fan-in and graph statistics per symbol, rebuilt by code_graph_stats from the live edges; named in server/code/store/index/graph_stats.py |
code_test_map |
RMT(version) (org, namespace, src_path, method, test_path) |
stays | Derived | step code_index.purge |
— | test-to-file links, rebuilt by code_crossref, code_graph_stats and code_coverage_import; named in server/code/store/index/coverage.py, server/code/store/index/facets.py |
code_unit_risk |
RMT(version) (org, namespace, unit_id) |
stays | Derived | step code_index.purge |
— | risk per code unit, rebuilt by code_ingest's pass commit and code_graph_stats; named in server/code/store/index/graph_stats.py, server/code/app/unit_risk.py |
code_chunks_prefix |
RMT(version) (org, namespace, embedding_model, path, chunk_id) |
stays | Derived | step code_index.purge |
— | the code_chunks vectors truncated to a prefix, written with each chunk insert and refilled from code_chunks by loom code vector-twin without a provider call (server/code/domain/index/prefix_twin.py); named in server/code/app/index/compaction.py, server/insights/app/provider_coordinator.py |
code_review_briefs |
RMT(version) (org, namespace, pr_number, merge_sha) |
stays | Derived | step code_index.purge |
— | review briefs, built by code_ingest for each merge it passes and on a miss by code.review_brief (server/code/app/review_brief.py); named in server/code/store/index/reviews.py |
code_edges |
RMT(version) (org, namespace, tier, to_symbol_id, edge_kind, from_symbol_id, path, line) |
stays | Derived | step code_index.purge |
— | code edges: the treesitter tier rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry), the scip tier by code_scip_full, code_scip_delta and the code_scip_{ts,py,go}_delta workers, and, for a repository whose SCIP comes only from uploads, only by a new upload code_scip_apply applies; named in server/code/store/index/edges.py, server/code/store/index/graph_stats.py |
code_symbols |
RMT(version) (org, namespace, tier, qualified_name, symbol_id) |
stays | Derived | step code_index.purge |
— | code symbols: the treesitter tier rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry), the scip tier by code_scip_full, code_scip_delta and the code_scip_{ts,py,go}_delta workers, and, for a repository whose SCIP comes only from uploads, only by a new upload code_scip_apply applies; named in server/code/store/index/symbols.py, server/code/store/index/graph_stats.py |
code_context_packs |
RMT(version) (org, namespace, pr_number) |
stays | Derived (SoR for packs of merges before the git_head watermark) |
step code_index.purge |
— | context packs, built by code_ingest for each merge it walks past the git_head watermark; nothing rebuilds a pack of an earlier merge, so those are held only here; named in server/code/store/index/reviews.py, server/code/store/index/status.py |
code_call_sites_unresolved |
RMT(version) (org, namespace, callee_name, path, line, from_symbol_id) |
stays | Derived | step code_index.purge |
— | code index projection, rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry); named in server/code/store/index/edges.py |
code_caller_guards |
RMT(version) (org, namespace, from_symbol_id, path) |
stays | Derived | step code_index.purge |
— | code index projection, rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry); named in server/code/store/index/edges.py |
code_files |
RMT(version) (org, namespace, path) |
stays | Derived | step code_index.purge |
— | code index projection, rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry); named in server/code/store/index/files.py, server/code/store/index/answers.py |
code_test_facets |
RMT(version) (org, namespace, path, facet_kind, name, ordinal) |
stays | Derived | step code_index.purge |
— | code index projection, rebuilt from the repository by loom code backfill (by code_ingest's first-index slices for a self-service entry); named in server/code/store/index/facets.py |
Tracker
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
qa_sources |
RMT(version) (org, name) |
Postgres qa_sources |
SoR | no content | 3 | registered sources with sealed credentials; named in server/tracker/app/registry_sources.py, server/identity/store/namespace_census.py |
qa_item_embeddings |
RMT(version) (org, namespace, kind, number) |
stays | Derived | no content | — | issue and PR vectors, rebuilt by tracker_embed at embedding cost; named in server/tracker/store/embed.py, server/core/vectors/placeholders.py |
qa_link_candidates |
RMT(version) (org, qa_namespace, issue_number, pr_number) |
stays | Derived | no content | — | issue-PR link candidates, rebuilt by tracker_links from the tracker items at embedding cost; named in server/tracker/store/links.py, server/tracker/workers/links.py |
qa_test_daily |
RMT(version) (org, qa_namespace, test_name, day) |
stays | Derived | no content | — | daily test rollups, rebuilt by tracker_test_daily from the check runs; named in server/tracker/store/daily.py, server/tracker/workers/test_daily.py |
qa_rollup_days |
RMT(version) (org, qa_namespace, day) |
stays | Derived | no content | — | rollup progress per day of tracker_test_daily: a build ledger kept beside the rollups it describes; named in server/tracker/store/daily.py, server/tracker/workers/test_daily.py |
qa_fail_hours |
RMT(version) (org, qa_namespace, hour) |
stays | Derived | no content | — | failing hours per test, rebuilt by tracker_test_daily; named in server/tracker/store/daily.py |
Runs, tasks and leases
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
tasks |
RMT(version) (org, inbox, task_id) |
Postgres tasks |
SoR | census, tasks.delete |
2 | a task's status, owner, fence and lease change in place; a claim is UPDATE … WHERE fence = $f; named in server/tasks/store/repo.py, server/erasure/store/steps_ledgers.py |
task_leases |
KeeperMap task_id |
merge into Postgres tasks (owner, fence, lease_until) |
Coord | no content | 2 | a KeeperMap copy of columns the task row already has; named in server/tasks/store/repo.py |
task_claims |
MT (task_id, fence, claim_ts, claim_id) |
delete | Coord | no content | 2 | no code path writes it (its census entry says so); no module outside the census names the table |
task_inboxes |
RMT(version) (org, inbox) |
Postgres task_inboxes |
SoR | no content | 2 | inbox registry, read by key on every task op; named in server/tasks/store/repo.py, server/identity/store/namespace_census.py |
task_poster_policies |
RMT(version) (org, inbox, poster, kind) |
Postgres task_poster_policies |
SoR | no content | 2 | who may post what, read on every task.create; named in server/tasks/store/repo.py, server/auth/self_signup.py |
task_events |
MT (org, inbox, seq), by toYYYYMM(ts) |
stays | Log | census, tasks.delete |
— | an inbox's append-only event stream; watchers page by its microsecond seq stamp, a wire format, so it stays a log; wave 2 adds event_id; named in server/code/store/coverage_watches.py, server/tasks/store/repo.py |
task_webhook_cursor |
RMT(version) (org, inbox) |
merge into Postgres cursors |
SoR | no content | 2 | a delivery cursor per inbox (consumer task_webhook, scope the inbox), copied at the flip so no webhook is replayed or skipped; named in server/tasks/store/repo.py |
agent_runs |
RMT(version) (org, namespace, run_id) |
Postgres agent_runs + ClickHouse projection |
SoR (head) | census, agent_runs.delete, user scope too |
2 | a run's lifecycle with a unique (org, namespace, run_id); the projection serves the scans of the mentor, proposals and decisions, and keeps its nanosecond version (deliveries); named in server/tasks/store/runs.py, server/insights/store/mentor.py |
agent_run_claims |
KeeperMap claim_key |
delete; the primary key of agent_runs |
Coord | step agent_runs.delete |
2 | a KeeperMap claim imitating a unique constraint; named in server/tasks/store/runs.py, server/erasure/store/steps_ledgers.py |
agent_turn_usage |
RMT(version) (org, namespace, session_id, message_id), by toYYYYMM(turn_at) |
stays | Log | no content | — | token usage per turn; named in server/insights/store/cost.py, server/tasks/store/runs.py |
session_outcomes_by_user |
RMT(version) (org, namespace, agent, user_id, session_id) |
stays | Derived (SoR for client labels) | step session_outcomes.delete |
— | per-session outcome labels: judged labels, rebuilt at model cost from session_summaries (server/memory/app/session_outcomes.py), and the client labels run.complete writes, held only here; named in server/memory/store/session_outcomes.py, server/erasure/store/steps_memory.py |
procedure_runs |
MT (org, namespace, memory_key, ts), by toYYYYMM(ts) |
stays | Log | step procedure_runs.delete |
— | procedure executions, append-only; named in server/memory/app/procedure_ledger.py, server/memory/store/lifecycle.py |
Erasure and idempotency
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
erasure_requests |
RMT(version) id, TTL |
stays | SoR | no content | — | ClickHouse's erasure record, saga checkpoints as version rows; Postgres keeps its own record in erasure_ledger, so after a restore of either store loom reconcile runs again the erasures the other records past the point (restores); moving it would put both records in one store; named in server/erasure/store/ledger.py, server/identity/store/namespace_census.py |
erasure_reversals |
RMT(version) (org, erasure_id, memory_id), TTL |
stays | Log | no content | — | evidence of restored rows, append-only with a 5-year TTL; read only joined with erasure_requests, so it stays beside it; named in server/memory/app/restore.py, server/erasure/store/ledger.py |
idempotency |
RMT(created_at) idempotency_key, TTL |
delete; retires with the ClickHouse-authority path | SoR | census, idempotency.delete, user scope too |
before wave 1 | the Idempotency-Key records of the ClickHouse authority; under Postgres every idempotent write op records in Postgres idempotency already (server/transport/gates.py); kept while N-1's erasure deletes from it, it retires by rolling-upgrades rule 8; named in server/core/idempotency.py, server/erasure/store/steps_ledgers.py |
usage_acted_credits |
RMT(credited_at) pair_sha256, TTL |
stays | Derived | no content | — | which acted usage events the fold has emitted into memory_usage_events, so no pair is credited twice; wave 4 rebuilds it with an org column and puts the pair digest on each acted event, so it is rebuilt from memory_usage_events; named in server/memory/workers/usage_fold.py |
Telemetry, audit and usage
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
events |
MT (org, namespace, kind, ts), by toYYYYMM(ts), TTL |
stays | Log | census, events.delete, user scope too |
— | the event stream, 30-day TTL; named in server/observability/events_stream.py, server/insights/store/admin.py |
agent_events |
MT (org, namespace, agent, ts), by toYYYYMM(ts) |
stays | Log | step agent_events.scrub |
— | the op ledger, monthly partitions; named in server/observability/events.py, server/assistant/store/ledger.py |
audit_log |
MT (org, namespace, event_type, ts), by toYYYYMM(ts) |
stays | Log | no content | — | hash-only audit rows, kept unless an operator's retention (retention_reconcile) expires them; named in server/memory/store/pg_publish.py, server/observability/audit.py |
audit_checkpoints |
MT window_start |
stays | Log | no content | — | verifier checkpoints over windows of audit_log across every org; named in server/insights/store/audit.py, server/observability/otel_probes.py |
search_trace_log |
MT (org, namespace, ts), by toYYYYMM(ts), TTL |
stays | Log | census, search_trace_log.delete |
— | search traces, 180-day TTL; named in server/insights/store/deploy_delta.py, server/tasks/store/runs.py |
policy_decisions |
MT (org, namespace, ts), by toYYYYMM(ts), TTL |
stays | Log | no content | — | policy gate decisions, 90-day TTL; named in server/identity/store/policy.py |
memory_exposures |
MT (org, namespace, actor, ts), by toYYYYMM(ts), TTL |
stays | Log | census, memory_exposures.delete, user scope too |
— | which actor was shown which memory, 180-day TTL; named in server/identity/domain/policy_rules.py, server/memory/app/exposure_ledger.py |
memory_exposure_breadth_hourly |
AMT (org, namespace, actor, agent, hour), by toYYYYMM(hour), TTL |
stays | Derived | no content | — | hourly uniq rollup of exposures; named in server/memory/app/exposure_ledger.py |
memory_usage_events |
MT (org, namespace, memory_id, ts), by toYYYYMM(ts) |
stays | Log | no content | — | usage events per memory; wave 4 puts the pair digest on each acted event; named in server/memory/store/usage.py, server/cli/import_clickgap.py |
memory_usage_totals |
SMT (org, namespace, memory_id) |
stays | Derived | no content | — | SummingMergeTree totals per memory fed by memory_usage_totals_mv from memory_usage_events; named in server/memory/store/reads.py, server/memory/workers/usage_fold.py |
memory_served_daily |
SMT (org, namespace, memory_id, agent, day), by toYYYYMM(day) |
stays | Log | no content | — | served counts per memory, agent and day, inserted directly by the agent read ops (server/memory/app/served_reads.py) and summed by SummingMergeTree; a log on SummingMergeTree, which keeps its layout; named in server/memory/store/served.py, server/memory/app/health.py |
agent_cost_rollup_daily |
AMT (org, namespace, agent, day, operation), by toYYYYMM(day) |
stays | Derived, SoR past its source's retention | no content | — | daily cost rollup from agent_events; once an operator's retention expires the source, the only copy; named in server/insights/store/cost.py |
namespace_snapshots |
MT (org, source_namespace, created_at), by org |
stays | Log | no content | — | who took each snapshot and when; the payload goes to the caller and is not stored; named in server/identity/app/namespace_snapshot.py |
loom_telemetry.sli_minute |
AMT (slo_class, minute, operation, transport, traffic_class, org), by toYYYYMM(ingested_hwm), TTL |
stays | Derived, SoR past its source's retention | no content | — | SLI rollup in loom_telemetry, refolded from agent_events by sli_minute_refresh; 400-day TTL, so past the source's retention the only copy; named in server/observability/otel_probes.py |
embedding_cache |
RMT(created_at) (model, key), TTL |
stays | Cache | step idempotency.delete |
— | embedder answers by model and key, 30-day TTL; named in server/embedding/_cache.py, server/erasure/store/mutations.py |
llm_response_cache |
RMT(created_at) (model, key), TTL |
stays | Cache | census, llm_response_cache.delete, user scope too |
— | model answers by key, 7-day TTL; named in server/memory/store/redaction.py, server/erasure/store/steps_graph.py |
Tables outside schemas/schema.sql (names that start with _)
| Table | Today | Target | Kind | Erasure | Wave | Why, and code that names it |
|---|---|---|---|---|---|---|
_migrations |
RMT(applied_at) filename |
stays | SoR | outside the census (not in schemas/schema.sql); no content |
— | the ClickHouse migration ledger; named in server/core/schema/migrate.py, server/core/rmt_carry.py |
_migration_lease |
RMT(version) (name, holder), TTL |
delete | Coord | outside the census (not in schemas/schema.sql); no content |
2 | a lease table the migrations create and no module names; no build reads it, so its wave drops it |
_migration_lock |
KeeperMap name |
Postgres leases (its lease keys) and its claim keys where leases places claims, with their callers' waves; ClickHouse (the probe and nonce keys) |
Coord | outside the census (not in schemas/schema.sql); no content |
1, 2, 3 | KeeperMap keys that leases and claims write in the table the migrator creates, with no fencing token; the probe and nonce keys the loom cell commands write stay (how a table moves); named in server/core/leases.py, server/insights/store/provider_lock.py |
_provider_participants |
RMT(version) participant_id |
merge into Postgres instances |
Coord | outside the census (not in schemas/schema.sql); no content |
1 | a second process registry with heartbeats, host proofs and transition acknowledgements that provider rebuilds drain on (server/insights/store/provider_participants.py, server/insights/app/provider_barrier.py); one registry holds every process; named in server/insights/store/provider_participants.py |
The Postgres tables
Every table here is in the cell's Postgres. Today's tables (storage
authority) keep their keys; the target adds the tables
the ClickHouse tables section moves. Every tenant table leads
its primary key with org. The router's directory is placed by cells. Where
a row names a step today's ClickHouse census owns, the pg: census row is owned by the
Postgres step the flip splits off it, which joins PG_ONLY_STEPS
(restores).
| Table | Key | Kind | Erasure |
|---|---|---|---|
authority |
storage authority; paired with ClickHouse's storage_authority |
SoR | no content |
pg_migrations |
the migration file | SoR | no content |
scope_generations |
storage authority | SoR (a namespace's row allocates its revision seq) |
no content |
cache_generations |
(org, kind) |
Coord | no content |
memory_current |
storage authority | SoR | census pg:loom.memory_current |
memory_revisions |
storage authority | SoR | census pg:loom.memory_revisions |
memory_deliveries |
storage authority | SoR | cascades with its revision |
idempotency, idempotency_fences |
storage authority | SoR | census pg:loom.idempotency; idempotency_fences holds no content (the census's bound) |
erased_memories, erased_documents |
storage authority | SoR | evidence, no content |
audit_outbox |
storage authority | SoR | keys and ids, no values |
document_catalog, document_uploaders |
(org, namespace, sha256), (org, user_id, namespace, sha256) |
SoR | a pg: census row each, owned by the Postgres step the flip splits off the steps that own the census rows of document_blobs and blob_uploads today, which joins PG_ONLY_STEPS (documents.tombstone, and the user step through document_uploaders) |
knowledge_datasets |
storage authority | SoR | census pg:loom.knowledge_datasets, step pg_knowledge.delete |
knowledge_jobs |
storage authority | SoR | census pg:loom.knowledge_jobs, step pg_knowledge.delete |
knowledge_heads, knowledge_deliveries |
storage authority | SoR | census pg:loom.knowledge_heads, pg:loom.knowledge_deliveries |
jobs, job_users, job_memories |
(org, id), (org, user_id, job_id), (org, namespace, memory_id, job_id) |
SoR | a census row, a step by namespace and user, a redaction step |
leases, write_marks, graph_inflight |
(org, name), (org, namespace, surface), (org, namespace, token) |
Coord | no content |
cursors |
(org, consumer, scope) |
SoR | no content |
carry_acks |
process |
Coord | no content |
analysis_secrets |
id |
SoR | no content |
sql_access |
(org, namespace, user_name) |
SoR | no content |
cell_self |
singleton | SoR until attach writes its fingerprint to the directory row, then Derived (rewritten from the directory on a restore, cells) | no content |
tenancy |
org |
SoR until attach places its org (the first cell's recording rows), then Derived (written back from the directory's placements on a restore, never deleted, cells) |
no content; an erasure leaves the org's row |
tenancy_mode |
singleton | Derived (the mode raised only upward from the directory's recorded mode on a restore, cells) | no content |
cell_first |
singleton | Derived (written when absent and refused when it differs on a restore; written once, its update, delete and truncate refused by triggers, cells) | no content |
cell_probe (the probe nonce the loom cell commands write, cells) |
name |
Coord | no content; deleted once the probe has read it |
job_parts |
(org, job_id, seq) |
SoR | cascades with its job; code_index.purge reaches it through the job |
idempotency_revisions |
(org, key_hash, namespace, seq) |
SoR | cascades with its record; redaction reaches replay answers through it |
revision_relations |
(org, namespace, seq, ord) |
SoR | census pg:loom.revision_relations, step pg_memory.delete; cascades with its revision; redaction reaches it through its revision |
revision_sources |
(org, user_id, namespace, seq) |
SoR | cascades with its revision; the user step deletes the user's rows; the user step and the census reach shared revisions through it |
table_moves, move_acks |
move, (move, process) |
SoR, Coord | no content; table_moves also holds the registry of kept tables (each kept table's name and scope predicates) |
backfill_steps |
the column, index or check it sets | SoR | no content; the backfill step's completion record, which boots read (schema rules) |
working_memory, records, operator_edges, context_offloads, session_briefs, session_brief_revisions, namespace_guides, learning_candidates, learning_supporters, conflict_allowlist |
led by (org, namespace, …), the ClickHouse sort key today |
SoR | a census row and a saga step each |
conversation_states |
(org, namespace, agent, thread_id, msg_id) |
SoR | a census row and the conversations.tombstone step, by namespace and by the saver's user_id |
offload_bytes |
(org, namespace, offload_id, part), its head's id and a part number |
SoR | deleted with its head by the context_offloads step, and counted by its census row |
shared_current |
(org, namespace, memory_key) |
SoR | a pg: census row and the provenance step shared has today, through revision_sources |
tasks, agent_runs |
(org, inbox, task_id), (org, namespace, run_id) |
SoR | a pg: census row each, owned by the Postgres step the flip splits off the step that owns its ClickHouse table's census row today, which joins PG_ONLY_STEPS (tasks in the namespace scope, agent_runs in both) |
task_inboxes, task_poster_policies |
(org, inbox), (org, inbox, poster, kind) |
SoR | no content |
erased_messages |
(org, erasure_id, namespace, agent, thread_id, msg_id) |
SoR | evidence, no content; the messages a conversation erasure matched by their saver, which its ClickHouse replay reads, swept with its erasure_ledger row |
erasure_ledger |
storage authority | SoR | evidence, no content; Postgres's erasure record, which loom reconcile runs again after a ClickHouse restore, swept after erasure_ledger_days |
record_schemas, namespace_visits, extraction_policies, namespace_policies, namespace_types, namespace_ontology, profile_schemas, user_state, webhooks, knowledge_sources, code_repos, qa_sources |
led by org, as the ClickHouse key |
SoR | no memory content |
knowledge_uploads |
led by org, as the ClickHouse key |
SoR | a pg: census row owned by the Postgres step split off documents.tombstone (restores) |
code_coverage_watches |
led by org, as the ClickHouse key |
SoR | no memory content; reached by the Postgres part of code_index.purge (restores) |
revoked_tokens |
(kind, key), kind subject_or_jti (the bare key, which holds both today), cutoff or scim |
SoR | no content |
signing_keys, service_accounts, machine_identities, scim_users, sso_links, principal_groups, oauth_state, oauth_signin_state, namespace_grants, namespaces, collections, blacklist, org_model_calls |
as the ClickHouse key, org first where the table is per org, except the tables of the cell that keys lead with org names |
SoR | no memory content; the identity steps (grants.revoke, groups.remove_member, personal.retire, collections.forget_owner) rewrite namespace_grants, principal_groups, namespaces and collections |
auth_attempts |
kind, hashed key and window | Coord | no content |
deployment_config, runtime_overrides, provider_configuration, assistant_service |
as the ClickHouse key | SoR | no content |
instances |
process | Coord | no content |
embedder_calibration, namespace_calibration |
as the ClickHouse key, org first for namespace_calibration |
Derived (rebuilt by a recalibration, at embedding cost) | no content |
A sweep deletes a revoked_tokens row ten days past its stored expires_at, as the
ClickHouse TTL does today; a token without exp, an operator cutoff and every SCIM
revocation are stored with the latest expiry the table holds (revocation_expiry in
server/auth/revocation.py), so they never lapse. namespace_grants keys by
(org, namespace, subject), with an index on (lower(subject), org) for a principal's
reach across orgs, which SCIM deprovisioning reads case-folded (_orgs_beyond in
server/auth/scim.py); the sign-up check reads it within one org (_refuse_empowered
in server/auth/self_signup.py). Erasure deletes the org's replay answers in Postgres
idempotency (its idempotency_revisions rows cascading) and sets the org's
idempotency_fences row, as server/erasure/store/steps_pg.py does today: by org,
since the table has no user column.
Waves
Each wave is one concern per pull request, ordered by correctness and latency payoff, and a table's wave is its row's Wave column; a wave moves together the tables its statements read together (a statement that spans the stores is rewritten, statements that cross the stores):
| Wave | Tables | Why |
|---|---|---|
| 1 | the tables whose Wave column says 1, and with them the mover with leases, table_moves, move_acks, the process registry (with _provider_participants) and jobs; tokens, keys, identities, grants, namespaces, collections, configuration and secrets, the blacklist, the org model switch, with cache generations and the namespaces and sql_access mirrors; extraction and namespace policies; the keys of _migration_lock whose callers move in it |
security reads that today depend on FINAL or a fail-closed cache |
| 2 | the tables whose Wave column says 2, and with them cursors, write_marks, graph_inflight; the queues, outboxes and webhook registrations, knowledge uploads and ingestion cancels, KeeperMap leases and claims, the keys of _migration_lock whose callers move in it and those of every caller whose tables stay in ClickHouse, carry_acks; tasks and runs; in place, the knowledge_ingestions rebuild and task_events.event_id |
correctness that today rests on read-back claims and keys outside a transaction |
| 3 | the tables whose Wave column says 3, and with them operator_edges, context_offloads, conflict_allowlist, revision_relations, the graph validity rows, and user-written state beyond memory heads: shared, working memory, records, briefs, guides, learnings, visits, conversation states, registrations; the keys of _migration_lock whose callers move in it; in place, version rows for the conversation tables' mutations, and the rebuilds a redaction owes the derived tables |
read-your-writes for state users write and read by key |
| 4 | the tables whose Wave column says 4, the merges inside ClickHouse: term_df, doc_lengths, row links; in place, the code_term_df feed and the row-link backfill, the org rebuild of usage_acted_credits with the pair digest on memory_usage_events, then the vector levers |
bytes and parts, proven ranking-neutral |
How a table moves
Tables move once every deployment records the Postgres memory authority and the
ClickHouse-authority path is deleted: a deployment that still holds memory under
ClickHouse moves first with the authority migration command. The layout depends on it:
memory is derived from memory_current, shared and records join the journal, and
the code-link worker reads memory_revisions.
A move is one table, or a group that flips together: the tables one statement reads in
one store, and the tables a cache reads with the counter that invalidates it
(namespace_grants, namespaces, principal_groups and collections with
acl_versions; blacklist with blacklist_version; revoked_tokens with
oauth_state, whose revocation_owed rows the revocation cache also reads). No table's
rows are split across the stores, the exceptions being the tables that split by role
(requirement 4) and _migration_lock (below). Build N carries both paths for each table
it moves and picks one by the move's recorded authority in table_moves; N-1 knows only
ClickHouse. One mover, shared with the authority migration command, runs every move. The
first wave's pull request designs it, and is reviewed on these requirements:
- One authority at a time. Until a table's flip, every build reads, decides, claims and writes it on ClickHouse exactly as today, and no reader serves a Postgres copy. The coordination tables requirement 3 cuts over are the exception. A table that can hold memory content is copied inside its flip, once every writer and saga step on it has stopped, or pre-copied under requirement 7's coverage. A content-free table may be pre-copied and reconciled at the flip.
- The flip is the operator's, after the window. It starts once no process older
than N runs, and is a point past which no rollback below N serves, as switching the
memory authority is (rolling-upgrades rule 33). It runs as a
jobsrow of a kind that serializes on one cell-wide key (jobs), so two flips never overlap; its Postgres steps lock the job row with its token, and its ClickHouse steps, bounded by the lease as every effect outside Postgres is, are idempotent and re-applied from the record (requirement 8), so a late step of a flipper that paused past its lease is overwritten. - Every writer stops before the copy. Every process that can write the table, CLIs
included, either acknowledges the flip or re-reads the record before each write. A
process whose registration lapsed stops writing a table with a pending move. The flip
copies only after every acknowledgement, and once no statement or pending
asynchronous insert touches the table on any replica. Coordination tables that refill
on their own are the exception:
instances,cell_heartbeats,keyring_readers,_provider_participants,carry_acksandauth_attemptsare cut over without a copy or a flip, the process registry first, before the mover's first flip relies on it;- while N-1 runs, N writes both stores and reads ClickHouse, which holds every build's rows, so a limit keeps its bound across the window; N reads Postgres alone only a full sliding window after the last N-1 write;
- the rebuild barrier reads ClickHouse while N-1 runs, and
_provider_participantscuts over with no provider transition in flight.
- Every stale access fails closed. The table's name becomes a view that refuses
every read and write, swapped in atomically so the name is never absent, and no later
ClickHouse migration touches it. A table that splits into a Postgres head and a
ClickHouse projection (
shared,memory_records,agent_runs,operator_edges, thenamespaces,code_reposandsql_accessmirrors) keeps its name, and a mirror that leaves a column behind (the sealed credentials ofcode_repos) is rebuilt without it and swapped in whole at the flip; a write to it by a process older than N after the flip is the rollback the flip forbids. A table that splits by role keeps its name for the role that stays (search pages incontext_blobs, the bytes indocument_blobswhere no bucket is configured, the messages inconversations, the code-index ledger rows incode_index_state), and the moved role is refused by a guard the flip adds: an insert constraint (no offload row, no catalog column change, noknowledge_source:key incode_index_state) and, for state written in place today, the revoke ofALTER UPDATEon that table from Loom's ClickHouse user, so a stale write of it fails. - One commit decides. The copy, its verify against the source key by key (inserts,
updates, and deletes of owned rows the source no longer holds) and the record commit
in one Postgres transaction. Before it, any failure or a flip past its deadline rolls
back: the name, the views and the record return to ClickHouse, and the move's rows
leave Postgres. After it:
- the old table is kept renamed until it may be dropped (restores), listed in one registry of kept tables that the census, erasure, redaction and recovery read;
- the flip classifies the table's saga steps by store (restores);
- each registry row carries the table's own scope predicates, reviewed with its move, which, with the mover (requirement 8), is the bounded exception to "no code for the old home" until the table is dropped.
- The flip deletes only rows its move owns. A target other writers share (
jobs,cursors,write_marks) is written only through the move's owned rows. - The window fits the caches and an availability budget. A content table either
flips within an availability budget its wave states and measures, or is pre-copied
with the census, erasure and redaction covering both copies until the flip, so only
the delta is copied inside it. Every access to the table waits or fails while the
flip runs, so a request-path table flips within its caches' bound
(
grant_cache_ttl_seconds,acl_watermark_max_stale_seconds), and each wave's pull request measures its flips. - The record survives a restore. Each move's record is paired with a marker in the
source ClickHouse, as
storage_authoritypairs withauthority, and the record is authoritative. The marker is written as pending before the flip's commit. On a mismatch (a crash between the commit and the ClickHouse steps, a ClickHouse restore to before the flip), the mover finishes or re-applies the flip from the record (marker, refusing view and kept-table rename) before a process refuses to boot. A Postgres restore to before a flip re-runs that flip from the kept copy (copy, verify, commit); N+1 and later builds keep the mover with the registry until the kept table is dropped. The restored record is never re-applied back to ClickHouse (restores). - N+1 depends on every flip. N+1 refuses to boot while a move N carries has not flipped, and carries no code for the old home but the kept-table registry (requirement 5) and the mover, which the census, erasure and recovery read until each kept table is dropped. A fresh install records every move as flipped and has the refusing views, the shape of an upgraded deployment.
loom._migration_lock holds keys of callers in more than one wave, so its keys move
with their callers' waves; the keys of callers whose tables stay in ClickHouse move in
wave 2. Each key moves at a flip of its own move, after the window (requirement 2), and
until then every build, N included, takes it in KeeperMap. A moved key is taken in
Postgres alone and the table refuses it by name. The last move retires run_pass in
server/workers/keeper_sweep.py. The probe and nonce keys the loom cell commands
write (cells) prove the ClickHouse service is the cell's own and live, so
they stay: _migration_lock keeps those reserved keys alone, still KeeperMap and still
checked at boot (require_keeper in server/boot.py) and by loom doctor.
idempotency does not move: under the Postgres authority every idempotent write op
records in Postgres already (server/transport/gates.py), identity and admin ops
included. A table that is deleted rather than moved while a build still reads or feeds
it (memory_code_link_queue, whose view fills it until N+1) retires by rolling-upgrades
rule 8: N stops using it, keeping what feeds it while N-1 may read it, and a later
release drops it. A wave-4 merge inside ClickHouse is a backfill of the merged table,
then the same retirement of the old ones.
Restores
The backup and restore work designs both restores, with the restore drill. This layout gives it the kind and backup of every table (above), and keeps each move's old table, renamed and reached by the census, erasure and redaction, until the oldest restorable Postgres backup postdates the move's flip, so a Postgres restore to before a flip can recover the table from it. A restore of either store must keep these promises for every row written before its recovery point, and re-apply every narrowing change and erasure committed after it:
- No erased row comes back, and no delete its writer committed is undone (a bucket object an erasure removed included: the bucket is part of the cell's backup, and a document catalog and its bytes are reconciled after either is restored).
- No change that narrows who can authenticate, what they reach, or where an org's data may go is undone: an org's model calls switched off, an extraction policy tightened, a revocation, a revoke, a group removal, a signing-key retirement or rotation, a service account or machine identity removed, a blacklist addition, an SQL-access revoke, a namespace restricted, deactivated or opted out. The SQL surface and the scans that join a mirror stay closed after a ClickHouse restore until the mirrors are republished.
- Every projection of a Postgres head that is not journaled (
conversation_states,operator_edges,agent_runs,namespaces,code_repos,sql_access, andknowledge_ingestionsfromknowledge_jobs) is rebuilt whole from its heads after a restore of either store, so a projection row of a head the restore lost is not served; every read that uses one as a delete or access filter (the SQL surface views, live conversation reads, graph joins) fails closed until that rebuild is done. The rebuild ofknowledge_ingestionskeeps only the jobs Postgres cannot hold (keys it cannot store, or a row a build that predatesknowledge_jobswrote), a job whoseknowledge_jobsrow the restore lost leaving with it;datasetsis the named exception its row describes. - No credential issued before the recovery point and spent before the restore (a sign-in mark, a code, a device grant, a rotated refresh token) becomes spendable again: a Postgres restore invalidates every single-use credential issued under an older restore generation, while OAuth client registrations, and the signing keys still active in the newest state the restore can re-apply, stay valid.
- Nothing its writer did not ask to delete goes, and writes after the point that a store does not replay are reported as lost.
- Since a ClickHouse backup is not consistent across tables, each build ledger is set by the ledger rule and each view-fed Derived table is reconciled or rebuilt from its source, except a table that is SoR past its source's retention, which is only refolded over the source's retained window, never truncated or rebuilt whole.
Erasures already keep these promises: each store keeps its own erasure record
(erasure_requests in ClickHouse, erasure_ledger in Postgres), and loom reconcile
runs again in a restored store the erasures and redactions the other records past the
point (backup and recovery); no move puts both
records in one store. That holds for a moved table only once its saga steps are
classified by store. The wave that moves a table:
- splits each saga step that touches it into a Postgres step and a ClickHouse step at
the flip; a step over tables of both stores, such as
curation.tombstoneorrecords.tombstone, becomes one of each; - puts the kept old table's delete in the ClickHouse step, never in a
PG_ONLY_STEPSstep, so the replay after a ClickHouse restore reaches it; - adds the Postgres step to
PG_ONLY_STEPS(server/erasure/app/saga_steps.py, and so to the replay's Postgres steps,server/erasure/app/replay.py), recording itself inerasure_ledger.pg_stepsin its own transaction; - makes redaction replay after a Postgres restore scrub every Postgres copy of a version's text the layout adds;
- adds its moved steps to the replay tests of both restore directions.
Which record a restore re-applies revocations and the other narrowing changes from, how that record is written durably before the change answers, and how long it is kept, are the backup and restore work's design, reviewed on these promises. They bind that work: until it ships, a cell's restore has a single deployment's limits (cells): keys, revocations, sessions, bots and SCIM rows come back as the backup holds them.
A table that leaves ClickHouse also leaves any step that finds its tables at run time:
code_index.purge finds the code tables by name in system.columns
(code_tables_keyed_by_namespace in server/code/app/index/retire.py; it leaves
code_repos out today, a registration retired by active = false, and keeps doing so),
so the waves that move code_coverage_watches and code_scip_batches give it a
Postgres part, and the wave that moves code_scip_parts has it delete each purged job's
bucket objects before the job row, as the context_offloads step does, and wave 4 gives
it the corpus = 'code' rows of term_df and doc_lengths.
Each wave's pull request names its tables, rewrites the statements that read them, runs
the offline ranking-neutral check where search is touched
(bench/retrieval_determinism.py --save/--against), puts a small local
before-and-after timing in its body, and updates this page's rows and the
rolling-upgrades rules it changes (rules 6 and 9 name ClickHouse leases and ClickHouse
tables for replica-wide state).
Objections, answered
"Two stores per request doubles the failure surface." The security reads move from a
ClickHouse FINAL read behind a cache to a Postgres primary-key read behind the same
cache; the request depended on a store for them either way. Once Postgres is required,
every deployment runs it for memory, so the set of stores a deployment depends on does
not grow.
"The read path gets slower." Point reads by key get faster: a Postgres primary-key
lookup does not merge parts at read time. The search legs keep reading ClickHouse:
vectors, terms, chunks, the graph and the record projection stay there, and the search
path's Postgres reads are the ones it makes today (the barrier and validation) plus
cached point reads after a cache miss (namespace_calibration), and, from wave 2, the
read-your-writes check on write_marks and the graph walk's poll of graph_inflight,
which move from KeeperMap keys to Postgres point reads. The wave-4 merges touch the
lexical leg's statistics and ship only when bench/retrieval_determinism.py shows
identical keys and order, and scores within the tolerance the first wave that uses it
adds.
"A grant change no longer fences memory writes." The change bumps the policy scope in its own commit, in the database where memory commits hold that scope, so verdicts judged under the old policy start over as they do today; authorization is bounded by the grant cache's invalidation, as it is today (the policy fence).
"Moving a table is a migration risk per table." One mover serves every move, the authority flips in one Postgres commit per move, and until it does no request path reads Postgres; only the census, erasure and redaction of a pre-copy do, and the self-refilling coordination tables of requirement 3. A move that fails its verify rolls back, and the table stays on ClickHouse.
"Rolling upgrades break." Builds N-1 and N both treat ClickHouse as the authority until no N-1 serves; the flip happens among N's processes only, after every one has acknowledged it, and the refusing view stops anything that missed it; N+1 boots only once every move has flipped. Nothing runs a compatibility path past N+1 but the kept-table registry and the mover (requirements 5 and 8), until each kept table is dropped.
"Erasure gets a second place to miss." A content table is copied inside the flip, or pre-copied with the census, erasure and redaction reaching both copies; the flip copies what ClickHouse holds once every process has stopped writing it, and a saga step that meets the flip retries; from the flip, the census and the saga cover the Postgres table, and a saga that cannot confirm a deletion fails closed as it does today.
"jsonb everywhere is a schema-less store." Columns that a predicate, index or
guard reads are typed; jsonb holds only what the database never looks inside.
"One job table is a hot spot." Claims touch only due rows, skip locked rows, take
orgs in turn, and finished rows leave the claim index at once. A cell where this shows
hash-partitions jobs by org, which keeps every org-led key and index (the cross-org
maintenance indexes become per-partition).
"Every cell repeats the deployment-wide rows." It does, by design: a cell's keys,
revocations, global blacklist and settings are its own, loom cell each applies an
org-free admin change in every cell until all succeed, and loom doctor names the cells
whose settings differ (cells). A store shared by the cells would put a
security check, a worker lease, an erasure, a policy generation and the audit chain
across two databases, each needing a protocol of its own.
Invariants
- A table has one authority at a time, and every build reads and writes it there until its flip.
- Every table that can hold memory content is covered by erasure in each store that holds a copy, the kept old table of a move included.
- Every cache over a moved table fails closed past its bound.
- A restore keeps the promises of restores.
Source entry points
schemas/schema.sql,schemas/pg_migrations/: the tables the rows place.server/erasure/store/census.py(_CENSUS,STEP_ONLY_TABLES,NO_MEMORY_CONTENT) andserver/erasure/store/steps_pg.py(PG_CENSUS,PG_NO_MEMORY_CONTENT): the census each Erasure cell names.server/erasure/app/saga_steps.py(PG_ONLY_STEPS) andserver/erasure/app/replay.py(PG_STEPS,replay): the saga steps classified by store.server/core/leases.py(take,cluster_lease) andserver/core/pg_lease.py(check_lease,leased): today's leases and the delivery fencing.server/memory/store/pg_memory.py(capture,redact,redacted_since) andserver/knowledge/store/pg_documents.py(commit,complete_own,claim_due): the memory and knowledge commits and their deliveries.tests/unit/test_storage_layout_rows.py: the checks this page cites, each named in its module docstring.
Related
- Cells: what a cell holds, the router and its directory.
- Storage authority: the memory heads, the journal, publication and erasure under Postgres.
- ClickHouse: engines, sort keys, vector storage and mutations.
- Rolling upgrades: the rules the move patterns follow.
- Memory architecture: the erasure saga and the residue census.