234 lines
9.3 KiB
Go
234 lines
9.3 KiB
Go
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")
|
|
var ErrInvalidDevelopmentScaffold = errors.New("invalid development scaffold candidate")
|
|
|
|
// 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
|
|
}
|
|
|
|
// BuildDevelopmentScaffold creates only a typed, isolated candidate. The
|
|
// caller supplies the protected path set so this domain function cannot infer
|
|
// or widen repository authority from ambient process state.
|
|
func BuildDevelopmentScaffold(request work.DevelopmentScaffoldRequest) (work.DevelopmentScaffold, error) {
|
|
modules := cleanStrings(request.Modules)
|
|
allowed := cleanPaths(request.AllowedPaths)
|
|
forbidden := cleanPaths(request.ForbiddenPaths)
|
|
protected := cleanPaths(request.ProtectedPaths)
|
|
tests := cleanStrings(request.RequiredTests)
|
|
lineage := cleanStrings(request.ArtifactLineageRefs)
|
|
if request.ID == "" || request.ProposalRef == "" || strings.TrimSpace(request.BaseRepoRevision) == "" || strings.TrimSpace(request.WorktreeRef) == "" || strings.TrimSpace(request.TaskIntent) == "" || request.CreatedAt.IsZero() || len(modules) == 0 || len(allowed) == 0 || len(tests) == 0 || len(lineage) == 0 || request.ProtectedMainAccess != "DENIED" || request.ProductionAccess != "DENIED" || request.SecretAccess != "DENIED" {
|
|
return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold
|
|
}
|
|
for _, path := range allowed {
|
|
if unsafePath(path) || pathMatchesAny(path, forbidden) || pathMatchesAny(path, protected) {
|
|
return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold
|
|
}
|
|
}
|
|
worktree := strings.ToLower(strings.TrimSpace(request.WorktreeRef))
|
|
if worktree == "main" || worktree == "protected-main" || worktree == "production" {
|
|
return work.DevelopmentScaffold{}, ErrInvalidDevelopmentScaffold
|
|
}
|
|
return work.DevelopmentScaffold{ID: request.ID, Stage: work.DevelopmentScaffoldSandboxed, ProposalRef: request.ProposalRef, BaseRepoRevision: strings.TrimSpace(request.BaseRepoRevision), WorktreeRef: strings.TrimSpace(request.WorktreeRef), TaskIntent: strings.TrimSpace(request.TaskIntent), Modules: modules, AllowedPaths: allowed, ForbiddenPaths: forbidden, RequiredTests: tests, ArtifactLineageRefs: lineage, ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "DENIED", CreatedAtUnixMillis: request.CreatedAt.UnixMilli()}, nil
|
|
}
|
|
|
|
func cleanPaths(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func unsafePath(path string) bool {
|
|
return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "~") || (len(path) > 1 && path[1] == ':') || slices.Contains(strings.Split(path, "/"), "..")
|
|
}
|
|
|
|
func pathMatchesAny(path string, scopes []string) bool {
|
|
for _, scope := range scopes {
|
|
scope = strings.TrimSuffix(scope, "/")
|
|
scope = strings.TrimSuffix(scope, "**")
|
|
scope = strings.TrimSuffix(scope, "/")
|
|
if path == scope || strings.HasPrefix(path, scope+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|