diff --git a/internal/platform/canvas_model_stream.go b/internal/platform/canvas_model_stream.go index 8bc9047..0942cc7 100644 --- a/internal/platform/canvas_model_stream.go +++ b/internal/platform/canvas_model_stream.go @@ -18,9 +18,22 @@ func writeCanvasModelStream(ctx context.Context, w http.ResponseWriter, model ca return } view := model.view(nil) + // A count covers direct members only; an empty canvas is explicit too. + counts := map[string]int{} + for _, canvas := range view.Canvases { + counts[canvas.ID] = 0 + } + for _, element := range view.Elements { + owner := modelString(element["ownerCanvasId"]) + if _, ok := counts[owner]; !ok { + reject(w, 500, "invalid_model_coverage") + return + } + 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)} + "versions": view.Versions, "tombstones": view.Tombstones, "removedCanvasIds": view.Removed, "elementCount": len(view.Elements), "canvasElementCounts": counts} 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 9ddce6d..0174908 100644 --- a/internal/platform/canvas_model_stream_test.go +++ b/internal/platform/canvas_model_stream_test.go @@ -90,3 +90,27 @@ func TestCanonicalModelStreamCancellationNeverClaimsCompletion(t *testing.T) { t.Fatal("cancelled stream wrote a tail", len(records)) } } + +func TestCanonicalModelStreamDeclaresDirectScopeCountsIncludingEmptyCanvases(t *testing.T) { + model := modelTestDocument() + empty, nested := modelTestID(41), modelTestID(42) + model.Canvases[empty] = modelCanvas{ID: empty, Parent: &modelParent{CanvasID: model.Root, EntryElementID: "frame-empty"}} + model.Canvases[nested] = modelCanvas{ID: nested, Parent: &modelParent{CanvasID: model.Root, EntryElementID: "frame-nested"}} + model.Versions[empty], model.Versions[nested] = 1, 1 + model.Elements["frame-empty"] = modelTestNode("frame-empty", model.Root) + model.Elements["frame-nested"] = modelTestNode("frame-nested", model.Root) + model.Elements["nested-node"] = modelTestNode("nested-node", nested) + writer := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), writer, model) + records := streamRecords(t, writer) + if !modelEqual(records[0]["canvasElementCounts"], map[string]int{model.Root: 3, empty: 0, nested: 1}) { + t.Fatal("scope counts must count direct members, preserve empty scopes and match the captured snapshot") + } + bad := modelTestDocument() + bad.Elements["a"]["ownerCanvasId"] = "foreign" + failed := httptest.NewRecorder() + writeCanvasModelStream(context.Background(), failed, bad) + if failed.Code != 500 || failed.Flushed { + t.Fatal("invalid owner must fail before publishing a scope certificate") + } +}