diff --git a/internal/platform/canvas_model_stream.go b/internal/platform/canvas_model_stream.go index 0942cc7..8c5cd96 100644 --- a/internal/platform/canvas_model_stream.go +++ b/internal/platform/canvas_model_stream.go @@ -20,6 +20,7 @@ func writeCanvasModelStream(ctx context.Context, w http.ResponseWriter, model ca view := model.view(nil) // A count covers direct members only; an empty canvas is explicit too. counts := map[string]int{} + owners := map[string]string{} for _, canvas := range view.Canvases { counts[canvas.ID] = 0 } @@ -29,11 +30,17 @@ func writeCanvasModelStream(ctx context.Context, w http.ResponseWriter, model ca reject(w, 500, "invalid_model_coverage") return } + id := modelString(element["id"]) + if id == "" || owners[id] != "" { + reject(w, 500, "invalid_model_identity") + return + } + owners[id] = owner counts[owner]++ } header := response{"streamVersion": 1, "kind": "header", "sequence": 0, "snapshotToken": token, "documentFormatVersion": canvasModelFormat, "projectId": model.Root, "canvases": view.Canvases, - "versions": view.Versions, "tombstones": view.Tombstones, "removedCanvasIds": view.Removed, "elementCount": len(view.Elements), "canvasElementCounts": counts} + "versions": view.Versions, "tombstones": view.Tombstones, "removedCanvasIds": view.Removed, "elementCount": len(view.Elements), "canvasElementCounts": counts, "elementOwners": owners} const maxLine = 8 * 1024 * 1024 const maxBytes = 64 * 1024 * 1024 sequence, total := 0, 0 diff --git a/internal/platform/canvas_model_stream_test.go b/internal/platform/canvas_model_stream_test.go index 0174908..b6dc281 100644 --- a/internal/platform/canvas_model_stream_test.go +++ b/internal/platform/canvas_model_stream_test.go @@ -46,6 +46,15 @@ func TestCanonicalModelStreamFramesAndSnapshot(t *testing.T) { if !uuidSlugPattern.MatchString(token) || records[0]["projectId"] != modelRootID || !modelEqual(records[0]["versions"], model.Versions) || !modelEqual(records[0]["tombstones"], model.Tombstones) { t.Fatal("stream header lost snapshot identity") } + owners := records[0]["elementOwners"].(map[string]any) + if len(owners) != len(model.Elements) { + t.Fatal("identity manifest must cover unloaded elements too") + } + for id, element := range model.Elements { + if owners[id] != element["ownerCanvasId"] { + t.Fatal("identity manifest mixed owners", id) + } + } seen := map[string]bool{} for i, record := range records { if record["sequence"] != float64(i) || record["snapshotToken"] != token || record["streamVersion"] != float64(1) { @@ -114,3 +123,15 @@ func TestCanonicalModelStreamDeclaresDirectScopeCountsIncludingEmptyCanvases(t * t.Fatal("invalid owner must fail before publishing a scope certificate") } } + +func TestCanonicalModelStreamRejectsAmbiguousIdentityManifest(t *testing.T) { + for _, id := range []string{"", "a"} { + model := modelTestDocument() + model.Elements["other"] = modelTestNode(id, modelRootID) + writer := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), writer, model) + if writer.Code != 500 || writer.Flushed { + t.Fatal("ambiguous identity was published before rejection") + } + } +}