From b2f922cbbc99a1d7d0e160dcb1f3875a81b4e72c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:29:43 +0300 Subject: [PATCH 1/7] feat(work): add deterministic graph harness --- contracts/work/graph.go | 72 +++++++++++++ dev/ACTIVE_TASK.json | 4 +- dev/tasks/DEV-000131-work-graph-harness.json | 23 ++++ modules/work/domain/graph.go | 107 +++++++++++++++++++ tests/agent_execution/graph_test.go | 35 ++++++ 5 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 contracts/work/graph.go create mode 100644 dev/tasks/DEV-000131-work-graph-harness.json create mode 100644 modules/work/domain/graph.go create mode 100644 tests/agent_execution/graph_test.go diff --git a/contracts/work/graph.go b/contracts/work/graph.go new file mode 100644 index 0000000..f9c83b9 --- /dev/null +++ b/contracts/work/graph.go @@ -0,0 +1,72 @@ +package work + +type GraphNodeID string + +type GraphState string +type GraphNodeState string + +const ( + GraphDraft GraphState = "DRAFT" + GraphAdmitted GraphState = "ADMITTED" + GraphRunning GraphState = "RUNNING" + GraphPartial GraphState = "PARTIAL" + GraphVerifying GraphState = "VERIFYING" + GraphCompleted GraphState = "COMPLETED" + GraphFailed GraphState = "FAILED" + GraphUnknown GraphState = "UNKNOWN" + + GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE" + GraphNodeReady GraphNodeState = "READY" + GraphNodeLeased GraphNodeState = "LEASED" + GraphNodeRunning GraphNodeState = "RUNNING" + GraphNodeSuccess GraphNodeState = "SUCCEEDED" + GraphNodeFailed GraphNodeState = "FAILED" + GraphNodeCancel GraphNodeState = "CANCELLED" + GraphNodeUnknown GraphNodeState = "UNKNOWN" +) + +type GraphBudget struct { + MaxParallel int + MaxAttempts int +} + +type GraphNode struct { + ID GraphNodeID + IdempotencyKey string + State GraphNodeState + Attempts int +} + +type GraphEdge struct { + From GraphNodeID + To GraphNodeID +} + +type WorkGraph struct { + ID string + Revision int64 + State GraphState + Nodes []GraphNode + Edges []GraphEdge + Budget GraphBudget +} + +type ResultCandidate struct { + ID string + GraphID string + GraphRevision int64 + NodeID GraphNodeID + AuthorRunID AgentRunID + ArtifactRefs []EvidenceRef + EvidenceSummary string + Summary string +} + +type ConflictSet struct { + ID string + GraphID string + GraphRevision int64 + NodeID GraphNodeID + CandidateIDs []string + Resolved bool +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 7a1c4a2..14d4146 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000130-agent-worker-recovery.json", - "task_id": "DEV-000130", + "task_file": "dev/tasks/DEV-000131-work-graph-harness.json", + "task_id": "DEV-000131", "task_revision": 1 } diff --git a/dev/tasks/DEV-000131-work-graph-harness.json b/dev/tasks/DEV-000131-work-graph-harness.json new file mode 100644 index 0000000..2019b27 --- /dev/null +++ b/dev/tasks/DEV-000131-work-graph-harness.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000131", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "a445034", + "risk_tier": "E", + "intent": "Implement the first deterministic Multi-Agent Work Graph harness: typed graph revision, DAG validation, budgets and dependency-aware ready-node selection.", + "requirement_ids": ["WP-400", "DAG validation", "Fan-out/fan-in foundation"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/graph.go", "modules/work/domain/graph.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000131-work-graph-harness.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["ADR-0021 graph proposal", "DEV-000130 worker/recovery contracts"], "may_change": ["Work graph typed contracts and pure domain harness"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["Typed WorkGraph value only; no persistence in this harness"], + "state_transitions": ["DRAFT/ADMITTED/RUNNING graph with dependency-aware READY nodes"], + "side_effects_and_privileges": ["Pure deterministic validation; no dispatch or external mutation"], + "persistence_and_migrations": ["Persistence/scheduler implementation follows after harness"], + "failure_retry_restart": ["Cycles, duplicate IDs/keys and invalid budgets reject before scheduling"], + "assumptions": ["Graph scheduler must not bypass Agent Execution leases or verifier policy"], + "acceptance_scenarios": ["Cycle rejected", "Ready nodes deterministic", "Dependent node remains blocked until prerequisite succeeds", "Budget is validated"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/graph.go b/modules/work/domain/graph.go new file mode 100644 index 0000000..039002d --- /dev/null +++ b/modules/work/domain/graph.go @@ -0,0 +1,107 @@ +package domain + +import ( + "errors" + "sort" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ( + ErrInvalidGraph = errors.New("invalid work graph") + ErrGraphCycle = errors.New("work graph contains a cycle") +) + +func ValidateWorkGraph(graph work.WorkGraph) error { + if graph.ID == "" || graph.Revision < 1 || graph.Budget.MaxParallel < 1 || graph.Budget.MaxAttempts < 1 || len(graph.Nodes) == 0 { + return ErrInvalidGraph + } + nodes := make(map[work.GraphNodeID]struct{}, len(graph.Nodes)) + keys := make(map[string]struct{}, len(graph.Nodes)) + for _, node := range graph.Nodes { + if node.ID == "" || node.IdempotencyKey == "" || node.Attempts < 0 { + return ErrInvalidGraph + } + if _, exists := nodes[node.ID]; exists { + return ErrInvalidGraph + } + if _, exists := keys[node.IdempotencyKey]; exists { + return ErrInvalidGraph + } + nodes[node.ID] = struct{}{} + keys[node.IdempotencyKey] = struct{}{} + } + adjacency := make(map[work.GraphNodeID][]work.GraphNodeID, len(nodes)) + for _, edge := range graph.Edges { + if edge.From == "" || edge.To == "" || edge.From == edge.To { + return ErrInvalidGraph + } + if _, ok := nodes[edge.From]; !ok { + return ErrInvalidGraph + } + if _, ok := nodes[edge.To]; !ok { + return ErrInvalidGraph + } + adjacency[edge.From] = append(adjacency[edge.From], edge.To) + } + visiting := map[work.GraphNodeID]bool{} + visited := map[work.GraphNodeID]bool{} + var visit func(work.GraphNodeID) error + visit = func(node work.GraphNodeID) error { + if visiting[node] { + return ErrGraphCycle + } + if visited[node] { + return nil + } + visiting[node] = true + for _, next := range adjacency[node] { + if err := visit(next); err != nil { + return err + } + } + delete(visiting, node) + visited[node] = true + return nil + } + for node := range nodes { + if err := visit(node); err != nil { + return err + } + } + return nil +} + +func ReadyGraphNodes(graph work.WorkGraph) ([]work.GraphNodeID, error) { + if err := ValidateWorkGraph(graph); err != nil { + return nil, err + } + completed := map[work.GraphNodeID]bool{} + for _, node := range graph.Nodes { + if node.State == work.GraphNodeSuccess { + completed[node.ID] = true + } + } + blockedBy := map[work.GraphNodeID][]work.GraphNodeID{} + for _, edge := range graph.Edges { + blockedBy[edge.To] = append(blockedBy[edge.To], edge.From) + } + ready := make([]work.GraphNodeID, 0) + for _, node := range graph.Nodes { + if node.State != work.GraphNodeReady && node.State != work.GraphNodeBlocked { + continue + } + dependenciesReady := true + for _, dependency := range blockedBy[node.ID] { + if !completed[dependency] { + dependenciesReady = false + break + } + } + if dependenciesReady { + ready = append(ready, node.ID) + } + } + sort.Slice(ready, func(i, j int) bool { return ready[i] < ready[j] }) + return ready, nil +} diff --git a/tests/agent_execution/graph_test.go b/tests/agent_execution/graph_test.go new file mode 100644 index 0000000..034c252 --- /dev/null +++ b/tests/agent_execution/graph_test.go @@ -0,0 +1,35 @@ +package agent_execution_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestWorkGraphRejectsCycleAndOrdersReadyNodes(t *testing.T) { + graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphAdmitted, Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Nodes: []work.GraphNode{ + {ID: "node-b", IdempotencyKey: "key-b", State: work.GraphNodeReady}, + {ID: "node-a", IdempotencyKey: "key-a", State: work.GraphNodeReady}, + {ID: "node-c", IdempotencyKey: "key-c", State: work.GraphNodeBlocked}, + }, Edges: []work.GraphEdge{{From: "node-a", To: "node-c"}}} + ready, err := workdomain.ReadyGraphNodes(graph) + if err != nil || len(ready) != 2 || ready[0] != "node-a" || ready[1] != "node-b" { + t.Fatalf("unexpected ready nodes: %#v %v", ready, err) + } + graph.Edges = append(graph.Edges, work.GraphEdge{From: "node-c", To: "node-a"}) + if err := workdomain.ValidateWorkGraph(graph); err != workdomain.ErrGraphCycle { + t.Fatalf("expected cycle rejection, got %v", err) + } +} + +func TestWorkGraphKeepsDependentNodeBlocked(t *testing.T) { + graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphRunning, Budget: work.GraphBudget{MaxParallel: 1, MaxAttempts: 1}, Nodes: []work.GraphNode{ + {ID: "source", IdempotencyKey: "source-key", State: work.GraphNodeRunning}, + {ID: "dependent", IdempotencyKey: "dependent-key", State: work.GraphNodeBlocked}, + }, Edges: []work.GraphEdge{{From: "source", To: "dependent"}}} + ready, err := workdomain.ReadyGraphNodes(graph) + if err != nil || len(ready) != 0 { + t.Fatalf("dependent node became ready early: %#v %v", ready, err) + } +} -- 2.54.0 From 4a800df1e1d32a1fa6928518e66715181210416e Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:31:39 +0300 Subject: [PATCH 2/7] feat(work): add graph admission scheduler --- dev/ACTIVE_TASK.json | 4 +- ...000132-work-graph-admission-scheduler.json | 23 +++++++ modules/work/domain/graph_scheduler.go | 60 +++++++++++++++++++ tests/agent_execution/graph_test.go | 23 +++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 dev/tasks/DEV-000132-work-graph-admission-scheduler.json create mode 100644 modules/work/domain/graph_scheduler.go diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 14d4146..30b185c 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000131-work-graph-harness.json", - "task_id": "DEV-000131", + "task_file": "dev/tasks/DEV-000132-work-graph-admission-scheduler.json", + "task_id": "DEV-000132", "task_revision": 1 } diff --git a/dev/tasks/DEV-000132-work-graph-admission-scheduler.json b/dev/tasks/DEV-000132-work-graph-admission-scheduler.json new file mode 100644 index 0000000..fff53a5 --- /dev/null +++ b/dev/tasks/DEV-000132-work-graph-admission-scheduler.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000132", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "b2f922c", + "risk_tier": "E", + "intent": "Add deterministic Work Graph admission, bounded ready-node scheduling and explicit conflict resolution to the graph harness.", + "requirement_ids": ["WP-400", "Graph admission", "Bounded scheduler", "Conflict resolution"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["modules/work/domain/graph_scheduler.go", "tests/agent_execution/graph_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000132-work-graph-admission-scheduler.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["DEV-000131 graph harness", "ADR-0021 graph lifecycle"], "may_change": ["Pure graph admission/scheduling/conflict domain functions"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No persistence; graph values remain deterministic harness state"], + "state_transitions": ["DRAFT -> ADMITTED; READY nodes bounded by MaxParallel; conflict unresolved -> explicitly resolved"], + "side_effects_and_privileges": ["Pure domain logic only"], + "persistence_and_migrations": ["Durable scheduler follows after the harness"], + "failure_retry_restart": ["Budget overflow and unknown conflict candidate reject without mutation"], + "assumptions": ["Runtime scheduler must use Agent Execution queue/leases in a later task"], + "acceptance_scenarios": ["Admission validates attempt budget", "Scheduler respects active/max parallel", "Conflict cannot resolve without explicit known candidate"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/graph_scheduler.go b/modules/work/domain/graph_scheduler.go new file mode 100644 index 0000000..ea5b4ca --- /dev/null +++ b/modules/work/domain/graph_scheduler.go @@ -0,0 +1,60 @@ +package domain + +import ( + "errors" + "sort" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ( + ErrGraphBudgetExceeded = errors.New("work graph budget exceeded") + ErrConflictUnresolved = errors.New("conflict resolution is not explicit") +) + +func ScheduleReadyNodes(graph work.WorkGraph, active int) ([]work.GraphNodeID, error) { + if err := ValidateWorkGraph(graph); err != nil { + return nil, err + } + if active < 0 || active > graph.Budget.MaxParallel { + return nil, ErrGraphBudgetExceeded + } + ready, err := ReadyGraphNodes(graph) + if err != nil { + return nil, err + } + available := graph.Budget.MaxParallel - active + if len(ready) > available { + ready = ready[:available] + } + return ready, nil +} + +func AdmitGraph(graph work.WorkGraph) (work.WorkGraph, error) { + if err := ValidateWorkGraph(graph); err != nil { + return work.WorkGraph{}, err + } + for _, node := range graph.Nodes { + if node.Attempts > graph.Budget.MaxAttempts { + return work.WorkGraph{}, ErrGraphBudgetExceeded + } + } + graph.State = work.GraphAdmitted + return graph, nil +} + +func ResolveGraphConflict(conflict work.ConflictSet, selectedCandidate string) (work.ConflictSet, error) { + if conflict.ID == "" || conflict.GraphID == "" || conflict.GraphRevision < 1 || conflict.NodeID == "" || conflict.Resolved || selectedCandidate == "" { + return work.ConflictSet{}, ErrConflictUnresolved + } + candidates := append([]string(nil), conflict.CandidateIDs...) + sort.Strings(candidates) + for _, candidate := range candidates { + if candidate == selectedCandidate { + conflict.CandidateIDs = candidates + conflict.Resolved = true + return conflict, nil + } + } + return work.ConflictSet{}, ErrConflictUnresolved +} diff --git a/tests/agent_execution/graph_test.go b/tests/agent_execution/graph_test.go index 034c252..cc959fd 100644 --- a/tests/agent_execution/graph_test.go +++ b/tests/agent_execution/graph_test.go @@ -33,3 +33,26 @@ func TestWorkGraphKeepsDependentNodeBlocked(t *testing.T) { t.Fatalf("dependent node became ready early: %#v %v", ready, err) } } + +func TestGraphSchedulerEnforcesParallelBudgetAndExplicitConflict(t *testing.T) { + graph := work.WorkGraph{ID: "graph-1", Revision: 1, State: work.GraphDraft, Budget: work.GraphBudget{MaxParallel: 2, MaxAttempts: 2}, Nodes: []work.GraphNode{ + {ID: "b", IdempotencyKey: "b-key", State: work.GraphNodeReady}, + {ID: "a", IdempotencyKey: "a-key", State: work.GraphNodeReady}, + }} + admitted, err := workdomain.AdmitGraph(graph) + if err != nil || admitted.State != work.GraphAdmitted { + t.Fatalf("graph was not admitted: %#v %v", admitted, err) + } + ready, err := workdomain.ScheduleReadyNodes(admitted, 1) + if err != nil || len(ready) != 1 || ready[0] != "a" { + t.Fatalf("scheduler ignored parallel budget/order: %#v %v", ready, err) + } + conflict := work.ConflictSet{ID: "conflict-1", GraphID: "graph-1", GraphRevision: 1, NodeID: "a", CandidateIDs: []string{"candidate-b", "candidate-a"}} + resolved, err := workdomain.ResolveGraphConflict(conflict, "candidate-a") + if err != nil || !resolved.Resolved || resolved.CandidateIDs[0] != "candidate-a" { + t.Fatalf("conflict was not explicitly resolved: %#v %v", resolved, err) + } + if _, err := workdomain.ResolveGraphConflict(conflict, "unknown"); err != workdomain.ErrConflictUnresolved { + t.Fatalf("unknown candidate was accepted: %v", err) + } +} -- 2.54.0 From e9bb42cd07f44421a15b13ca42d0e1c5527b3f2f Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:33:54 +0300 Subject: [PATCH 3/7] feat(work): persist graph revisions --- adapters/postgres/work_repository.go | 65 +++++++++++++++++++ .../migrations/031_work_graph_persistence.sql | 41 ++++++++++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000133-work-graph-persistence.json | 23 +++++++ .../graph_persistence_sql_test.go | 20 ++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 database/migrations/031_work_graph_persistence.sql create mode 100644 dev/tasks/DEV-000133-work-graph-persistence.json create mode 100644 tests/agent_execution/graph_persistence_sql_test.go diff --git a/adapters/postgres/work_repository.go b/adapters/postgres/work_repository.go index 05c6fb0..38e39ce 100644 --- a/adapters/postgres/work_repository.go +++ b/adapters/postgres/work_repository.go @@ -121,6 +121,71 @@ func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, worke return nil } +func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts) VALUES($1,$2,$3,$4,$5) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts); err != nil { + return err + } + for _, node := range graph.Nodes { + if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.IdempotencyKey, node.State, node.Attempts); err != nil { + return err + } + } + for _, edge := range graph.Edges { + if _, err := tx.Exec(ctx, `INSERT INTO work.graph_edges(graph_id,revision,from_node_id,to_node_id) VALUES($1,$2,$3,$4) ON CONFLICT(graph_id,revision,from_node_id,to_node_id) DO NOTHING`, graph.ID, graph.Revision, edge.From, edge.To); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) { + var graph work.WorkGraph + err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts) + if err != nil { + return work.WorkGraph{}, fmt.Errorf("load graph: %w", err) + } + rows, err := r.pool.Query(ctx, `SELECT node_id,idempotency_key,state,attempts FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision) + if err != nil { + return work.WorkGraph{}, err + } + for rows.Next() { + var node work.GraphNode + if err := rows.Scan(&node.ID, &node.IdempotencyKey, &node.State, &node.Attempts); err != nil { + rows.Close() + return work.WorkGraph{}, err + } + graph.Nodes = append(graph.Nodes, node) + } + if err := rows.Err(); err != nil { + rows.Close() + return work.WorkGraph{}, err + } + rows.Close() + edges, err := r.pool.Query(ctx, `SELECT from_node_id,to_node_id FROM work.graph_edges WHERE graph_id=$1 AND revision=$2 ORDER BY from_node_id,to_node_id`, graphID, revision) + if err != nil { + return work.WorkGraph{}, err + } + for edges.Next() { + var edge work.GraphEdge + if err := edges.Scan(&edge.From, &edge.To); err != nil { + edges.Close() + return work.WorkGraph{}, err + } + graph.Edges = append(graph.Edges, edge) + } + if err := edges.Err(); err != nil { + edges.Close() + return work.WorkGraph{}, err + } + edges.Close() + return graph, nil +} + func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) { command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state='QUEUED',claimed_by='',updated_at_unix_millis=$1 WHERE state='CLAIMED' AND updated_at_unix_millis <= $2`, nowUnixMillis, nowUnixMillis-staleAfterMillis) if err != nil { diff --git a/database/migrations/031_work_graph_persistence.sql b/database/migrations/031_work_graph_persistence.sql new file mode 100644 index 0000000..4bd7501 --- /dev/null +++ b/database/migrations/031_work_graph_persistence.sql @@ -0,0 +1,41 @@ +BEGIN; + +SET ROLE work_role; + +CREATE TABLE IF NOT EXISTS work.graphs ( + graph_id TEXT NOT NULL, + revision BIGINT NOT NULL, + state TEXT NOT NULL, + max_parallel INTEGER NOT NULL, + max_attempts INTEGER NOT NULL, + PRIMARY KEY (graph_id, revision), + CHECK (graph_id <> ''), CHECK (revision > 0), + CHECK (state IN ('DRAFT','ADMITTED','RUNNING','PAUSED','CANCELLING','PARTIAL','VERIFYING','COMPLETED','FAILED','UNKNOWN','RECOVERING')), + CHECK (max_parallel > 0), CHECK (max_attempts > 0) +); + +CREATE TABLE IF NOT EXISTS work.graph_nodes ( + graph_id TEXT NOT NULL, + revision BIGINT NOT NULL, + node_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + state TEXT NOT NULL, + attempts INTEGER NOT NULL, + PRIMARY KEY (graph_id, revision, node_id), + FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision), + CHECK (node_id <> ''), CHECK (idempotency_key <> ''), CHECK (attempts >= 0), + CHECK (state IN ('BLOCKED_PREREQUISITE','READY','LEASED','RUNNING','SUCCEEDED','FAILED','CANCELLED','UNKNOWN','RECOVERING')) +); + +CREATE TABLE IF NOT EXISTS work.graph_edges ( + graph_id TEXT NOT NULL, + revision BIGINT NOT NULL, + from_node_id TEXT NOT NULL, + to_node_id TEXT NOT NULL, + PRIMARY KEY (graph_id, revision, from_node_id, to_node_id), + FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision), + CHECK (from_node_id <> ''), CHECK (to_node_id <> ''), CHECK (from_node_id <> to_node_id) +); + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 30b185c..1d21d87 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000132-work-graph-admission-scheduler.json", - "task_id": "DEV-000132", + "task_file": "dev/tasks/DEV-000133-work-graph-persistence.json", + "task_id": "DEV-000133", "task_revision": 1 } diff --git a/dev/tasks/DEV-000133-work-graph-persistence.json b/dev/tasks/DEV-000133-work-graph-persistence.json new file mode 100644 index 0000000..1c9a83d --- /dev/null +++ b/dev/tasks/DEV-000133-work-graph-persistence.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000133", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "4a800df", + "risk_tier": "E", + "intent": "Persist immutable Work Graph revisions with typed nodes and dependency edges for durable scheduler integration.", + "requirement_ids": ["WP-401", "Durable graph revision", "Graph node dependencies"], + "allowed_change_scope": {"modules": ["work-agent-execution", "work-storage"], "paths": ["database/migrations/031_work_graph_persistence.sql", "adapters/postgres/work_repository.go", "tests/agent_execution/graph_persistence_sql_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000133-work-graph-persistence.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "runtime/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["DEV-000131/132 graph contracts and scheduler"], "may_change": ["Work graph persistence schema and repository methods"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["work.graphs", "work.graph_nodes", "work.graph_edges"], + "state_transitions": ["graph revision persisted once; nodes and edges immutable within revision"], + "side_effects_and_privileges": ["Work schema only; no cross-owner SQL"], + "persistence_and_migrations": ["Append-only migration 031; production apply is later canary"], + "failure_retry_restart": ["Idempotent inserts; load reconstructs deterministic node/edge order"], + "assumptions": ["Durable scheduler and result/conflict persistence follow"], + "acceptance_scenarios": ["Graph revision cannot collide into mutation", "Nodes/edges remain tied to revision", "Load reconstructs graph"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/agent_execution/graph_persistence_sql_test.go b/tests/agent_execution/graph_persistence_sql_test.go new file mode 100644 index 0000000..15756c0 --- /dev/null +++ b/tests/agent_execution/graph_persistence_sql_test.go @@ -0,0 +1,20 @@ +package agent_execution_test + +import ( + "os" + "strings" + "testing" +) + +func TestGraphPersistenceMigrationIsRevisionScoped(t *testing.T) { + data, err := os.ReadFile("../../database/migrations/031_work_graph_persistence.sql") + if err != nil { + t.Fatal(err) + } + sql := string(data) + for _, marker := range []string{"work.graphs", "work.graph_nodes", "work.graph_edges", "PRIMARY KEY (graph_id, revision)", "FOREIGN KEY (graph_id, revision)"} { + if !strings.Contains(sql, marker) { + t.Fatalf("graph persistence marker missing: %s", marker) + } + } +} -- 2.54.0 From 32e33e3050520435dd227940e2ae035e7d21ac51 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:37:22 +0300 Subject: [PATCH 4/7] feat(epistemics): compile truth evidence bundle --- contracts/epistemics/truth.go | 38 +++++++++++++++++++ dev/ACTIVE_TASK.json | 4 +- .../DEV-000134-truth-bundle-compiler.json | 23 +++++++++++ modules/epistemics/domain/truth.go | 31 +++++++++++++++ tests/research_contracts/truth_bundle_test.go | 27 +++++++++++++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 contracts/epistemics/truth.go create mode 100644 dev/tasks/DEV-000134-truth-bundle-compiler.json create mode 100644 modules/epistemics/domain/truth.go create mode 100644 tests/research_contracts/truth_bundle_test.go diff --git a/contracts/epistemics/truth.go b/contracts/epistemics/truth.go new file mode 100644 index 0000000..b597c61 --- /dev/null +++ b/contracts/epistemics/truth.go @@ -0,0 +1,38 @@ +package epistemics + +type BeliefState string + +const ( + BeliefProvisional BeliefState = "PROVISIONAL" + BeliefConflicted BeliefState = "CONFLICTED" + BeliefVerified BeliefState = "VERIFIED" +) + +type Observation struct { + ID ObservationID + SourceSnapshot SourceSnapshotID + Excerpt SourceExcerptID + Text string + ObservedAt int64 +} + +type Claim struct { + ID ClaimID + Statement string + ObservationID ObservationID + CitationID CitationID + Verification VerificationState +} + +type Belief struct { + ID BeliefID + ClaimID ClaimID + State BeliefState +} + +type TruthBundle struct { + Observation Observation + Claim Claim + Belief Belief + Citation Citation +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 1d21d87..bd408de 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000133-work-graph-persistence.json", - "task_id": "DEV-000133", + "task_file": "dev/tasks/DEV-000134-truth-bundle-compiler.json", + "task_id": "DEV-000134", "task_revision": 1 } diff --git a/dev/tasks/DEV-000134-truth-bundle-compiler.json b/dev/tasks/DEV-000134-truth-bundle-compiler.json new file mode 100644 index 0000000..e97005c --- /dev/null +++ b/dev/tasks/DEV-000134-truth-bundle-compiler.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000134", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "e9bb42c", + "risk_tier": "E", + "intent": "Implement the first Truth runtime compiler that produces typed Observation, Claim, Belief and Citation values from an exact source chain.", + "requirement_ids": ["WP-100", "Observation/Claim/Belief", "Truth citation provenance"], + "allowed_change_scope": {"modules": ["epistemics"], "paths": ["contracts/epistemics/truth.go", "modules/epistemics/domain/truth.go", "tests/research_contracts/truth_bundle_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000134-truth-bundle-compiler.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "adapters/**", "cmd/**", "runtime/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["DEV-000109 research contracts", "DEV-000110 citation resolver"], "may_change": ["Typed Truth bundle contracts and pure compiler"]}, + "semantic_owner": "epistemics", + "canonical_state": ["Typed TruthBundle value only; persistence follows"], + "state_transitions": ["source chain -> observed observation -> unverified claim -> provisional/conflicted belief"], + "side_effects_and_privileges": ["Pure compiler; no fetching, external mutation or authority grant"], + "persistence_and_migrations": ["Truth persistence is a later task"], + "failure_retry_restart": ["Broken source chain rejects; conflict remains visible"], + "assumptions": ["Research executor will supply exact source snapshots/excerpts"], + "acceptance_scenarios": ["Observation/claim/citation refs align", "Conflict produces CONFLICTED belief", "Broken source chain is rejected"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/epistemics/domain/truth.go b/modules/epistemics/domain/truth.go new file mode 100644 index 0000000..3b9a1a0 --- /dev/null +++ b/modules/epistemics/domain/truth.go @@ -0,0 +1,31 @@ +package domain + +import ( + "errors" + "strings" + + "git.cin.su/m/Assistant/contracts/epistemics" +) + +var ErrInvalidTruthBundle = errors.New("invalid truth bundle") + +func CompileObservedClaim(source epistemics.Source, snapshot epistemics.SourceSnapshot, excerpt epistemics.SourceExcerpt, statement string, observedAt int64, conflict epistemics.ConflictState) (epistemics.TruthBundle, error) { + if strings.TrimSpace(statement) == "" || observedAt < 1 { + return epistemics.TruthBundle{}, ErrInvalidTruthBundle + } + citation, err := epistemics.ResolveCitation(source, snapshot, excerpt, epistemics.EvidenceObserved, epistemics.CoverageComplete, conflict, observedAt) + if err != nil { + return epistemics.TruthBundle{}, ErrInvalidTruthBundle + } + observation := epistemics.Observation{ID: epistemics.ObservationID("observation:" + string(excerpt.ID)), SourceSnapshot: snapshot.ID, Excerpt: excerpt.ID, Text: excerpt.Text, ObservedAt: observedAt} + claim := epistemics.Claim{ID: epistemics.ClaimID("claim:" + string(excerpt.ID)), Statement: statement, ObservationID: observation.ID, CitationID: citation.ID, Verification: epistemics.VerificationUnverified} + state := epistemics.BeliefProvisional + if conflict == epistemics.ConflictPresent || conflict == epistemics.ConflictUnknown { + state = epistemics.BeliefConflicted + } + bundle := epistemics.TruthBundle{Observation: observation, Claim: claim, Belief: epistemics.Belief{ID: epistemics.BeliefID("belief:" + string(excerpt.ID)), ClaimID: claim.ID, State: state}, Citation: citation} + if bundle.Observation.SourceSnapshot != snapshot.ID || bundle.Claim.ObservationID != bundle.Observation.ID || bundle.Claim.CitationID != bundle.Citation.ID || bundle.Belief.ClaimID != bundle.Claim.ID { + return epistemics.TruthBundle{}, ErrInvalidTruthBundle + } + return bundle, nil +} diff --git a/tests/research_contracts/truth_bundle_test.go b/tests/research_contracts/truth_bundle_test.go new file mode 100644 index 0000000..c972185 --- /dev/null +++ b/tests/research_contracts/truth_bundle_test.go @@ -0,0 +1,27 @@ +package research_contracts_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/epistemics" + episdomain "git.cin.su/m/Assistant/modules/epistemics/domain" +) + +func TestCompileObservedClaimPreservesEvidenceChainAndConflict(t *testing.T) { + source := epistemics.Source{ID: "source-1", Locator: "https://example.test/source"} + snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: source.ID, ContentDigest: "sha256:test", CapturedAtUnixMillis: 100, Freshness: epistemics.FreshnessCurrent} + excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact observation", Start: 0, End: 17} + bundle, err := episdomain.CompileObservedClaim(source, snapshot, excerpt, "The observed fact is true", 200, epistemics.ConflictPresent) + if err != nil || bundle.Claim.ObservationID != bundle.Observation.ID || bundle.Belief.State != epistemics.BeliefConflicted || bundle.Citation.Evidence != epistemics.EvidenceObserved { + t.Fatalf("invalid truth bundle: %#v %v", bundle, err) + } +} + +func TestCompileObservedClaimRejectsBrokenSourceChain(t *testing.T) { + source := epistemics.Source{ID: "source-1", Locator: "https://example.test/source"} + snapshot := epistemics.SourceSnapshot{ID: "snapshot-1", SourceID: "other-source", ContentDigest: "sha256:test", CapturedAtUnixMillis: 100, Freshness: epistemics.FreshnessCurrent} + excerpt := epistemics.SourceExcerpt{ID: "excerpt-1", SnapshotID: snapshot.ID, Text: "exact observation", Start: 0, End: 17} + if _, err := episdomain.CompileObservedClaim(source, snapshot, excerpt, "statement", 200, epistemics.ConflictNone); err != episdomain.ErrInvalidTruthBundle { + t.Fatalf("broken chain was accepted: %v", err) + } +} -- 2.54.0 From f73c69960cd505d5904788a8a9e10da6bc05fc40 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:41:44 +0300 Subject: [PATCH 5/7] feat(epistemics): add durable reflection worker --- adapters/postgres/epistemics_repository.go | 51 ++++++++++++++++++- cmd/reflection-worker/main.go | 39 ++++++++++++++ contracts/epistemics/types.go | 24 +++++---- .../032_reflection_candidate_leases.sql | 19 +++++++ dev/ACTIVE_TASK.json | 2 +- dev/tasks/DEV-000135-reflection-worker.json | 23 +++++++++ .../reflection_worker_sql_test.go | 27 ++++++++++ 7 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 cmd/reflection-worker/main.go create mode 100644 database/migrations/032_reflection_candidate_leases.sql create mode 100644 dev/tasks/DEV-000135-reflection-worker.json create mode 100644 tests/research_contracts/reflection_worker_sql_test.go diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 6b4a4aa..ef8f8f9 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -64,7 +64,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l } rows, err := r.pool.Query(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id, - summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision + summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit) if err != nil { return nil, err @@ -74,7 +74,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l for rows.Next() { var value epistemics.ReflectionCandidate if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, - &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision); err != nil { + &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis); err != nil { return nil, err } result = append(result, value) @@ -82,6 +82,53 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l return result, rows.Err() } +func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, workerID string, nowUnixMillis, leaseMillis int64) (epistemics.ReflectionCandidate, error) { + if workerID == "" || leaseMillis < 1 { + return epistemics.ReflectionCandidate{}, fmt.Errorf("invalid reflection claim") + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return epistemics.ReflectionCandidate{}, err + } + defer tx.Rollback(ctx) + var value epistemics.ReflectionCandidate + err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis + FROM epistemics.reflection_candidates + WHERE state='OPEN' AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1) + ORDER BY recorded_at_unix_millis,reflection_candidate_id + FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis) + if err != nil { + return epistemics.ReflectionCandidate{}, err + } + value.State = epistemics.ReflectionCandidateEvaluating + value.ClaimedBy = workerID + value.ClaimExpiresAtUnixMillis = nowUnixMillis + leaseMillis + _, err = tx.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state=$1,claimed_by=$2,claim_expires_at_unix_millis=$3 WHERE reflection_candidate_id=$4 AND state='OPEN'`, value.State, value.ClaimedBy, value.ClaimExpiresAtUnixMillis, value.ID) + if err != nil { + return epistemics.ReflectionCandidate{}, err + } + if err := tx.Commit(ctx); err != nil { + return epistemics.ReflectionCandidate{}, err + } + return value, nil +} + +func (r *EpistemicsRepository) RecoverReflectionClaims(ctx context.Context, nowUnixMillis int64) (int64, error) { + result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='OPEN',claimed_by='',claim_expires_at_unix_millis=0 WHERE state='EVALUATING' AND claim_expires_at_unix_millis > 0 AND claim_expires_at_unix_millis < $1`, nowUnixMillis) + return result.RowsAffected(), err +} + +func (r *EpistemicsRepository) ResolveReflectionCandidate(ctx context.Context, id epistemics.ReflectionCandidateID, workerID string, nowUnixMillis int64) error { + if id == "" || workerID == "" { + return fmt.Errorf("invalid reflection resolution") + } + result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='RESOLVED',claimed_by='',claim_expires_at_unix_millis=0,revision=revision+1 WHERE reflection_candidate_id=$1 AND state='EVALUATING' AND claimed_by=$2 AND claim_expires_at_unix_millis >= $3`, id, workerID, nowUnixMillis) + if err == nil && result.RowsAffected() != 1 { + return fmt.Errorf("reflection candidate claim lost") + } + return err +} + func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error { _, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records ( experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, diff --git a/cmd/reflection-worker/main.go b/cmd/reflection-worker/main.go new file mode 100644 index 0000000..9a7bfcc --- /dev/null +++ b/cmd/reflection-worker/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "git.cin.su/m/Assistant/adapters/postgres" +) + +func main() { + workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity") + lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease") + flag.Parse() + ctx := context.Background() + config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: os.Getenv("EPISTEMICS_POSTGRES_PASSWORD")} + repository, err := postgres.OpenEpistemics(ctx, config) + if err != nil { + panic(err) + } + defer repository.Close() + now := time.Now().UnixMilli() + if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil { + panic(err) + } + candidate, err := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds()) + if err != nil { + fmt.Println("no reflection candidate") + return + } + // This worker only closes the durable evaluation loop. Any cognitive + // proposal remains a candidate and must be admitted by a separate governed flow. + if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil { + panic(err) + } + fmt.Printf("resolved reflection candidate %s\n", candidate.ID) +} diff --git a/contracts/epistemics/types.go b/contracts/epistemics/types.go index da7be41..2927aed 100644 --- a/contracts/epistemics/types.go +++ b/contracts/epistemics/types.go @@ -120,15 +120,17 @@ type ExperienceRecord struct { // Cognitive reflection, if used, must go through Cognitive Gateway/Router and // produce candidates only; it cannot directly rewrite canonical state. type ReflectionCandidate struct { - ID ReflectionCandidateID - Trigger ReflectionTriggerKind - State ReflectionCandidateState - SourceExperienceID ExperienceRecordID - OriginalIntentID OriginalIntentRef - SourceUtteranceID SourceUtteranceRef - Summary string - EvidenceRefs []EvidenceRef - RequiresCognition bool - RecordedAtUnixMillis int64 - Revision int64 + ID ReflectionCandidateID + Trigger ReflectionTriggerKind + State ReflectionCandidateState + SourceExperienceID ExperienceRecordID + OriginalIntentID OriginalIntentRef + SourceUtteranceID SourceUtteranceRef + Summary string + EvidenceRefs []EvidenceRef + RequiresCognition bool + RecordedAtUnixMillis int64 + Revision int64 + ClaimedBy string + ClaimExpiresAtUnixMillis int64 } diff --git a/database/migrations/032_reflection_candidate_leases.sql b/database/migrations/032_reflection_candidate_leases.sql new file mode 100644 index 0000000..be110b6 --- /dev/null +++ b/database/migrations/032_reflection_candidate_leases.sql @@ -0,0 +1,19 @@ +BEGIN; + +SET ROLE epistemics_role; + +ALTER TABLE epistemics.reflection_candidates + ADD COLUMN IF NOT EXISTS claimed_by TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS claim_expires_at_unix_millis BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE epistemics.reflection_candidates + DROP CONSTRAINT IF EXISTS reflection_candidates_claim_expiry_check; +ALTER TABLE epistemics.reflection_candidates + ADD CONSTRAINT reflection_candidates_claim_expiry_check + CHECK (claim_expires_at_unix_millis >= 0); + +CREATE INDEX IF NOT EXISTS reflection_candidates_claimable_idx + ON epistemics.reflection_candidates (state, claim_expires_at_unix_millis, recorded_at_unix_millis, reflection_candidate_id); + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index bd408de..673e0bf 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { "task_file": "dev/tasks/DEV-000134-truth-bundle-compiler.json", - "task_id": "DEV-000134", + "task_id": "DEV-000135", "task_revision": 1 } diff --git a/dev/tasks/DEV-000135-reflection-worker.json b/dev/tasks/DEV-000135-reflection-worker.json new file mode 100644 index 0000000..64b04c8 --- /dev/null +++ b/dev/tasks/DEV-000135-reflection-worker.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000135", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "32e33e3", + "risk_tier": "E", + "intent": "Add durable lease, stale recovery, and one-shot governed closure for reflection candidates.", + "requirement_ids": ["WP-501", "Reflection candidate worker", "Stale claim recovery"], + "allowed_change_scope": {"modules": ["epistemics", "epistemics-storage"], "paths": ["contracts/epistemics/types.go", "database/migrations/032_reflection_candidate_leases.sql", "adapters/postgres/epistemics_repository.go", "cmd/reflection-worker/main.go", "tests/research_contracts/reflection_worker_sql_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000135-reflection-worker.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "contracts/authority/**", "modules/cognitive/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["Existing epistemics ReflectionCandidate contract"], "may_change": ["ReflectionCandidate lease metadata"]}, + "semantic_owner": "epistemics", + "canonical_state": ["epistemics.reflection_candidates"], + "state_transitions": ["OPEN to EVALUATING under lease; EVALUATING to OPEN on expiry; EVALUATING to RESOLVED by owning worker"], + "side_effects_and_privileges": ["Epistemics schema only; no cognitive call and no proposal promotion"], + "persistence_and_migrations": ["Append-only migration 032; production apply is later canary"], + "failure_retry_restart": ["FOR UPDATE SKIP LOCKED claim; expired claims are recoverable; one-shot worker is restart-safe"], + "assumptions": ["Cognitive evaluation and skill admission remain separate governed stages"], + "acceptance_scenarios": ["Concurrent workers claim distinct candidates", "expired evaluation returns to OPEN", "only claim owner can resolve"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_MIGRATION_DECISION", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/research_contracts/reflection_worker_sql_test.go b/tests/research_contracts/reflection_worker_sql_test.go new file mode 100644 index 0000000..461f909 --- /dev/null +++ b/tests/research_contracts/reflection_worker_sql_test.go @@ -0,0 +1,27 @@ +package research_contracts_test + +import ( + "os" + "strings" + "testing" +) + +func TestReflectionCandidateLeaseSQLIsDurableAndRecoverable(t *testing.T) { + checks := map[string][]string{ + "../../database/migrations/032_reflection_candidate_leases.sql": {"claim_expires_at_unix_millis", "reflection_candidates_claimable_idx"}, + "../../adapters/postgres/epistemics_repository.go": {"claim_expires_at_unix_millis", "FOR UPDATE SKIP LOCKED", "state='OPEN'", "state='RESOLVED'"}, + "../../cmd/reflection-worker/main.go": {"RecoverReflectionClaims", "ClaimReflectionCandidate", "ResolveReflectionCandidate"}, + } + for path, needles := range checks { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(data) + for _, needle := range needles { + if !strings.Contains(text, needle) { + t.Fatalf("%s missing %q", path, needle) + } + } + } +} -- 2.54.0 From d38406fbc5eecdf534a80380614e6540c1e98379 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:43:36 +0300 Subject: [PATCH 6/7] feat(capability): add fleet readiness policy --- contracts/capability/fleet.go | 25 ++++++++ dev/ACTIVE_TASK.json | 2 +- .../DEV-000136-fleet-readiness-policy.json | 23 +++++++ modules/capability/domain/fleet.go | 60 +++++++++++++++++++ modules/capability/domain/fleet_test.go | 18 ++++++ .../cognitive_registry/fleet_contract_test.go | 18 ++++++ 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 contracts/capability/fleet.go create mode 100644 dev/tasks/DEV-000136-fleet-readiness-policy.json create mode 100644 modules/capability/domain/fleet.go create mode 100644 modules/capability/domain/fleet_test.go create mode 100644 tests/cognitive_registry/fleet_contract_test.go diff --git a/contracts/capability/fleet.go b/contracts/capability/fleet.go new file mode 100644 index 0000000..0b60213 --- /dev/null +++ b/contracts/capability/fleet.go @@ -0,0 +1,25 @@ +package capability + +type FleetTier string + +const ( + FleetTierFast FleetTier = "FAST" + FleetTierMedium FleetTier = "MEDIUM" + FleetTierHeavy FleetTier = "HEAVY" + FleetTierLifeboat FleetTier = "LIFEBOAT" +) + +type FleetMember struct { + ProviderInstanceID ProviderInstanceID + ModelDescriptorID ModelDescriptorID + Tier FleetTier + Ready bool + Reason string +} + +type FleetReadiness struct { + ObservedAtUnixMillis int64 + Members []FleetMember + ReadyTiers []FleetTier + FallbackTier FleetTier +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 673e0bf..e0ac6fc 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { "task_file": "dev/tasks/DEV-000134-truth-bundle-compiler.json", - "task_id": "DEV-000135", + "task_id": "DEV-000136", "task_revision": 1 } diff --git a/dev/tasks/DEV-000136-fleet-readiness-policy.json b/dev/tasks/DEV-000136-fleet-readiness-policy.json new file mode 100644 index 0000000..61b3bb9 --- /dev/null +++ b/dev/tasks/DEV-000136-fleet-readiness-policy.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000136", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "f73c699", + "risk_tier": "E", + "intent": "Make model fleet tiers, readiness, and explicit lifeboat fallback durable as typed capability policy.", + "requirement_ids": ["WP-601", "Model fleet policy", "Fallback readiness"], + "allowed_change_scope": {"modules": ["capability"], "paths": ["contracts/capability/fleet.go", "modules/capability/domain/fleet.go", "modules/capability/domain/fleet_test.go", "tests/cognitive_registry/fleet_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000136-fleet-readiness-policy.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["Existing cognitive registry and health observations"], "may_change": ["Typed fleet readiness policy"]}, + "semantic_owner": "capability", + "canonical_state": ["FleetReadiness value object"], + "state_transitions": ["observed member readiness determines selected preferred or fallback tier"], + "side_effects_and_privileges": ["No provider invocation or external mutation"], + "persistence_and_migrations": ["In-memory typed decision; runtime persistence follows fleet registration integration"], + "failure_retry_restart": ["No ready fallback yields explicit error"], + "assumptions": ["Existing Cognitive Gateway remains the only invocation boundary"], + "acceptance_scenarios": ["heavy timeout selects ready lifeboat", "no ready member fails closed"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/capability/domain/fleet.go b/modules/capability/domain/fleet.go new file mode 100644 index 0000000..1baee78 --- /dev/null +++ b/modules/capability/domain/fleet.go @@ -0,0 +1,60 @@ +package domain + +import ( + "errors" + "slices" + "sort" + + "git.cin.su/m/Assistant/contracts/capability" +) + +var ErrInvalidFleetReadiness = errors.New("invalid fleet readiness") + +func BuildFleetReadiness(observedAt int64, members []capability.FleetMember) (capability.FleetReadiness, error) { + if observedAt < 1 || len(members) == 0 { + return capability.FleetReadiness{}, ErrInvalidFleetReadiness + } + result := capability.FleetReadiness{ObservedAtUnixMillis: observedAt, Members: append([]capability.FleetMember(nil), members...), FallbackTier: capability.FleetTierLifeboat} + seen := map[capability.FleetTier]bool{} + for i := range result.Members { + member := &result.Members[i] + if member.ProviderInstanceID == "" || member.ModelDescriptorID == "" || !validFleetTier(member.Tier) || seen[member.Tier] && member.Ready { + return capability.FleetReadiness{}, ErrInvalidFleetReadiness + } + if member.Ready { + seen[member.Tier] = true + } + } + for _, tier := range []capability.FleetTier{capability.FleetTierFast, capability.FleetTierMedium, capability.FleetTierHeavy, capability.FleetTierLifeboat} { + if seen[tier] { + result.ReadyTiers = append(result.ReadyTiers, tier) + } + } + return result, nil +} + +func SelectFleetTier(readiness capability.FleetReadiness, preferred capability.FleetTier) (capability.FleetTier, error) { + if readiness.ObservedAtUnixMillis < 1 || len(readiness.Members) == 0 || !validFleetTier(readiness.FallbackTier) { + return "", ErrInvalidFleetReadiness + } + if slices.Contains(readiness.ReadyTiers, preferred) { + return preferred, nil + } + if slices.Contains(readiness.ReadyTiers, readiness.FallbackTier) { + return readiness.FallbackTier, nil + } + return "", ErrInvalidFleetReadiness +} + +func validFleetTier(value capability.FleetTier) bool { + return value == capability.FleetTierFast || value == capability.FleetTierMedium || value == capability.FleetTierHeavy || value == capability.FleetTierLifeboat +} + +func SortFleetMembers(members []capability.FleetMember) { + sort.SliceStable(members, func(i, j int) bool { + if members[i].Tier != members[j].Tier { + return members[i].Tier < members[j].Tier + } + return members[i].ProviderInstanceID < members[j].ProviderInstanceID + }) +} diff --git a/modules/capability/domain/fleet_test.go b/modules/capability/domain/fleet_test.go new file mode 100644 index 0000000..a8b5957 --- /dev/null +++ b/modules/capability/domain/fleet_test.go @@ -0,0 +1,18 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestFleetReadinessFallsBackToLifeboatWhenPrimaryTiersUnavailable(t *testing.T) { + readiness, err := BuildFleetReadiness(100, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "qwen", Tier: capability.FleetTierLifeboat, Ready: true, Reason: "local"}, {ProviderInstanceID: "gpu", ModelDescriptorID: "heavy", Tier: capability.FleetTierHeavy, Ready: false, Reason: "timeout"}}) + if err != nil { + t.Fatal(err) + } + selected, err := SelectFleetTier(readiness, capability.FleetTierHeavy) + if err != nil || selected != capability.FleetTierLifeboat { + t.Fatalf("selected=%q err=%v", selected, err) + } +} diff --git a/tests/cognitive_registry/fleet_contract_test.go b/tests/cognitive_registry/fleet_contract_test.go new file mode 100644 index 0000000..bbf187a --- /dev/null +++ b/tests/cognitive_registry/fleet_contract_test.go @@ -0,0 +1,18 @@ +package cognitive_registry_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/modules/capability/domain" +) + +func TestFleetPolicyRequiresAnExplicitReadyFallback(t *testing.T) { + readiness, err := domain.BuildFleetReadiness(1, []capability.FleetMember{{ProviderInstanceID: "cpu", ModelDescriptorID: "model", Tier: capability.FleetTierFast, Ready: false, Reason: "401"}}) + if err != nil { + t.Fatal(err) + } + if _, err := domain.SelectFleetTier(readiness, capability.FleetTierFast); err == nil { + t.Fatal("expected no invocable fallback") + } +} -- 2.54.0 From 1ccd43c5528f09badf7ca955bb02ce793eec17b1 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 22 Aug 2026 13:46:01 +0300 Subject: [PATCH 7/7] feat(work): add governed self-development gate --- contracts/work/self_development.go | 29 ++++++ dev/ACTIVE_TASK.json | 2 +- ...000137-governed-self-development-gate.json | 23 +++++ modules/work/domain/self_development.go | 97 +++++++++++++++++++ modules/work/domain/self_development_test.go | 28 ++++++ .../self_development_contract_test.go | 15 +++ 6 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 contracts/work/self_development.go create mode 100644 dev/tasks/DEV-000137-governed-self-development-gate.json create mode 100644 modules/work/domain/self_development.go create mode 100644 modules/work/domain/self_development_test.go create mode 100644 tests/semantic_evolution/self_development_contract_test.go diff --git a/contracts/work/self_development.go b/contracts/work/self_development.go new file mode 100644 index 0000000..5e3b6ba --- /dev/null +++ b/contracts/work/self_development.go @@ -0,0 +1,29 @@ +package work + +type DevelopmentStage string + +const ( + DevelopmentEvidenceCollected DevelopmentStage = "EVIDENCE_COLLECTED" + DevelopmentProposalCreated DevelopmentStage = "PROPOSAL_CREATED" + DevelopmentTaskAdmitted DevelopmentStage = "TASK_ADMITTED" + DevelopmentIsolated DevelopmentStage = "ISOLATED" + DevelopmentVerified DevelopmentStage = "VERIFIED" + DevelopmentCanary DevelopmentStage = "CANARY" + DevelopmentPromoted DevelopmentStage = "PROMOTED" + DevelopmentRolledBack DevelopmentStage = "ROLLED_BACK" +) + +type DevelopmentGate struct { + ID string + Stage DevelopmentStage + EvidenceRefs []string + ProposalRef string + TaskPacketRef string + WorktreeRef string + ProducerRunRefs []AgentRunID + VerifierRunRefs []AgentRunID + VerificationEvidence string + CanaryEvidence string + PromotionRef string + RollbackRef string +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index e0ac6fc..56e69b3 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { "task_file": "dev/tasks/DEV-000134-truth-bundle-compiler.json", - "task_id": "DEV-000136", + "task_id": "DEV-000137", "task_revision": 1 } diff --git a/dev/tasks/DEV-000137-governed-self-development-gate.json b/dev/tasks/DEV-000137-governed-self-development-gate.json new file mode 100644 index 0000000..129b558 --- /dev/null +++ b/dev/tasks/DEV-000137-governed-self-development-gate.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000137", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "d38406f", + "risk_tier": "E", + "intent": "Add typed governed self-development gate requiring evidence, task packet, isolated worktree, independent verifier, canary, promotion or rollback.", + "requirement_ids": ["WP-701", "Governed self-development", "Independent verification", "Rollback"], + "allowed_change_scope": {"modules": ["work-agent-execution"], "paths": ["contracts/work/self_development.go", "modules/work/domain/self_development.go", "modules/work/domain/self_development_test.go", "tests/semantic_evolution/self_development_contract_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000137-governed-self-development-gate.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "database/**", "cmd/**", "contracts/authority/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["Existing AgentRun and work graph contracts"], "may_change": ["Typed self-development stage gate"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["DevelopmentGate value object"], + "state_transitions": ["evidence to proposal to task to isolation to independent verification to canary to promotion; canary may rollback"], + "side_effects_and_privileges": ["No direct code mutation, provider invocation, or promotion side effect"], + "persistence_and_migrations": ["Value object only; durable gate persistence follows execution-plane integration"], + "failure_retry_restart": ["Invalid stage transitions and missing evidence fail closed"], + "assumptions": ["Task packet and worktree identity are provided by the execution plane"], + "acceptance_scenarios": ["same producer cannot verify output", "promotion is terminal", "canary can rollback"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/self_development.go b/modules/work/domain/self_development.go new file mode 100644 index 0000000..3fbac1f --- /dev/null +++ b/modules/work/domain/self_development.go @@ -0,0 +1,97 @@ +package domain + +import ( + "errors" + "slices" + + "git.cin.su/m/Assistant/contracts/work" +) + +var ErrInvalidDevelopmentGate = errors.New("invalid governed self-development gate") + +func AdvanceDevelopmentGate(current work.DevelopmentGate, next work.DevelopmentStage) (work.DevelopmentGate, error) { + if err := ValidateDevelopmentGate(current); err != nil { + return work.DevelopmentGate{}, err + } + if !validDevelopmentTransition(current.Stage, next) { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + current.Stage = next + switch next { + case work.DevelopmentProposalCreated: + if current.ProposalRef == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentTaskAdmitted: + if current.TaskPacketRef == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentIsolated: + if current.WorktreeRef == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentVerified: + if current.VerificationEvidence == "" || len(current.VerifierRunRefs) == 0 || len(current.ProducerRunRefs) == 0 || sameRuns(current.ProducerRunRefs, current.VerifierRunRefs) { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentCanary: + if current.CanaryEvidence == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentPromoted: + if current.PromotionRef == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + case work.DevelopmentRolledBack: + if current.RollbackRef == "" { + return work.DevelopmentGate{}, ErrInvalidDevelopmentGate + } + } + return current, nil +} + +func ValidateDevelopmentGate(value work.DevelopmentGate) error { + if value.ID == "" || len(value.EvidenceRefs) == 0 || !validDevelopmentStage(value.Stage) || duplicateStrings(value.EvidenceRefs) { + return ErrInvalidDevelopmentGate + } + return nil +} + +func validDevelopmentTransition(current, next work.DevelopmentStage) bool { + if current == work.DevelopmentCanary && next == work.DevelopmentRolledBack { + return true + } + if current == work.DevelopmentPromoted || current == work.DevelopmentRolledBack { + return false + } + order := []work.DevelopmentStage{work.DevelopmentEvidenceCollected, work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted} + index := func(value work.DevelopmentStage) int { return slices.Index(order, value) } + return index(next) == index(current)+1 +} + +func validDevelopmentStage(value work.DevelopmentStage) bool { + return slices.Contains([]work.DevelopmentStage{work.DevelopmentEvidenceCollected, work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted, work.DevelopmentRolledBack}, value) +} + +func duplicateStrings(values []string) bool { + seen := map[string]bool{} + for _, value := range values { + if value == "" || seen[value] { + return true + } + seen[value] = true + } + return false +} + +func sameRuns(left, right []work.AgentRunID) bool { + if len(left) != len(right) { + return false + } + for _, value := range left { + if !slices.Contains(right, value) { + return false + } + } + return true +} diff --git a/modules/work/domain/self_development_test.go b/modules/work/domain/self_development_test.go new file mode 100644 index 0000000..da0e884 --- /dev/null +++ b/modules/work/domain/self_development_test.go @@ -0,0 +1,28 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/work" +) + +func TestGovernedSelfDevelopmentRequiresIndependentVerifierBeforeCanary(t *testing.T) { + gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"obs-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "worktree-1", ProducerRunRefs: []work.AgentRunID{"producer"}, VerifierRunRefs: []work.AgentRunID{"verifier"}, VerificationEvidence: "verify-1", CanaryEvidence: "canary-1", PromotionRef: "promotion-1"} + for _, stage := range []work.DevelopmentStage{work.DevelopmentProposalCreated, work.DevelopmentTaskAdmitted, work.DevelopmentIsolated, work.DevelopmentVerified, work.DevelopmentCanary, work.DevelopmentPromoted} { + var err error + gate, err = AdvanceDevelopmentGate(gate, stage) + if err != nil { + t.Fatalf("stage %s: %v", stage, err) + } + } + if _, err := AdvanceDevelopmentGate(gate, work.DevelopmentRolledBack); err == nil { + t.Fatal("promoted gate must be terminal") + } +} + +func TestGovernedSelfDevelopmentRejectsSameProducerAndVerifier(t *testing.T) { + gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentIsolated, EvidenceRefs: []string{"obs-1"}, ProducerRunRefs: []work.AgentRunID{"same"}, VerifierRunRefs: []work.AgentRunID{"same"}, VerificationEvidence: "verify-1"} + if _, err := AdvanceDevelopmentGate(gate, work.DevelopmentVerified); err == nil { + t.Fatal("same run cannot verify its own output") + } +} diff --git a/tests/semantic_evolution/self_development_contract_test.go b/tests/semantic_evolution/self_development_contract_test.go new file mode 100644 index 0000000..b3de01c --- /dev/null +++ b/tests/semantic_evolution/self_development_contract_test.go @@ -0,0 +1,15 @@ +package semantic_evolution_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/work" + workdomain "git.cin.su/m/Assistant/modules/work/domain" +) + +func TestSelfDevelopmentRollbackIsAvailableOnlyFromCanary(t *testing.T) { + gate := work.DevelopmentGate{ID: "dev-1", Stage: work.DevelopmentCanary, EvidenceRefs: []string{"obs"}, CanaryEvidence: "canary", RollbackRef: "rollback"} + if _, err := workdomain.AdvanceDevelopmentGate(gate, work.DevelopmentRolledBack); err != nil { + t.Fatal(err) + } +} -- 2.54.0