diff --git a/contracts/work/self_development.go b/contracts/work/self_development.go index 5e3b6ba..366bef5 100644 --- a/contracts/work/self_development.go +++ b/contracts/work/self_development.go @@ -1,5 +1,7 @@ package work +import "time" + type DevelopmentStage string const ( @@ -27,3 +29,56 @@ type DevelopmentGate struct { PromotionRef string RollbackRef string } + +// ExtensionProposalStage is deliberately proposal-only in this contract. A +// proposal does not authorize source changes, provider calls, deployment or +// promotion; those stages require the existing governed development gate. +type ExtensionProposalStage string + +const ExtensionProposalProposed ExtensionProposalStage = "PROPOSED" + +// ExtensionEvidence is an immutable reference to an observation that points +// at a repeated or corroborated improvement signal. The payload is owned by +// its source module; Work stores only the typed reference and stable signal +// key. +type ExtensionEvidence struct { + Ref string + SignalKey string +} + +// ExtensionProposalRequest contains the operator/governance information +// needed to decide whether an evidence pattern is worth reviewing. Empty +// fields are rejected by the proposal generator rather than inferred. +type ExtensionProposalRequest struct { + ID string + DesiredOutcome string + SmallestChangeLayer string + AffectedOwners []string + AffectedContracts []string + RiskTier string + EvaluationPlan []string + Alternatives []string + RollbackPlan string + Assumptions []string + CreatedAt time.Time +} + +// ExtensionProposal is a bounded, evidence-bearing candidate for governed +// development. It intentionally contains no executable command, credential, +// authority grant, implementation body or promotion decision. +type ExtensionProposal struct { + ID string + Stage ExtensionProposalStage + EvidenceRefs []string + DeduplicatedSignalKeys []string + DesiredOutcome string + SmallestChangeLayer string + AffectedOwners []string + AffectedContracts []string + RiskTier string + EvaluationPlan []string + Alternatives []string + RollbackPlan string + Assumptions []string + CreatedAtUnixMillis int64 +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 539e6fc..7aba33d 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000185-agent-bridge-protected-config.json", - "task_id": "DEV-000185", + "task_file": "dev/tasks/DEV-000186-extension-proposal-generator.json", + "task_id": "DEV-000186", "task_revision": 1 } diff --git a/dev/tasks/DEV-000186-extension-proposal-generator.json b/dev/tasks/DEV-000186-extension-proposal-generator.json new file mode 100644 index 0000000..cc90078 --- /dev/null +++ b/dev/tasks/DEV-000186-extension-proposal-generator.json @@ -0,0 +1,37 @@ +{ + "task_id": "DEV-000186", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "cdbccb6", + "risk_tier": "E", + "intent": "Add a typed, proposal-only ExtensionProposal generator from repeated or corroborated evidence without granting mutation, authority, deployment, or promotion capability.", + "requirement_ids": ["WP-601", "Extension proposal generator", "Evidence deduplication", "Governed rollback plan"], + "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-000186-extension-proposal-generator.json"] + }, + "forbidden_change_scope": { + "paths": ["architecture/**", "security/**", "database/**", "cmd/**", "adapters/**", "deploy/**", "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": ["ADR-0022 governed self-development boundary", "existing Work DevelopmentGate"], + "may_change": ["typed Work ExtensionProposal value objects"] + }, + "semantic_owner": "work-agent-execution", + "canonical_state": ["No durable canonical state; proposal value object only"], + "state_transitions": ["repeated evidence -> PROPOSED ExtensionProposal", "insufficient/conflicting/incomplete evidence -> rejected result"], + "side_effects_and_privileges": ["No source mutation, provider invocation, secret access, deployment, promotion, or authority expansion"], + "persistence_and_migrations": ["No schema or migration changes"], + "failure_retry_restart": ["Deterministic generation is rerunnable; unique evidence refs and signal keys preserve lineage; one failure cannot generate a proposal"], + "assumptions": ["Human governance remains the promotion authority", "A later task may add durable proposal persistence only after owner and migration review"], + "acceptance_scenarios": ["one failure is rejected", "repeated same-signal evidence produces one proposal with deduplicated signal keys and retained refs", "missing assumptions/evaluation/rollback fails closed", "proposal contains no executable authority or mutation field"], + "required_tests": ["go test ./modules/work/domain ./tests/semantic_evolution", "go vet ./...", "go run ./cmd/devctl preflight -task dev/tasks/DEV-000186-extension-proposal-generator.json", "go run ./cmd/devctl check-diff -task dev/tasks/DEV-000186-extension-proposal-generator.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check", "CI"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"] +} diff --git a/modules/work/domain/self_development.go b/modules/work/domain/self_development.go index 3fbac1f..0bea924 100644 --- a/modules/work/domain/self_development.go +++ b/modules/work/domain/self_development.go @@ -3,11 +3,88 @@ package domain import ( "errors" "slices" + "strings" "git.cin.su/m/Assistant/contracts/work" ) var ErrInvalidDevelopmentGate = errors.New("invalid governed self-development gate") +var ErrInsufficientExtensionEvidence = errors.New("insufficient repeated extension evidence") +var ErrInvalidExtensionProposal = errors.New("invalid extension proposal") + +// BuildExtensionProposal deterministically turns a repeated evidence signal +// into a proposal. It retains every distinct evidence reference for lineage, +// while deduplicating signal keys so repeated reports do not create duplicate +// proposals. It never creates a task, mutates an implementation or grants +// authority. +func BuildExtensionProposal(evidence []work.ExtensionEvidence, request work.ExtensionProposalRequest) (work.ExtensionProposal, error) { + owners := cleanStrings(request.AffectedOwners) + contracts := cleanStrings(request.AffectedContracts) + evaluation := cleanStrings(request.EvaluationPlan) + alternatives := cleanStrings(request.Alternatives) + assumptions := cleanStrings(request.Assumptions) + if request.ID == "" || request.CreatedAt.IsZero() || strings.TrimSpace(request.DesiredOutcome) == "" || strings.TrimSpace(request.SmallestChangeLayer) == "" || strings.TrimSpace(request.RiskTier) == "" || strings.TrimSpace(request.RollbackPlan) == "" || len(owners) == 0 || len(contracts) == 0 || len(evaluation) == 0 || len(alternatives) == 0 || len(assumptions) == 0 { + return work.ExtensionProposal{}, ErrInvalidExtensionProposal + } + refs := make([]string, 0, len(evidence)) + refSeen := make(map[string]struct{}, len(evidence)) + counts := make(map[string]int, len(evidence)) + keys := make([]string, 0, len(evidence)) + keySeen := make(map[string]struct{}, len(evidence)) + for _, item := range evidence { + ref := strings.TrimSpace(item.Ref) + key := strings.TrimSpace(item.SignalKey) + if ref == "" || key == "" { + return work.ExtensionProposal{}, ErrInvalidExtensionProposal + } + if _, ok := refSeen[ref]; !ok { + refs = append(refs, ref) + refSeen[ref] = struct{}{} + } + counts[key]++ + if _, ok := keySeen[key]; !ok { + keys = append(keys, key) + keySeen[key] = struct{}{} + } + } + if len(refs) < 2 { + return work.ExtensionProposal{}, ErrInsufficientExtensionEvidence + } + repeated := false + for _, count := range counts { + if count >= 2 { + repeated = true + break + } + } + if !repeated { + return work.ExtensionProposal{}, ErrInsufficientExtensionEvidence + } + return work.ExtensionProposal{ + ID: request.ID, Stage: work.ExtensionProposalProposed, EvidenceRefs: refs, DeduplicatedSignalKeys: keys, + DesiredOutcome: strings.TrimSpace(request.DesiredOutcome), SmallestChangeLayer: strings.TrimSpace(request.SmallestChangeLayer), + AffectedOwners: owners, AffectedContracts: contracts, + RiskTier: strings.TrimSpace(request.RiskTier), EvaluationPlan: evaluation, Alternatives: alternatives, + RollbackPlan: strings.TrimSpace(request.RollbackPlan), Assumptions: assumptions, CreatedAtUnixMillis: request.CreatedAt.UnixMilli(), + }, nil +} + +func cleanStrings(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} func AdvanceDevelopmentGate(current work.DevelopmentGate, next work.DevelopmentStage) (work.DevelopmentGate, error) { if err := ValidateDevelopmentGate(current); err != nil { diff --git a/modules/work/domain/self_development_test.go b/modules/work/domain/self_development_test.go index da0e884..2348dcf 100644 --- a/modules/work/domain/self_development_test.go +++ b/modules/work/domain/self_development_test.go @@ -2,6 +2,7 @@ package domain import ( "testing" + "time" "git.cin.su/m/Assistant/contracts/work" ) @@ -20,6 +21,36 @@ func TestGovernedSelfDevelopmentRequiresIndependentVerifierBeforeCanary(t *testi } } +func TestBuildExtensionProposalRequiresRepeatedEvidenceAndRetainsLineage(t *testing.T) { + proposal, err := BuildExtensionProposal([]work.ExtensionEvidence{ + {Ref: "reflection-1", SignalKey: "send-email-gap"}, + {Ref: "reflection-2", SignalKey: "send-email-gap"}, + {Ref: "reflection-2", SignalKey: "send-email-gap"}, + }, work.ExtensionProposalRequest{ + ID: "proposal-1", DesiredOutcome: "connect a reviewed email capability", SmallestChangeLayer: "capability-adapter", + AffectedOwners: []string{"capability", "commit"}, AffectedContracts: []string{"CapabilityImplementation@v1"}, RiskTier: "D", + EvaluationPlan: []string{"fake transport", "adversarial corpus"}, Alternatives: []string{"keep capability gap"}, + RollbackPlan: "restore previous implementation reference", Assumptions: []string{"human promotion required"}, CreatedAt: time.UnixMilli(42), + }) + if err != nil { + t.Fatal(err) + } + if proposal.Stage != work.ExtensionProposalProposed || len(proposal.EvidenceRefs) != 2 || len(proposal.DeduplicatedSignalKeys) != 1 || proposal.CreatedAtUnixMillis != 42 { + t.Fatalf("unexpected proposal: %+v", proposal) + } +} + +func TestBuildExtensionProposalRejectsSingleFailureAndIncompleteGovernance(t *testing.T) { + request := work.ExtensionProposalRequest{ID: "proposal-1", DesiredOutcome: "x", SmallestChangeLayer: "skill", AffectedOwners: []string{"capability"}, AffectedContracts: []string{"Skill@v1"}, RiskTier: "B", EvaluationPlan: []string{"test"}, Alternatives: []string{"keep current behavior"}, RollbackPlan: "disable", Assumptions: []string{"human review"}, CreatedAt: time.UnixMilli(42)} + if _, err := BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "failure-1", SignalKey: "same"}}, request); err != ErrInsufficientExtensionEvidence { + t.Fatalf("single failure must be rejected, got %v", err) + } + request.Assumptions = nil + if _, err := BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "failure-1", SignalKey: "same"}, {Ref: "failure-2", SignalKey: "same"}}, request); err != ErrInvalidExtensionProposal { + t.Fatalf("missing assumptions must be rejected, got %v", err) + } +} + 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 { diff --git a/tests/semantic_evolution/self_development_contract_test.go b/tests/semantic_evolution/self_development_contract_test.go index b3de01c..9e3c2d8 100644 --- a/tests/semantic_evolution/self_development_contract_test.go +++ b/tests/semantic_evolution/self_development_contract_test.go @@ -2,6 +2,7 @@ package semantic_evolution_test import ( "testing" + "time" "git.cin.su/m/Assistant/contracts/work" workdomain "git.cin.su/m/Assistant/modules/work/domain" @@ -13,3 +14,15 @@ func TestSelfDevelopmentRollbackIsAvailableOnlyFromCanary(t *testing.T) { t.Fatal(err) } } + +func TestExtensionProposalIsProposalOnlyAndDeduplicatesSignals(t *testing.T) { + proposal, err := workdomain.BuildExtensionProposal([]work.ExtensionEvidence{{Ref: "gap-1", SignalKey: "printer-driver"}, {Ref: "gap-2", SignalKey: "printer-driver"}}, work.ExtensionProposalRequest{ + ID: "proposal-printer", DesiredOutcome: "review printer adapter", SmallestChangeLayer: "capability-adapter", AffectedOwners: []string{"capability"}, AffectedContracts: []string{"CapabilityImplementation@v1"}, RiskTier: "C", EvaluationPlan: []string{"contract tests"}, Alternatives: []string{"retain capability gap"}, RollbackPlan: "disable candidate", Assumptions: []string{"human promotion"}, CreatedAt: time.UnixMilli(7), + }) + if err != nil { + t.Fatal(err) + } + if proposal.Stage != work.ExtensionProposalProposed || len(proposal.DeduplicatedSignalKeys) != 1 { + t.Fatalf("unexpected proposal stage/keys: %+v", proposal) + } +}