📋 Pre-flight Checks
📝 Bug Description
A relation can exist in the local memory_relations table with no corresponding sync_mutations row, and there is no mechanism to ever create one. Such relations are silently never replicated to Engram Cloud — even though the local chunk sync path (post-#489/#494) exports every non-orphaned relation directly from the table.
This is distinct from the already-resolved relation cloud issues, all of which operate on relation mutations that already exist in the journal:
None of these create a sync_mutations row for a relation that never had one. The cloud's own row-creation/repair mechanism, backfillProjectSyncMutationsTx, backfills sessions/observations/prompts but never relations.
Root cause
Three confluent code paths, all verified against engram 1.16.3:
-
No relation backfill. backfillProjectSyncMutationsTx (internal/store/store.go:4996) calls only backfillSessionSyncMutationsTx, backfillObservationSyncMutationsTx, and backfillPromptSyncMutationsTx. projectNeedsBackfill (store.go:5009) only counts missing sessions/observations/prompts. There is no backfillRelationSyncMutationsTx anywhere in the repo. These run at startup (New → repairEnrolledProjectSyncMutations, store.go:643) and on enrollment/rename/merge — the exact "row exists in source table but not in journal" repair that relations are excluded from.
-
JudgeBySemantic never enqueues. JudgeBySemantic (internal/store/relations.go:732) inserts/updates memory_relations but never calls enqueueSyncMutationTx. The only relation enqueue in the codebase is relations.go:687, inside JudgeRelation. JudgeBySemantic is live: it backs mem_compare (internal/server/server.go:1315, internal/mcp/mcp.go:2134) and ScanProject (relations.go:1400). Every semantic/system verdict therefore lands in the table with no journal row.
-
JudgeRelation skips the enqueue when the project is not enrolled. relations.go:653: if enrolled == 0 { return nil // not enrolled — no mutation enqueued }. By contrast, enqueueSyncMutationTx (store.go:5364) does not gate on enrollment — observations/sessions/prompts are enqueued unconditionally and the backfill catches legacy ones. Relations have neither: judged-before-enrollment relations get no row, and enrolling later does not backfill them.
The net asymmetry: the local chunk export reads relations straight from memory_relations (query at store.go:~3380, used by engram sync), so local sync carries them regardless of how they were judged. The cloud path is journal-based and only ever carries what was enqueued — so the same table yields different replication outcomes for local vs cloud, and for JudgeRelation vs JudgeBySemantic.
🔄 Steps to Reproduce
Path A — JudgeBySemantic (no upgrade required):
- Enroll a project in cloud sync.
- Trigger a semantic verdict that is not
not_conflict — e.g. an agent calls mem_compare on two observations, or ScanProject runs.
- Inspect the journal:
-- a row exists in memory_relations:
SELECT sync_id, relation FROM memory_relations ORDER BY created_at DESC LIMIT 1;
-- but no matching mutation was enqueued:
SELECT COUNT(*) FROM sync_mutations WHERE entity='relation' AND entity_key='<that sync_id>'; -- 0
- The relation is never pushed to cloud;
local sync (engram sync) would export it.
Path B — pre-enrollment JudgeRelation:
- With a project not enrolled, resolve a conflict candidate via
mem_judge (→ JudgeRelation). relations.go:653 returns without enqueuing.
- Enroll the project.
repairEnrolledProjectSyncMutations backfills sessions/observations/prompts but not the relation.
- The relation is permanently absent from the cloud journal.
✅ Expected Behavior
- A relation present in
memory_relations (non-orphaned) is eventually represented by a sync_mutations row and replicated to cloud, regardless of the judge path or enrollment ordering — matching how local chunk sync already treats relations and how the cloud already treats sessions/observations/prompts.
- Concretely:
backfillProjectSyncMutationsTx / projectNeedsBackfill cover relations, and/or JudgeBySemantic enqueues, and/or JudgeRelation enqueues even when not-yet-enrolled (so backfill can pick it up later).
❌ Actual Behavior
Relations created via JudgeBySemantic, or via JudgeRelation before enrollment, have no sync_mutations row and no repair path. They never reach Engram Cloud and fail silently — there is no warning, and the local sync path replicates them, masking the loss until a cloud-only consumer is compared.
Operating System
macOS
Engram Version
1.16.3
Agent / Client
Claude Code
📋 Relevant Logs
# Verified by source inspection (engram 1.16.3):
internal/store/store.go:4996 backfillProjectSyncMutationsTx -> session/observation/prompt only
internal/store/store.go:5009 projectNeedsBackfill -> counts session/observation/prompt only
internal/store/store.go:5364 enqueueSyncMutationTx -> no enrollment gate (obs/sessions/prompts always enqueue)
internal/store/relations.go:653 JudgeRelation -> "return nil // not enrolled — no mutation enqueued"
internal/store/relations.go:687 JudgeRelation -> the ONLY relation enqueue in the codebase
internal/store/relations.go:732 JudgeBySemantic -> inserts/updates memory_relations, never enqueues
grep -rn "backfillRelation" internal/ --include="*.go" -> no match (no relation backfill exists)
💡 Additional Context
📋 Pre-flight Checks
status:approvedbefore a PR can be opened📝 Bug Description
A relation can exist in the local
memory_relationstable with no correspondingsync_mutationsrow, and there is no mechanism to ever create one. Such relations are silently never replicated to Engram Cloud — even though the local chunk sync path (post-#489/#494) exports every non-orphaned relation directly from the table.This is distinct from the already-resolved relation cloud issues, all of which operate on relation mutations that already exist in the journal:
SyncEntityRelationis now whitelisted inevaluateCloudUpgradeLegacyMutationTx,internal/store/store.go:1514).None of these create a
sync_mutationsrow for a relation that never had one. The cloud's own row-creation/repair mechanism,backfillProjectSyncMutationsTx, backfills sessions/observations/prompts but never relations.Root cause
Three confluent code paths, all verified against
engram 1.16.3:No relation backfill.
backfillProjectSyncMutationsTx(internal/store/store.go:4996) calls onlybackfillSessionSyncMutationsTx,backfillObservationSyncMutationsTx, andbackfillPromptSyncMutationsTx.projectNeedsBackfill(store.go:5009) only counts missing sessions/observations/prompts. There is nobackfillRelationSyncMutationsTxanywhere in the repo. These run at startup (New→repairEnrolledProjectSyncMutations,store.go:643) and on enrollment/rename/merge — the exact "row exists in source table but not in journal" repair that relations are excluded from.JudgeBySemanticnever enqueues.JudgeBySemantic(internal/store/relations.go:732) inserts/updatesmemory_relationsbut never callsenqueueSyncMutationTx. The only relation enqueue in the codebase isrelations.go:687, insideJudgeRelation.JudgeBySemanticis live: it backsmem_compare(internal/server/server.go:1315,internal/mcp/mcp.go:2134) andScanProject(relations.go:1400). Every semantic/system verdict therefore lands in the table with no journal row.JudgeRelationskips the enqueue when the project is not enrolled.relations.go:653:if enrolled == 0 { return nil // not enrolled — no mutation enqueued }. By contrast,enqueueSyncMutationTx(store.go:5364) does not gate on enrollment — observations/sessions/prompts are enqueued unconditionally and the backfill catches legacy ones. Relations have neither: judged-before-enrollment relations get no row, and enrolling later does not backfill them.The net asymmetry: the local chunk export reads relations straight from
memory_relations(query atstore.go:~3380, used byengram sync), so local sync carries them regardless of how they were judged. The cloud path is journal-based and only ever carries what was enqueued — so the same table yields different replication outcomes for local vs cloud, and forJudgeRelationvsJudgeBySemantic.🔄 Steps to Reproduce
Path A —
JudgeBySemantic(no upgrade required):not_conflict— e.g. an agent callsmem_compareon two observations, orScanProjectruns.localsync (engram sync) would export it.Path B — pre-enrollment
JudgeRelation:mem_judge(→JudgeRelation).relations.go:653returns without enqueuing.repairEnrolledProjectSyncMutationsbackfills sessions/observations/prompts but not the relation.✅ Expected Behavior
memory_relations(non-orphaned) is eventually represented by async_mutationsrow and replicated to cloud, regardless of the judge path or enrollment ordering — matching how local chunk sync already treats relations and how the cloud already treats sessions/observations/prompts.backfillProjectSyncMutationsTx/projectNeedsBackfillcover relations, and/orJudgeBySemanticenqueues, and/orJudgeRelationenqueues even when not-yet-enrolled (so backfill can pick it up later).❌ Actual Behavior
Relations created via
JudgeBySemantic, or viaJudgeRelationbefore enrollment, have nosync_mutationsrow and no repair path. They never reach Engram Cloud and fail silently — there is no warning, and the local sync path replicates them, masking the loss until a cloud-only consumer is compared.Operating System
macOS
Engram Version
1.16.3
Agent / Client
Claude Code
📋 Relevant Logs
💡 Additional Context
fix(sync): local chunk sync silently drops memory_relations) but for the cloud journal path. The local fix (PR fix(sync): export pre-existing relations missing from local chunks #494) reads relations from the table on export; the cloud path cannot, because it syncs from the journal.REQ-009: memory_relations are local-only in Phase 1(internal/mcp/mcp_conflict_loop_test.go:621). SinceJudgeRelationnow enqueues relations and fix(store,chunkcodec): cloud sync blocked by unsupported relation mutations #313/fix(sync): accept cloud relation mutations #379/cloud upgrade legacy evaluator does not whitelist relation entity (regression after #379) #383 explicitly enabled relation cloud sync, that constraint appears superseded — but please confirm whether full relation cloud replication is the intended end state before this is implemented.backfillRelationSyncMutationsTxmirroring the observation backfill (scanmemory_relationswherejudgment_status != orphanedand no matchingsync_mutationsrow for the enrolled project), include relations inprojectNeedsBackfill, and haveJudgeBySemanticenqueue on the enrolled path (or rely on the new backfill). Add regression coverage analogous to the fix(sync): local chunk sync silently drops memory_relations without warning #353 export test (internal/sync/sync_test.go:693).engram sync(local chunk) does replicate these relations, so cross-machine continuity via local sync is unaffected; only cloud replication loses them.