Fix canonical archive writes and retain scoped lifecycle evidence
Foundry Go checks / check (push) Successful in 2m41s
Foundry Go checks / check (pull_request) Successful in 2m40s

This commit is contained in:
2026-11-07 22:34:42 +08:00
parent b85e8bd2ca
commit 12458e10dc
5 changed files with 118 additions and 22 deletions
+49 -8
View File
@@ -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"]) {
+12
View File
@@ -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
}
@@ -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")
}
}
+30 -13
View File
@@ -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
}
+5 -1
View File
@@ -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