diff --git a/cmd/core-runtime/learning.go b/cmd/core-runtime/learning.go index b268f68..4a32261 100644 --- a/cmd/core-runtime/learning.go +++ b/cmd/core-runtime/learning.go @@ -11,8 +11,11 @@ import ( "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/interaction" + selfcontract "git.cin.su/m/Assistant/contracts/self" capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" epistemicsdomain "git.cin.su/m/Assistant/modules/epistemics/domain" + selfdomain "git.cin.su/m/Assistant/modules/self/domain" + "git.cin.su/m/Assistant/platform/ids" "git.cin.su/m/Assistant/platform/telemetry" ) @@ -29,15 +32,55 @@ type learningCapabilityStore interface { SaveSkillProfile(context.Context, capability.SkillProfile) error } +type learningSelfStore interface { + SaveLearningSignal(context.Context, selfcontract.LearningSignal) error + SaveImprovementNeed(context.Context, selfcontract.ImprovementNeed) error + SaveLearningLedger(context.Context, selfcontract.LearningLedgerEntry) error +} + type learningRecorder struct { epistemics learningEpistemicsStore capability capabilityLearningStore + self learningSelfStore } type capabilityLearningStore interface { learningCapabilityStore } +func (r learningRecorder) recordSelfSignal(ctx context.Context, signal selfcontract.LearningSignal) { + if r.self == nil { + return + } + if err := signal.Validate(); err != nil { + log.Printf("learning SELF signal invalid: %v", err) + return + } + if err := r.self.SaveLearningSignal(ctx, signal); err != nil { + log.Printf("learning SELF signal unavailable: %v", err) + return + } + diagnosis := selfdomain.AttributeFailure(signal) + need, err := selfdomain.BuildImprovementNeed(signal, diagnosis, string(signal.Kind)) + if err != nil { + log.Printf("learning SELF improvement need invalid: %v", err) + return + } + if err := r.self.SaveImprovementNeed(ctx, need); err != nil { + log.Printf("learning SELF improvement need unavailable: %v", err) + return + } + needRef := selfcontract.SemanticReference{OwnerNamespace: "self", SemanticKind: "improvement_need", StableID: ids.StableID(need.ID)} + ledger := selfcontract.LearningLedgerEntry{ID: selfcontract.LearningLedgerEntryID("ledger:observed:" + signal.ID), PrincipalID: signal.PrincipalID, ImprovementNeedRef: &needRef, ObservedProblem: need.ProblemSummary, EvidenceRefs: signal.EvidenceRefs, Diagnosis: string(diagnosis.Class), Hypothesis: diagnosis.Rationale, ProposedChangeRef: signal.SubjectRef, ExpectedImprovement: need.ExpectedBenefit, Decision: "OBSERVED_REQUIRES_GOVERNED_REVIEW", RecordedAt: signal.ObservedAt} + if err := r.self.SaveLearningLedger(ctx, ledger); err != nil { + log.Printf("learning SELF ledger unavailable: %v", err) + } +} + +func selfEvidence(owner, kind, stable string) selfcontract.SemanticReference { + return selfcontract.SemanticReference{OwnerNamespace: owner, SemanticKind: kind, StableID: ids.StableID(stable)} +} + type reminderResponseLearningInput struct { Utterance interaction.HumanUtterance Intent interaction.OriginalHumanIntent @@ -157,6 +200,11 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction RecordedAtUnixMillis: input.At.UnixMilli(), } r.saveExperienceAndReflection(ctx, experience) + r.recordSelfSignal(ctx, selfcontract.LearningSignal{ + ID: "signal:correction:" + string(input.Utterance.ID), PrincipalID: string(input.Utterance.PrincipalID), Kind: selfcontract.SignalUserCorrection, + TaskPattern: "interaction-correction", ScopeKey: "principal:" + string(input.Utterance.PrincipalID), + SubjectRef: selfEvidence("interaction", "intent", string(input.Intent.ID)), EvidenceRefs: []selfcontract.SemanticReference{selfEvidence("epistemics", "experience", string(input.Utterance.ID))}, ObservedAt: input.At.UnixMilli(), Severity: 2, UserImpact: 2, + }) } func (r learningRecorder) createDelegationCandidateSkill(ctx context.Context, input delegationLearningInput, experience epistemics.ExperienceRecord) { @@ -209,6 +257,11 @@ func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastP if ok { r.updateReminderSkill(ctx, saved, capability.UsageSkipped, false) } + r.recordSelfSignal(ctx, selfcontract.LearningSignal{ + ID: "signal:fast-path:" + string(input.Utterance.ID), PrincipalID: string(input.Utterance.PrincipalID), Kind: selfcontract.SignalSkillFallback, + TaskPattern: "fast-path", ScopeKey: "principal:" + string(input.Utterance.PrincipalID), SubjectRef: selfEvidence("capability", "skill", string(reminderResponseSkillID)), + EvidenceRefs: []selfcontract.SemanticReference{selfEvidence("epistemics", "experience", string(input.Utterance.ID))}, ActualOutcome: string(input.Reason), ObservedAt: input.At.UnixMilli(), Severity: 1, + }) } func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabilityLearningInput) { @@ -242,6 +295,13 @@ func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabil RecordedAtUnixMillis: input.At.UnixMilli(), } r.saveExperienceAndReflection(ctx, experience) + if input.Outcome == capability.UsageFailed || input.Outcome == capability.UsageUnknown { + r.recordSelfSignal(ctx, selfcontract.LearningSignal{ + ID: "signal:capability:" + string(input.Utterance.ID), PrincipalID: string(input.Utterance.PrincipalID), Kind: selfcontract.SignalVerifiedFailure, + TaskPattern: "capability-use", ScopeKey: "principal:" + string(input.Utterance.PrincipalID), SubjectRef: selfEvidence("capability", "implementation", string(input.ImplementationID)), EvidenceRefs: []selfcontract.SemanticReference{selfEvidence("epistemics", "experience", string(input.Utterance.ID))}, + ActualOutcome: string(input.Outcome), ObservedAt: input.At.UnixMilli(), Severity: 2, + }) + } } func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input delegationLearningInput) { @@ -282,6 +342,13 @@ func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input del if ok && input.Verified { r.createDelegationCandidateSkill(ctx, input, saved) } + if !input.Verified { + r.recordSelfSignal(ctx, selfcontract.LearningSignal{ + ID: "signal:delegation:" + input.DelegationID, PrincipalID: string(input.Utterance.PrincipalID), Kind: selfcontract.SignalVerifiedFailure, + TaskPattern: "delegation", ScopeKey: "principal:" + string(input.Utterance.PrincipalID), SubjectRef: selfEvidence("capability", "implementation", string(input.ImplementationID)), EvidenceRefs: []selfcontract.SemanticReference{selfEvidence("epistemics", "experience", input.OutcomeRef)}, + ActualOutcome: string(actual), ObservedAt: input.At.UnixMilli(), Severity: 2, + }) + } } func (r learningRecorder) saveExperienceAndReflection(ctx context.Context, value epistemics.ExperienceRecord) (epistemics.ExperienceRecord, bool) { diff --git a/cmd/core-runtime/learning_test.go b/cmd/core-runtime/learning_test.go index aa563a4..35ffb78 100644 --- a/cmd/core-runtime/learning_test.go +++ b/cmd/core-runtime/learning_test.go @@ -8,6 +8,7 @@ import ( "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/interaction" + selfcontract "git.cin.su/m/Assistant/contracts/self" ) type learningEpistemicsStub struct { @@ -31,6 +32,25 @@ type learningCapabilityStub struct { saved []capability.SkillProfile } +type learningSelfStub struct { + signals int + needs int + ledgers int +} + +func (s *learningSelfStub) SaveLearningSignal(context.Context, selfcontract.LearningSignal) error { + s.signals++ + return nil +} +func (s *learningSelfStub) SaveImprovementNeed(context.Context, selfcontract.ImprovementNeed) error { + s.needs++ + return nil +} +func (s *learningSelfStub) SaveLearningLedger(context.Context, selfcontract.LearningLedgerEntry) error { + s.ledgers++ + return nil +} + func (s *learningCapabilityStub) LoadSkillProfile(context.Context, capability.SkillProfileID) (capability.SkillProfile, error) { s.loadCalls++ return capability.SkillProfile{}, nil @@ -67,3 +87,20 @@ func TestGenericCorrectionDoesNotDegradeReminderSkill(t *testing.T) { t.Fatalf("generic correction must remain evidence without a reminder skill reference: %#v", epistemicsStore.experiences) } } + +func TestCorrectionWritesBoundedSelfEvidenceWithoutChangingResponsePath(t *testing.T) { + epistemicsStore := &learningEpistemicsStub{} + selfStore := &learningSelfStub{} + recorder := learningRecorder{epistemics: epistemicsStore, self: selfStore} + recorder.recordCorrection(context.Background(), correctionLearningInput{ + Intent: interaction.OriginalHumanIntent{ID: "intent-2"}, + Utterance: interaction.HumanUtterance{ID: "utterance-2", PrincipalID: "principal-2"}, + At: time.UnixMilli(2000), + }) + if selfStore.signals != 1 || selfStore.needs != 1 || selfStore.ledgers != 1 { + t.Fatalf("self evidence counts: signals=%d needs=%d ledgers=%d", selfStore.signals, selfStore.needs, selfStore.ledgers) + } + if len(epistemicsStore.experiences) != 1 { + t.Fatalf("completed learning path was changed") + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 2f76c69..767df7f 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -44,6 +44,7 @@ const ( creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" interactionDBPasswordPath = "/run/secrets/interaction_db_password" + selfDBPasswordPath = "/run/secrets/self_db_password" ) const maxCreatorCredentialsBytes = 4 << 10 @@ -117,6 +118,25 @@ func main() { log.Fatal(err) } defer interactionRepository.Close() + var selfRepository *postgres.SelfRepository + selfUser := os.Getenv("SELF_POSTGRES_USER") + selfPassword := "" + if selfUser != "" && os.Getenv("SELF_POSTGRES_CREDENTIAL_REF") != "" { + selfPassword, err = readCredentialRefSecret("SELF_POSTGRES_PASSWORD", "SELF_POSTGRES_CREDENTIAL_REF", selfDBPasswordPath) + if err != nil { + log.Printf("SELF learning repository unavailable; user responses remain unaffected: %v", err) + } + } + if selfUser != "" && selfPassword != "" { + selfRepository, err = postgres.OpenSelf(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: selfUser, Password: selfPassword}) + if err != nil { + log.Printf("SELF learning repository unavailable; user responses remain unaffected: %v", err) + } else { + defer selfRepository.Close() + } + } else if selfUser == "" || os.Getenv("SELF_POSTGRES_CREDENTIAL_REF") == "" { + log.Printf("SELF learning repository is not configured; user responses remain unaffected") + } reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second}) if err != nil { log.Fatal(err) @@ -137,7 +157,7 @@ func main() { if err != nil { log.Fatal(err) } - learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository} + learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository, self: selfRepository} mux := http.NewServeMux() authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index 4d814cb..2571d38 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -53,6 +53,8 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + SELF_POSTGRES_USER: self_role + SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 INTERACTION_POSTGRES_USER: interaction_role diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 19f6029..e122546 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -70,6 +70,8 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + SELF_POSTGRES_USER: self_role + SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1 INTERACTION_POSTGRES_USER: interaction_role diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 15172c9..feef746 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-EVOLVE-004-self-improvement-loop-weakness-diagnosis-and-governed-learning.json","task_id":"GEN2-EVOLVE-004","task_revision":2} +{"task_file":"dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json","task_id":"GEN2-EVOLVE-004-RUNTIME-001","task_revision":2} diff --git a/dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json b/dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json new file mode 100644 index 0000000..474cdce --- /dev/null +++ b/dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json @@ -0,0 +1,23 @@ +{ + "task_id": "GEN2-EVOLVE-004-RUNTIME-001", + "task_revision": 2, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Wire the existing bounded SELF learning loop into the Core composition root without creating a new owner or bypassing existing ports.", + "requirement_ids": ["REQ-GEN2-EVOLVE-004-SELF-RUNTIME-WIRING"], + "allowed_change_scope": {"modules": ["core-runtime learning composition", "SELF persistence integration", "production secret reference wiring", "targeted evidence"], "paths": ["cmd/core-runtime/main.go", "cmd/core-runtime/learning.go", "cmd/core-runtime/learning_test.go", "adapters/postgres/self_repository.go", "adapters/postgres/self_repository_test.go", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "docs/CREDENTIALS_AND_SECRETS.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json", "docs/evidence/**", "docs/PROJECT_STATUS.md"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "go.mod", "go.sum", "cmd/cognitive-worker/**", "modules/**", "adapters/models/**", "database/**", "deploy/caddy/**", "deploy/images/**", "deploy/windows/**", "deploy/PORTAINER.md", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["existing learningRecorder", "existing SELF repository methods", "existing SELF domain contracts"], "may_change": ["Core composition wiring only"]}, + "semantic_owner": "SELF remains the sole owner of derived learning state; Core only coordinates evidence recording.", + "canonical_state": ["SELF write failures never replace a completed user response with an error", "no direct database access outside SelfRepository", "no automatic promotion or production mutation"], + "state_transitions": ["learning signal -> SelfRepository -> bounded derived evidence -> ledger"], + "side_effects_and_privileges": ["use existing self_role repository boundary; do not read or print secrets", "the positive new_privileges value is only the devctl deployment-surface compatibility gate; no privilege is granted and self_role grants are unchanged"], + "persistence_and_migrations": ["no schema change; consume migration 055 tables"], + "failure_retry_restart": ["recording is best-effort after response completion and durable when repository is available; restart reads remain repository-owned"], + "assumptions": ["existing external assistant_core_secrets control plane can materialize self_db_password without exposing its value", "existing production database has migration 055"], + "acceptance_scenarios": ["correction/failure reaches SelfRepository", "repository failure is diagnostic and non-fatal", "durable evidence remains available after repository reopen"], + "required_tests": ["targeted go test", "targeted go vet", "preflight", "check-diff", "independent", "git diff --check", "focused MILESTONE"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index 291ff42..2409200 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -21,6 +21,8 @@ Do not guess Portainer credentials and do not search other project source trees ## Production services Each privileged runtime gets only its required secret/capability bindings. `commit-runtime` is intentionally isolated from the general Core Runtime. +The Core SELF learning repository uses the existing `assistant_core_secrets` volume and credential-reference mechanism with `self_role` and `credential://domovoy/self/db-password/v1`. The secret is consumed from the fixed `/run/secrets/self_db_password` path; its value is never placed in Compose environment, Git, logs or ordinary configuration. The role and schema already exist; this wiring does not create a role or broaden privileges. + Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together. The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile.