Skip to content

fix(store): backfill relation sync mutations so relations reach cloud#497

Merged
Alan-TheGentleman merged 5 commits into
mainfrom
fix/cloud-relation-backfill
Jun 14, 2026
Merged

fix(store): backfill relation sync mutations so relations reach cloud#497
Alan-TheGentleman merged 5 commits into
mainfrom
fix/cloud-relation-backfill

Conversation

@Alan-TheGentleman

@Alan-TheGentleman Alan-TheGentleman commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Closes #496

PR Type

  • type:bug

Summary

Cloud-journal sibling of #353 (PR #494, which fixed the local chunk path). A relation in memory_relations could exist with no sync_mutations row, with no mechanism to ever create one, so it never replicated to Engram Cloud (journal-based) and failed silently. Three confluent gaps, all addressed:

  1. No relation backfill. backfillProjectSyncMutationsTx only backfilled sessions/observations/prompts. Added backfillRelationSyncMutationsTx, mirroring backfillObservationSyncMutationsTx (two-phase collect-then-insert, non-orphaned only, NOT EXISTS guard against the cloud target key). Wired into backfillProjectSyncMutationsTx, so it runs on startup repair, enrollment, rename, and merge.
  2. projectNeedsBackfill ignored relations. Added a 4th COUNT matching the backfill SELECT exactly, so the startup fast-path detects the gap.
  3. JudgeBySemantic never enqueued. It backs mem_compare and ScanProject; every semantic verdict landed in memory_relations with no journal row. It now enqueues on the enrolled path, mirroring JudgeRelations enrollment gate. Unenrolled stays a no-op and is caught by the backfill on later enrollment.

REQ-009 intent (the open question in the issue)

Confirmed superseded. #313/#379/#383 already enabled relation cloud sync (JudgeRelation already enqueued). The TestConflictLoop_SyncRegression / assertNoRelationSyncMutations guard still passes and is kept — but its comment was updated: it guards the enrollment gate (an unenrolled project must never enqueue), not a blanket "local-only" rule.

Changes

File Change
internal/store/store.go backfillRelationSyncMutationsTx (new), call from backfillProjectSyncMutationsTx, relation COUNT in projectNeedsBackfill, accurate doc comment on CountRelationSyncMutations
internal/store/relations.go JudgeBySemantic enrollment-gated enqueue
internal/store/relation_backfill_test.go New: backfill creates/excludes-orphaned/idempotent, projectNeedsBackfill cases, JudgeBySemantic enrolled/unenrolled/upsert, and TestEnrollProject_BackfillsPreExistingRelations (real pre-enrollment → EnrollProject → backfill end-to-end)
internal/mcp/mcp_conflict_loop_test.go Updated stale REQ-009 comments to reflect the enrollment-gate invariant

Test Plan

Note for @forNerzul

You filed this and described the exact fix direction (mirror the observation/session/prompt backfill for relations) plus the REQ-009 intent question. This implements that. Since you have the real cloud-enrolled cognicion-modular setup that reproduces the silent drop, could you validate it against your scenario? Build from this branch:

git fetch origin fix/cloud-relation-backfill
git checkout fix/cloud-relation-backfill
go build -o ./engram ./cmd/engram

Then on an enrolled project: trigger a JudgeBySemantic verdict (mem_compare / ScanProject) or enroll a project that already has relations, and confirm a relation row appears in sync_mutations and reaches cloud (before this it was 0). Backfill path: engram startup on an enrolled project with pre-existing relations should now enqueue them.

If your own fix diverged in approach, flag it here and we will reconcile before merge.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved cloud sync backfill/repair for relations, including idempotent creation of missing relation upsert sync mutations.
    • Enforced enrollment-gated relation syncing: relations won’t enqueue sync while unenrolled, but are backfilled after enrollment.
    • Hardened cross-project validation and improved project resolution using session fallback; orphaned and pending/unjudged relations are excluded.
  • Tests

    • Added and expanded automated coverage for relation sync backfill/repair, enrollment-gated enqueue behavior, payload project fallback, and cross-project guard scenarios.

Relations in memory_relations could lack a sync_mutations row and never
replicate to Engram Cloud (journal-based), silently. Cloud-journal sibling
of #353 (local chunk path).

