Skip to content

fix(cloud): materialize relation mutations on chunk-ingest (WriteChunk)#600

Open
forNerzul wants to merge 1 commit into
Gentleman-Programming:mainfrom
forNerzul:fix/cloud-chunk-ingest-relations
Open

fix(cloud): materialize relation mutations on chunk-ingest (WriteChunk)#600
forNerzul wants to merge 1 commit into
Gentleman-Programming:mainfrom
forNerzul:fix/cloud-chunk-ingest-relations

Conversation

@forNerzul

@forNerzul forNerzul commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

🔗 Linked Issue

Closes #502


🏷️ PR Type

  • type:bug — Bug fix
  • type:feature — New feature
  • type:docs — Documentation only
  • type:refactor — Code refactoring (no behavior change)
  • type:chore — Maintenance, dependencies, tooling
  • type:breaking-change — Breaking change

📝 Summary

📂 Changes

File Change
internal/cloud/cloudstore/cloudstore.go materializedChunkMutations now iterates chunk.Mutations and emits a relation MutationEntry for each relation-entity mutation (default upsert, empty payload → {}, entity_key required). Session/observation/prompt mutations are skipped because they are already materialized from the typed collections, avoiding duplicate rows. Capacity hint updated.
internal/cloud/cloudstore/cloudstore_test.go Adds TestMaterializedChunkMutationsCarriesRelationFromChunkMutations (unit: relation materialized, typed rows not duplicated), a relation missing entity key validation case, and TestWriteChunkMaterializesRelationMutationIntoCloudMutations (Postgres-gated end-to-end + replay idempotency).

🧪 Test Plan

  • Unit tests pass locally: go test ./... (22 packages ok, 0 failures)
  • E2E tests pass locally: go test -tags e2e ./internal/server/... (ok)
  • Manually tested the affected functionality

TDD: the new unit test failed before the fix (got 2 entries — the relation was dropped) and passes after. go vet ./... and go build ./... are clean. The end-to-end WriteChunk → cloud_mutations test is gated on CLOUDSTORE_TEST_DSN (requires Postgres) and is skipped locally without it; the unit-level coverage runs unconditionally.


