diff --git a/internal/platform/canvas_ai_model.go b/internal/platform/canvas_ai_model.go index 47370a7..2a1f18a 100644 --- a/internal/platform/canvas_ai_model.go +++ b/internal/platform/canvas_ai_model.go @@ -20,6 +20,25 @@ type canvasAIChange struct { Incarnation int64 } +// Until a narrower read-set protocol exists, admission binds every active +// canvas in this canonical project. Archived versions are not graph inputs. +// Workspace locking keeps validation, plan freezing and admission atomic. +func validateCanvasAIVersions(model canvasModel, board string, header int64, expected map[string]int64) string { + if len(expected) != len(model.Canvases) || expected[board] != header { + return "invalid_ai_versions" + } + for id := range model.Canvases { + version, ok := expected[id] + if !ok || version < 1 || version > 9007199254740991 { + return "invalid_ai_versions" + } + if version != model.Versions[id] { + return "version_conflict" + } + } + return "" +} + func prepareModelAIPatch(before canvasModel, board, node string, patch response) (canvasModel, error) { element := before.Elements[node] if element == nil || element["structure"] != "node" || element["ownerCanvasId"] != board || modelInt(element["dataVersion"]) != 1 || !canvasChoice(element["componentId"], "text", "script", "plain-text", "image", "plain-image") { diff --git a/internal/platform/canvas_ai_model_integration_test.go b/internal/platform/canvas_ai_model_integration_test.go index e8cbc7d..f39d1d0 100644 --- a/internal/platform/canvas_ai_model_integration_test.go +++ b/internal/platform/canvas_ai_model_integration_test.go @@ -211,7 +211,7 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) { command := func(board, target, model string, incarnation int64) (response, string) { next += 2 key := modelTestID(next) - return response{"protocol_version": 2, "request_id": key, "operation_id": modelTestID(next + 1), "target_incarnation": incarnation, "node_id": target, "model_id": model, "prompt": "Generate", "parameters": response{}, "propagate": false}, key + return response{"protocol_version": 2, "request_id": key, "operation_id": modelTestID(next + 1), "target_incarnation": incarnation, "expected_versions": read().Versions, "node_id": target, "model_id": model, "prompt": "Generate", "parameters": response{}, "propagate": false}, key } start := func(board, target, model string, incarnation int64) response { input, key := command(board, target, model, incarnation) @@ -235,6 +235,7 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) { if !modelEqual(admitted, retry) { t.Fatal("admission retry changed result") } + call("POST", base(root)+"/ai/runs", input, 409, sourceVersion+1, key) queried := call("GET", base(root)+"/ai/operations/"+key, nil, 200, 0, "") if !modelEqual(admitted, queried) { t.Fatal("lost admission response not recoverable") @@ -364,6 +365,13 @@ func TestCanonicalAIAdmissionAndLifecycle(t *testing.T) { if e = s.completeCanvasRun(ctx, *moving, nil); e != nil { t.Fatal(e) } + staleInput, staleKey := command(child, imageID, "image", 1) + staleVersions := staleInput["expected_versions"].(map[string]int64) + staleVersions[root]-- // The target version is current; its ancestor context is not. + call("POST", base(child)+"/ai/runs", staleInput, 412, read().Versions[child], staleKey) + missingInput, missingKey := command(child, imageID, "image", 1) + delete(missingInput, "expected_versions") + call("POST", base(child)+"/ai/runs", missingInput, 400, read().Versions[child], missingKey) childRun := start(child, imageID, "image", 1) execute(childRun["run_id"].(string)) if read().Elements[imageID]["ownerCanvasId"] != child { diff --git a/internal/platform/canvas_ai_model_test.go b/internal/platform/canvas_ai_model_test.go index cbfa3b6..7bfa052 100644 --- a/internal/platform/canvas_ai_model_test.go +++ b/internal/platform/canvas_ai_model_test.go @@ -56,3 +56,42 @@ func TestCanonicalAIProjectionDoesNotExecuteFutureConnections(t *testing.T) { t.Fatal("future connection was executed or removed") } } + +func TestCanonicalAIAdmissionVersions(t *testing.T) { + model := modelTestDocument() + model.Canvases[modelChildID] = modelCanvas{ID: modelChildID} + model.Versions[modelChildID] = 3 + // Include an archived version: it must not become an executable read dependency. + model.Versions["archived"] = 8 + valid := map[string]int64{} + for id := range model.Canvases { + valid[id] = model.Versions[id] + } + if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], valid); code != "" { + t.Fatal(code) + } + for _, test := range []struct { + name string + versions map[string]int64 + header int64 + want string + }{ + {"missing", nil, 1, "invalid_ai_versions"}, + {"changed-header", valid, 99, "invalid_ai_versions"}, + {"incomplete", map[string]int64{modelRootID: 1}, 1, "invalid_ai_versions"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := validateCanvasAIVersions(model, modelRootID, test.header, test.versions); got != test.want { + t.Fatalf("got %q want %q", got, test.want) + } + }) + } + stale := map[string]int64{} + for id, version := range valid { + stale[id] = version + } + stale[modelChildID]++ + if code := validateCanvasAIVersions(model, modelRootID, valid[modelRootID], stale); code != "version_conflict" { + t.Fatal(code) + } +} diff --git a/internal/platform/canvas_ai_runs.go b/internal/platform/canvas_ai_runs.go index d151e23..ee4634f 100644 --- a/internal/platform/canvas_ai_runs.go +++ b/internal/platform/canvas_ai_runs.go @@ -47,15 +47,16 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return nil } var input struct { - ProtocolVersion int `json:"protocol_version,omitempty"` - OperationID string `json:"operation_id,omitempty"` - RequestID string `json:"request_id,omitempty"` - TargetIncarnation int64 `json:"target_incarnation,omitempty"` - NodeID string `json:"node_id"` - ModelID string `json:"model_id"` - Prompt string `json:"prompt"` - Parameters map[string]any `json:"parameters"` - Propagate bool `json:"propagate"` + ProtocolVersion int `json:"protocol_version,omitempty"` + OperationID string `json:"operation_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + TargetIncarnation int64 `json:"target_incarnation,omitempty"` + ExpectedVersions map[string]int64 `json:"expected_versions,omitempty"` + NodeID string `json:"node_id"` + ModelID string `json:"model_id"` + Prompt string `json:"prompt"` + Parameters map[string]any `json:"parameters"` + Propagate bool `json:"propagate"` } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() @@ -76,6 +77,10 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj return nil } hash := payloadDigest(input) + if input.ProtocolVersion != 0 { + // The version header is part of the immutable canonical admission request. + hash = payloadDigest(response{"input": input, "if_match": expected}) + } workspace, board := r.PathValue("workspace"), r.PathValue("material") tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { @@ -161,7 +166,7 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj reject(w, 409, "target_changed") return nil } - } else if input.ProtocolVersion != 0 || input.RequestID != "" || input.TargetIncarnation != 0 || input.OperationID != "" { + } else if input.ProtocolVersion != 0 || input.RequestID != "" || input.TargetIncarnation != 0 || input.OperationID != "" || input.ExpectedVersions != nil { reject(w, 409, "canonical_migration_required") return nil } @@ -170,6 +175,16 @@ func (s *service) createCanvasAIRun(w http.ResponseWriter, r *http.Request, proj reject(w, 400, "invalid_ai_request") return nil } + if change.Before != nil { + if code := validateCanvasAIVersions(*change.Before, board, expected, input.ExpectedVersions); code != "" { + status := http.StatusBadRequest + if code == "version_conflict" { + status = http.StatusPreconditionFailed + } + reject(w, status, code) + return nil + } + } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() models, err := s.canvasAI.Models(ctx)