- Add backfillRelationSyncMutationsTx mirroring the observation backfill
  (two-phase collect-then-insert, non-orphaned, NOT EXISTS guard).
- Call it from backfillProjectSyncMutationsTx (startup repair, enroll,
  rename, merge).
- Count non-orphaned relations missing a mutation in projectNeedsBackfill.
- Enqueue from JudgeBySemantic on the enrolled path (mem_compare/ScanProject
  verdicts previously never enqueued).

Closes #496
Copilot AI review requested due to automatic review settings June 14, 2026 09:54
@Alan-TheGentleman Alan-TheGentleman added the type:bug Bug fix label Jun 14, 2026
@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds cloud sync journal coverage for memory_relations rows. JudgeBySemantic now enqueues a SyncEntityRelation upsert mutation when the project is enrolled, using observation/session-fallback project derivation. projectNeedsBackfill and backfillProjectSyncMutationsTx are extended to detect and repair missing relation mutations via a new backfillRelationSyncMutationsTx. A new test file and updated regression comments cover all paths.

Changes

Cloud Relation Backfill (Issue #496)

Layer / File(s) Summary
Session-fallback project derivation for cross-project validation
internal/store/relations.go
JudgeRelation and validateCrossProjectGuard now derive srcProject/tgtProject using coalesce(nullif(observations.project,''), sessions.project, '') via a sessions LEFT JOIN instead of ifnull(project,'') only. This enables both functions to resolve blank observation projects to session project values for enrollment checks and cross-project guard validation.
JudgeBySemantic enrollment-gated sync enqueue
internal/store/relations.go
After writing the memory_relations row, derives source/target projects with observation/session fallback, checks sync_enrolled_projects, and when enrolled reads the relation row in-transaction, builds a syncRelationPayload, and enqueues an upsert mutation for SyncEntityRelation. Returns without enqueueing when not enrolled. Logs a WARNING when srcProject resolves empty.
Backfill infrastructure and chaining
internal/store/store.go
backfillProjectSyncMutationsTx no longer early-returns after prompt backfill and now chains into backfillRelationSyncMutationsTx. projectNeedsBackfill adds a COUNT query detecting missing relation upsert sync_mutations for non-orphaned, fully-judged memory_relations with locally-present observations. backfillRelationSyncMutationsTx implements a two-phase collect-then-insert pattern to enqueue SyncEntityRelation upserts for eligible relations.
Test helpers and comprehensive backfill/enqueue coverage
internal/store/relation_backfill_test.go
New test file with setupBackfillStore, insertRelationDirect, insertJudgedRelationDirect, and countRelationSyncMutationsByKey helpers. Covers backfillRelationSyncMutationsTx creation/skip/idempotency, JudgeBySemantic enqueue semantics (enrolled vs unenrolled, UPSERT stability), projectNeedsBackfill detection, enrollment-time backfill, pending-relation exclusion, session-fallback project derivation, cross-project guard validation with blank observation projects, and JudgeRelation session-fallback for pending relations.
Enrollment-gate regression guard comment updates
internal/mcp/mcp_conflict_loop_test.go, internal/store/store.go
Updates Phase G.4 comment block, test log message, and assertNoRelationSyncMutations documentation/error messages to frame behavior as enrollment-gate regression for unenrolled projects while noting relations remain valid for enrolled projects. Expands CountRelationSyncMutations doc comment to clarify the unenrolled test regression context. No control-flow or query logic changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(store): backfill relation sync mutations so relations reach cloud' clearly summarizes the main change: implementing a backfill mechanism for relation sync mutations to enable cloud replication.
Linked Issues check ✅ Passed All three root causes identified in issue #496 are addressed: (1) new backfillRelationSyncMutationsTx added and integrated; (2) projectNeedsBackfill extended with relation COUNT; (3) JudgeBySemantic now enqueues relations on enrolled path with session-fallback project derivation; (4) JudgeRelation fixed to use session-fallback project and validateCrossProjectGuard updated to match.
Out of Scope Changes check ✅ Passed All changes directly support the PR objectives: test refactoring for enrollment-gate framing, new comprehensive relation backfill test suite, enhanced relations.go with enqueue logic and project derivation, and store.go backfill/detection integration. No extraneous modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cloud-relation-backfill

Comment @coderabbitai help to get the list of available commands and usage tips.

@Alan-TheGentleman

Copy link
Copy Markdown
Collaborator Author

@forNerzul concrete review request: this is the #496 fix ready for your eyes. GitHub will not let me add you as a formal reviewer (external/fork contributor), so flagging it here directly.

Could you review the diff and validate it against your cloud-enrolled cognicion-modular repro? Build from the branch:

git fetch origin fix/cloud-relation-backfill
git checkout fix/cloud-relation-backfill
go build -o ./engram ./cmd/engram

What to confirm:

  1. On an enrolled project, a JudgeBySemantic verdict (mem_compare / ScanProject) now creates a relation row in sync_mutations (was 0).
  2. Backfill: starting engram on an enrolled project that already had relations enqueues them (backfillRelationSyncMutationsTx via the enroll/startup-repair path).
  3. Orphaned relations stay excluded.

If your own intended fix diverged in approach (you described mirroring the observation/session/prompt backfill — which is what this does), call it out and we will reconcile before merge. Trying to land this soon, so your validation is the last gate.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/relations.go`:
- Around line 818-865: The JudgeBySemantic function is missing a warning log
that exists in JudgeRelation (lines 660-662) for the case when the source
observation is absent locally. After determining that srcProject is empty and
setting enrollCheckProject to tgtProject as a fallback, add a warning log to
document this edge case so the server's rejection of the mutation is properly
recorded in observability logs. This ensures consistent logging behavior between
JudgeRelation and JudgeBySemantic for the same missing source observation
scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a0a75df1-ed7d-4537-8f83-72e7a75033d3

📥 Commits

Reviewing files that changed from the base of the PR and between b851cb9 and 3514f03.

📒 Files selected for processing (4)
  • internal/mcp/mcp_conflict_loop_test.go
  • internal/store/relation_backfill_test.go
  • internal/store/relations.go
  • internal/store/store.go

Comment thread internal/store/relations.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a cloud-sync replication gap where memory_relations rows could exist without corresponding sync_mutations entries, preventing relations from reaching Engram Cloud’s journal-based sync pipeline.

Changes:

  • Add relation backfill (backfillRelationSyncMutationsTx) and wire it into backfillProjectSyncMutationsTx; extend projectNeedsBackfill to detect missing relation mutations.
  • Update JudgeBySemantic to enqueue relation sync mutations when the project is enrolled (mirroring JudgeRelation’s enrollment gate).
  • Add/extend tests to cover relation backfill behavior, enrollment gating, and end-to-end enrollment backfill; update MCP conflict-loop test comments to reflect the enrollment-gate invariant.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
internal/store/store.go Adds relation mutation backfill and updates startup “needs backfill” detection; updates helper comment describing the intended enrollment-gate invariant.
internal/store/relations.go Enqueues relation sync mutations from JudgeBySemantic when enrolled (previously never enqueued).
internal/store/relation_backfill_test.go Adds regression tests for relation backfill + enrollment gating + enrollment-triggered backfill.
internal/mcp/mcp_conflict_loop_test.go Updates REQ-009-era comments to clarify the invariant is “unenrolled projects must not enqueue relation mutations.”

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/store/store.go
Comment on lines +5048 to +5064
{
// Count non-orphaned relations whose source and target observations are
// locally available and that have no local upsert sync_mutations row.
// Mirrors the SELECT in backfillRelationSyncMutationsTx.
q: `SELECT COUNT(*)
FROM memory_relations r
JOIN observations src ON src.sync_id = r.source_id AND src.deleted_at IS NULL
JOIN observations tgt ON tgt.sync_id = r.target_id AND tgt.deleted_at IS NULL
LEFT JOIN sessions src_s ON src_s.id = src.session_id
WHERE r.judgment_status != ?
AND coalesce(nullif(src.project, ''), src_s.project, '') = ?
AND NOT EXISTS (
SELECT 1 FROM sync_mutations sm
WHERE sm.target_key = ? AND sm.entity = ? AND sm.entity_key = r.sync_id AND sm.source = ?
)`,
args: []any{JudgmentStatusOrphaned, project, DefaultSyncTargetKey, SyncEntityRelation, SyncSourceLocal},
},
Comment thread internal/store/store.go
Comment on lines +5409 to +5423
JOIN observations tgt ON tgt.sync_id = r.target_id AND tgt.deleted_at IS NULL
LEFT JOIN sessions src_s ON src_s.id = src.session_id
WHERE r.judgment_status != ?
AND coalesce(nullif(src.project, ''), src_s.project, '') = ?
AND NOT EXISTS (
SELECT 1 FROM sync_mutations sm
WHERE sm.target_key = ?
AND sm.entity = ?
AND sm.entity_key = r.sync_id
AND sm.source = ?
)
ORDER BY r.created_at ASC, r.sync_id ASC`,
JudgmentStatusOrphaned,
project,
DefaultSyncTargetKey, SyncEntityRelation, SyncSourceLocal,
Comment on lines +821 to +827
var srcProject, tgtProject string
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, p.SourceID,
).Scan(&srcProject)
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, p.TargetID,
).Scan(&tgtProject)
Comment on lines +43 to +49
if _, err := s.db.Exec(`
INSERT INTO memory_relations
(sync_id, source_id, target_id, relation, judgment_status, created_at, updated_at)
VALUES (?, ?, ?, 'related', ?, datetime('now'), datetime('now'))
`, syncID, sourceID, targetID, judgmentStatus); err != nil {
t.Fatalf("insertRelationDirect: %v", err)
}
…ject via session

