feat(memory): the memory_relations table behind memory_link (step 1 of the graph) - #780
feat(memory): the memory_relations table behind memory_link (step 1 of the graph)#780Eldad-Caura wants to merge 1 commit into
Conversation
SummaryThis PR adds a new Medium/Low IssuesNo constraint restricting
|
…f the graph) `memory_link` promises a typed relation between two MEMORY ids, and nothing in the schema could hold one: `relations` is entity<->entity, `memory_entity_links` is memory<->entity, and `memories.supersedes_id` is memory->memory but single-valued and written only by the contradiction detector. That is why the tool has been accepted-but-unadvertised since memclawd #130. This adds the store and nothing else. NOTHING CONSUMES IT YET — no storage endpoint, no core-api route, no dispatcher wiring, and `memory_link` stays unadvertised. `TestAdvertisedToolsAreAllServable` in memclawd enforces that pairing, so the cloud side has to exist before the tool can be re-advertised; this is the prerequisite, deliberately landed on its own. ## Design, as decided rather than inferred The six relation types are `contracts/mcp-tools.md` §8 verbatim — Eldad confirmed the set rather than letting it be read off a tool description. **`supersedes` is NOT stored here.** It reuses `memories.supersedes_id`, so the detector and the API write one field instead of two stores that can disagree. A CHECK constraint makes that structural: a `supersedes` row cannot be inserted at all. The consequence is a real asymmetry the tool must document — `supersedes` is 1:1 and a second one OVERWRITES, while a second `elaborates` is another row. **One directed row per link, never two.** Three of the five stored types are semantically symmetric, and writing both directions creates pairs that must stay in sync plus half-edges when one is deleted. Symmetry is a READ concern; all per-type knowledge lives in one constant (`SYMMETRIC_RELATION_TYPES`) rather than spreading into the schema. **Soft delete leaves the rows.** `memories` is soft-deleted routinely and that is reversible, so cascading would make un-delete lossy. The read path filters `deleted_at IS NULL` — free, since it already joins `memories` for endpoint content — and `ON DELETE CASCADE` handles the terminal case when the purge sweep hard-deletes the row. No new retention machinery. ## Verified rather than asserted - **Model and migration produce byte-identical schemas.** Applied 037 to a scratch database and diffed `\d memory_relations` against the `create_all` result: identical across columns, defaults, all four indexes, both CHECKs and both CASCADE FKs. Tests build with `create_all` and prod runs alembic, so a divergence there is invisible until production. - **Seven tests exercise the guarantees against a real database**, because a comment claiming a constraint exists is worth nothing until something has watched it refuse a write: `supersedes` rejected, self-link rejected, duplicate rejected, hard delete cascades, soft delete does NOT. - One of them pins a GAP on purpose: the reversed pair of a symmetric type IS accepted by the natural key, which is exactly why the write path will have to check it. The schema does not make symmetry idempotent, and that is documented where someone would otherwise assume it does. ## Notes Indexes are plain, not CONCURRENTLY: the table is created in the same migration, so it is empty and unlocked. `test_no_plain_create_index_on_large_tables` scopes its requirement to large pre-existing tables, which is the case that crashed six storage-writer boots on 2026-06-16. Both endpoint columns get their own index because the natural key leads with `tenant_id` and so serves neither as a prefix — `test_every_fk_referencing_column_is_index_leading` fails at PR time otherwise, and an unindexed FK referencing column is what cost 15.3 s of a 15.5 s bulk delete before migration 035. `test_single_head` expectation moved 036 -> 037. Measured: `tests/` 4382 -> 4389 (+7, the new tests); `core-storage-api/tests/` 118 on a fresh database; mypy clean on both `src/` trees; ruff clean at CI's scopes. Signed-off-by: eldad-caura <eldad@caura.ai>
4fe4c38 to
9fffd60
Compare
What, and what this deliberately is not
memory_linkpromises a typed relation between two memory ids, and nothing in the schema could hold one —relationsis entity↔entity,memory_entity_linksis memory↔entity, andmemories.supersedes_idis memory→memory but single-valued and written only by the contradiction detector. That is why the tool has been accepted-but-unadvertised since memclawd #130.This adds the store and nothing else. Nothing consumes it yet: no storage endpoint, no core-api route, no
cloud.Clientmethod, no dispatcher wiring, andmemory_linkstays unadvertised. memclawd'sTestAdvertisedToolsAreAllServableenforces that pairing, so the cloud side must exist before the tool can be re-advertised — this is the prerequisite, landed on its own rather than as part of a cross-repo change.Design, decided rather than inferred
The six relation types are
contracts/mcp-tools.md§8 verbatim; Eldad confirmed the set rather than letting it be read off a tool description.supersedesis not stored here. It reusesmemories.supersedes_idso the detector and the API write one field instead of two stores that can disagree. A CHECK constraint makes that structural — asupersedesrow cannot be inserted at all. The consequence is a real asymmetry the tool will have to document:supersedesis 1:1 and a second one overwrites, while a secondelaboratesis an additional row.One directed row per link, never two. Three of the five stored types are semantically symmetric, and writing both directions creates pairs that must stay in sync plus half-edges when one is deleted. Symmetry is a read concern, and all per-type knowledge lives in one constant (
SYMMETRIC_RELATION_TYPES) instead of spreading into the schema.Soft delete leaves the rows.
memoriesis soft-deleted routinely and that is reversible, so cascading would make un-delete lossy. The read path filtersdeleted_at IS NULL— free, since it already joinsmemoriesfor endpoint content — andON DELETE CASCADEcovers the terminal case when the purge sweep hard-deletes the row. No new retention machinery.Verified rather than asserted
Model and migration produce byte-identical schemas. Applied 037 to a scratch database and diffed
\d memory_relationsagainst thecreate_allresult: identical across columns, defaults, all four indexes, both CHECKs, and both CASCADE FKs. Tests build the schema withcreate_allwhile prod runs alembic, so a divergence there stays invisible until production.Seven tests exercise the guarantees against a real database — a comment claiming a constraint exists is worth nothing until something has watched it refuse a write:
supersedesrejected by CHECK(tenant, from, type, to)rejected by the natural keyOne test pins a gap on purpose: the reversed pair of a symmetric type is accepted by the natural key. That is exactly why the write path will have to check it — the schema does not make symmetry idempotent, and this documents that where someone would otherwise assume it does. If a later change makes the schema enforce it, that test should fail and be replaced rather than deleted.
Notes on the migration
Indexes are plain, not
CONCURRENTLY: the table is created in the same migration, so it is empty and unlocked.test_no_plain_create_index_on_large_tablesscopes its requirement to large pre-existing tables — the case that crashed six storage-writer boots on 2026-06-16.Both endpoint columns get their own index because the natural key leads with
tenant_idand therefore serves neither as a prefix.test_every_fk_referencing_column_is_index_leadingfails at PR time otherwise, and an unindexed FK referencing column is what cost 15.3 s of a 15.5 s bulk delete before migration 035.test_single_headexpectation moved 036 → 037.Measurement
tests/core-storage-api/tests/src/treesOne local-only wrinkle worth recording: running
core-storage-api/tests/against a database where the roottests/tree had already runcreate_allgives 106DuplicateTableErrors, becausecreate_allmadememory_relationswhilealembic_versionstill said 036. It is not reachable in CI — that job gets its own fresh Postgres service, and CI runsalembic upgrade headbeforepytest tests/, so alembic is always ahead. Flagging it so nobody debugs it as a code defect.Remaining chain for
memory_linksupersedespath —PATCH /memories/{id}/statusalready has the full authz stack and already reaches a storage layer supportingsupersedes_id/unset_supersedes/expected_supersedes_id; it simply forwards none of them. Use the CAS gate so an API write cannot clobber the detector's pointer.cloud.Clientmethod,CloudLinkercapability,newCloudDispatcherwiring, a real input schema, then re-advertise intools/listBROKER_OPERATIONSrow here