feat: generate governed extension proposals from repeated evidence #159
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user