Skip to content

Commit 436c03f

Browse files
fix(sync): accept cloud relation mutations (#379)
1 parent e8c932f commit 436c03f

8 files changed

Lines changed: 227 additions & 6 deletions

File tree

internal/cloud/chunkcodec/chunkcodec.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,24 @@ type mutationPromptPayload struct {
260260
HardDelete bool `json:"hard_delete,omitempty"`
261261
}
262262

263+
type mutationRelationPayload struct {
264+
SyncID string `json:"sync_id"`
265+
SourceID string `json:"source_id"`
266+
TargetID string `json:"target_id"`
267+
Relation string `json:"relation"`
268+
Reason *string `json:"reason,omitempty"`
269+
Evidence *string `json:"evidence,omitempty"`
270+
Confidence *float64 `json:"confidence,omitempty"`
271+
JudgmentStatus string `json:"judgment_status"`
272+
MarkedByActor *string `json:"marked_by_actor,omitempty"`
273+
MarkedByKind *string `json:"marked_by_kind,omitempty"`
274+
MarkedByModel *string `json:"marked_by_model,omitempty"`
275+
SessionID *string `json:"session_id,omitempty"`
276+
Project string `json:"project"`
277+
CreatedAt string `json:"created_at,omitempty"`
278+
UpdatedAt string `json:"updated_at,omitempty"`
279+
}
280+
263281
func normalizeChunkMutation(raw map[string]any, project string) (map[string]any, error) {
264282
mutationJSON, err := json.Marshal(raw)
265283
if err != nil {
@@ -321,6 +339,11 @@ func validateSupportedMutation(entity, op string) error {
321339
return fmt.Errorf("unsupported mutation %q/%q", entity, op)
322340
}
323341
return nil
342+
case store.SyncEntityRelation:
343+
if op != store.SyncOpUpsert {
344+
return fmt.Errorf("unsupported mutation %q/%q", entity, op)
345+
}
346+
return nil
324347
default:
325348
return fmt.Errorf("unsupported mutation %q/%q", entity, op)
326349
}
@@ -425,6 +448,51 @@ func normalizeMutationPayload(entity, op, payload, project string) (normalizedPa
425448
return "", "", fmt.Errorf("encode mutation payload: %w", err)
426449
}
427450
return string(encoded), body.SyncID, nil
451+
case store.SyncEntityRelation:
452+
var body mutationRelationPayload
453+
if err := DecodeSyncMutationPayload(payload, &body); err != nil {
454+
return "", "", fmt.Errorf("decode mutation payload: %w", err)
455+
}
456+
body.SyncID = strings.TrimSpace(body.SyncID)
457+
body.SourceID = strings.TrimSpace(body.SourceID)
458+
body.TargetID = strings.TrimSpace(body.TargetID)
459+
body.Relation = strings.TrimSpace(body.Relation)
460+
body.JudgmentStatus = strings.TrimSpace(body.JudgmentStatus)
461+
if body.MarkedByActor != nil {
462+
trimmed := strings.TrimSpace(*body.MarkedByActor)
463+
body.MarkedByActor = &trimmed
464+
}
465+
if body.MarkedByKind != nil {
466+
trimmed := strings.TrimSpace(*body.MarkedByKind)
467+
body.MarkedByKind = &trimmed
468+
}
469+
if body.SyncID == "" {
470+
return "", "", fmt.Errorf("relation payload sync_id is required for upsert")
471+
}
472+
if body.SourceID == "" {
473+
return "", "", fmt.Errorf("relation payload source_id is required for upsert")
474+
}
475+
if body.TargetID == "" {
476+
return "", "", fmt.Errorf("relation payload target_id is required for upsert")
477+
}
478+
if body.Relation == "" {
479+
return "", "", fmt.Errorf("relation payload relation is required for upsert")
480+
}
481+
if body.JudgmentStatus == "" {
482+
return "", "", fmt.Errorf("relation payload judgment_status is required for upsert")
483+
}
484+
if body.MarkedByActor == nil || *body.MarkedByActor == "" {
485+
return "", "", fmt.Errorf("relation payload marked_by_actor is required for upsert")
486+
}
487+
if body.MarkedByKind == nil || *body.MarkedByKind == "" {
488+
return "", "", fmt.Errorf("relation payload marked_by_kind is required for upsert")
489+
}
490+
body.Project = project
491+
encoded, err := json.Marshal(body)
492+
if err != nil {
493+
return "", "", fmt.Errorf("encode mutation payload: %w", err)
494+
}
495+
return string(encoded), body.SyncID, nil
428496
default:
429497
return "", "", fmt.Errorf("unsupported mutation %q/%q", entity, op)
430498
}

internal/cloud/chunkcodec/chunkcodec_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package chunkcodec
22

33
import (
44
"encoding/json"
5+
"strings"
56
"testing"
67

78
"github.com/Gentleman-Programming/engram/internal/store"
@@ -72,6 +73,82 @@ func TestCanonicalizeForProjectPreservesMutationMetadataPayloadFields(t *testing
7273
assertPayloadField(2, "created_at", "2026-04-08T09:00:00Z")
7374
}
7475

76+
func TestCanonicalizeForProjectAcceptsRelationUpsertMutation(t *testing.T) {
77+
raw := []byte(`{
78+
"mutations": [
79+
{
80+
"entity": "relation",
81+
"entity_key": "rel-1",
82+
"op": "upsert",
83+
"project": "wrong",
84+
"payload": "{\"sync_id\":\"rel-1\",\"source_id\":\"obs-a\",\"target_id\":\"obs-b\",\"relation\":\"conflicts_with\",\"reason\":\"different decisions\",\"judgment_status\":\"judged\",\"marked_by_actor\":\"agent-a\",\"marked_by_kind\":\"agent\",\"marked_by_model\":\"model-a\",\"project\":\"wrong\",\"created_at\":\"2026-05-04T01:00:00Z\",\"updated_at\":\"2026-05-04T01:01:00Z\"}"
85+
}
86+
]
87+
}`)
88+
89+
normalized, err := CanonicalizeForProject(raw, "proj-a")
90+
if err != nil {
91+
t.Fatalf("canonicalize relation mutation: %v", err)
92+
}
93+
94+
var chunk struct {
95+
Mutations []store.SyncMutation `json:"mutations"`
96+
}
97+
if err := json.Unmarshal(normalized, &chunk); err != nil {
98+
t.Fatalf("decode canonicalized chunk: %v", err)
99+
}
100+
if len(chunk.Mutations) != 1 {
101+
t.Fatalf("expected 1 mutation, got %d", len(chunk.Mutations))
102+
}
103+
mutation := chunk.Mutations[0]
104+
if mutation.Entity != store.SyncEntityRelation || mutation.Op != store.SyncOpUpsert || mutation.EntityKey != "rel-1" || mutation.Project != "proj-a" {
105+
t.Fatalf("expected canonical relation/upsert mutation, got %+v", mutation)
106+
}
107+
108+
var payload map[string]any
109+
if err := json.Unmarshal([]byte(mutation.Payload), &payload); err != nil {
110+
t.Fatalf("decode canonical relation payload: %v", err)
111+
}
112+
if payload["project"] != "proj-a" {
113+
t.Fatalf("expected relation payload project rewritten to proj-a, got %#v", payload["project"])
114+
}
115+
for _, field := range []string{"sync_id", "source_id", "target_id", "relation", "judgment_status", "marked_by_actor", "marked_by_kind"} {
116+
if payload[field] == "" || payload[field] == nil {
117+
t.Fatalf("expected relation payload field %q to be preserved, got %#v", field, payload)
118+
}
119+
}
120+
}
121+
122+
func TestCanonicalizeForProjectRejectsInvalidRelationMutation(t *testing.T) {
123+
raw := []byte(`{
124+
"mutations": [
125+
{
126+
"entity": "relation",
127+
"entity_key": "rel-1",
128+
"op": "upsert",
129+
"payload": "{\"sync_id\":\"rel-1\",\"source_id\":\"obs-a\",\"target_id\":\"\",\"judgment_status\":\"judged\",\"marked_by_actor\":\"agent-a\",\"marked_by_kind\":\"agent\"}"
130+
}
131+
]
132+
}`)
133+
134+
_, err := CanonicalizeForProject(raw, "proj-a")
135+
if err == nil {
136+
t.Fatal("expected invalid relation mutation to fail")
137+
}
138+
if got := err.Error(); got == "" || !containsAll(got, []string{"relation", "target_id"}) {
139+
t.Fatalf("expected relation target_id validation error, got %q", got)
140+
}
141+
}
142+
143+
func containsAll(s string, parts []string) bool {
144+
for _, part := range parts {
145+
if !strings.Contains(s, part) {
146+
return false
147+
}
148+
}
149+
return true
150+
}
151+
75152
func TestCanonicalizeForProjectPreservesClosureOnlyDirectSessionOwnership(t *testing.T) {
76153
raw := []byte(`{
77154
"sessions": [

internal/cloud/cloudserver/mutations.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func (s *CloudServer) handleMutationPush(w http.ResponseWriter, r *http.Request)
170170
}
171171

172172
// REQ-006 / REQ-008: Validate each entry's payload before storage.
173-
// Relation entries are strictly validated (all 6 required fields).
173+
// Relation entries are strictly validated (all required fields).
174174
// Legacy entities (session, observation, prompt) use the lenient floor only.
175175
// Any failure rejects the ENTIRE batch (atomic — no partial inserts).
176176
var invalid []map[string]any
@@ -289,20 +289,21 @@ func (s *CloudServer) handleMutationPull(w http.ResponseWriter, r *http.Request)
289289

290290
// ─── REQ-006 / REQ-008: Per-entity payload validation ────────────────────────
291291

292-
// relationRequiredFields lists the 6 fields that MUST be present and non-empty
292+
// relationRequiredFields lists the fields that MUST be present and non-empty
293293
// in every relation mutation payload (REQ-006). This list is the stable
294294
// validation contract — Phase 3 MUST NOT remove or rename these fields without
295295
// a wire-format version bump.
296296
var relationRequiredFields = []string{
297297
"sync_id",
298298
"source_id",
299299
"target_id",
300+
"relation",
300301
"judgment_status",
301302
"marked_by_actor",
302303
"marked_by_kind",
303304
}
304305

305-
// validateRelationPayload checks that all 6 required relation fields are present
306+
// validateRelationPayload checks that all required relation fields are present
306307
// and non-empty in the decoded payload map.
307308
// Returns (missingField, false) when any required field is absent or empty,
308309
// or ("", true) when all required fields are present.

internal/cloud/cloudserver/mutations_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,7 @@ func (a *simpleProjectAuth) AuthorizeProject(_ string) error {
10161016
// ─── REQ-006, REQ-008: Relation payload validation tests (Phase D) ───────────
10171017

10181018
// TestHandleMutationPush_ValidRelation_Returns200 (D.1a) verifies REQ-006 happy
1019-
// path: a complete relation payload with all 6 required fields returns HTTP 200.
1019+
// path: a complete relation payload with all required fields returns HTTP 200.
10201020
func TestHandleMutationPush_ValidRelation_Returns200(t *testing.T) {
10211021
ms := newFakeMutationStore()
10221022
srv := newMutationTestServer(ms, "secret", []string{"proj-a"})
@@ -1025,6 +1025,7 @@ func TestHandleMutationPush_ValidRelation_Returns200(t *testing.T) {
10251025
"sync_id": "rel-001",
10261026
"source_id": "obs-src-001",
10271027
"target_id": "obs-tgt-001",
1028+
"relation": "conflicts_with",
10281029
"judgment_status": "judged",
10291030
"marked_by_actor": "alice",
10301031
"marked_by_kind": "human",
@@ -1063,6 +1064,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
10631064
payload: json.RawMessage(`{
10641065
"source_id": "obs-src",
10651066
"target_id": "obs-tgt",
1067+
"relation": "conflicts_with",
10661068
"judgment_status": "judged",
10671069
"marked_by_actor": "alice",
10681070
"marked_by_kind": "human"
@@ -1073,6 +1075,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
10731075
payload: json.RawMessage(`{
10741076
"sync_id": "rel-001",
10751077
"target_id": "obs-tgt",
1078+
"relation": "conflicts_with",
10761079
"judgment_status": "judged",
10771080
"marked_by_actor": "alice",
10781081
"marked_by_kind": "human"
@@ -1083,6 +1086,18 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
10831086
payload: json.RawMessage(`{
10841087
"sync_id": "rel-001",
10851088
"source_id": "obs-src",
1089+
"relation": "conflicts_with",
1090+
"judgment_status": "judged",
1091+
"marked_by_actor": "alice",
1092+
"marked_by_kind": "human"
1093+
}`),
1094+
},
1095+
{
1096+
name: "relation",
1097+
payload: json.RawMessage(`{
1098+
"sync_id": "rel-001",
1099+
"source_id": "obs-src",
1100+
"target_id": "obs-tgt",
10861101
"judgment_status": "judged",
10871102
"marked_by_actor": "alice",
10881103
"marked_by_kind": "human"
@@ -1094,6 +1109,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
10941109
"sync_id": "rel-001",
10951110
"source_id": "obs-src",
10961111
"target_id": "obs-tgt",
1112+
"relation": "conflicts_with",
10971113
"marked_by_actor": "alice",
10981114
"marked_by_kind": "human"
10991115
}`),
@@ -1104,6 +1120,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
11041120
"sync_id": "rel-001",
11051121
"source_id": "obs-src",
11061122
"target_id": "obs-tgt",
1123+
"relation": "conflicts_with",
11071124
"judgment_status": "judged",
11081125
"marked_by_kind": "human"
11091126
}`),
@@ -1114,6 +1131,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
11141131
"sync_id": "rel-001",
11151132
"source_id": "obs-src",
11161133
"target_id": "obs-tgt",
1134+
"relation": "conflicts_with",
11171135
"judgment_status": "judged",
11181136
"marked_by_actor": "alice"
11191137
}`),
@@ -1145,7 +1163,7 @@ func TestHandleMutationPush_RelationMissingEachRequiredField(t *testing.T) {
11451163
}
11461164
// Verify the missing field name appears in the response
11471165
var resp struct {
1148-
Error string `json:"error"`
1166+
Error string `json:"error"`
11491167
Invalid []struct {
11501168
Field string `json:"field"`
11511169
Index int `json:"index"`
@@ -1175,6 +1193,7 @@ func TestHandleMutationPush_PartialBatch_Atomic(t *testing.T) {
11751193
"sync_id": "rel-001",
11761194
"source_id": "obs-src-001",
11771195
"target_id": "obs-tgt-001",
1196+
"relation": "conflicts_with",
11781197
"judgment_status": "judged",
11791198
"marked_by_actor": "alice",
11801199
"marked_by_kind": "human"
@@ -1183,6 +1202,7 @@ func TestHandleMutationPush_PartialBatch_Atomic(t *testing.T) {
11831202
invalidPayload := json.RawMessage(`{
11841203
"sync_id": "rel-002",
11851204
"source_id": "obs-src-002",
1205+
"relation": "conflicts_with",
11861206
"judgment_status": "judged",
11871207
"marked_by_actor": "bob",
11881208
"marked_by_kind": "human"
@@ -1576,6 +1596,7 @@ func TestRelationSync_ServerValidation_MissingField(t *testing.T) {
15761596
"sync_id": "rel-g4-001",
15771597
"source_id": "obs-src-g4",
15781598
"target_id": "obs-tgt-g4",
1599+
"relation": "conflicts_with",
15791600
"marked_by_actor": "agent:test",
15801601
"marked_by_kind": "agent"
15811602
}`)

internal/cloud/cloudstore/cloudstore.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,7 @@ func materializedMutationBatchChunk(batch []MutationEntry) ([]byte, chunkSummary
10481048

10491049
func isChunkMaterializableMutationEntity(entity string) bool {
10501050
switch strings.TrimSpace(entity) {
1051-
case store.SyncEntitySession, store.SyncEntityObservation, store.SyncEntityPrompt:
1051+
case store.SyncEntitySession, store.SyncEntityObservation, store.SyncEntityPrompt, store.SyncEntityRelation:
10521052
return true
10531053
default:
10541054
return false

internal/cloud/cloudstore/cloudstore_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,47 @@ func TestMaterializedMutationBatchChunkIncludesObservationAlongsidePromptAndSess
140140
}
141141
}
142142

143+
func TestMaterializedMutationBatchChunkCarriesRelationMutationWithoutTypedRows(t *testing.T) {
144+
relationPayload := json.RawMessage(`{
145+
"sync_id":"rel-04081be99000bdf5",
146+
"source_id":"obs-a",
147+
"target_id":"obs-b",
148+
"relation":"conflicts_with",
149+
"judgment_status":"judged",
150+
"marked_by_actor":"agent-a",
151+
"marked_by_kind":"agent",
152+
"marked_by_model":"model-a",
153+
"project":"sias-app",
154+
"created_at":"2026-05-04T01:49:52Z",
155+
"updated_at":"2026-05-04T01:50:00Z"
156+
}`)
157+
158+
payload, counts, err := materializedMutationBatchChunk([]MutationEntry{
159+
{Project: "sias-app", Entity: store.SyncEntityRelation, EntityKey: "rel-04081be99000bdf5", Op: store.SyncOpUpsert, Payload: relationPayload},
160+
})
161+
if err != nil {
162+
t.Fatalf("materializedMutationBatchChunk: %v", err)
163+
}
164+
if counts.sessions != 0 || counts.observations != 0 || counts.prompts != 0 {
165+
t.Fatalf("relation-only mutation chunk must not increment typed counts, got %+v", counts)
166+
}
167+
168+
var chunk engramsync.ChunkData
169+
if err := json.Unmarshal(payload, &chunk); err != nil {
170+
t.Fatalf("decode materialized chunk: %v", err)
171+
}
172+
if len(chunk.Sessions) != 0 || len(chunk.Observations) != 0 || len(chunk.Prompts) != 0 {
173+
t.Fatalf("relation-only mutation must not create typed rows, got sessions=%d observations=%d prompts=%d", len(chunk.Sessions), len(chunk.Observations), len(chunk.Prompts))
174+
}
175+
if len(chunk.Mutations) != 1 {
176+
t.Fatalf("expected relation mutation retained for replay, got %d", len(chunk.Mutations))
177+
}
178+
mutation := chunk.Mutations[0]
179+
if mutation.Entity != store.SyncEntityRelation || mutation.EntityKey != "rel-04081be99000bdf5" || mutation.Project != "sias-app" {
180+
t.Fatalf("expected canonical relation mutation, got %+v", mutation)
181+
}
182+
}
183+
143184
func TestMaterializedMutationBatchChunksKeepProjectsSeparate(t *testing.T) {
144185
chunks, err := materializedMutationBatchChunks([]MutationEntry{
145186
{Project: "proj-a", Entity: store.SyncEntityPrompt, EntityKey: "prompt-a", Op: store.SyncOpUpsert, Payload: json.RawMessage(`{"sync_id":"prompt-a","session_id":"sess-a","content":"a"}`)},

internal/store/diagnostic.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ func ValidateSyncMutationPayload(entity, op, payload, entityKey string) SyncMuta
203203
require("target_id")
204204
require("relation")
205205
require("judgment_status")
206+
require("marked_by_actor")
207+
require("marked_by_kind")
206208
require("project")
207209
}
208210
default:

0 commit comments

Comments
 (0)