From 12458e10dc2262234fee41bebeeb42923822f53b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 22:34:42 +0800 Subject: [PATCH] Fix canonical archive writes and retain scoped lifecycle evidence --- internal/platform/canvas_model.go | 57 ++++++++++++++++--- internal/platform/canvas_model_http.go | 12 ++++ .../platform/canvas_model_integration_test.go | 22 +++++++ internal/platform/canvas_model_store.go | 43 +++++++++----- internal/platform/http.go | 6 +- 5 files changed, 118 insertions(+), 22 deletions(-) diff --git a/internal/platform/canvas_model.go b/internal/platform/canvas_model.go index 3baf1ec..392318f 100644 --- a/internal/platform/canvas_model.go +++ b/internal/platform/canvas_model.go @@ -24,11 +24,13 @@ type modelCanvas struct { Parent *modelParent `json:"parent"` } type canvasModel struct { - Root string - Canvases map[string]modelCanvas - Elements map[string]map[string]any - Tombstones map[string]int64 - Versions map[string]int64 + Root string + Canvases map[string]modelCanvas + Elements map[string]map[string]any + Tombstones map[string]int64 + TombstoneOwners map[string]string + DeadRecords map[string]map[string]any + Versions map[string]int64 } type modelView struct { Format int `json:"documentFormatVersion"` @@ -66,7 +68,7 @@ type modelBatch struct { } func emptyModel(root string) canvasModel { - return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}} + return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, TombstoneOwners: map[string]string{}, DeadRecords: map[string]map[string]any{}, Versions: map[string]int64{}} } func modelString(value any) string { text, _ := value.(string); return text } func modelInt(value any) int64 { @@ -76,6 +78,32 @@ func modelInt(value any) int64 { } return int64(number) } +func modelJSONSafe(value any, depth int) bool { + if depth > 64 { + return false + } + switch value := value.(type) { + case nil, bool, string: + return true + case float64: + return !math.IsNaN(value) && !math.IsInf(value, 0) + case []any: + for _, item := range value { + if !modelJSONSafe(item, depth+1) { + return false + } + } + return true + case map[string]any: + for key, item := range value { + if canvasChoice(key, "__proto__", "constructor", "prototype") || !modelJSONSafe(item, depth+1) { + return false + } + } + return true + } + return false +} func modelEqual(left, right any) bool { // Round-trip numeric Go structs and JSON records to the same JSON value domain. a, ea := json.Marshal(left) @@ -106,8 +134,16 @@ func (model canvasModel) view(participants map[string]int64) modelView { } view.Elements = append(view.Elements, element) } - // Tombstones are small identity facts, not payloads; include them for safe incarnation checks. for id, incarnation := range model.Tombstones { + owner := model.TombstoneOwners[id] + if live := model.Elements[id]; live != nil { + owner = modelString(live["ownerCanvasId"]) + } + if participants != nil { + if _, exists := participants[owner]; !exists { + continue + } + } view.Tombstones[id] = incarnation } for _, id := range sortedKeys(model.Versions) { @@ -207,6 +243,9 @@ func validateCanvasModel(model canvasModel) error { } } for id, element := range model.Elements { + if !modelJSONSafe(element, 0) { + return errCanvasModel + } owner, component := modelString(element["ownerCanvasId"]), modelString(element["componentId"]) _, canvasExists := model.Canvases[owner] incarnation, version := modelInt(element["incarnation"]), modelInt(element["dataVersion"]) @@ -333,6 +372,8 @@ func applyModelOperations(initial canvasModel, operations []modelOperation) (can if after == nil { if prior := model.Elements[change.ID]; prior != nil { model.Tombstones[change.ID] = modelInt(prior["incarnation"]) + model.TombstoneOwners[change.ID] = modelString(prior["ownerCanvasId"]) + model.DeadRecords[change.ID] = prior } delete(model.Elements, change.ID) } else { @@ -340,7 +381,7 @@ func applyModelOperations(initial canvasModel, operations []modelOperation) (can if !ok || record["id"] != change.ID { return model, nil, errCanvasModel } - if before == nil && modelInt(record["incarnation"]) != model.Tombstones[change.ID]+1 { + if before == nil && modelInt(record["incarnation"]) <= model.Tombstones[change.ID] { return model, nil, errModelConflict } if prior := model.Elements[change.ID]; prior != nil && modelInt(record["incarnation"]) != modelInt(prior["incarnation"]) { diff --git a/internal/platform/canvas_model_http.go b/internal/platform/canvas_model_http.go index 6193b2d..89b069b 100644 --- a/internal/platform/canvas_model_http.go +++ b/internal/platform/canvas_model_http.go @@ -415,6 +415,18 @@ func (s *service) submitCanvasModelOperations(w http.ResponseWriter, r *http.Req } } } + for id := range after.DeadRecords { + if before.Elements[id] != nil || before.Tombstones[id] > 0 { + continue + } + var foreign bool + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND (workspace_id<>$3 OR root_id<>$4))`, project, id, workspace, root).Scan(&foreign); err != nil { + return err + } + if foreign { + return rejectBatch(409, "element_identity_conflict") + } + } if err = s.saveCanvasModel(r, tx, project, workspace, account, before, after, touched); err != nil { return err } diff --git a/internal/platform/canvas_model_integration_test.go b/internal/platform/canvas_model_integration_test.go index 73ac5ab..c376f8f 100644 --- a/internal/platform/canvas_model_integration_test.go +++ b/internal/platform/canvas_model_integration_test.go @@ -214,4 +214,26 @@ func TestCanonicalModelMigrationAndIncrementalLifecycle(t *testing.T) { if call("GET", base+"/model", nil, "", 200)["model"].(map[string]any)["complete"] != true { t.Fatal("initial read has no explicit coverage") } + // An object created and removed inside one transaction still has a lifecycle fact. + ghostID := modelTestID(4000) + ghost := modelTestNode(ghostID, root) + edit(1008, map[string]int64{root: 6}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(ghost), After: modelJSON(nil)}) + model = read() + if model.Tombstones[ghostID] != 1 || model.Elements[ghostID] != nil { + t.Fatal("in-batch create/delete lost its tombstone") + } + ghost["incarnation"] = float64(2) + edit(1009, map[string]int64{root: 7}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)}) + if modelInt(read().Elements[ghostID]["incarnation"]) != 2 { + t.Fatal("restore after an in-batch delete did not preserve identity") + } + // Session changes cannot send an old account's queued request as a new account. + request := httptest.NewRequest("GET", base+"/model", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("X-Expected-Account", modelTestID(7777)) + writer := httptest.NewRecorder() + mux.ServeHTTP(writer, request) + if writer.Code != 409 { + t.Fatal("expected-account barrier was ignored") + } } diff --git a/internal/platform/canvas_model_store.go b/internal/platform/canvas_model_store.go index 57021d0..6031250 100644 --- a/internal/platform/canvas_model_store.go +++ b/internal/platform/canvas_model_store.go @@ -60,12 +60,17 @@ func loadCanvasModel(ctx context.Context, tx *sql.Tx, project, workspace, root s if dead > 0 { model.Tombstones[id] = dead } + var element map[string]any + if err = json.Unmarshal(raw, &element); err != nil { + return model, err + } + if dead > 0 { + model.TombstoneOwners[id] = modelString(element["ownerCanvasId"]) + } if !deleted { - var element map[string]any - if err = json.Unmarshal(raw, &element); err != nil { - return model, err - } model.Elements[id] = element + } else { + model.DeadRecords[id] = element } } if err = rows.Err(); err != nil { @@ -131,11 +136,26 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac return errModelConflict } } - for _, id := range sortedKeys(before.Elements) { - if after.Elements[id] == nil { - if _, err = tx.ExecContext(ctx, `UPDATE canvas_model_elements SET deleted=true,deleted_incarnation=incarnation WHERE project=$1 AND workspace_id=$2 AND root_id=$3 AND element_id=$4`, project, workspace, after.Root, id); err != nil { - return err - } + for _, id := range sortedKeys(after.Tombstones) { + if after.Elements[id] != nil || before.Elements[id] == nil && before.Tombstones[id] == after.Tombstones[id] { + continue + } + record := after.DeadRecords[id] + if record == nil { + return errCanvasModel + } + raw, err := json.Marshal(record) + if err != nil { + return err + } + written, err := tx.ExecContext(ctx, `INSERT INTO canvas_model_elements(project,workspace_id,root_id,element_id,owner_canvas_id,incarnation,deleted_incarnation,record,deleted) VALUES($1,$2,$3,$4,$5,$6,$6,$7,true) ON CONFLICT(project,element_id) DO UPDATE SET owner_canvas_id=EXCLUDED.owner_canvas_id,incarnation=EXCLUDED.incarnation,deleted_incarnation=EXCLUDED.deleted_incarnation,record=EXCLUDED.record,deleted=true WHERE canvas_model_elements.workspace_id=EXCLUDED.workspace_id AND canvas_model_elements.root_id=EXCLUDED.root_id`, project, workspace, after.Root, id, record["ownerCanvasId"], after.Tombstones[id], raw) + if err != nil { + return err + } + if count, err := written.RowsAffected(); err != nil { + return err + } else if count != 1 { + return errModelConflict } } // Archive relations first; parent/entry changes then install the final owning tree. @@ -164,7 +184,7 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac } for _, id := range sortedKeys(participants) { if _, active := after.Canvases[id]; !active { - _, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=COALESCE(archived_at,now()),canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id]) + _, err = tx.ExecContext(ctx, `UPDATE materials SET canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id]) if err != nil { return err } @@ -174,9 +194,6 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac if err = s.saveCanvas(r, tx, project, workspace, canvasSnapshot{BoardID: id, Version: after.Versions[id], Document: projection[id]}); err != nil { return err } - if _, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=NULL WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id); err != nil { - return err - } } return nil } diff --git a/internal/platform/http.go b/internal/platform/http.go index b8294c1..bb83880 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -76,7 +76,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account") reply(w, 204, nil) return } @@ -88,6 +88,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H account := session.AccountID if err == nil { auth.WriteSessionHeaders(w, session) + if expected := r.Header.Get("X-Expected-Account"); expected != "" && expected != account { + reject(w, 409, "identity_changed") + return + } } if err == nil && r.PathValue("workspace") != "" { var id string