- Tighten backfillRelationSyncMutationsTx and projectNeedsBackfill to
  exclude pending/orphaned rows and any row missing marked_by_actor or
  marked_by_kind. Both predicates are now identical to prevent the
  fast-path check from desyncing with the write path.
- JudgeBySemantic now derives srcProject via session-fallback
  (coalesce(obs.project, session.project)) matching the backfill SQL,
  so enqueued payloads carry a non-empty project when obs.project is blank.
- Add REQ-011 WARNING log in JudgeBySemantic when srcProject is empty,
  matching the wording in JudgeRelation.
- Update insertRelationDirect fixture usages to insertJudgedRelationDirect
  (with marked_by_actor/kind) for judged-relation test cases.
- Add tests: pending row not backfilled, projectNeedsBackfill not triggered
  by pending row, JudgeBySemantic session-fallback populates payload project.
@Alan-TheGentleman

Copy link
Copy Markdown
Collaborator Author

Addressed all CodeRabbit + Copilot findings in e83d2d0. Each was verified against the actual cloud validation before changing anything — and all were valid. The cloud path enforces the requirement at three layers: chunkcodec.go:484-489 (client) and cloudserver/mutations.go:303-333 reject relation mutations missing marked_by_actor/marked_by_kind, and mutations.go:92-100 rejects project == "".

  • Copilot (store.go:5064 / 5423) — backfill enqueued pending rows. Fixed. Both backfillRelationSyncMutationsTx and the projectNeedsBackfill COUNT now use an identical predicate: judgment_status NOT IN (orphaned, pending) AND marked_by_actor != "" AND marked_by_kind != "". This prevents enqueuing candidate rows that would 400 the whole cloud batch. Keeping the two predicates identical avoids fast-path desync.
  • Copilot (relations.go:827) — JudgeBySemantic project from observations only. Fixed. It now resolves project via coalesce(nullif(o.project,""), s.project, "") with a session join, matching the backfill SELECT, so the enqueued payload never carries an empty project when the session has one.
  • CodeRabbit (relations.go:865) — missing REQ-011 warning. Fixed. JudgeBySemantic now logs the same project="" WARNING breadcrumb as JudgeRelation.
  • Copilot (relation_backfill_test.go:49) — fixture lacked marked_by_*. Fixed. New insertJudgedRelationDirect helper populates marked_by_actor/marked_by_kind; the bare insertRelationDirect is kept for orphaned/pending fixtures.

