Bind canonical AI admission to the project version vector
Foundry Go checks / check (push) Successful in 2m52s
Foundry Go checks / check (pull_request) Successful in 2m58s

This commit is contained in:
2026-11-08 00:28:13 +08:00
parent fa76773efc
commit 00ff738705
4 changed files with 92 additions and 11 deletions
+19
View File
@@ -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") {
@@ -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 {
+39
View File
@@ -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)
}
}
+25 -10
View File
@@ -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)