diff --git a/adapters/postgres/work_repository.go b/adapters/postgres/work_repository.go index 8d225d5..f9a9cf5 100644 --- a/adapters/postgres/work_repository.go +++ b/adapters/postgres/work_repository.go @@ -14,9 +14,10 @@ import ( type WorkRepository struct{ pool *pgxpool.Pool } var ( - ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict") - ErrAgentLeaseUnavailable = errors.New("agent lease unavailable") - ErrAgentCallbackRejected = errors.New("agent callback rejected") + ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict") + ErrAgentLeaseUnavailable = errors.New("agent lease unavailable") + ErrAgentCallbackRejected = errors.New("agent callback rejected") + ErrDevelopmentGateConflict = errors.New("development gate revision conflict") ) func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) { @@ -49,6 +50,56 @@ func (r *WorkRepository) Load(ctx context.Context, id work.TaskID) (work.TaskTar return target, nil } +func (r *WorkRepository) LoadDevelopmentGate(ctx context.Context, id string) (work.DevelopmentGate, error) { + var gate work.DevelopmentGate + var evidence, producer, verifier []string + err := r.pool.QueryRow(ctx, `SELECT gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref FROM work.development_gates WHERE gate_id=$1`, id).Scan( + &gate.ID, &gate.Revision, &gate.UpdatedAtUnixMillis, &gate.Stage, &evidence, &gate.ProposalRef, &gate.TaskPacketRef, &gate.WorktreeRef, &producer, &verifier, &gate.VerificationEvidence, &gate.CanaryEvidence, &gate.PromotionRef, &gate.RollbackRef) + if err != nil { + return work.DevelopmentGate{}, fmt.Errorf("load development gate: %w", err) + } + gate.EvidenceRefs = append([]string(nil), evidence...) + for _, run := range producer { + gate.ProducerRunRefs = append(gate.ProducerRunRefs, work.AgentRunID(run)) + } + for _, run := range verifier { + gate.VerifierRunRefs = append(gate.VerifierRunRefs, work.AgentRunID(run)) + } + return gate, nil +} + +func (r *WorkRepository) SaveDevelopmentGate(ctx context.Context, gate work.DevelopmentGate, expectedRevision int64) error { + if gate.ID == "" || expectedRevision < 0 || gate.Revision < 1 || gate.UpdatedAtUnixMillis < 1 { + return ErrDevelopmentGateConflict + } + producer := make([]string, 0, len(gate.ProducerRunRefs)) + for _, run := range gate.ProducerRunRefs { + producer = append(producer, string(run)) + } + verifier := make([]string, 0, len(gate.VerifierRunRefs)) + for _, run := range gate.VerifierRunRefs { + verifier = append(verifier, string(run)) + } + if expectedRevision == 0 { + command, err := r.pool.Exec(ctx, `INSERT INTO work.development_gates(gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(gate_id) DO NOTHING`, gate.ID, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef) + if err != nil { + return err + } + if command.RowsAffected() != 1 { + return ErrDevelopmentGateConflict + } + return nil + } + command, err := r.pool.Exec(ctx, `UPDATE work.development_gates SET revision=$1,updated_at_unix_millis=$2,stage=$3,evidence_refs=$4,proposal_ref=$5,task_packet_ref=$6,worktree_ref=$7,producer_run_refs=$8,verifier_run_refs=$9,verification_evidence=$10,canary_evidence=$11,promotion_ref=$12,rollback_ref=$13 WHERE gate_id=$14 AND revision=$15`, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef, gate.ID, expectedRevision) + if err != nil { + return err + } + if command.RowsAffected() != 1 { + return ErrDevelopmentGateConflict + } + return nil +} + func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error { tx, err := r.pool.Begin(ctx) if err != nil { diff --git a/contracts/work/self_development.go b/contracts/work/self_development.go index 61f7036..872641f 100644 --- a/contracts/work/self_development.go +++ b/contracts/work/self_development.go @@ -17,6 +17,8 @@ const ( type DevelopmentGate struct { ID string + Revision int64 + UpdatedAtUnixMillis int64 Stage DevelopmentStage EvidenceRefs []string ProposalRef string diff --git a/database/migrations/037_work_development_gates.sql b/database/migrations/037_work_development_gates.sql new file mode 100644 index 0000000..1656375 --- /dev/null +++ b/database/migrations/037_work_development_gates.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS work.development_gates ( + gate_id TEXT PRIMARY KEY, + revision BIGINT NOT NULL CHECK (revision > 0), + updated_at_unix_millis BIGINT NOT NULL CHECK (updated_at_unix_millis > 0), + stage TEXT NOT NULL, + evidence_refs TEXT[] NOT NULL, + proposal_ref TEXT NOT NULL, + task_packet_ref TEXT NOT NULL, + worktree_ref TEXT NOT NULL, + producer_run_refs TEXT[] NOT NULL, + verifier_run_refs TEXT[] NOT NULL, + verification_evidence TEXT NOT NULL, + canary_evidence TEXT NOT NULL, + promotion_ref TEXT NOT NULL, + rollback_ref TEXT NOT NULL +); diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b88e947..9a297b9 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000196-agent-canary-live-evidence.json", - "task_id": "DEV-000196", + "task_file": "dev/tasks/DEV-000197-durable-development-gate.json", + "task_id": "DEV-000197", "task_revision": 1 } diff --git a/dev/tasks/DEV-000197-durable-development-gate.json b/dev/tasks/DEV-000197-durable-development-gate.json new file mode 100644 index 0000000..cbaa310 --- /dev/null +++ b/dev/tasks/DEV-000197-durable-development-gate.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000197", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "fde14cf0b81e2772c81ee7cb9b0335b56943f6e4", + "risk_tier": "E", + "intent": "Persist the governed self-development gate as a Work-owned revisioned state machine with optimistic concurrency, without executing code or granting promotion authority.", + "requirement_ids": ["WP-701", "durable governed self-development provenance", "CAS stage transitions"], + "allowed_change_scope": {"modules": ["work", "postgres persistence"], "paths": ["contracts/work/self_development.go", "modules/work/ports/development.go", "adapters/postgres/work_repository.go", "database/migrations/037_work_development_gates.sql", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000197-durable-development-gate.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/**", "modules/identity/**", "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": 1}, + "contracts": {"consume": ["existing DevelopmentGate state machine", "Work-owned persistence boundary"], "may_change": ["revision metadata on DevelopmentGate"]}, + "semantic_owner": "work-agent-execution", + "canonical_state": ["work.development_gates"], + "state_transitions": ["persist initial evidence gate", "advance only through caller-validated stage with expected revision", "reject stale revision"], + "side_effects_and_privileges": ["database persistence only; no worktree, provider, source, deployment or promotion mutation"], + "persistence_and_migrations": ["037_work_development_gates.sql"], + "failure_retry_restart": ["CAS conflict is explicit and retry requires reloading the gate; restart reloads durable provenance"], + "assumptions": ["domain AdvanceDevelopmentGate remains the sole stage transition validator", "external bridge is a later independently operated capability"], + "acceptance_scenarios": ["initial gate persists at revision 1", "update with expected revision succeeds", "stale revision is rejected", "all provenance fields round-trip without secrets"], + "required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent -task dev/tasks/DEV-000197-durable-development-gate.json", "git diff --check", "CI", "production migration and health check"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/modules/work/ports/development.go b/modules/work/ports/development.go new file mode 100644 index 0000000..8d3aaea --- /dev/null +++ b/modules/work/ports/development.go @@ -0,0 +1,15 @@ +package ports + +import ( + "context" + + "git.cin.su/m/Assistant/contracts/work" +) + +// DevelopmentGateStore persists only the governed state machine and its +// provenance. It does not create worktrees, execute code or grant promotion +// authority. +type DevelopmentGateStore interface { + LoadDevelopmentGate(context.Context, string) (work.DevelopmentGate, error) + SaveDevelopmentGate(context.Context, work.DevelopmentGate, int64) error +}