✅ Contributor Checklist

  • I linked an approved issue above (Closes #502)
  • I added exactly one type:* label to this PR — checked type:bug above; I have READ access only, so a maintainer may need to apply the label.
  • I ran unit tests locally: go test ./...
  • I ran e2e tests locally: go test -tags e2e ./internal/server/...
  • Docs updated (if behavior changed) — no update needed; docs/codebase/sync-and-cloud.md:31 already documents that chunks carry the relations graph, and this fix makes the code honor that contract.
  • Commits follow conventional commits format
  • No Co-Authored-By trailers in commits

💬 Notes for Reviewers

  • The fix is deliberately scoped to relations: typed entities (session/observation/prompt) are already materialized from chunk.Sessions/Observations/Prompts, so the new loop filters to store.SyncEntityRelation to avoid duplicate cloud_mutations rows (the push materializer emits an observation both as a typed row and as a chunk.Mutations entry, so the dedup matters — covered by a test).
  • Sibling issue Cloud client import fails: relation FK precondition not met - referenced observation missing #576 ("Cloud client import fails: relation FK precondition not met") is the client-side import counterpart and is independent of this change — that one is ApplyPulledChunk aborting a chunk on a relation FK miss instead of deferring. This PR does not touch it.

Summary by CodeRabbit

  • Bug Fixes
    • Relation mutations are now correctly materialized and persisted during chunk processing.
    • Invalid relation mutations missing an entity key are rejected with a clearer error.
    • Reprocessing the same chunk no longer creates duplicate relation records.
  • Tests
    • Expanded coverage for relation-mutation materialization, validation, and idempotent writes.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 39c3aeac-6cab-491a-83a8-591fea28076e

📥 Commits

Reviewing files that changed from the base of the PR and between cfabcab and 06f86d9.

📒 Files selected for processing (2)
  • internal/cloud/cloudstore/cloudstore.go
  • internal/cloud/cloudstore/cloudstore_test.go
 __________________________________
< The only good bug is a dead bug. >
 ----------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
📝 Walkthrough

Walkthrough

Chunk ingestion now materializes relation mutations from chunk.Mutations into cloud_mutations, applying key, operation, and payload defaults. Tests cover missing keys, duplicate avoidance, persistence, and repeated WriteChunk calls.

Changes

Chunk relation materialization

Layer / File(s) Summary
Materialize chunk relation mutations
internal/cloud/cloudstore/cloudstore.go
materializedChunkMutations includes chunk.Mutations, validates relation keys, applies upsert and empty-payload defaults, and emits relation mutation entries.
Validate persistence and idempotency
internal/cloud/cloudstore/cloudstore_test.go
Tests cover missing entity_key, single materialization alongside typed observations, cloud persistence, and repeated-write deduplication.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WriteChunk
  participant materializedChunkMutations
  participant cloud_mutations
  participant ListMutationsSince
  WriteChunk->>materializedChunkMutations: materialize chunk.Mutations
  materializedChunkMutations->>cloud_mutations: persist relation MutationEntry
  ListMutationsSince->>cloud_mutations: read relation mutation
  cloud_mutations-->>ListMutationsSince: return persisted relation
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements #502 by materializing relation rows from chunk.Mutations, validating keys, and preserving existing typed-row behavior.
Out of Scope Changes check ✅ Passed The code changes and tests stay focused on chunk-ingest relation materialization and related coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: materializing relation mutations during WriteChunk chunk-ingest.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 2

🤖 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/cloud/cloudstore/cloudstore_test.go`:
- Around line 475-479: Update the relation-upsert test fixtures in the relevant
cloudstore tests so every happy-path relation payload includes judgment_status,
marked_by_actor, and marked_by_kind. Add an explicit error case omitting a
required relation payload field and assert the server-side validation error,
while preserving the existing missing entity_key coverage.

In `@internal/cloud/cloudstore/cloudstore.go`:
- Around line 414-418: Validate relation upsert mutations before appending them
to entries. In the mutation-building flow around ValidateSyncMutationPayload,
preserve the default op behavior but reject missing or incomplete relation
payloads—including required relation identifiers, judgment metadata, and project
fields—with an explicit error; do not persist `{}` or silently discard invalid
mutations.
🪄 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: 4e8a88dc-ba91-40db-9063-99144d4e4385

📥 Commits

Reviewing files that changed from the base of the PR and between be4b613 and cfabcab.

📒 Files selected for processing (2)
  • internal/cloud/cloudstore/cloudstore.go
  • internal/cloud/cloudstore/cloudstore_test.go

Comment on lines +475 to +479
{
name: "relation missing entity key",
chunk: engramsync.ChunkData{Mutations: []store.SyncMutation{{Entity: store.SyncEntityRelation}}},
want: "mutations[0].entity_key is required",
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use valid relation-upsert fixtures and cover missing payload fields.

The direct helper fixture omits required judgment_status, marked_by_actor, and marked_by_kind fields. It currently tests acceptance of an invalid upsert; make both happy-path payloads complete and add an error case for a missing required relation payload field.

As per path instructions, “Verify coverage of happy path, error paths, and edge cases,” and “Admin-enforceable sync policies must be validated server-side and fail loudly.”

Also applies to: 491-594

🤖 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/cloud/cloudstore/cloudstore_test.go` around lines 475 - 479, Update
the relation-upsert test fixtures in the relevant cloudstore tests so every
happy-path relation payload includes judgment_status, marked_by_actor, and
marked_by_kind. Add an explicit error case omitting a required relation payload
field and assert the server-side validation error, while preserving the existing
missing entity_key coverage.

Source: Path instructions

Comment on lines +414 to +418
payload := json.RawMessage(strings.TrimSpace(mutation.Payload))
if len(payload) == 0 {
payload = json.RawMessage("{}")
}
entries = append(entries, MutationEntry{Project: project, Entity: store.SyncEntityRelation, EntityKey: entityKey, Op: op, Payload: payload})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate relation upserts before inserting them.

A missing op becomes upsert, while an empty payload becomes {} and incomplete payloads are persisted. ValidateSyncMutationPayload requires relation identifiers, judgment metadata, and project fields for an upsert; otherwise mutation-pull consumers receive an unappliable row.

Proposed fix
 		payload := json.RawMessage(strings.TrimSpace(mutation.Payload))
 		if len(payload) == 0 {
 			payload = json.RawMessage("{}")
 		}
+		validation := store.ValidateSyncMutationPayload(
+			store.SyncEntityRelation, op, string(payload), entityKey,
+		)
+		if validation.Message != "" {
+			return nil, fmt.Errorf(
+				"cloudstore: materialize chunk: mutations[%d] relation: %s",
+				i, validation.Message,
+			)
+		}
 		entries = append(entries, MutationEntry{Project: project, Entity: store.SyncEntityRelation, EntityKey: entityKey, Op: op, Payload: payload})

As per path instructions, “Admin-enforceable sync policies must be validated server-side and fail loudly, never drop data silently.”

📝 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
payload := json.RawMessage(strings.TrimSpace(mutation.Payload))
if len(payload) == 0 {
payload = json.RawMessage("{}")
}
entries = append(entries, MutationEntry{Project: project, Entity: store.SyncEntityRelation, EntityKey: entityKey, Op: op, Payload: payload})
payload := json.RawMessage(strings.TrimSpace(mutation.Payload))
if len(payload) == 0 {
payload = json.RawMessage("{}")
}
validation := store.ValidateSyncMutationPayload(
store.SyncEntityRelation, op, string(payload), entityKey,
)
if validation.Message != "" {
return nil, fmt.Errorf(
"cloudstore: materialize chunk: mutations[%d] relation: %s",
i, validation.Message,
)
}
entries = append(entries, MutationEntry{Project: project, Entity: store.SyncEntityRelation, EntityKey: entityKey, Op: op, Payload: payload})
🤖 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/cloud/cloudstore/cloudstore.go` around lines 414 - 418, Validate
relation upsert mutations before appending them to entries. In the
mutation-building flow around ValidateSyncMutationPayload, preserve the default
op behavior but reject missing or incomplete relation payloads—including
required relation identifiers, judgment metadata, and project fields—with an
explicit error; do not persist `{}` or silently discard invalid mutations.

Source: Path instructions

@forNerzul

Copy link
Copy Markdown
Contributor Author

@coderabbitai — good catch on the substance, and thanks. You're right that there's a real relation-payload validation asymmetry here, but the exact fix can't land in this PR. Detail below so a maintainer has the full picture.

The gap is real. The mutation-push path validates relation required-fields and atomically rejects the batch (cloudserver/mutations.go:64 handleMutationPushvalidateMutationEntry:346validateRelationPayload:317, relationRequiredFields:303). The chunk-ingest path this PR touches does not, and the consumer applyRelationUpsertTx (store.go:5632) only guards decode + source/target presence + FK existence — never judgment_status/marked_by_*. So an incomplete relation that /mutations/push 400s would be silently persisted via the chunk endpoint and applied with empty metadata. That's a genuine server-side validation bypass, and it should be closed.

Why not this exact snippet, though — three verified reasons:

  1. Wrong validator. store.ValidateSyncMutationPayload is a diagnostic-only helper: its sole production caller is the cloud upgrade doctor check (internal/diagnostic/checks.go:143), and cloudstore doesn't import internal/diagnostic. Using it wouldn't mirror the push path — it introduces a third, differently-sourced contract. The push validator to mirror is validateRelationPayload / relationRequiredFields.

  2. It rejects legitimate data. ValidateSyncMutationPayload's relation branch additionally requires a non-empty project (diagnostic.go:208), but both judge paths deliberately enqueue relations with project="" in the REQ-011 source-missing race (store/relations.go:696, :897). The push validator's relationRequiredFields omits project for exactly this reason. The verbatim snippet would abort ingest for relations the framework is designed to emit — a data-loss regression.

  3. It aborts the whole chunk. materializedChunkMutations runs before WriteChunk opens its transaction (cloudstore.go:246 materialize → :251 BeginTx), so a return nil, err on one incomplete relation drops the entire chunk — every valid session/observation/prompt/relation in it — with no cloud_chunks row written.

Plan: keeping this PR scoped to #502 (restore relation presence in chunk-ingest, matching the sibling session/observation/prompt branches that also don't field-validate here). I'm filing a separate issue for the validation asymmetry, which needs a real design choice — the push validator (7 fields, no project) plus per-row skip/quarantine vs. adopting the push's atomic-batch rejection. Filed as #601.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@forNerzul Thanks for the thorough write-up — this is exactly the kind of context that should live on the issue. Your reasoning is sound on all three points:

  1. store.ValidateSyncMutationPayload is diagnostic-only tooling, not the production contract the push path enforces.
  2. Its project requirement would reject relations that the REQ-011 source-missing race intentionally emits with project="".
  3. Failing fast inside materializedChunkMutations (pre-BeginTx) would drop an entire chunk over one bad relation row, which is a worse outcome than the current gap.

Keeping #600 scoped to restoring relation presence (mirroring the sibling session/observation/prompt branches) and tracking the validation-parity work in #601 (with the push validator — relationRequiredFields, no project, and a skip/quarantine-vs-atomic-reject design decision) is the right call. I'll follow #601 for that discussion.


✏️ Learnings added
Learnt from: forNerzul
URL: https://github.com/Gentleman-Programming/engram/pull/600

Timestamp: 2026-07-12T01:10:05.980Z
Learning: In this Go codebase, `internal/cloud/cloudstore/cloudstore.go`'s `materializedChunkMutations` (called before `WriteChunk` opens its transaction, e.g. around line 246-251) does not field-validate relation mutation payloads, unlike the mutation-push path in `cloudserver/mutations.go` (`handleMutationPush` → `validateMutationEntry` → `validateRelationPayload` → `relationRequiredFields`, around lines 64/346/317/303). Do not suggest using `store.ValidateSyncMutationPayload` (in `internal/diagnostic`, e.g. `diagnostic.go:208`) to fix this: it's a diagnostic-only helper used solely by the `cloud upgrade doctor` check, and its relation branch requires a non-empty `project` field, which would incorrectly reject legitimate relations that judge paths intentionally enqueue with `project=""` during the REQ-011 source-missing race (`store/relations.go:696`, `:897`). The push path's `relationRequiredFields` (7 fields, no `project`) is the correct contract to mirror. Also, failing validation inside `materializedChunkMutations` would abort the entire chunk transaction (no `cloud_chunks` row written) rather than just the offending relation row, since it runs before `WriteChunk`'s `BeginTx`. This validation-parity gap between chunk-ingest and mutation-push for relations is tracked as a separate follow-up issue (`#601`) from PR `#600`, intentionally kept out of scope for `#600` which only restores relation presence in `cloud_mutations`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

materializedChunkMutations built cloud_mutations rows only from
chunk.Sessions/Observations/Prompts and never iterated chunk.Mutations,
where relations ride. A relation uploaded via the chunk path landed in
cloud_chunks.payload but never reached cloud_mutations, so mutation-pull
consumers never saw it — the chunk-ingest sibling of the push-path gap
that Gentleman-Programming#379 fixed.

Mirror Gentleman-Programming#379 in materializedChunkMutations: emit relation MutationEntry
rows from chunk.Mutations, skipping typed entities already materialized
above to avoid duplicate cloud_mutations rows. Adds unit coverage plus a
Postgres-gated WriteChunk end-to-end + replay-idempotency test.

Refs Gentleman-Programming#502
@forNerzul forNerzul force-pushed the fix/cloud-chunk-ingest-relations branch from cfabcab to 06f86d9 Compare July 14, 2026 04:31
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@forNerzul

Copy link
Copy Markdown
Contributor Author

@Alan-TheGentleman rebased on latest main and all checks are green except the Has type:* Label gate — I can't set a label from a fork. Could you add type:bug? The linked issue #502 is already status:approved.

The relation-payload validation asymmetry CodeRabbit flagged is intentionally deferred to #601 (rationale in the thread above); this PR stays scoped to restoring relation presence in chunk-ingest per #502.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server chunk-ingest (WriteChunk) drops relation mutations from cloud_mutations, unlike the mutation-push path (#379)

1 participant