Added two regression tests: TestBackfillRelationSyncMutations_SkipsPending (pending rows are neither backfilled nor flagged by projectNeedsBackfill) and TestJudgeBySemantic_UsesSessionFallback_ForProject (enqueued payload carries the session-resolved project). go build, go vet, and go test ./... all green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/store/relations.go (1)

843-857: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move the REQ-011 warning below the enrollment gate.

When enrollCheckProject is not enrolled, Line 857 returns before any sync mutation is queued, but this warning has already logged "enqueueing relation ...". That creates false-positive breadcrumbs for the intentionally skipped path.

Suggested fix
-		// REQ-011: log at WARNING level when source observation is missing locally
-		// (project='' race condition). The server will reject with 400; this log
-		// is the local breadcrumb so the gap is not silently swallowed.
-		if srcProject == "" {
-			log.Printf("[store] WARNING: JudgeBySemantic enqueueing relation %s with project='' (source observation missing locally); server will reject", existingSyncID)
-		}
-
 		var enrolled int
 		if err := tx.QueryRow(
 			`SELECT 1 FROM sync_enrolled_projects WHERE project = ? LIMIT 1`, enrollCheckProject,
 		).Scan(&enrolled); err != nil && err != sql.ErrNoRows {
 			return fmt.Errorf("JudgeBySemantic: check enrollment: %w", err)
@@
 		if enrolled == 0 {
 			return nil // not enrolled — backfill will cover it on enrollment
 		}
+
+		// REQ-011: log at WARNING level when source observation is missing locally
+		// (project='' race condition). The server will reject with 400; this log
+		// is the local breadcrumb so the gap is not silently swallowed.
+		if srcProject == "" {
+			log.Printf("[store] WARNING: JudgeBySemantic enqueueing relation %s with project='' (source observation missing locally); server will reject", existingSyncID)
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 843 - 857, The REQ-011 warning logs
that a relation is being enqueued even when the enrollment check at the enrolled
== 0 gate will cause the function to return early without actually enqueueing
anything, creating false-positive breadcrumbs. Move the warning log block (the
if srcProject == "" conditional that logs the WARNING message) to execute after
the enrollment check, specifically after the if enrolled == 0 return statement,
so the warning only logs when the relation will actually be enqueued and not for
intentionally skipped paths.
internal/store/relation_backfill_test.go (1)

371-393: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider mirroring the full backfill predicate in the manual SQL query.

The manual SQL verification query (lines 374-383) simplifies the real projectNeedsBackfill relation predicate by omitting:

  • Pending status exclusion (r.judgment_status NOT IN (?, ?))
  • marked_by_actor and marked_by_kind checks
  • Project filter via coalesce(nullif(src.project, ''), src_s.project, '') = ?
  • Session LEFT JOIN for project derivation

While the test passes correctly for the current scenario (only an orphaned relation is inserted), the simplified query could produce false positives if test data changes or if a future maintainer adds non-orphaned, unmarked relations. Mirroring the exact predicate from context snippet line 5046–5061 would improve clarity and robustness.

♻️ Proposed alignment with real predicate
 		var n int
 		err := tx.QueryRow(`
 			SELECT COUNT(*) FROM memory_relations r
 			JOIN observations src ON src.sync_id = r.source_id AND src.deleted_at IS NULL
 			JOIN observations tgt ON tgt.sync_id = r.target_id AND tgt.deleted_at IS NULL
-			WHERE r.judgment_status != ?
+			LEFT JOIN sessions src_s ON src_s.id = src.session_id
+			WHERE r.judgment_status NOT IN (?, ?)
+			  AND ifnull(r.marked_by_actor, '') != ''
+			  AND ifnull(r.marked_by_kind, '') != ''
+			  AND coalesce(nullif(src.project, ''), src_s.project, '') = ?
 			  AND NOT EXISTS (
 				SELECT 1 FROM sync_mutations sm
 				WHERE sm.target_key = ? AND sm.entity = ? AND sm.entity_key = r.sync_id AND sm.source = ?
 			  )
-		`, JudgmentStatusOrphaned, DefaultSyncTargetKey, SyncEntityRelation, SyncSourceLocal).Scan(&n)
+		`, JudgmentStatusOrphaned, JudgmentStatusPending, "proj-orph", DefaultSyncTargetKey, SyncEntityRelation, SyncSourceLocal).Scan(&n)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relation_backfill_test.go` around lines 371 - 393, The manual
SQL verification query in the test does not fully match the real
projectNeedsBackfill relation predicate. Update the SQL query in the withTx
block to include all the filtering conditions from the actual predicate: add the
pending status exclusion check (judgment_status NOT IN), include the
marked_by_actor and marked_by_kind conditions, add the project filter with the
coalesce/nullif logic, and perform the necessary LEFT JOIN with the session
table for project derivation. This ensures the test validates against the exact
same logic as the production code and prevents false positives if test data
changes in the future.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/relations.go`:
- Around line 824-840: The validateCrossProjectGuard check is performed against
only the observation.project columns, but the srcProject and tgtProject
resolution here includes session fallback logic when observations.project is
blank. This creates a gap where relations with blank project values in
observations but different projects in their associated sessions will
incorrectly pass the guard. Update the validateCrossProjectGuard to apply the
same session-fallback logic used for resolving srcProject and tgtProject,
ensuring it queries both observation and session project columns with the same
coalesce/nullif pattern before comparing the resolved projects.

---

Outside diff comments:
In `@internal/store/relation_backfill_test.go`:
- Around line 371-393: The manual SQL verification query in the test does not
fully match the real projectNeedsBackfill relation predicate. Update the SQL
query in the withTx block to include all the filtering conditions from the
actual predicate: add the pending status exclusion check (judgment_status NOT
IN), include the marked_by_actor and marked_by_kind conditions, add the project
filter with the coalesce/nullif logic, and perform the necessary LEFT JOIN with
the session table for project derivation. This ensures the test validates
against the exact same logic as the production code and prevents false positives
if test data changes in the future.

In `@internal/store/relations.go`:
- Around line 843-857: The REQ-011 warning logs that a relation is being
enqueued even when the enrollment check at the enrolled == 0 gate will cause the
function to return early without actually enqueueing anything, creating
false-positive breadcrumbs. Move the warning log block (the if srcProject == ""
conditional that logs the WARNING message) to execute after the enrollment
check, specifically after the if enrolled == 0 return statement, so the warning
only logs when the relation will actually be enqueued and not for intentionally
skipped paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f77bee98-66b3-42c6-a8fa-99ac1ca23363

📥 Commits

Reviewing files that changed from the base of the PR and between 3514f03 and e83d2d0.

📒 Files selected for processing (3)
  • internal/store/relation_backfill_test.go
  • internal/store/relations.go
  • internal/store/store.go

Comment thread internal/store/relations.go
…-011 log placement

- validateCrossProjectGuard now uses coalesce(nullif(o.project,''), s.project, '')
  with LEFT JOIN sessions for both source and target, matching the JudgeBySemantic
  project-derivation query so blank observations.project cannot bypass the guard
  when session projects differ (closes cross-project guard bypass via session fallback)
- Move REQ-011 warning log in JudgeBySemantic to after the enrollment gate so it
  only fires when a mutation will actually be enqueued, matching JudgeRelation ordering
- Add regression tests: TestCrossProjectGuard_SessionFallback_RejectsDifferentSessionProjects
  and TestCrossProjectGuard_SessionFallback_AllowsSameSessionProject
Copilot AI review requested due to automatic review settings June 14, 2026 10:25
@Alan-TheGentleman

Copy link
Copy Markdown
Collaborator Author

Both CodeRabbit findings addressed in e1ed602 — verified against the code first, both were valid (the cross-project one was a regression I introduced when adding the session fallback).

  • Cross-project guard bypass (relations.go:701-714) — Major. validateCrossProjectGuard compared observations.project only, while JudgeBySemantic now resolves project with a session fallback. So observations with blank observations.project but sessions in different projects slipped past the guard and got enqueued. Fixed at the source: the shared guard now uses the same coalesce(nullif(o.project,""), s.project, "") LEFT JOIN sessions form, so it catches cross-project relations regardless of whether observations.project is populated — and it fixes both JudgeRelation and JudgeBySemantic in one place rather than a duplicated inline check. REQ-011 "both empty allowed" semantics preserved.
  • REQ-011 warning placement (JudgeBySemantic) — Minor. Moved the project="" WARNING log to after the if enrolled == 0 { return } gate so it only fires when the relation will actually be enqueued, matching JudgeRelations ordering. No more false-positive breadcrumbs on the not-enrolled path.

Added TestCrossProjectGuard_SessionFallback_RejectsDifferentSessionProjects (fails before the guard fix, passes after; covers both judge paths) and TestCrossProjectGuard_SessionFallback_AllowsSameSessionProject (guard not over-tightened). go build, go vet, go test ./... all green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

internal/store/store.go:6762

  • CountRelationSyncMutations currently counts all sync_mutations whose entity is not session/observation/prompt. That’s broader than "relation" and will start failing the enrollment-gate test if any new non-relation entity is added to the journal in the future. Since the helper is now specifically about relation enrollment gating, it should count only entity = SyncEntityRelation.
	err := s.db.QueryRow(`
		SELECT count(*)
		FROM sync_mutations
		WHERE entity NOT IN ('session', 'observation', 'prompt')
	`).Scan(&count)

internal/store/store.go:5012

  • The doc comment for projectNeedsBackfill is now outdated: it says it covers sessions/live observations/prompts and runs three COUNT queries, but relations are now included and the function runs four COUNTs. Updating the comment will keep future readers from missing the relation backfill fast-path dependency.
// projectNeedsBackfill returns true when a project has any sessions, live observations,
// or prompts that are missing a corresponding sync_mutation row.
// It runs three lightweight COUNT queries — no cursor is held open.
func (s *Store) projectNeedsBackfill(project string) (bool, error) {

Comment on lines 701 to +705
func validateCrossProjectGuard(tx *sql.Tx, sourceID, targetID string) error {
var srcProject, tgtProject string
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, sourceID,
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
Comment on lines +830 to +842
var srcProject, tgtProject string
_ = tx.QueryRow(
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, p.SourceID,
).Scan(&srcProject)
_ = tx.QueryRow(
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, p.TargetID,
).Scan(&tgtProject)
Comment on lines +375 to +384
err := tx.QueryRow(`
SELECT COUNT(*) FROM memory_relations r
JOIN observations src ON src.sync_id = r.source_id AND src.deleted_at IS NULL
JOIN observations tgt ON tgt.sync_id = r.target_id AND tgt.deleted_at IS NULL
WHERE r.judgment_status != ?
AND NOT EXISTS (
SELECT 1 FROM sync_mutations sm
WHERE sm.target_key = ? AND sm.entity = ? AND sm.entity_key = r.sync_id AND sm.source = ?
)
`, JudgmentStatusOrphaned, DefaultSyncTargetKey, SyncEntityRelation, SyncSourceLocal).Scan(&n)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/relations.go`:
- Around line 704-713: The JudgeRelation function currently derives srcProject
and tgtProject from observations.project alone, which doesn't account for the
session fallback logic. This creates inconsistency with the session-fallback
project lookup pattern shown in the diff that uses
coalesce(nullif(o.project,''), s.project, ''). Update the JudgeRelation function
to use the same session-fallback query pattern when determining srcProject and
tgtProject before the enrollment check, replacing the direct
observations.project lookups with queries that LEFT JOIN to sessions and apply
the coalesce-nullif logic to properly handle blank observation projects that
should fall back to the session project value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b9afc659-4dbe-467b-8ea6-bb2b6115ac8b

📥 Commits

Reviewing files that changed from the base of the PR and between e83d2d0 and e1ed602.

📒 Files selected for processing (2)
  • internal/store/relation_backfill_test.go
  • internal/store/relations.go

Comment thread internal/store/relations.go
Replace the plain `SELECT ifnull(project,'') FROM observations` lookups
for srcProject/tgtProject in JudgeRelation with the session-fallback
form already used by JudgeBySemantic:

  SELECT coalesce(nullif(o.project,''), s.project, '')
    FROM observations o
    LEFT JOIN sessions s ON s.id = o.session_id
   WHERE o.sync_id = ?

Without this, enrolled-project relations whose observations carry a blank
observations.project (project lives only on the session) resolve
srcProject="" → enrollCheckProject="" → enrolled=0 → return nil with no
sync_mutations row, recreating the #496 replication gap for the
JudgeRelation path. The fix makes enqueue behaviour consistent with
JudgeBySemantic and ensures the payload Project is non-empty.

Adds TestJudgeRelation_UsesSessionFallback_ForProject (TDD: red before
fix, green after) which covers both the missing mutation row and the
empty payload.project assertions.
@Alan-TheGentleman

Copy link
Copy Markdown
Collaborator Author

Good catch — addressed in 9225c57. Verified it was reachable: JudgeRelation derived srcProject/tgtProject from observations.project alone, so a relation judged on an enrolled project whose observations have blank project but whose session carries it resolved enrollCheckProject="" and returned without enqueuing — recreating the replication gap for the JudgeRelation path (only caught later by backfill-on-startup, not immediately).

Fixed by giving JudgeRelation the same session-fallback lookup already used by JudgeBySemantic and validateCrossProjectGuard:

SELECT coalesce(nullif(o.project,""), s.project, "") FROM observations o LEFT JOIN sessions s ON s.id = o.session_id WHERE o.sync_id = ?

Now enrollCheckProject and the payload Project carry the session-resolved project, so enrolled-project relations enqueue immediately and consistently across both judge paths. REQ-011 "source genuinely missing → project=""" semantics preserved.

Added TestJudgeRelation_UsesSessionFallback_ForProject (fails before: 0 mutation rows; passes after: row exists with project resolved from the session). go test ./... all green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/store/relations.go`:
- Around line 599-610: The code at the two QueryRow().Scan() calls for
srcProject and tgtProject is ignoring errors by using underscore assignments
instead of capturing and handling them explicitly. This allows database errors
to silently result in empty project values, which can cause incorrect
cross-project behavior or skip important operations. Replace the underscore with
an error variable for both Scan calls and add explicit error handling that
checks if the error is not nil and either returns the error, logs it, or takes
appropriate action to prevent the query from silently failing and leaving
srcProject and tgtProject as empty strings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a91b5927-28b9-4328-9da2-143ecacbac14

📥 Commits

Reviewing files that changed from the base of the PR and between e1ed602 and 9225c57.

📒 Files selected for processing (2)
  • internal/store/relation_backfill_test.go
  • internal/store/relations.go

Comment on lines 599 to 610
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, sourceID,
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, sourceID,
).Scan(&srcProject)
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, targetID,
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, targetID,
).Scan(&tgtProject)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle project-lookup query errors explicitly instead of silently treating failures as empty project values.

At Line 599 and Line 605, Scan errors are ignored. A real DB error then becomes srcProject/tgtProject == "", which can silently skip enqueue or alter cross-project behavior.

Suggested fix
 		var srcProject, tgtProject string
-		_ = tx.QueryRow(
+		if err := tx.QueryRow(
 			`SELECT coalesce(nullif(o.project,''), s.project, '')
 			   FROM observations o
 			   LEFT JOIN sessions s ON s.id = o.session_id
 			  WHERE o.sync_id = ?`, sourceID,
-		).Scan(&srcProject)
-		_ = tx.QueryRow(
+		).Scan(&srcProject); err != nil && err != sql.ErrNoRows {
+			return fmt.Errorf("JudgeRelation: resolve source project: %w", err)
+		}
+		if err := tx.QueryRow(
 			`SELECT coalesce(nullif(o.project,''), s.project, '')
 			   FROM observations o
 			   LEFT JOIN sessions s ON s.id = o.session_id
 			  WHERE o.sync_id = ?`, targetID,
-		).Scan(&tgtProject)
+		).Scan(&tgtProject); err != nil && err != sql.ErrNoRows {
+			return fmt.Errorf("JudgeRelation: resolve target project: %w", err)
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, sourceID,
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, sourceID,
).Scan(&srcProject)
_ = tx.QueryRow(
`SELECT ifnull(project,'') FROM observations WHERE sync_id = ?`, targetID,
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, targetID,
).Scan(&tgtProject)
if err := tx.QueryRow(
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, sourceID,
).Scan(&srcProject); err != nil && err != sql.ErrNoRows {
return fmt.Errorf("JudgeRelation: resolve source project: %w", err)
}
if err := tx.QueryRow(
`SELECT coalesce(nullif(o.project,''), s.project, '')
FROM observations o
LEFT JOIN sessions s ON s.id = o.session_id
WHERE o.sync_id = ?`, targetID,
).Scan(&tgtProject); err != nil && err != sql.ErrNoRows {
return fmt.Errorf("JudgeRelation: resolve target project: %w", err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/store/relations.go` around lines 599 - 610, The code at the two
QueryRow().Scan() calls for srcProject and tgtProject is ignoring errors by
using underscore assignments instead of capturing and handling them explicitly.
This allows database errors to silently result in empty project values, which
can cause incorrect cross-project behavior or skip important operations. Replace
the underscore with an error variable for both Scan calls and add explicit error
handling that checks if the error is not nil and either returns the error, logs
it, or takes appropriate action to prevent the query from silently failing and
leaving srcProject and tgtProject as empty strings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(store): relations missing a sync_mutations row never reach cloud (no relation backfill)

2 participants