This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user