Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44541b1ba3 | ||
|
|
9c42b18850 | ||
|
|
3a8c6d4827 | ||
|
|
8b1d75f337 | ||
|
|
cb50b7357b | ||
|
|
dfbee7568f | ||
|
|
f803cb67f0 | ||
|
|
75fa8477d8 | ||
|
|
b4c3479614 | ||
|
|
82b89a37d3 | ||
|
|
4559e0439c | ||
|
|
b39c3ed153 | ||
|
|
dd04a67731 | ||
|
|
a0bd2a1831 | ||
|
|
c39126e0c1 | ||
|
|
8ec7c7419f | ||
|
|
a07aba056d | ||
|
|
901d5e011d | ||
|
|
74f326f11a | ||
|
|
850fe92ecb | ||
|
|
9312441d00 | ||
|
|
a6273d0134 | ||
|
|
e648d0423d | ||
|
|
07f0f6e380 | ||
|
|
e1b9c7ca84 | ||
|
|
00af176a20 | ||
|
|
ce7000e78e | ||
|
|
6204e14177 | ||
|
|
185317ee6a | ||
|
|
2e824b3c1f | ||
|
|
f0360a8e13 | ||
|
|
5593f2ac2c | ||
|
|
216d76f1d8 | ||
|
|
499b9678e1 | ||
|
|
78a39ec011 | ||
|
|
d120cc0958 | ||
|
|
2ee5da8114 | ||
|
|
91f0c9e799 | ||
|
|
cb66a60db2 | ||
|
|
6a4f973e38 | ||
|
|
87ec7efd0f | ||
|
|
51e33e7283 | ||
|
|
e8f35f6393 | ||
|
|
8babf6ae1f | ||
|
|
d0210e5aed | ||
|
|
ebdfaa0769 | ||
|
|
9e0faa7c47 | ||
|
|
7f8a05ccfb | ||
|
|
c94d04acd0 | ||
|
|
bb866cd2ab | ||
|
|
892266a4c8 | ||
|
|
ac531c3ec3 | ||
|
|
4cd50a0937 | ||
|
|
8b751257e8 | ||
|
|
cbbb60e081 | ||
|
|
46e3cfc445 | ||
|
|
5832c49ad8 | ||
|
|
b13d12583b | ||
|
|
9d5473d260 | ||
|
|
50b508b641 | ||
|
|
8400cdd92d | ||
|
|
ec1860d441 | ||
|
|
134ba26e08 | ||
|
|
b82b8b874f | ||
|
|
5c9d4663f8 | ||
|
|
ec9d434650 | ||
|
|
a8a60ce29e | ||
|
|
96cf66a876 | ||
|
|
381b52d06d | ||
|
|
4ceec5a316 | ||
|
|
93a9c1a3bd | ||
|
|
f3ad7d682b | ||
|
|
387de89efe | ||
|
|
346e0acb6b | ||
|
|
3efcbbded8 | ||
|
|
a0b742b1d5 | ||
|
|
86aacea99e | ||
|
|
2a8e4e1b81 | ||
|
|
d64080ef2b | ||
|
|
59d32143bf | ||
|
|
031ba7666c | ||
|
|
93feb9bf2d | ||
|
|
a957172d3c | ||
|
|
3182092d5c | ||
|
|
8aa5179d47 | ||
|
|
5c95916648 | ||
|
|
5b5875d0f4 | ||
|
|
550738f3ca | ||
|
|
ad851a98aa | ||
|
|
62fd265bb7 | ||
|
|
e1c2e5f59c | ||
|
|
41b5c57d05 | ||
|
|
d0103bbf8e | ||
|
|
e9c3df26ae | ||
|
|
916b93517a | ||
|
|
44ac0203a9 |
@@ -32,6 +32,8 @@ type OpenAICompatibleLocalAdapter struct {
|
||||
// qualification/evaluation in the capability registry.
|
||||
const compatibleDefaultContextBytes int64 = 32 << 10
|
||||
|
||||
const compatibleAdvisoryMaxTokens = 32
|
||||
|
||||
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
|
||||
}
|
||||
@@ -65,6 +67,9 @@ type compatibleModelsResponse struct {
|
||||
ID string `json:"id"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
ModelKind string `json:"model_kind"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
Modalities map[string]bool `json:"modalities"`
|
||||
Meta struct {
|
||||
Context int64 `json:"n_ctx"`
|
||||
TrainingContext int64 `json:"n_ctx_train"`
|
||||
@@ -94,6 +99,12 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
|
||||
if item.ID == "" {
|
||||
continue
|
||||
}
|
||||
// The gateway catalogue may contain image/audio runtimes beside local
|
||||
// chat models. Discovery is not allowed to qualify those as text
|
||||
// advisory models merely because they appear in /v1/models.
|
||||
if !isTextAdvisoryModel(item.ModelKind, item.Capabilities, item.Modalities) {
|
||||
continue
|
||||
}
|
||||
stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType)
|
||||
digest := sha256.Sum256([]byte(stableMetadata))
|
||||
contextBytes := item.Meta.Context * 4
|
||||
@@ -105,6 +116,19 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
|
||||
return models, ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
|
||||
func isTextAdvisoryModel(kind string, capabilities, modalities map[string]bool) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(kind), "image-generation") || strings.EqualFold(strings.TrimSpace(kind), "audio-transcription") || strings.EqualFold(strings.TrimSpace(kind), "text-to-speech") {
|
||||
return false
|
||||
}
|
||||
if chat, known := capabilities["chat"]; known && !chat {
|
||||
return false
|
||||
}
|
||||
if text, known := modalities["text"]; known && !text {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type compatibleChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []compatibleMessage `json:"messages"`
|
||||
@@ -135,7 +159,11 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
|
||||
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" {
|
||||
return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory
|
||||
}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Ты отвечаешь от имени Домового — локального помощника пользователя. Не называй себя именем модели, поставщиком или внутренним runtime. Если спрашивают имя, ответь: «Меня зовут Домовой». Понимай смысл сообщения и помогай довести цель до полезного результата. По умолчанию отвечай кратко, примерно одним-тремя предложениями; подробности давай только по просьбе. Отвечай только некатегоричным текстом совета; не утверждай, что внешнее действие уже выполнено."}, {Role: "user", Content: localAdvisoryMessage(request)}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
|
||||
maxTokens := request.MaxOutputBytes / 4
|
||||
if maxTokens <= 0 || maxTokens > compatibleAdvisoryMaxTokens {
|
||||
maxTokens = compatibleAdvisoryMaxTokens
|
||||
}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Ты отвечаешь от имени Домового — локального помощника пользователя. Не называй себя именем модели, поставщиком или внутренним runtime. Если спрашивают имя, ответь: «Меня зовут Домовой». Понимай смысл сообщения и помогай довести цель до полезного результата. По умолчанию отвечай кратко, примерно одним-тремя предложениями; подробности давай только по просьбе. Отвечай только некатегоричным текстом совета; не утверждай, что внешнее действие уже выполнено."}, {Role: "user", Content: localAdvisoryMessage(request)}}, Temperature: 0, Stream: false, MaxTokens: maxTokens, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
|
||||
var result compatibleChatResponse
|
||||
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
|
||||
if err != nil {
|
||||
@@ -236,6 +264,8 @@ func mapCompatibleStatus(status int) cognitive.FinishReason {
|
||||
return cognitive.FinishContextTooLarge
|
||||
case 429:
|
||||
return cognitive.FinishRateLimited
|
||||
case 400, 422:
|
||||
return cognitive.FinishInvalidProviderResponse
|
||||
default:
|
||||
return cognitive.FinishProviderUnavailable
|
||||
}
|
||||
|
||||
@@ -29,6 +29,37 @@ func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Respon
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mixedModelsRoundTripper struct{}
|
||||
|
||||
func (mixedModelsRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"image-model","model_kind":"image-generation","capabilities":{"chat":false,"image_generation":true},"modalities":{"text":false,"image_generation":true}},{"id":"text-model","model_kind":"chat","capabilities":{"chat":true},"modalities":{"text":true},"meta":{}}]}`)),
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterDoesNotQualifyNonTextModels(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", mixedModelsRoundTripper{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models, probe := adapter.Discover(context.Background())
|
||||
if probe.Health != capability.HealthAvailable || len(models) != 1 || models[0].ProviderModelID != "text-model" {
|
||||
t.Fatalf("unexpected filtered discovery: probe=%+v models=%+v", probe, models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterMapsImmediateBadRequest(t *testing.T) {
|
||||
if got := mapCompatibleStatus(http.StatusBadRequest); got != cognitive.FinishInvalidProviderResponse {
|
||||
t.Fatalf("400 finish=%s, want %s", got, cognitive.FinishInvalidProviderResponse)
|
||||
}
|
||||
if got := mapCompatibleStatus(http.StatusUnprocessableEntity); got != cognitive.FinishInvalidProviderResponse {
|
||||
t.Fatalf("422 finish=%s, want %s", got, cognitive.FinishInvalidProviderResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{})
|
||||
if err != nil {
|
||||
@@ -109,6 +140,7 @@ func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
ChatTemplateKwargs struct {
|
||||
EnableThinking bool `json:"enable_thinking"`
|
||||
} `json:"chat_template_kwargs"`
|
||||
@@ -116,6 +148,9 @@ func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(
|
||||
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.MaxTokens != compatibleAdvisoryMaxTokens {
|
||||
t.Fatalf("max_tokens=%d, want bounded %d", payload.MaxTokens, compatibleAdvisoryMaxTokens)
|
||||
}
|
||||
if payload.ChatTemplateKwargs.EnableThinking {
|
||||
t.Fatal("expected bounded advisory to disable template thinking")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -305,12 +306,14 @@ func (r *CapabilityRepository) SaveSkillProfile(ctx context.Context, value capab
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.skill_profiles (
|
||||
skill_profile_id,revision,name,description,applicable_context,implementation_ref,implementation_version,status,
|
||||
supporting_experience_refs,known_failure_experience_refs,confidence_millis,last_validated_unix_millis,
|
||||
observed_failure_count,observed_correction_count,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT DO NOTHING`,
|
||||
observed_failure_count,observed_correction_count,recorded_at_unix_millis,applicability,dependency_refs,validation_evidence_refs,
|
||||
independent_evaluation_refs,known_failure_conditions,verified_outcome_count,must_not_worsen_satisfied)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.Revision, value.Name, value.Description, value.ApplicableContext, value.ImplementationRef,
|
||||
value.ImplementationVersion, value.Status, value.SupportingExperienceRefs, value.KnownFailureExperienceRefs,
|
||||
value.ConfidenceMillis, value.LastValidatedUnixMillis, value.ObservedFailureCount, value.ObservedCorrectionCount,
|
||||
value.RecordedAtUnixMillis)
|
||||
value.RecordedAtUnixMillis, mustJSON(value.Applicability), value.DependencyRefs, value.ValidationEvidenceRefs,
|
||||
value.IndependentEvaluationRefs, value.KnownFailureConditions, value.VerifiedOutcomeCount, value.MustNotWorsenSatisfied)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -329,13 +332,16 @@ func (r *CapabilityRepository) LoadSkillProfile(ctx context.Context, id capabili
|
||||
err := r.pool.QueryRow(ctx, `SELECT
|
||||
p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status,
|
||||
p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis,
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis,p.applicability,p.dependency_refs,
|
||||
p.validation_evidence_refs,p.independent_evaluation_refs,p.known_failure_conditions,p.verified_outcome_count,p.must_not_worsen_satisfied
|
||||
FROM capability.skill_profile_heads h
|
||||
JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision
|
||||
WHERE p.skill_profile_id=$1`, id).Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext,
|
||||
&value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs,
|
||||
&value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis,
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis)
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis, newJSONTarget(&value.Applicability),
|
||||
&value.DependencyRefs, &value.ValidationEvidenceRefs, &value.IndependentEvaluationRefs, &value.KnownFailureConditions,
|
||||
&value.VerifiedOutcomeCount, &value.MustNotWorsenSatisfied)
|
||||
if err != nil {
|
||||
return capability.SkillProfile{}, fmt.Errorf("load skill profile: %w", err)
|
||||
}
|
||||
@@ -349,7 +355,8 @@ func (r *CapabilityRepository) RecentSkillProfiles(ctx context.Context, limit in
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
p.skill_profile_id,p.revision,p.name,p.description,p.applicable_context,p.implementation_ref,p.implementation_version,p.status,
|
||||
p.supporting_experience_refs,p.known_failure_experience_refs,p.confidence_millis,p.last_validated_unix_millis,
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis
|
||||
p.observed_failure_count,p.observed_correction_count,p.recorded_at_unix_millis,p.applicability,p.dependency_refs,
|
||||
p.validation_evidence_refs,p.independent_evaluation_refs,p.known_failure_conditions,p.verified_outcome_count,p.must_not_worsen_satisfied
|
||||
FROM capability.skill_profile_heads h
|
||||
JOIN capability.skill_profiles p ON p.skill_profile_id=h.skill_profile_id AND p.revision=h.revision
|
||||
ORDER BY h.updated_at DESC, p.skill_profile_id LIMIT $1`, limit)
|
||||
@@ -363,7 +370,9 @@ func (r *CapabilityRepository) RecentSkillProfiles(ctx context.Context, limit in
|
||||
if err := rows.Scan(&value.ID, &value.Revision, &value.Name, &value.Description, &value.ApplicableContext,
|
||||
&value.ImplementationRef, &value.ImplementationVersion, &value.Status, &value.SupportingExperienceRefs,
|
||||
&value.KnownFailureExperienceRefs, &value.ConfidenceMillis, &value.LastValidatedUnixMillis,
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis); err != nil {
|
||||
&value.ObservedFailureCount, &value.ObservedCorrectionCount, &value.RecordedAtUnixMillis, newJSONTarget(&value.Applicability),
|
||||
&value.DependencyRefs, &value.ValidationEvidenceRefs, &value.IndependentEvaluationRefs, &value.KnownFailureConditions,
|
||||
&value.VerifiedOutcomeCount, &value.MustNotWorsenSatisfied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
@@ -371,6 +380,40 @@ func (r *CapabilityRepository) RecentSkillProfiles(ctx context.Context, limit in
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveReflexRevision(ctx context.Context, reflex capability.Reflex, value capability.ReflexRevision) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.reflex_revisions (
|
||||
reflex_id,revision,principal_id,source_skill_id,source_skill_revision,kind,applicability,implementation_ref,implementation_version,artifact_digest,
|
||||
dependency_refs,validation_evidence_refs,known_failure_experience_refs,shadow_policy,revalidation_policy,last_validated_at,lifecycle,
|
||||
supersedes_revision,execution_count,failure_count,divergence_count,recorded_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) ON CONFLICT DO NOTHING`,
|
||||
value.ReflexID, value.Revision, reflex.PrincipalID, reflex.SourceSkillRef, value.SourceSkillRevision, value.Kind, mustJSON(value.Applicability), value.ImplementationRef, value.ImplementationVersion, value.ArtifactDigest,
|
||||
value.DependencyRefs, value.ValidationEvidenceRefs, value.KnownFailureExperienceRefs, mustJSON(value.Shadow), mustJSON(value.Revalidation), value.LastValidatedAt, value.Lifecycle,
|
||||
value.SupersedesRevision, value.ExecutionCount, value.FailureCount, value.DivergenceCount, value.RecordedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO capability.reflex_heads(reflex_id,revision,lifecycle) VALUES($1,$2,$3)
|
||||
ON CONFLICT(reflex_id) DO UPDATE SET revision=EXCLUDED.revision,lifecycle=EXCLUDED.lifecycle,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE capability.reflex_heads.revision < EXCLUDED.revision`, value.ReflexID, value.Revision, value.Lifecycle)
|
||||
return err
|
||||
}
|
||||
|
||||
func mustJSON(value any) []byte { encoded, _ := json.Marshal(value); return encoded }
|
||||
|
||||
type jsonTarget struct{ target any }
|
||||
|
||||
func newJSONTarget(target any) *jsonTarget { return &jsonTarget{target: target} }
|
||||
func (v *jsonTarget) Scan(source any) error {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
bytes, ok := source.([]byte)
|
||||
if !ok {
|
||||
bytes = []byte(fmt.Sprint(source))
|
||||
}
|
||||
return json.Unmarshal(bytes, v.target)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) {
|
||||
var binding capability.Binding
|
||||
err := r.pool.QueryRow(ctx, `SELECT binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/platform/telemetry"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,11 @@ func (r *EpistemicsRepository) Ping(ctx context.Context) error {
|
||||
// TruthRuntimeStatus is a read-only projection of durable truth bundles.
|
||||
// It does not promote claims, revise beliefs, or mutate epistemic state.
|
||||
func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRuntimeStatus, error) {
|
||||
started := time.Now()
|
||||
outcome := "SUCCESS"
|
||||
defer func() {
|
||||
telemetry.RecordCompatibility("truth-bundles-status-reader", "epistemics-repository", "read", "research-status", true, false, true, outcome, "truth-status", "epistemics", started)
|
||||
}()
|
||||
status := TruthRuntimeStatus{
|
||||
ClaimsByVerification: map[string]int64{},
|
||||
BeliefsByState: map[string]int64{},
|
||||
@@ -43,6 +49,7 @@ func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRun
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM epistemics.truth_bundles`).Scan(&status.BundleCount); err != nil {
|
||||
outcome = "FAILURE"
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
queries := []struct {
|
||||
@@ -56,18 +63,21 @@ func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRun
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
outcome = "FAILURE"
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
outcome = "FAILURE"
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
outcome = "FAILURE"
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
@@ -91,10 +101,10 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi
|
||||
return nil, fmt.Errorf("invalid experience limit")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
experience_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref,
|
||||
experience_id,principal_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref,
|
||||
capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id,
|
||||
expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id,
|
||||
verification_evidence,confidence_millis,recorded_at_unix_millis
|
||||
verification_evidence,confidence_millis,recorded_at_unix_millis,context_scope_ref,problem_statement,applicability_conditions,why_succeeded_or_failed
|
||||
FROM epistemics.experience_records ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -103,10 +113,44 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi
|
||||
var result []epistemics.ExperienceRecord
|
||||
for rows.Next() {
|
||||
var value epistemics.ExperienceRecord
|
||||
if err := rows.Scan(&value.ID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef,
|
||||
if err := rows.Scan(&value.ID, &value.PrincipalID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef,
|
||||
&value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID,
|
||||
&value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID,
|
||||
&value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis); err != nil {
|
||||
&value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis, &value.ContextScopeRef, &value.ProblemStatement, &value.ApplicabilityConditions, &value.WhySucceededOrFailed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// RecentExperienceRecordsForPrincipalAndScope is an owner-local Epistemics
|
||||
// read. It intentionally applies Principal and ContextScope in SQL before any
|
||||
// Core-side relevance ranking, so another realm's experience never becomes a
|
||||
// retrieval candidate.
|
||||
func (r *EpistemicsRepository) RecentExperienceRecordsForPrincipalAndScope(ctx context.Context, principalID, scopeRef string, limit int) ([]epistemics.ExperienceRecord, error) {
|
||||
if principalID == "" || limit < 1 || limit > 100 {
|
||||
return nil, fmt.Errorf("invalid scoped experience query")
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
experience_id,principal_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref,
|
||||
capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id,
|
||||
expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id,
|
||||
verification_evidence,confidence_millis,recorded_at_unix_millis,context_scope_ref,problem_statement,applicability_conditions,why_succeeded_or_failed
|
||||
FROM epistemics.experience_records
|
||||
WHERE principal_id=$1 AND ($2='' OR context_scope_ref=$2)
|
||||
ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $3`, principalID, scopeRef, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]epistemics.ExperienceRecord, 0)
|
||||
for rows.Next() {
|
||||
var value epistemics.ExperienceRecord
|
||||
if err := rows.Scan(&value.ID, &value.PrincipalID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef,
|
||||
&value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID,
|
||||
&value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID,
|
||||
&value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis, &value.ContextScopeRef, &value.ProblemStatement, &value.ApplicabilityConditions, &value.WhySucceededOrFailed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
@@ -195,15 +239,15 @@ func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, i
|
||||
|
||||
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
|
||||
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
||||
experience_id, principal_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
||||
capability_implementation_ref, capability_usage_observation_ref, capability_gap_ref, skill_ref, expectation_id,
|
||||
expected_outcome, actual_outcome, outcome_ref, outcome_detail, human_response_ref, correction_utterance_id,
|
||||
verification_evidence, confidence_millis, recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef,
|
||||
verification_evidence, confidence_millis, recorded_at_unix_millis, context_scope_ref, problem_statement, applicability_conditions, why_succeeded_or_failed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26) ON CONFLICT DO NOTHING`,
|
||||
value.ID, value.PrincipalID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef,
|
||||
value.CapabilityImplementationRef, value.CapabilityUsageObservationRef, value.CapabilityGapRef, value.SkillRef, value.ExpectationID,
|
||||
value.ExpectedOutcome, value.ActualOutcome, value.OutcomeRef, value.OutcomeDetail, value.HumanResponseRef, value.CorrectionUtteranceID,
|
||||
value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis)
|
||||
value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis, value.ContextScopeRef, value.ProblemStatement, value.ApplicabilityConditions, value.WhySucceededOrFailed)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -218,7 +262,13 @@ func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, valu
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error {
|
||||
started := time.Now()
|
||||
outcome := "SUCCESS"
|
||||
defer func() {
|
||||
telemetry.RecordCompatibility("truth-bundles-writer", "epistemics-repository", "write", "persisted-research", true, false, true, outcome, string(value.Claim.ID), "epistemics", started)
|
||||
}()
|
||||
if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 {
|
||||
outcome = "INVALID"
|
||||
return fmt.Errorf("invalid truth bundle")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles (
|
||||
@@ -232,5 +282,8 @@ func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value episte
|
||||
value.Excerpt.ID, value.Excerpt.SnapshotID, value.Excerpt.Text, value.Excerpt.Start, value.Excerpt.End,
|
||||
value.Observation.ID, value.Observation.ObservedAt, value.Claim.ID, value.Claim.Statement, value.Claim.ObservationID, value.Claim.CitationID, value.Claim.Verification,
|
||||
value.Belief.ID, value.Belief.ClaimID, value.Belief.State, value.Citation.ID, value.Citation.ExcerptID, value.Citation.Evidence, value.Citation.Coverage, value.Citation.Conflict, value.Citation.ResolvedAtUnixMillis, recordedAtUnixMillis)
|
||||
if err != nil {
|
||||
outcome = "FAILURE"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -100,6 +100,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UtterancesWithProjectReferences returns canonical utterances together with
|
||||
// their already-persisted Interaction-owned project references. The caller
|
||||
// uses the pair only as a read projection; this method does not infer or write
|
||||
// scope membership.
|
||||
func (r *InteractionRepository) UtterancesWithProjectReferences(ctx context.Context, id interaction.ConversationID) ([]interaction.HumanUtterance, []interaction.ProjectReference, error) {
|
||||
utterances, err := r.Utterances(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
references, err := r.projectReferencesForUtterances(ctx, utterances)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return utterances, references, nil
|
||||
}
|
||||
|
||||
// RecentUtterancesForPrincipal provides durable conversational context when a
|
||||
// client starts a fresh conversation instead of carrying the previous ID.
|
||||
func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, error) {
|
||||
@@ -118,3 +134,52 @@ func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecentUtterancesForPrincipalWithProjectReferences keeps a new conversation
|
||||
// within the same durable Interaction read boundary as its scoped context.
|
||||
func (r *InteractionRepository) RecentUtterancesForPrincipalWithProjectReferences(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, []interaction.ProjectReference, error) {
|
||||
utterances, err := r.RecentUtterancesForPrincipal(ctx, principalID, limit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
references, err := r.projectReferencesForUtterances(ctx, utterances)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return utterances, references, nil
|
||||
}
|
||||
|
||||
func (r *InteractionRepository) projectReferencesForUtterances(ctx context.Context, utterances []interaction.HumanUtterance) ([]interaction.ProjectReference, error) {
|
||||
if len(utterances) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]string, 0, len(utterances))
|
||||
seen := make(map[interaction.HumanUtteranceID]struct{}, len(utterances))
|
||||
for _, utterance := range utterances {
|
||||
if utterance.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[utterance.ID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[utterance.ID] = struct{}{}
|
||||
ids = append(ids, string(utterance.ID))
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT project_reference_id,utterance_id,project_id,project_display_name,role,provenance,confidence_millis,recorded_at_unix_millis FROM interaction.project_references WHERE utterance_id = ANY($1::text[]) ORDER BY recorded_at_unix_millis,project_reference_id`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
refs := make([]interaction.ProjectReference, 0)
|
||||
for rows.Next() {
|
||||
var ref interaction.ProjectReference
|
||||
if err := rows.Scan(&ref.ID, &ref.UtteranceID, &ref.ProjectID, &ref.ProjectDisplayName, &ref.Role, &ref.Provenance, &ref.ConfidenceMillis, &ref.RecordedAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, rows.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
selfcontract "git.cin.su/m/Assistant/contracts/self"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// SelfRepository persists only SELF-owned derived assessments and ledger
|
||||
// evidence. It never evaluates, promotes or changes Capability state.
|
||||
type SelfRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func OpenSelf(ctx context.Context, config ContinuityConfig) (*SelfRepository, error) {
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SelfRepository{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (r *SelfRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *SelfRepository) SaveImprovementNeed(ctx context.Context, value selfcontract.ImprovementNeed) error {
|
||||
subject, err := json.Marshal(value.SubjectRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence, err := json.Marshal(value.EvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.improvement_needs(improvement_need_id,principal_id,subject_ref,problem_summary,evidence_refs,expected_benefit,priority,state,created_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, subject, value.ProblemSummary, evidence, value.ExpectedBenefit, value.Priority, value.State, value.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SelfRepository) SaveLearningLedger(ctx context.Context, value selfcontract.LearningLedgerEntry) error {
|
||||
evidence, err := json.Marshal(value.EvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
improvement, err := json.Marshal(value.ImprovementNeedRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proposed, err := json.Marshal(value.ProposedChangeRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evaluations, err := json.Marshal(value.ActualEvaluationRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gate, err := json.Marshal(value.DevelopmentGateRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := json.Marshal(value.BeforeVersionRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := json.Marshal(value.AfterVersionRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sideEffects, err := json.Marshal(value.SideEffects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rollback, err := json.Marshal(value.RollbackRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.learning_ledger(entry_id,principal_id,improvement_need_ref,observed_problem,evidence_refs,diagnosis,hypothesis,proposed_change_ref,expected_improvement,evaluation_refs,development_gate_ref,before_version_refs,after_version_refs,decision,side_effects,rollback_ref,recorded_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, improvement, value.ObservedProblem, evidence, value.Diagnosis, value.Hypothesis, proposed, value.ExpectedImprovement, evaluations, gate, before, after, value.Decision, sideEffects, rollback, value.RecordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SelfRepository) SaveLearningSignal(ctx context.Context, value selfcontract.LearningSignal) error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
subject, err := json.Marshal(value.SubjectRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence, err := json.Marshal(value.EvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps, err := json.Marshal(value.DependencyRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.learning_signals(signal_id,principal_id,kind,task_pattern,scope_key,subject_ref,evidence_refs,expected_outcome,actual_outcome,dependency_refs,environment_version,observed_at_unix_millis,severity,user_impact,correction_count,decision_regret)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, value.Kind, value.TaskPattern, value.ScopeKey, subject, evidence, value.ExpectedOutcome, value.ActualOutcome, deps, value.EnvironmentVersion, value.ObservedAt, value.Severity, value.UserImpact, value.CorrectionCount, value.DecisionRegret)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SelfRepository) SaveWeaknessPattern(ctx context.Context, value selfcontract.WeaknessPattern) error {
|
||||
evidence, err := json.Marshal(value.DetectionEvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps, err := json.Marshal(value.DependencyRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.weakness_patterns(assessment_id,principal_id,revision,pattern_key,task_pattern,scope_key,environment_version,dependency_refs,failure_mode,detection_evidence_refs,frequency,last_observed_at_unix_millis,severity,affected_component,affected_strategy,confidence_millis,epistemic_status,state,recorded_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, value.Revision, value.PatternKey, value.TaskPattern, value.ScopeKey, value.EnvironmentVersion, deps, value.FailureMode, evidence, value.Frequency, value.LastObservedAt, value.Severity, value.AffectedComponent, value.AffectedStrategy, value.ConfidenceMillis, value.EpistemicStatus, value.State, value.RecordedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SelfRepository) SaveImprovementCandidate(ctx context.Context, value selfcontract.ImprovementCandidate) error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
need, err := json.Marshal(value.NeedRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
must, err := json.Marshal(value.MustNotWorsen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
evidence, err := json.Marshal(value.EvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.improvement_candidates(candidate_id,principal_id,need_ref,hypothesis,expected_effect,evaluation_plan,must_not_worsen,rollback_plan,resource_cost,creator,independent_evaluator,state,evidence_refs,created_at_unix_millis)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, need, value.Hypothesis, value.ExpectedEffect, value.EvaluationPlan, must, value.RollbackPlan, value.ResourceCost, value.Creator, value.IndependentEvaluator, value.State, evidence, value.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SelfRepository) SaveBenchmarkCase(ctx context.Context, value selfcontract.BenchmarkCase) error {
|
||||
if !value.Valid() {
|
||||
return fmt.Errorf("benchmark case is incomplete")
|
||||
}
|
||||
evidence, err := json.Marshal(value.EvidenceRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO self.personal_benchmark_cases(case_id,principal_id,task_class,evidence_refs,recorded_at_unix_millis) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, value.ID, value.PrincipalID, value.TaskClass, evidence, value.RecordedAt)
|
||||
return err
|
||||
}
|
||||
@@ -16,15 +16,17 @@ func (p *admissionTestProvider) Advise(context.Context, cognitive.AdvisoryReques
|
||||
}
|
||||
|
||||
type admissionTestClient struct {
|
||||
decision capability.AdmissionDecision
|
||||
releases int
|
||||
decision capability.AdmissionDecision
|
||||
releases int
|
||||
releaseContextErr error
|
||||
}
|
||||
|
||||
func (c *admissionTestClient) Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
return c.decision, capability.AdmissionLease{ID: "lease-1", PolicyID: "policy-1"}, nil
|
||||
}
|
||||
func (c *admissionTestClient) Release(context.Context, capability.AdmissionLease) error {
|
||||
func (c *admissionTestClient) Release(ctx context.Context, _ capability.AdmissionLease) error {
|
||||
c.releases++
|
||||
c.releaseContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,3 +49,14 @@ func TestInvokeAdmittedReleasesDurableLeaseAfterProvider(t *testing.T) {
|
||||
t.Fatalf("admitted lease was not released: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeAdmittedReleasesLeaseAfterCallerCancellation(t *testing.T) {
|
||||
provider := &admissionTestProvider{}
|
||||
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: "policy-1"}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, _ = (&cognitiveGateway{admission: client}).invokeAdmitted(ctx, "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
|
||||
if client.releases != 1 || client.releaseContextErr != nil {
|
||||
t.Fatalf("cancelled caller leaked durable lease: releases=%d release_context_err=%v", client.releases, client.releaseContextErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -22,17 +24,7 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
s.statusHandler(w, r)
|
||||
return
|
||||
}
|
||||
registration, ok := s.providers[r.PathValue("provider")]
|
||||
if !ok || registration.Discovery == nil {
|
||||
http.Error(w, "provider discovery not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
discovered, probe := registration.Discovery.Discover(r.Context())
|
||||
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, AuthMode: registration.AuthMode, BillingMode: registration.BillingMode, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: registration.DataPolicy, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
|
||||
for _, model := range discovered {
|
||||
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved})
|
||||
}
|
||||
result, err := s.resolver.RegisterDiscovery(r.Context(), snapshot)
|
||||
result, err := s.register(r.Context(), r.PathValue("provider"))
|
||||
if err != nil {
|
||||
http.Error(w, "provider discovery registration unavailable", http.StatusBadGateway)
|
||||
return
|
||||
@@ -41,6 +33,19 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (s discoveryService) register(ctx context.Context, provider string) (capability.CognitiveDiscoveryRegistration, error) {
|
||||
registration, ok := s.providers[provider]
|
||||
if !ok || registration.Discovery == nil {
|
||||
return capability.CognitiveDiscoveryRegistration{}, fmt.Errorf("provider discovery not found")
|
||||
}
|
||||
discovered, probe := registration.Discovery.Discover(ctx)
|
||||
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, AuthMode: registration.AuthMode, BillingMode: registration.BillingMode, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: registration.DataPolicy, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
|
||||
for _, model := range discovered {
|
||||
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved})
|
||||
}
|
||||
return s.resolver.RegisterDiscovery(ctx, snapshot)
|
||||
}
|
||||
|
||||
type discoveryStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderKind capability.ProviderKind `json:"provider_kind"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
|
||||
const creatorAdvisoryCapabilityID capability.CapabilityID = "creator-advisory"
|
||||
|
||||
var errNoInvocableCognitiveBinding = errors.New("no invocable cognitive binding")
|
||||
|
||||
type bindingResolver interface {
|
||||
Resolve(context.Context, capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error)
|
||||
Observe(context.Context, capability.CognitiveUsageObservation) error
|
||||
@@ -56,6 +59,7 @@ type cognitiveGateway struct {
|
||||
allowCanary bool
|
||||
canaryMaxOutputBytes int
|
||||
admission admissionClient
|
||||
revalidatePrimary func(context.Context) error
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) policyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -89,7 +93,7 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
maxOutputBytes = g.canaryMaxOutputBytes
|
||||
}
|
||||
requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes), Objective: selectionObjectiveForMeaning(request.Meaning, request.Message)}
|
||||
selected, err := g.resolver.Resolve(r.Context(), requirement)
|
||||
selected, err := g.resolveWithRevalidation(r.Context(), requirement)
|
||||
if err != nil {
|
||||
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
@@ -131,7 +135,51 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
|
||||
return
|
||||
}
|
||||
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage})
|
||||
writeGatewayResponse(w, gatewayAdvisoryResponse(request, response))
|
||||
}
|
||||
|
||||
const iterativeRetrievalMarker = "<domovoy-retrieval>"
|
||||
|
||||
// gatewayAdvisoryResponse is the only model-facing extraction point. The
|
||||
// model may express a bounded retrieval intent, but it cannot provide scope,
|
||||
// identity, policy or storage details; Core supplies those on continuation.
|
||||
func gatewayAdvisoryResponse(request cognitive.GatewayAdvisoryRequest, response cognitive.AdvisoryResponse) cognitive.GatewayAdvisoryResponse {
|
||||
result := cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}
|
||||
if !request.AllowIterativeRetrieval || response.FinishReason != cognitive.FinishCompleted {
|
||||
return result
|
||||
}
|
||||
intent, text, ok := parseIterativeRetrievalIntent(response.AdvisoryText)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
result.AdvisoryText = text
|
||||
result.IterativeRetrieval = &intent
|
||||
return result
|
||||
}
|
||||
|
||||
func parseIterativeRetrievalIntent(text string) (cognitive.IterativeRetrievalRequest, string, bool) {
|
||||
start := strings.LastIndex(text, iterativeRetrievalMarker)
|
||||
if start < 0 {
|
||||
return cognitive.IterativeRetrievalRequest{}, text, false
|
||||
}
|
||||
payload := strings.TrimSpace(text[start+len(iterativeRetrievalMarker):])
|
||||
var intent cognitive.IterativeRetrievalRequest
|
||||
if err := json.Unmarshal([]byte(payload), &intent); err != nil || !validIterativeRetrievalIntent(intent) {
|
||||
return cognitive.IterativeRetrievalRequest{}, text, false
|
||||
}
|
||||
return intent, strings.TrimSpace(text[:start]), true
|
||||
}
|
||||
|
||||
func validIterativeRetrievalIntent(intent cognitive.IterativeRetrievalRequest) bool {
|
||||
if strings.TrimSpace(intent.Query) == "" || strings.TrimSpace(intent.Reason) == "" || len([]rune(intent.Query)) > 512 || len([]rune(intent.Reason)) > 512 {
|
||||
return false
|
||||
}
|
||||
switch intent.Lane {
|
||||
case cognitive.IterativeRetrievalCurrentFact, cognitive.IterativeRetrievalPositiveExperience, cognitive.IterativeRetrievalNegativeExperience, cognitive.IterativeRetrievalContradiction, cognitive.IterativeRetrievalHistoricalAnalogy:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, provider advisoryProvider, invocation cognitive.AdvisoryRequest, maxOutputBytes int) (cognitive.AdvisoryResponse, error) {
|
||||
@@ -145,11 +193,33 @@ func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, pr
|
||||
return cognitive.AdvisoryResponse{Version: invocation.Version, RequestID: invocation.RequestID, ProviderBindingID: invocation.ProviderBindingID, ProviderBindingRevision: invocation.ProviderBindingRevision, FinishReason: cognitive.FinishRateLimited}, nil
|
||||
}
|
||||
lease = admittedLease
|
||||
defer func() { _ = g.admission.Release(ctx, lease) }()
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = g.admission.Release(releaseCtx, lease)
|
||||
}()
|
||||
}
|
||||
return provider.Advise(ctx, invocation)
|
||||
}
|
||||
|
||||
// resolveWithRevalidation makes one bounded recovery attempt when Capability
|
||||
// has an active/validated binding but its durable health projection rejected it
|
||||
// as non-invocable. Discovery remains the canonical Capability write path; the
|
||||
// retry observes the configured primary local provider and re-runs selection
|
||||
// once. Provider invocation, admission, and fallback remain governed elsewhere.
|
||||
func (g cognitiveGateway) resolveWithRevalidation(ctx context.Context, requirement capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
selected, err := g.resolver.Resolve(ctx, requirement)
|
||||
if err == nil || !errors.Is(err, errNoInvocableCognitiveBinding) || g.revalidatePrimary == nil {
|
||||
return selected, err
|
||||
}
|
||||
revalidationCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
if revalidationErr := g.revalidatePrimary(revalidationCtx); revalidationErr != nil {
|
||||
return selected, err
|
||||
}
|
||||
return g.resolver.Resolve(ctx, requirement)
|
||||
}
|
||||
|
||||
func selectionObjectiveForMessage(message string) capability.SelectionObjective {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "дешев") || strings.Contains(lower, "бесплат") || strings.Contains(lower, "эконом") || strings.Contains(lower, "cost") || strings.Contains(lower, "cheap") {
|
||||
@@ -374,6 +444,10 @@ type httpBindingResolver struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type httpStatusError struct{ status int }
|
||||
|
||||
func (e httpStatusError) Error() string { return fmt.Sprintf("registry status %d", e.status) }
|
||||
|
||||
func newHTTPBindingResolver(raw string, client *http.Client) (*httpBindingResolver, error) {
|
||||
base, err := url.Parse(raw)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
@@ -385,6 +459,10 @@ func newHTTPBindingResolver(raw string, client *http.Client) (*httpBindingResolv
|
||||
func (r *httpBindingResolver) Resolve(ctx context.Context, value capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
var result capability.ResolvedCognitiveBinding
|
||||
err := r.post(ctx, "/v1/cognitive-bindings/resolve", value, &result)
|
||||
var statusErr httpStatusError
|
||||
if errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound {
|
||||
return result, errNoInvocableCognitiveBinding
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -437,7 +515,7 @@ func (r *httpBindingResolver) post(ctx context.Context, path string, value, dest
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("registry status %d", response.StatusCode)
|
||||
return httpStatusError{status: response.StatusCode}
|
||||
}
|
||||
if destination != nil {
|
||||
return json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(destination)
|
||||
|
||||
@@ -22,6 +22,26 @@ type registryStub struct {
|
||||
observation *capability.CognitiveUsageObservation
|
||||
}
|
||||
|
||||
type revalidatingRegistry struct {
|
||||
selected capability.ResolvedCognitiveBinding
|
||||
calls int
|
||||
alwaysNo bool
|
||||
}
|
||||
|
||||
func (s *revalidatingRegistry) Resolve(context.Context, capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
s.calls++
|
||||
if s.alwaysNo || s.calls == 1 {
|
||||
return capability.ResolvedCognitiveBinding{}, errNoInvocableCognitiveBinding
|
||||
}
|
||||
return s.selected, nil
|
||||
}
|
||||
func (*revalidatingRegistry) Observe(context.Context, capability.CognitiveUsageObservation) error {
|
||||
return nil
|
||||
}
|
||||
func (*revalidatingRegistry) RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) {
|
||||
return capability.CognitiveDiscoveryRegistration{}, nil
|
||||
}
|
||||
|
||||
type failoverRegistry struct {
|
||||
primary, fallback capability.ResolvedCognitiveBinding
|
||||
observations []capability.CognitiveUsageObservation
|
||||
@@ -88,6 +108,73 @@ func TestActiveRegistryBindingReachesProviderAdapter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayExtractsOnlyBoundedTypedRetrievalIntent(t *testing.T) {
|
||||
request := cognitive.GatewayAdvisoryRequest{AllowIterativeRetrieval: true}
|
||||
response := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request", FinishReason: cognitive.FinishCompleted, AdvisoryText: "<domovoy-retrieval>{\"Query\":\"sync catalog\",\"SemanticSubject\":\"catalog sync\",\"Lane\":\"NEGATIVE_EXPERIENCE\",\"Reason\":\"need known failure\"}"}
|
||||
got := gatewayAdvisoryResponse(request, response)
|
||||
if got.IterativeRetrieval == nil || got.IterativeRetrieval.Lane != cognitive.IterativeRetrievalNegativeExperience || got.AdvisoryText != "" {
|
||||
t.Fatalf("response=%+v", got)
|
||||
}
|
||||
invalid := response
|
||||
invalid.AdvisoryText = "<domovoy-retrieval>{\"Query\":\"all memory\",\"Lane\":\"UNRESTRICTED\",\"Reason\":\"everything\"}"
|
||||
if got := gatewayAdvisoryResponse(request, invalid); got.IterativeRetrieval != nil {
|
||||
t.Fatalf("invalid intent accepted: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleUnavailableBindingRevalidatesOnceBeforeInvocation(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
selected.Binding.DataPolicy = capability.DataPolicyLocalOnly
|
||||
registry := &revalidatingRegistry{selected: selected}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted}}
|
||||
revalidations := 0
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"provider-kind-1": {{EndpointRef: "endpoint-1", CredentialRef: "credential-1", Provider: provider}}}, now: time.Now, revalidatePrimary: func(context.Context) error { revalidations++; return nil }}
|
||||
response := httptest.NewRecorder()
|
||||
gateway.advisoryHandler(response, httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())))
|
||||
if response.Code != http.StatusOK || !provider.called || revalidations != 1 || registry.calls != 2 {
|
||||
t.Fatalf("status=%d provider_called=%t revalidations=%d resolves=%d", response.Code, provider.called, revalidations, registry.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevalidationFailureDoesNotMakeBindingInvocable(t *testing.T) {
|
||||
registry := ®istryStub{err: errNoInvocableCognitiveBinding}
|
||||
provider := &stubAdvisoryProvider{}
|
||||
revalidations := 0
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"provider-kind-1": {{Provider: provider}}}, now: time.Now, revalidatePrimary: func(context.Context) error { revalidations++; return errors.New("provider unavailable") }}
|
||||
gateway.advisoryHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())))
|
||||
if provider.called || revalidations != 1 {
|
||||
t.Fatalf("failed provider revalidation was treated as invocable: provider_called=%t revalidations=%d", provider.called, revalidations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevalidationIsNotRetriedInALoop(t *testing.T) {
|
||||
registry := &revalidatingRegistry{alwaysNo: true}
|
||||
revalidations := 0
|
||||
gateway := cognitiveGateway{resolver: registry, now: time.Now, revalidatePrimary: func(context.Context) error { revalidations++; return nil }}
|
||||
gateway.advisoryHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())))
|
||||
if revalidations != 1 || registry.calls != 2 {
|
||||
t.Fatalf("revalidation loop: revalidations=%d resolves=%d", revalidations, registry.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPResolverMarksNoInvocableBindingForSingleRevalidation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/cognitive-bindings/resolve" {
|
||||
t.Fatalf("path=%s", r.URL.Path)
|
||||
}
|
||||
http.Error(w, "no invocable cognitive binding", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
resolver, err := newHTTPBindingResolver(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = resolver.Resolve(context.Background(), capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: "principal-1", MaxOutputBytes: 1})
|
||||
if !errors.Is(err, errNoInvocableCognitiveBinding) {
|
||||
t.Fatalf("resolve error=%v, want no-invocable sentinel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesStableInstanceWhenCredentialMetadataIsStale(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{}
|
||||
gateway := cognitiveGateway{providers: map[capability.ProviderKind][]providerRegistration{
|
||||
|
||||
@@ -113,11 +113,15 @@ func main() {
|
||||
kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1"))
|
||||
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: openAIProvider})
|
||||
}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096, admission: newHTTPAdmissionClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second})}
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096, admission: newHTTPAdmissionClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second}), revalidatePrimary: func(ctx context.Context) error {
|
||||
_, err := discovery.register(ctx, "medium")
|
||||
return err
|
||||
}}
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
cpuReadinessClient := &http.Client{Timeout: 10 * time.Second}
|
||||
mediumReadinessTimeout := 120 * time.Second
|
||||
mediumReadinessTimeout := 120 * time.Second
|
||||
mediumReadinessClient := &http.Client{Timeout: mediumReadinessTimeout}
|
||||
lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health"
|
||||
fleetClient := &http.Client{Timeout: 2 * time.Second}
|
||||
@@ -199,7 +203,6 @@ func main() {
|
||||
mux.HandleFunc("POST /v1/multimodal/ocr", multimodalGateway.handler(capability.MultimodalRecognizeOCR))
|
||||
mux.HandleFunc("POST /v1/multimodal/video-understand", multimodalGateway.handler(capability.MultimodalUnderstandVideo))
|
||||
mux.HandleFunc("POST /v1/multimodal/generate-image", multimodalGateway.handler(capability.MultimodalGenerateImage))
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(discoveries, string(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), httpQualificationObserver{resolver: resolver}))
|
||||
mux.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
|
||||
|
||||
@@ -16,8 +16,10 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
epistemicsdomain "git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
@@ -28,6 +30,145 @@ type cognitiveInterpretationClient struct {
|
||||
media *http.Client
|
||||
}
|
||||
|
||||
// compileInteractionContext is the Core-side handoff from the existing
|
||||
// Interaction retrieval projection to the existing Context Compiler. The
|
||||
// compiler receives only already-authorized, typed fragments and applies its
|
||||
// scope and byte-budget guards before the cognitive gateway sees them.
|
||||
func compileInteractionContext(projection interactiondomain.ContextProjection, focusProjectID string) (string, error) {
|
||||
return compileInteractionContextWithExperience(projection, focusProjectID, nil, "")
|
||||
}
|
||||
|
||||
func compileInteractionContextWithExperience(projection interactiondomain.ContextProjection, focusProjectID string, experiences []epistemics.ExperienceRecord, query string) (string, error) {
|
||||
if len(projection.Fragments) == 0 && len(experiences) == 0 {
|
||||
return projection.Text, nil
|
||||
}
|
||||
candidates := interactionContextCandidates(projection, focusProjectID, experiences, query)
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: candidates, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: 2048, ContextScopeRef: focusProjectID, FreshnessRequirement: "conversation-current", RiskClass: "low"})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := make([]string, 0, len(bundle.Excerpts))
|
||||
for _, excerpt := range bundle.Excerpts {
|
||||
parts = append(parts, excerpt.Text)
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
func interactionContextCandidates(projection interactiondomain.ContextProjection, focusProjectID string, experiences []epistemics.ExperienceRecord, query string) []cognitive.ContextCandidate {
|
||||
candidates := make([]cognitive.ContextCandidate, 0, len(projection.Fragments)+len(experiences))
|
||||
for _, fragment := range projection.Fragments {
|
||||
candidates = append(candidates, cognitive.ContextCandidate{
|
||||
Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("interaction:" + string(fragment.UtteranceID)), SourceKind: "interaction-gen2-retrieval", SourceID: "interaction:utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: fragment.Text},
|
||||
Relevance: fragment.Relevance,
|
||||
Lane: "current-fact",
|
||||
ContextScopeRef: fragment.ScopeRef,
|
||||
FreshnessState: "CURRENT",
|
||||
})
|
||||
}
|
||||
if needsHistoricalAnalogy(query) {
|
||||
for _, fragment := range projection.Historical {
|
||||
candidates = append(candidates, cognitive.ContextCandidate{
|
||||
Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("interaction:historical:" + string(fragment.UtteranceID)), SourceKind: "interaction-gen2-retrieval", SourceID: "interaction:utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: fragment.Text},
|
||||
Relevance: fragment.Relevance - 1, Lane: "historical-analogy", ContextScopeRef: fragment.ScopeRef, FreshnessState: "HISTORICAL_ONLY",
|
||||
})
|
||||
}
|
||||
}
|
||||
retrieved := epistemicsdomain.RetrieveExperience(experiences, "", focusProjectID, query, 4)
|
||||
for _, record := range retrieved.Positive {
|
||||
candidates = append(candidates, experienceContextCandidate(record, "positive-experience", false))
|
||||
}
|
||||
for _, record := range retrieved.Negative {
|
||||
candidates = append(candidates, experienceContextCandidate(record, "negative-experience", true))
|
||||
}
|
||||
for _, record := range retrieved.Contradictions {
|
||||
candidates = append(candidates, experienceContextCandidate(record, "contradiction", true))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func needsHistoricalAnalogy(query string) bool {
|
||||
for _, marker := range []string{"истор", "раньше", "предыдущ", "старое", "аналог"} {
|
||||
if strings.Contains(strings.ToLower(query), marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func experienceContextCandidate(record epistemics.ExperienceRecord, lane string, negative bool) cognitive.ContextCandidate {
|
||||
text := strings.TrimSpace(record.ProblemStatement + ": " + record.OutcomeDetail)
|
||||
if detail := strings.TrimSpace(record.WhySucceededOrFailed); detail != "" {
|
||||
text += " (" + detail + ")"
|
||||
}
|
||||
if len(record.ApplicabilityConditions) > 0 {
|
||||
text += " [conditions: " + strings.Join(record.ApplicabilityConditions, ", ") + "]"
|
||||
}
|
||||
return cognitive.ContextCandidate{Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("experience:" + string(record.ID)), SourceKind: "epistemics-experience", SourceID: "epistemics:experience-records", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: text}, Relevance: 1, Lane: lane, ContextScopeRef: record.ContextScopeRef, FreshnessState: "CURRENT", Negative: negative}
|
||||
}
|
||||
|
||||
const (
|
||||
iterativeRetrievalMaxRounds = 1
|
||||
iterativeRetrievalMaxFragments = 2
|
||||
iterativeRetrievalMaxAddedBytes = 1024
|
||||
iterativeRetrievalMaxLatency = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
func newIterativeContextRetriever(history []interaction.HumanUtterance, references []interaction.ProjectReference, principalID, focusProjectID string, experiences []epistemics.ExperienceRecord) iterativeContextRetriever {
|
||||
seen := map[string]struct{}{}
|
||||
return func(ctx context.Context, request cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse {
|
||||
started := time.Now()
|
||||
if request.Pass < 0 || request.Pass >= iterativeRetrievalMaxRounds || request.RemainingBytes <= 0 || request.RemainingBytes > iterativeRetrievalMaxAddedBytes {
|
||||
return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "budget"}
|
||||
}
|
||||
projection := interactiondomain.BuildScopedGen2ContextForQuery(history, references, principalID, focusProjectID, request.Query)
|
||||
candidates := interactionContextCandidates(projection, focusProjectID, experiences, request.Query)
|
||||
filtered := make([]cognitive.ContextCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if !iterativeLaneMatches(request.Lane, candidate.Lane) {
|
||||
continue
|
||||
}
|
||||
key := string(request.Lane) + ":" + string(candidate.Excerpt.ID)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: filtered, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: request.RemainingBytes, ContextScopeRef: focusProjectID, TaskRef: "chat", FocusFrameRef: focusProjectID, FreshnessRequirement: "conversation-current"})
|
||||
if err != nil || len(bundle.Excerpts) == 0 {
|
||||
return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "no_additional_evidence"}
|
||||
}
|
||||
out := make([]cognitive.ContextCandidate, 0, iterativeRetrievalMaxFragments)
|
||||
for _, excerpt := range bundle.Excerpts {
|
||||
if len(out) == iterativeRetrievalMaxFragments || time.Since(started) > iterativeRetrievalMaxLatency {
|
||||
break
|
||||
}
|
||||
seen[string(request.Lane)+":"+string(excerpt.ID)] = struct{}{}
|
||||
for _, candidate := range filtered {
|
||||
if candidate.Excerpt.ID == excerpt.ID {
|
||||
out = append(out, candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "latency_budget"}
|
||||
}
|
||||
addedBytes := 0
|
||||
for _, candidate := range out {
|
||||
addedBytes += len([]byte(candidate.Excerpt.Text))
|
||||
}
|
||||
return cognitive.IterativeRetrievalResponse{Candidates: out, Exhausted: true, Reason: "completed", AddedBytes: addedBytes}
|
||||
}
|
||||
}
|
||||
|
||||
func iterativeLaneMatches(request cognitive.IterativeRetrievalLane, actual string) bool {
|
||||
return (request == cognitive.IterativeRetrievalCurrentFact && actual == "current-fact") ||
|
||||
(request == cognitive.IterativeRetrievalPositiveExperience && actual == "positive-experience") ||
|
||||
(request == cognitive.IterativeRetrievalNegativeExperience && actual == "negative-experience") ||
|
||||
(request == cognitive.IterativeRetrievalContradiction && actual == "contradiction") ||
|
||||
(request == cognitive.IterativeRetrievalHistoricalAnalogy && actual == "historical-analogy")
|
||||
}
|
||||
|
||||
const (
|
||||
// Household interpretation and response review are intentionally compact
|
||||
// calls. Keeping their output bounded prevents a loaded local model from
|
||||
@@ -95,43 +236,53 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
|
||||
// non-current question only after evidence retrieval did not produce a usable
|
||||
// result. The model decides whether its knowledge is stable enough; it must
|
||||
// explicitly decline changing or source-dependent facts instead of guessing.
|
||||
type iterativeContextRetriever func(context.Context, cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse
|
||||
|
||||
func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
return c.AnswerStableKnowledgeWithContinuation(ctx, session, userMessage, personalMemory, meaning, nil)
|
||||
}
|
||||
|
||||
// AnswerStableKnowledgeWithContinuation permits one model-requested, Core-run
|
||||
// retrieval round. The callback receives only a typed intent and cannot alter
|
||||
// identity, scope or provider selection.
|
||||
func (c cognitiveInterpretationClient) AnswerStableKnowledgeWithContinuation(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning, retrieve iterativeContextRetriever) (string, error) {
|
||||
requestID, err := ids.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prompt := "Ты отвечаешь как Домовой на обычное сообщение пользователя. Сначала пойми цель: на вопрос дай короткий естественный ответ по устойчивому общеизвестному знанию, а безопасную просьбу создать текстовый или структурный результат выполни прямо в ответе. Начинай сразу с содержания и не представляйся, если пользователь не спросил об имени. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты и не утверждай выполнение внешнего действия.\n\nСообщение пользователя:\n" + userMessage
|
||||
if retrieve != nil {
|
||||
prompt += "\n\nЕсли именно для ответа не хватает ограниченного контекста, вместо ответа верни единственную строку <domovoy-retrieval>{\"Query\":\"...\",\"SemanticSubject\":\"...\",\"Lane\":\"CURRENT_FACT|POSITIVE_EXPERIENCE|NEGATIVE_EXPERIENCE|CONTRADICTION|HISTORICAL_ANALOGY\",\"Reason\":\"...\"}. Не запрашивай широкий дамп памяти."
|
||||
}
|
||||
context := []cognitive.ContextExcerpt{}
|
||||
if strings.TrimSpace(personalMemory) != "" {
|
||||
context = append(context, cognitive.ContextExcerpt{ID: "personal-memory", SourceKind: "derived-personal-memory", SourceID: "interaction:human-utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: personalMemory})
|
||||
}
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
requestValue := cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: stableKnowledgeMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
|
||||
})
|
||||
AllowIterativeRetrieval: retrieve != nil,
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
|
||||
}
|
||||
result, err := c.requestAdvisory(ctx, requestValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayAdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
if result.IterativeRetrieval != nil && retrieve != nil {
|
||||
additional := retrieve(ctx, *result.IterativeRetrieval)
|
||||
for _, candidate := range additional.Candidates {
|
||||
context = append(context, candidate.Excerpt)
|
||||
}
|
||||
if len(additional.Candidates) == 0 {
|
||||
context = append(context, cognitive.ContextExcerpt{ID: "continuation:no-additional-evidence", SourceKind: "iterative-retrieval", SourceID: "core", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "No additional evidence was found; answer with uncertainty rather than inventing facts."})
|
||||
}
|
||||
requestValue.Context = context
|
||||
requestValue.AllowIterativeRetrieval = false
|
||||
result, err = c.requestAdvisory(ctx, requestValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" {
|
||||
return "", fmt.Errorf("cognitive stable knowledge incomplete: %s", result.FinishReason)
|
||||
@@ -139,6 +290,33 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context
|
||||
return strings.TrimSpace(result.AdvisoryText), nil
|
||||
}
|
||||
|
||||
func (c cognitiveInterpretationClient) requestAdvisory(ctx context.Context, value cognitive.GatewayAdvisoryRequest) (cognitive.GatewayAdvisoryResponse, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return cognitive.GatewayAdvisoryResponse{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return cognitive.GatewayAdvisoryResponse{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return cognitive.GatewayAdvisoryResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return cognitive.GatewayAdvisoryResponse{}, fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayAdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return cognitive.GatewayAdvisoryResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
@@ -70,6 +71,84 @@ func TestAnswerStableKnowledgeUsesLocalOnlyCognitiveRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileInteractionContextAppliesTypedScopeBeforeGateway(t *testing.T) {
|
||||
context, err := compileInteractionContext(interactiondomain.ContextProjection{Fragments: []interactiondomain.ContextFragment{
|
||||
{UtteranceID: "a", Text: "контрольный код = ALPHA", ScopeRef: "company-a", Relevance: 2},
|
||||
{UtteranceID: "b", Text: "контрольный код = BETA", ScopeRef: "company-b", Relevance: 3},
|
||||
}}, "company-a")
|
||||
if err != nil || context != "контрольный код = ALPHA" {
|
||||
t.Fatalf("context=%q err=%v", context, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileInteractionContextUsesHistoricalOnlyWhenRequested(t *testing.T) {
|
||||
projection := interactiondomain.ContextProjection{Fragments: []interactiondomain.ContextFragment{{UtteranceID: "current", Text: "код = NEW", Relevance: 2}}, Historical: []interactiondomain.ContextFragment{{UtteranceID: "old", Text: "код = OLD", Relevance: 1, Historical: true}}}
|
||||
current, err := compileInteractionContextWithExperience(projection, "", nil, "Какой текущий код?")
|
||||
if err != nil || strings.Contains(current, "OLD") {
|
||||
t.Fatalf("current context=%q err=%v", current, err)
|
||||
}
|
||||
historical, err := compileInteractionContextWithExperience(projection, "", nil, "Какой старый код был раньше?")
|
||||
if err != nil || !strings.Contains(historical, "OLD") {
|
||||
t.Fatalf("historical context=%q err=%v", historical, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableKnowledgeContinuesOnceWithBoundedAdditionalContext(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls == 1 {
|
||||
if !request.AllowIterativeRetrieval {
|
||||
t.Fatal("initial advisory did not permit bounded continuation")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishCompleted, IterativeRetrieval: &cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "initial context is insufficient", RemainingBytes: 512}})
|
||||
return
|
||||
}
|
||||
if request.AllowIterativeRetrieval || len(request.Context) != 1 || request.Context[0].Text != "контрольный код = ORBIT-731" {
|
||||
t.Fatalf("continuation request=%+v", request)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishCompleted, AdvisoryText: "Контрольный код — ORBIT-731."})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newCognitiveInterpretationClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, err := client.AnswerStableKnowledgeWithContinuation(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "Какой контрольный код?", "", cognitive.TaskMeaning{SpeechAct: "QUESTION"}, func(context.Context, cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse {
|
||||
return cognitive.IterativeRetrievalResponse{Candidates: []cognitive.ContextCandidate{{Excerpt: cognitive.ContextExcerpt{ID: "interaction:code", Text: "контрольный код = ORBIT-731", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact"}}, Exhausted: true, AddedBytes: 28}
|
||||
})
|
||||
if err != nil || answer != "Контрольный код — ORBIT-731." || calls != 2 {
|
||||
t.Fatalf("answer=%q err=%v calls=%d", answer, err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterativeRetrieverPreservesScopeLanesAndBudgets(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{{ID: "a", PrincipalID: "p", Content: "контрольный код = ALPHA", ReceivedAtUnixMillis: 1}, {ID: "b", PrincipalID: "p", Content: "контрольный код = BETA", ReceivedAtUnixMillis: 2}}
|
||||
references := []interaction.ProjectReference{{ID: "a-ref", UtteranceID: "a", ProjectID: "company-a"}, {ID: "b-ref", UtteranceID: "b", ProjectID: "company-b"}}
|
||||
experiences := []epistemics.ExperienceRecord{{ID: "failure", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "catalog sync", ActualOutcome: epistemics.OutcomeFailed, OutcomeDetail: "old connector failed", RecordedAtUnixMillis: 1}, {ID: "conflict", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "catalog sync", ActualOutcome: epistemics.OutcomeConflict, OutcomeDetail: "conflicting result", RecordedAtUnixMillis: 2}}
|
||||
retrieve := newIterativeContextRetriever(history, references, "p", "company-a", experiences)
|
||||
current := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "need scoped fact", RemainingBytes: 512})
|
||||
if len(current.Candidates) != 1 || !strings.Contains(current.Candidates[0].Excerpt.Text, "ALPHA") || strings.Contains(current.Candidates[0].Excerpt.Text, "BETA") {
|
||||
t.Fatalf("scope leaked: %#v", current)
|
||||
}
|
||||
if duplicate := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "repeat", RemainingBytes: 512}); duplicate.Reason != "no_additional_evidence" {
|
||||
t.Fatalf("duplicate was re-added: %#v", duplicate)
|
||||
}
|
||||
if negative := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "catalog sync", Lane: cognitive.IterativeRetrievalNegativeExperience, Reason: "need failure", RemainingBytes: 512}); len(negative.Candidates) < 1 || !strings.Contains(strings.Join([]string{negative.Candidates[0].Excerpt.Text, negative.Candidates[len(negative.Candidates)-1].Excerpt.Text}, " "), "old connector failed") {
|
||||
t.Fatalf("negative lane=%#v", negative)
|
||||
}
|
||||
if contradiction := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "catalog sync", Lane: cognitive.IterativeRetrievalContradiction, Reason: "need conflict", RemainingBytes: 512}); len(contradiction.Candidates) != 1 || !strings.Contains(contradiction.Candidates[0].Excerpt.Text, "conflicting result") {
|
||||
t.Fatalf("contradiction lane=%#v", contradiction)
|
||||
}
|
||||
if budget := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "anything", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "bad", RemainingBytes: 2048}); budget.Reason != "budget" {
|
||||
t.Fatalf("budget=%#v", budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" {
|
||||
|
||||
@@ -11,8 +11,12 @@ 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"
|
||||
)
|
||||
|
||||
const reminderResponseSkillID = capability.SkillProfileID("skill-obvious-reminder-response-v1")
|
||||
@@ -28,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
|
||||
@@ -112,6 +156,7 @@ func (r learningRecorder) recordReminderResponse(ctx context.Context, input remi
|
||||
actual, confidence := outcomeForHumanResponse(input.Result.Response)
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceContinuity,
|
||||
PrincipalID: string(input.Utterance.PrincipalID),
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
ContextRefs: contextRefs(
|
||||
@@ -142,6 +187,7 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceInteraction,
|
||||
PrincipalID: string(input.Utterance.PrincipalID),
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
HelpMethod: epistemics.HelpMethodClarification,
|
||||
@@ -154,12 +200,45 @@ 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) {
|
||||
if r.capability == nil || experience.ID == "" || len(experience.VerificationEvidence) == 0 {
|
||||
return
|
||||
}
|
||||
// One verified result creates only a candidate. Repeated outcome evidence and
|
||||
// independent validation are required by Capability before activation.
|
||||
profile := capability.SkillProfile{
|
||||
ID: capability.SkillProfileID("skill:delegation:" + string(input.ImplementationID)),
|
||||
Revision: 1,
|
||||
Name: "Verified bounded delegation strategy",
|
||||
Description: "Observed verified delegation outcome; requires repeatability and independent validation.",
|
||||
ApplicableContext: []string{"work:delegation"},
|
||||
ImplementationRef: "work:agent-delegation-v1",
|
||||
ImplementationVersion: "1",
|
||||
Status: capability.SkillCandidate,
|
||||
ConfidenceMillis: 500,
|
||||
RecordedAtUnixMillis: experience.RecordedAtUnixMillis,
|
||||
}
|
||||
next, err := capabilitydomain.CandidateFromVerifiedExperience(profile, capability.SkillEvidenceRef("epistemics:experience:"+string(experience.ID)), true, experience.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := r.capability.SaveSkillProfile(ctx, next); err != nil {
|
||||
log.Printf("learning candidate skill unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastPathDeclineLearningInput) {
|
||||
if r.epistemics == nil || input.Intent.ID == "" || input.Utterance.ID == "" {
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("fast-path-v1", "core-runtime", "decision", "decline-or-escalate", true, true, false, "OBSERVED", string(input.Utterance.CorrelationID), string(input.Utterance.PrincipalID), input.At)
|
||||
experience, err := epistemicsdomain.FastPathDeclineExperience(
|
||||
epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
@@ -173,10 +252,16 @@ func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastP
|
||||
if input.Result.Response.ID != "" {
|
||||
experience.ContextRefs = contextRefs("continuity:response-candidate:" + string(input.Result.Response.Operation))
|
||||
}
|
||||
experience.PrincipalID = string(input.Utterance.PrincipalID)
|
||||
saved, ok := r.saveExperienceAndReflection(ctx, experience)
|
||||
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) {
|
||||
@@ -194,6 +279,7 @@ func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabil
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceCapability,
|
||||
PrincipalID: string(input.Utterance.PrincipalID),
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
HelpMethod: epistemics.HelpMethodCapabilityExecution,
|
||||
@@ -209,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) {
|
||||
@@ -230,6 +323,7 @@ func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input del
|
||||
}
|
||||
experience := epistemics.ExperienceRecord{
|
||||
SourceKind: epistemics.ExperienceSourceCapability,
|
||||
PrincipalID: string(input.Utterance.PrincipalID),
|
||||
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
|
||||
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
|
||||
ContextRefs: contextRefs("work:delegation:"+input.DelegationID, "work:run:"+input.RunID),
|
||||
@@ -244,7 +338,17 @@ func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input del
|
||||
ConfidenceMillis: confidence,
|
||||
RecordedAtUnixMillis: input.At.UnixMilli(),
|
||||
}
|
||||
r.saveExperienceAndReflection(ctx, experience)
|
||||
saved, ok := r.saveExperienceAndReflection(ctx, experience)
|
||||
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) {
|
||||
@@ -273,9 +377,11 @@ func (r learningRecorder) updateReminderSkill(ctx context.Context, experience ep
|
||||
}
|
||||
profile, err := r.capability.LoadSkillProfile(ctx, reminderResponseSkillID)
|
||||
if err != nil {
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "read", "fast-path-learning", true, false, true, "FAILURE", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
log.Printf("learning skill profile unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "read", "fast-path-learning", true, false, true, "SUCCESS", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
next, err := capabilitydomain.ObserveSkillOutcome(profile, capability.SkillOutcomeObservation{
|
||||
ExperienceRef: capability.SkillEvidenceRef("epistemics:experience:" + string(experience.ID)),
|
||||
Outcome: outcome,
|
||||
@@ -287,9 +393,11 @@ func (r learningRecorder) updateReminderSkill(ctx context.Context, experience ep
|
||||
return
|
||||
}
|
||||
if err := r.capability.SaveSkillProfile(ctx, next); err != nil {
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "write", "fast-path-learning", true, false, true, "FAILURE", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
log.Printf("learning skill profile save unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("skill-profile-v1", "core-runtime", "write", "fast-path-learning", true, false, true, "SUCCESS", string(experience.OriginalIntentID), string(experience.SourceUtteranceID), time.UnixMilli(experience.RecordedAtUnixMillis))
|
||||
}
|
||||
|
||||
func outcomeForHumanResponse(response continuity.HumanResponse) (epistemics.OutcomeClass, int64) {
|
||||
|
||||
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
@@ -28,17 +30,60 @@ func (s *learningEpistemicsStub) SaveReflectionCandidate(context.Context, episte
|
||||
type learningCapabilityStub struct {
|
||||
loadCalls int
|
||||
saveCalls int
|
||||
saved []capability.SkillProfile
|
||||
}
|
||||
|
||||
type learningSelfStub struct {
|
||||
signals int
|
||||
needs int
|
||||
ledgers int
|
||||
}
|
||||
|
||||
type failingLearningSelfStub struct{}
|
||||
|
||||
func (failingLearningSelfStub) SaveLearningSignal(context.Context, selfcontract.LearningSignal) error {
|
||||
return errors.New("self repository unavailable")
|
||||
}
|
||||
func (failingLearningSelfStub) SaveImprovementNeed(context.Context, selfcontract.ImprovementNeed) error {
|
||||
return errors.New("self repository unavailable")
|
||||
}
|
||||
func (failingLearningSelfStub) SaveLearningLedger(context.Context, selfcontract.LearningLedgerEntry) error {
|
||||
return errors.New("self repository unavailable")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
func (s *learningCapabilityStub) SaveSkillProfile(context.Context, capability.SkillProfile) error {
|
||||
func (s *learningCapabilityStub) SaveSkillProfile(_ context.Context, value capability.SkillProfile) error {
|
||||
s.saveCalls++
|
||||
s.saved = append(s.saved, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVerifiedDelegationCreatesCandidateNotActiveSkill(t *testing.T) {
|
||||
e := &learningEpistemicsStub{}
|
||||
c := &learningCapabilityStub{}
|
||||
r := learningRecorder{epistemics: e, capability: c}
|
||||
r.recordDelegationOutcome(context.Background(), delegationLearningInput{Intent: interaction.OriginalHumanIntent{ID: "i"}, Utterance: interaction.HumanUtterance{ID: "u"}, DelegationID: "d", RunID: "r", ImplementationID: "impl", OutcomeRef: "outcome", VerificationRefs: []epistemics.EvidenceRef{"verified"}, Verified: true, At: time.UnixMilli(1000)})
|
||||
if len(c.saved) != 1 || c.saved[0].Status != capability.SkillCandidate || c.saved[0].VerifiedOutcomeCount != 1 {
|
||||
t.Fatalf("candidate=%#v", c.saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCorrectionDoesNotDegradeReminderSkill(t *testing.T) {
|
||||
epistemicsStore := &learningEpistemicsStub{}
|
||||
capabilityStore := &learningCapabilityStub{}
|
||||
@@ -55,3 +100,33 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfRepositoryFailureDoesNotDiscardUserFacingLearningPath(t *testing.T) {
|
||||
epistemicsStore := &learningEpistemicsStub{}
|
||||
recorder := learningRecorder{epistemics: epistemicsStore, self: failingLearningSelfStub{}}
|
||||
recorder.recordCorrection(context.Background(), correctionLearningInput{
|
||||
Intent: interaction.OriginalHumanIntent{ID: "intent-failure"},
|
||||
Utterance: interaction.HumanUtterance{ID: "utterance-failure", PrincipalID: "principal-failure"},
|
||||
At: time.UnixMilli(3000),
|
||||
})
|
||||
if len(epistemicsStore.experiences) != 1 {
|
||||
t.Fatalf("SELF repository failure must not discard the completed epistemics response path")
|
||||
}
|
||||
}
|
||||
|
||||
+62
-10
@@ -24,6 +24,7 @@ import (
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/platform/telemetry"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"io"
|
||||
@@ -43,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
|
||||
@@ -65,6 +67,8 @@ func main() {
|
||||
if creatorUsername == "" {
|
||||
log.Fatal("CREATOR_USERNAME is required")
|
||||
}
|
||||
telemetry.RecordCompatibility("non-bootstrap-env-consumers", "core-runtime", "read", "runtime-startup-configuration", true, true, true, "OBSERVED", "startup", "core-runtime", time.Now())
|
||||
telemetry.RecordCompatibility("migration-compatibility-switches", "core-runtime", "observe", "runtime-startup-switch-scan", true, false, false, "NOT_OBSERVED", "startup", "core-runtime", time.Now())
|
||||
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -114,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)
|
||||
@@ -134,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())
|
||||
@@ -553,9 +576,13 @@ func main() {
|
||||
storedFocus, err := repository.ActiveProjectFocus(r.Context(), session.ActorID, session.PrincipalID)
|
||||
if err == nil {
|
||||
previousFocus = &storedFocus
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "SUCCESS", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "FAILURE", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
} else {
|
||||
telemetry.RecordCompatibility("active-project-focus-read", "core-runtime", "read", "chat-focus-load", true, false, true, "EMPTY", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
}
|
||||
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
creativeUserRequest := interactiondomain.IsCreativeRequest(utterance.Content)
|
||||
@@ -564,9 +591,10 @@ func main() {
|
||||
travelContinuation := false
|
||||
var previousTask *interaction.HumanUtterance
|
||||
var conversationHistory []interaction.HumanUtterance
|
||||
var contextProjectReferences []interaction.ProjectReference
|
||||
if request.ConversationID != "" {
|
||||
var historyErr error
|
||||
conversationHistory, historyErr = interactionRepository.Utterances(r.Context(), conversation.ID)
|
||||
conversationHistory, contextProjectReferences, historyErr = interactionRepository.UtterancesWithProjectReferences(r.Context(), conversation.ID)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction conversation history unavailable: %v", historyErr)
|
||||
}
|
||||
@@ -574,14 +602,32 @@ func main() {
|
||||
// Keep durable user context available even when the web client carries a
|
||||
// conversation ID; a new or rotated conversation must not erase learned
|
||||
// preferences.
|
||||
recentUserHistory, historyErr := interactionRepository.RecentUtterancesForPrincipal(r.Context(), session.PrincipalID, 64)
|
||||
recentUserHistory, recentProjectReferences, historyErr := interactionRepository.RecentUtterancesForPrincipalWithProjectReferences(r.Context(), session.PrincipalID, 64)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction principal history unavailable: %v", historyErr)
|
||||
} else {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
|
||||
contextProjectReferences = append(contextProjectReferences, recentProjectReferences...)
|
||||
focusProjectID := ""
|
||||
if previousFocus != nil {
|
||||
focusProjectID = string(previousFocus.ProjectID)
|
||||
}
|
||||
gen2Context := interactiondomain.BuildScopedGen2ContextForQuery(conversationHistory, contextProjectReferences, string(session.PrincipalID), focusProjectID, effectiveTaskMessage)
|
||||
experienceRecords, experienceErr := epistemicsRepository.RecentExperienceRecordsForPrincipalAndScope(r.Context(), string(session.PrincipalID), focusProjectID, 16)
|
||||
if experienceErr != nil {
|
||||
log.Printf("epistemics experience context unavailable: %v", experienceErr)
|
||||
}
|
||||
if compiledContext, contextErr := compileInteractionContextWithExperience(gen2Context, focusProjectID, experienceRecords, effectiveTaskMessage); contextErr != nil {
|
||||
log.Printf("interaction context compilation unavailable: %v", contextErr)
|
||||
} else {
|
||||
gen2Context.Text = compiledContext
|
||||
}
|
||||
iterativeRetriever := newIterativeContextRetriever(conversationHistory, contextProjectReferences, string(session.PrincipalID), focusProjectID, experienceRecords)
|
||||
if gen2Context.UsedPersonalMemoryFallback {
|
||||
telemetry.RecordCompatibility("personal-memory-fallback", "core-runtime", "read", "gen2-context-empty", true, true, false, "USED", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
}
|
||||
retrievedMemoryReply := interactiondomain.RecallPersonalPreference(conversationHistory, effectiveTaskMessage)
|
||||
stableConversationalReply, stableConversational := interactiondomain.StableConversationalReply(
|
||||
effectiveTaskMessage,
|
||||
@@ -780,9 +826,11 @@ func main() {
|
||||
activeFocusProvenance := situation.ProjectResolutionProvenance("")
|
||||
if semantic.ActiveFocus != nil {
|
||||
if err := repository.SaveActiveProjectFocus(r.Context(), *semantic.ActiveFocus); err != nil {
|
||||
telemetry.RecordCompatibility("active-project-focus-write", "core-runtime", "write", "semantic-focus-update", true, false, true, "FAILURE", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
http.Error(w, "save project focus", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
telemetry.RecordCompatibility("active-project-focus-write", "core-runtime", "write", "semantic-focus-update", true, false, true, "SUCCESS", utterance.CorrelationID, string(session.PrincipalID), now)
|
||||
activeFocusName = semantic.ActiveFocus.ProjectDisplayName
|
||||
activeFocusProvenance = semantic.ActiveFocus.Provenance
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus && previousFocus != nil {
|
||||
@@ -875,7 +923,7 @@ func main() {
|
||||
planningReply = retrievedMemoryReply
|
||||
needsCognition = false
|
||||
}
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && len(gen2Context.Fragments) == 0 && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
|
||||
researchAttempted = true
|
||||
if planID, idErr := ids.New(); idErr == nil {
|
||||
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||
@@ -904,9 +952,9 @@ func main() {
|
||||
reply, backed := renderResearchReply(searchQuery, result, researchErr)
|
||||
if !backed && cognitiveAvailable {
|
||||
answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, part, gen2Context.Text, cognitive.TaskMeaning{
|
||||
answer, answerErr := cognitiveClient.AnswerStableKnowledgeWithContinuation(answerCtx, session, part, gen2Context.Text, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
}, iterativeRetriever)
|
||||
cancelAnswer()
|
||||
if answerErr == nil && stableKnowledgeAnswer(answer) {
|
||||
reply, backed = answer, true
|
||||
@@ -942,11 +990,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
canGenerateTextReply := shouldGenerateNonMutatingTextDirective(semantic, routePlan, helpPath, creativeRequest) && planningReply == deterministicReply
|
||||
if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && !hasChatRoute(routePlan, chatRouteResearch) && !creativeRequest {
|
||||
if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && (!hasChatRoute(routePlan, chatRouteResearch) || len(gen2Context.Fragments) > 0) && !creativeRequest {
|
||||
answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, effectiveTaskMessage, gen2Context.Text, cognitive.TaskMeaning{
|
||||
answer, answerErr := cognitiveClient.AnswerStableKnowledgeWithContinuation(answerCtx, session, effectiveTaskMessage, gen2Context.Text, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
}, iterativeRetriever)
|
||||
cancelAnswer()
|
||||
if answerErr == nil && stableKnowledgeAnswer(answer) {
|
||||
planningReply = answer
|
||||
@@ -1471,6 +1519,10 @@ func main() {
|
||||
candidates, err = capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID)
|
||||
}
|
||||
if err != nil {
|
||||
// The resolver is an internal capability boundary. Keep request identity
|
||||
// and content out of logs, but preserve the repository error so a 503
|
||||
// can be distinguished from a true empty/unsuitable binding set.
|
||||
log.Printf("cognitive registry resolve unavailable: %v", err)
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRoot = "/run/control-plane-secrets"
|
||||
defaultRuntimeUID = 100
|
||||
defaultRuntimeGID = 101
|
||||
)
|
||||
|
||||
func main() {
|
||||
reason := flag.String("reason", "", "rotation reason: owner-requested-rotation or exposure-detected")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fail("usage: secret-control-plane [ensure|rotate]")
|
||||
}
|
||||
store, err := platformsecrets.NewLocalMachineCredentialStore(defaultRoot, defaultRuntimeUID, defaultRuntimeGID, nil)
|
||||
if err != nil {
|
||||
fail("control-plane credential store unavailable")
|
||||
}
|
||||
spec := platformsecrets.ControlPlaneServiceTokenSpec()
|
||||
switch strings.TrimSpace(flag.Arg(0)) {
|
||||
case "ensure":
|
||||
result, err := store.Ensure(spec)
|
||||
if err != nil {
|
||||
fail("control-plane credential provisioning failed")
|
||||
}
|
||||
if result.Created {
|
||||
fmt.Println("control-plane credential provisioned")
|
||||
return
|
||||
}
|
||||
fmt.Println("control-plane credential already active")
|
||||
case "rotate":
|
||||
if !validRotationReason(*reason) {
|
||||
fail("rotation reason is invalid")
|
||||
}
|
||||
if _, err := store.Rotate(spec, *reason); err != nil {
|
||||
fail("control-plane credential rotation failed")
|
||||
}
|
||||
fmt.Println("control-plane credential rotated")
|
||||
default:
|
||||
fail("unsupported control-plane credential operation")
|
||||
}
|
||||
}
|
||||
|
||||
func validRotationReason(reason string) bool {
|
||||
switch strings.TrimSpace(reason) {
|
||||
case "owner-requested-rotation", "exposure-detected":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func fail(message string) {
|
||||
fmt.Fprintln(os.Stderr, message)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSecretControlPlaneDefaultsUseDedicatedProtectedRoot(t *testing.T) {
|
||||
if defaultRoot != "/run/control-plane-secrets" || defaultRuntimeUID <= 0 || defaultRuntimeGID <= 0 {
|
||||
t.Fatal("unsafe control-plane defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotationReasonsAreBoundedAndNeverArbitraryDiagnosticText(t *testing.T) {
|
||||
if !validRotationReason("owner-requested-rotation") || !validRotationReason("exposure-detected") || validRotationReason("Bearer secret-value") {
|
||||
t.Fatal("unsafe rotation reason validation")
|
||||
}
|
||||
}
|
||||
+104
-5
@@ -45,7 +45,7 @@ func main() {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 5 * time.Second}}
|
||||
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
public := http.NewServeMux()
|
||||
public.HandleFunc("GET /healthz", s.health)
|
||||
public.HandleFunc("GET /readyz", s.ready)
|
||||
@@ -53,8 +53,11 @@ func main() {
|
||||
control := http.NewServeMux()
|
||||
control.HandleFunc("GET /v1/state", s.state)
|
||||
control.HandleFunc("POST /v1/releases/start", s.start)
|
||||
control.HandleFunc("POST /v1/releases/start-set", s.startSet)
|
||||
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
|
||||
control.HandleFunc("POST /v1/releases/baseline-set", s.baselineSet)
|
||||
control.HandleFunc("POST /v1/releases/observe", s.observe)
|
||||
control.HandleFunc("POST /v1/releases/observe-set", s.observeSet)
|
||||
control.HandleFunc("POST /v1/releases/promote", s.promote)
|
||||
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
|
||||
control.HandleFunc("POST /v1/traffic/canary", s.canary)
|
||||
@@ -81,6 +84,10 @@ func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if state.StableReleaseSet != nil && !s.checkReleaseSet(r, state.StableReleaseSet) {
|
||||
http.Error(w, "stable release set unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, state.StableUpstream+"/healthz", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
@@ -98,6 +105,28 @@ func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ready"})
|
||||
}
|
||||
|
||||
func (s *server) checkReleaseSet(r *http.Request, releaseSet *supervisor.ReleaseSet) bool {
|
||||
if releaseSet == nil || !releaseSet.Validate() {
|
||||
return false
|
||||
}
|
||||
for _, name := range releaseSet.ComponentNames() {
|
||||
component := releaseSet.Components[name]
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, component.ReadyURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil || response.StatusCode/100 != 2 {
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
return false
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *server) proxy(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
@@ -142,6 +171,14 @@ func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := s.lifeboat.Start(release)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) startSet(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseSet supervisor.ReleaseSet
|
||||
if !decode(w, r, &releaseSet) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.StartSet(releaseSet)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
|
||||
var release supervisor.Release
|
||||
if !decode(w, r, &release) {
|
||||
@@ -150,6 +187,14 @@ func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := s.lifeboat.EstablishBaseline(release)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) baselineSet(w http.ResponseWriter, r *http.Request) {
|
||||
var releaseSet supervisor.ReleaseSet
|
||||
if !decode(w, r, &releaseSet) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.EstablishBaselineSet(releaseSet)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
|
||||
var observation supervisor.Observation
|
||||
if !decode(w, r, &observation) {
|
||||
@@ -158,16 +203,57 @@ func (s *server) observe(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := s.lifeboat.Record(observation)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) observeSet(w http.ResponseWriter, r *http.Request) {
|
||||
var observation supervisor.ReleaseSetObservation
|
||||
if !decode(w, r, &observation) {
|
||||
return
|
||||
}
|
||||
ok, err := s.lifeboat.RecordSet(observation)
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promote(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Promote()
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil {
|
||||
ok, err = s.lifeboat.PromoteSet()
|
||||
} else {
|
||||
ok, err = s.lifeboat.Promote()
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollback(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.Rollback()
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil || state.RollbackTargetReleaseSet != nil {
|
||||
ok, err = s.lifeboat.RollbackSet()
|
||||
} else {
|
||||
ok, err = s.lifeboat.Rollback()
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) canary(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
var err error
|
||||
if state.CandidateReleaseSet != nil {
|
||||
ok, err = s.lifeboat.SetTrafficForSets(supervisor.TrafficCanaryHeader)
|
||||
} else {
|
||||
ok, err = s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -175,7 +261,20 @@ func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
ok, err := s.lifeboat.RollbackTraffic()
|
||||
state, snapshotErr := s.lifeboat.Snapshot()
|
||||
if snapshotErr != nil {
|
||||
respond(w, false, snapshotErr)
|
||||
return
|
||||
}
|
||||
// A ReleaseSet must roll back as a whole. The legacy release-only method
|
||||
// would merely flip the traffic mode while retaining a candidate upstream.
|
||||
var ok bool
|
||||
var err error
|
||||
if state.StableReleaseSet != nil {
|
||||
ok, err = s.lifeboat.RollbackSet()
|
||||
} else {
|
||||
ok, err = s.lifeboat.RollbackTraffic()
|
||||
}
|
||||
respond(w, ok, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,30 @@ type StrategyDecision struct {
|
||||
Escalate bool
|
||||
}
|
||||
|
||||
// StrategyEligibility is supplied by Capability lifecycle resolution. It keeps
|
||||
// a requested cheap route from becoming an entitlement to use stale evidence.
|
||||
type StrategyEligibility struct {
|
||||
RuleEligible bool
|
||||
ReflexEligible bool
|
||||
SkillEligible bool
|
||||
}
|
||||
|
||||
func (r StrategyRequest) Select() StrategyDecision {
|
||||
return r.SelectEligible(StrategyEligibility{RuleEligible: true, ReflexEligible: true, SkillEligible: true})
|
||||
}
|
||||
|
||||
func (r StrategyRequest) SelectEligible(eligible StrategyEligibility) StrategyDecision {
|
||||
if r.RiskClass == "HIGH" || r.CostOfError >= 800 {
|
||||
return StrategyDecision{Path: PathStrongLocal, Reason: "high consequence requires deeper reasoning", Escalate: true}
|
||||
}
|
||||
if r.RequiredQuality <= 500 && r.CostBudgetMicros > 0 {
|
||||
if eligible.RuleEligible && r.RequiredQuality <= 500 && r.CostBudgetMicros > 0 {
|
||||
return StrategyDecision{Path: PathRule, Reason: "bounded low-risk path"}
|
||||
}
|
||||
if r.PreferLowLatency {
|
||||
if eligible.ReflexEligible && r.PreferLowLatency {
|
||||
return StrategyDecision{Path: PathReflex, Reason: "latency preference with later escalation"}
|
||||
}
|
||||
return StrategyDecision{Path: PathSkill, Reason: "validated reusable strategy"}
|
||||
if eligible.SkillEligible {
|
||||
return StrategyDecision{Path: PathSkill, Reason: "validated reusable strategy"}
|
||||
}
|
||||
return StrategyDecision{Path: PathSmallLocal, Reason: "no eligible reflex or skill; escalate to reasoning", Escalate: true}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
ReflexActive ReflexLifecycle = "ACTIVE"
|
||||
ReflexDegraded ReflexLifecycle = "DEGRADED"
|
||||
ReflexStale ReflexLifecycle = "STALE"
|
||||
ReflexRevalidating ReflexLifecycle = "REVALIDATING"
|
||||
ReflexSuspended ReflexLifecycle = "SUSPENDED"
|
||||
ReflexRetired ReflexLifecycle = "RETIRED"
|
||||
ReflexDeterministicCode ReflexKind = "DETERMINISTIC_CODE"
|
||||
@@ -58,4 +59,19 @@ type ReflexRevision struct {
|
||||
LastValidatedAt int64
|
||||
Lifecycle ReflexLifecycle
|
||||
RecordedAt int64
|
||||
SupersedesRevision int64
|
||||
ExecutionCount int64
|
||||
FailureCount int64
|
||||
DivergenceCount int64
|
||||
}
|
||||
|
||||
// ShadowComparison is evidence produced by a bounded shadow sample. It is not
|
||||
// permission to rewrite a Reflex; SELF/governed evaluation decide next steps.
|
||||
type ShadowComparison struct {
|
||||
ReflexID ReflexID
|
||||
ReflexRevision int64
|
||||
EvidenceRefs []SkillEvidenceRef
|
||||
VerifierPassed bool
|
||||
Diverged bool
|
||||
ObservedAt int64
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const (
|
||||
SkillCandidate SkillLifecycleStatus = "CANDIDATE"
|
||||
SkillEvaluating SkillLifecycleStatus = "EVALUATING"
|
||||
SkillValidated SkillLifecycleStatus = "VALIDATED"
|
||||
SkillActive SkillLifecycleStatus = "ACTIVE"
|
||||
SkillDegraded SkillLifecycleStatus = "DEGRADED"
|
||||
SkillSuspended SkillLifecycleStatus = "SUSPENDED"
|
||||
SkillRetired SkillLifecycleStatus = "RETIRED"
|
||||
@@ -202,6 +203,15 @@ type SkillProfile struct {
|
||||
ObservedFailureCount int64
|
||||
ObservedCorrectionCount int64
|
||||
RecordedAtUnixMillis int64
|
||||
// The fields below are the additive Gen2 lifecycle view. The older fields
|
||||
// remain a compatibility projection; they are not another Skill registry.
|
||||
Applicability ApplicabilitySpec
|
||||
DependencyRefs []string
|
||||
ValidationEvidenceRefs []SkillEvidenceRef
|
||||
IndependentEvaluationRefs []SkillEvidenceRef
|
||||
KnownFailureConditions []string
|
||||
VerifiedOutcomeCount int64
|
||||
MustNotWorsenSatisfied bool
|
||||
}
|
||||
|
||||
type SkillOutcomeObservation struct {
|
||||
|
||||
@@ -36,6 +36,14 @@ type ContextOmission struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ContextInclusion records the bounded compiler decision without exposing
|
||||
// source text. It is trace-only metadata and is never user-facing output.
|
||||
type ContextInclusion struct {
|
||||
ID ContextExcerptID
|
||||
Lane string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ContextBundle struct {
|
||||
Excerpts []ContextExcerpt
|
||||
Omissions []ContextOmission
|
||||
@@ -45,19 +53,60 @@ type ContextBundle struct {
|
||||
|
||||
type ContextAssemblyTrace struct {
|
||||
Included []ContextExcerptID
|
||||
Inclusions []ContextInclusion
|
||||
Excluded []ContextOmission
|
||||
Coverage string
|
||||
CompilerVersion string
|
||||
}
|
||||
|
||||
type IterativeRetrievalLane string
|
||||
|
||||
const (
|
||||
IterativeRetrievalCurrentFact IterativeRetrievalLane = "CURRENT_FACT"
|
||||
IterativeRetrievalPositiveExperience IterativeRetrievalLane = "POSITIVE_EXPERIENCE"
|
||||
IterativeRetrievalNegativeExperience IterativeRetrievalLane = "NEGATIVE_EXPERIENCE"
|
||||
IterativeRetrievalContradiction IterativeRetrievalLane = "CONTRADICTION"
|
||||
IterativeRetrievalHistoricalAnalogy IterativeRetrievalLane = "HISTORICAL_ANALOGY"
|
||||
)
|
||||
|
||||
// IterativeRetrievalRequest is the model-visible, storage-agnostic intent.
|
||||
// Principal, Scope, Focus and policy are deliberately absent: Core binds them
|
||||
// from the already-authorized request and never accepts them from a model.
|
||||
type IterativeRetrievalRequest struct {
|
||||
Query string
|
||||
RemainingBytes int
|
||||
Pass int
|
||||
Query string
|
||||
SemanticSubject string
|
||||
Lane IterativeRetrievalLane
|
||||
Reason string
|
||||
RemainingBytes int
|
||||
Pass int
|
||||
}
|
||||
|
||||
// IterativeRetrievalBudget is a Core-enforced per-request budget.
|
||||
type IterativeRetrievalBudget struct {
|
||||
MaxRounds int
|
||||
MaxFragments int
|
||||
MaxAddedBytes int
|
||||
MaxLatencyMillis int64
|
||||
}
|
||||
|
||||
// IterativeRetrievalEnvelope is the trusted continuation input assembled by
|
||||
// Core. It is never produced by a provider or model adapter.
|
||||
type IterativeRetrievalEnvelope struct {
|
||||
Request IterativeRetrievalRequest
|
||||
PrincipalID string
|
||||
ContextScopeRef string
|
||||
FocusFrameRef string
|
||||
TaskRef string
|
||||
FreshnessRequirement string
|
||||
DataPolicy capability.DataPolicy
|
||||
Budget IterativeRetrievalBudget
|
||||
SeenExcerptIDs []ContextExcerptID
|
||||
}
|
||||
type IterativeRetrievalResponse struct {
|
||||
Candidates []ContextCandidate
|
||||
Exhausted bool
|
||||
Reason string
|
||||
AddedBytes int
|
||||
}
|
||||
|
||||
// CompileContext deterministically selects permitted, relevant excerpts for a
|
||||
@@ -67,14 +116,9 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) {
|
||||
return ContextBundle{}, ErrInvalidContextBudget
|
||||
}
|
||||
candidates := append([]ContextCandidate(nil), input.Candidates...)
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Relevance != candidates[j].Relevance {
|
||||
return candidates[i].Relevance > candidates[j].Relevance
|
||||
}
|
||||
return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID)
|
||||
})
|
||||
bundle := ContextBundle{Excerpts: []ContextExcerpt{}, Omissions: []ContextOmission{}}
|
||||
seen := make(map[ContextExcerptID]struct{}, len(candidates))
|
||||
eligible := make([]ContextCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
excerpt := candidate.Excerpt
|
||||
if _, ok := seen[excerpt.ID]; ok {
|
||||
@@ -86,8 +130,7 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "context_scope"})
|
||||
continue
|
||||
}
|
||||
reason := permittedContextReason(excerpt, input.DataPolicy)
|
||||
if reason != "" {
|
||||
if reason := permittedContextReason(excerpt, input.DataPolicy); reason != "" {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: reason})
|
||||
continue
|
||||
}
|
||||
@@ -95,14 +138,45 @@ func CompileContext(input ContextCompileInput) (ContextBundle, error) {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "empty"})
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, candidate)
|
||||
}
|
||||
candidates = eligible
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Relevance != candidates[j].Relevance {
|
||||
return candidates[i].Relevance > candidates[j].Relevance
|
||||
}
|
||||
return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID)
|
||||
})
|
||||
included := make(map[ContextExcerptID]struct{}, len(candidates))
|
||||
include := func(candidate ContextCandidate, reason string) bool {
|
||||
excerpt := candidate.Excerpt
|
||||
bytes := len([]byte(excerpt.Text))
|
||||
if bundle.Bytes+bytes > input.MaxBytes {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "budget"})
|
||||
continue
|
||||
return false
|
||||
}
|
||||
bundle.Excerpts = append(bundle.Excerpts, excerpt)
|
||||
bundle.Bytes += bytes
|
||||
bundle.Trace.Included = append(bundle.Trace.Included, excerpt.ID)
|
||||
bundle.Trace.Inclusions = append(bundle.Trace.Inclusions, ContextInclusion{ID: excerpt.ID, Lane: candidate.Lane, Reason: reason})
|
||||
included[excerpt.ID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
// Preserve one candidate from each semantic lane before filling by score so
|
||||
// many similar current facts cannot erase a relevant failure or conflict.
|
||||
for _, lane := range []string{"current-fact", "positive-experience", "negative-experience", "contradiction", "historical-analogy", "interaction"} {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Lane == lane && include(candidate, "lane_coverage") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, ok := included[candidate.Excerpt.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if !include(candidate, "salience") {
|
||||
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: candidate.Excerpt.ID, Reason: "budget"})
|
||||
}
|
||||
}
|
||||
bundle.Trace.Excluded = append([]ContextOmission(nil), bundle.Omissions...)
|
||||
bundle.Trace.CompilerVersion = "context-compiler-v2-additive"
|
||||
|
||||
@@ -12,3 +12,19 @@ func TestContextCompilerV2TraceAndBudgetRemainDeterministic(t *testing.T) {
|
||||
t.Fatalf("bundle=%#v err=%v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCompilerPreservesNegativeLaneWithinBoundedBudget(t *testing.T) {
|
||||
b, err := cognitive.CompileContext(cognitive.ContextCompileInput{MaxBytes: 27, DataPolicy: capability.DataPolicyLocalOnly, Candidates: []cognitive.ContextCandidate{
|
||||
{Excerpt: cognitive.ContextExcerpt{ID: "current", Text: "current-value", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact", Relevance: 100},
|
||||
{Excerpt: cognitive.ContextExcerpt{ID: "failure", Text: "known-failure", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "negative-experience", Relevance: 1, Negative: true},
|
||||
{Excerpt: cognitive.ContextExcerpt{ID: "crowd", Text: "similar-current", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact", Relevance: 99},
|
||||
}})
|
||||
if err != nil || len(b.Excerpts) != 2 || b.Excerpts[0].ID != "current" || b.Excerpts[1].ID != "failure" {
|
||||
t.Fatalf("bundle=%#v err=%v", b, err)
|
||||
}
|
||||
// The negative record fits and is retained before a lower-value crowd of
|
||||
// similar facts can consume the remaining bounded budget.
|
||||
if len(b.Trace.Inclusions) != 2 || b.Trace.Inclusions[0].Reason != "lane_coverage" || b.Trace.Inclusions[1].Reason != "lane_coverage" {
|
||||
t.Fatalf("trace=%#v", b.Trace)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +69,19 @@ type AdvisoryRequest struct {
|
||||
// GatewayAdvisoryRequest describes the task and policy requirement. It cannot
|
||||
// name a provider, model or binding; Capability owns that selection.
|
||||
type GatewayAdvisoryRequest struct {
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Message string
|
||||
Context []ContextExcerpt
|
||||
Locale string
|
||||
MaxOutputBytes int
|
||||
DataPolicy capability.DataPolicy
|
||||
EgressAcknowledged bool
|
||||
Meaning TaskMeaning
|
||||
Version ContractVersion
|
||||
RequestID RequestID
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Message string
|
||||
Context []ContextExcerpt
|
||||
Locale string
|
||||
MaxOutputBytes int
|
||||
DataPolicy capability.DataPolicy
|
||||
EgressAcknowledged bool
|
||||
AllowIterativeRetrieval bool
|
||||
Meaning TaskMeaning
|
||||
}
|
||||
|
||||
// TaskMeaning is the trusted semantic summary produced by Core. It is not a
|
||||
@@ -177,4 +178,5 @@ type GatewayAdvisoryResponse struct {
|
||||
ReferencedExcerptIDs []ContextExcerptID
|
||||
FinishReason FinishReason
|
||||
Usage *ProviderUsage
|
||||
IterativeRetrieval *IterativeRetrievalRequest
|
||||
}
|
||||
|
||||
@@ -56,6 +56,24 @@ type FreshnessAssessment struct {
|
||||
RevalidationAttemptRef string
|
||||
AssessedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// FreshnessRequirement is supplied by the current task/focus owner. It is an
|
||||
// evaluation input, not Epistemics-owned task state, so freshness remains
|
||||
// relative to the decision being made.
|
||||
type FreshnessRequirement struct {
|
||||
Mode string
|
||||
MaximumAgeMillis *int64
|
||||
RequiredVersionRefs []SemanticReference
|
||||
}
|
||||
|
||||
// WithExactVersionBinding is a typed contract helper for callers that need to
|
||||
// attach an exact environment/version applicability constraint without taking
|
||||
// a direct dependency on the World package.
|
||||
func WithExactVersionBinding(value TemporalApplicability, subject, version SemanticReference) TemporalApplicability {
|
||||
value.VersionBindings = append(value.VersionBindings, world.VersionBinding{SubjectRef: subject, VersionRef: version, MatchMode: world.VersionExact})
|
||||
return value
|
||||
}
|
||||
|
||||
type RevalidationRequest struct {
|
||||
ID string
|
||||
SourceKnowledgeRef string
|
||||
|
||||
@@ -102,6 +102,7 @@ type SystemExpectation struct {
|
||||
// state or grants authority.
|
||||
type ExperienceRecord struct {
|
||||
ID ExperienceRecordID
|
||||
PrincipalID string
|
||||
SourceKind ExperienceSourceKind
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
@@ -133,6 +134,15 @@ type ExperienceRecord struct {
|
||||
WhySucceededOrFailed string
|
||||
}
|
||||
|
||||
// ConsolidationCandidate is a rebuildable Epistemics projection. It preserves
|
||||
// source ExperienceRecord references and cannot activate a Skill by itself.
|
||||
type ConsolidationCandidate struct {
|
||||
EpisodeRef string
|
||||
ExperienceRefs []ExperienceRecordID
|
||||
PropositionText string
|
||||
SkillCandidate bool
|
||||
}
|
||||
|
||||
// ReflectionCandidate records that broader reflection may be warranted.
|
||||
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
|
||||
// produce candidates only; it cannot directly rewrite canonical state.
|
||||
|
||||
@@ -8,8 +8,9 @@ type CredentialRef string
|
||||
type SecretPurpose string
|
||||
|
||||
const (
|
||||
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
|
||||
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
|
||||
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
|
||||
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
|
||||
SecretPurposeServiceAuthentication SecretPurpose = "SERVICE_AUTHENTICATION"
|
||||
)
|
||||
|
||||
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
|
||||
@@ -17,6 +18,7 @@ type RuntimeConsumer string
|
||||
|
||||
const (
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
RuntimeConsumerCommit RuntimeConsumer = "commit-runtime"
|
||||
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
|
||||
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
|
||||
RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker"
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
package self
|
||||
|
||||
type BenchmarkCaseRef string
|
||||
type BenchmarkCase struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
TaskClass string
|
||||
EvidenceRefs []SemanticReference
|
||||
RecordedAt int64
|
||||
}
|
||||
|
||||
func (c BenchmarkCase) Valid() bool {
|
||||
return c.ID != "" && c.PrincipalID != "" && c.TaskClass != "" && len(c.EvidenceRefs) > 0 && c.RecordedAt > 0
|
||||
}
|
||||
|
||||
type PersonalBenchmark struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
|
||||
+161
-9
@@ -6,6 +6,58 @@ type AssessmentID string
|
||||
type LearningLedgerEntryID string
|
||||
type AssessmentState string
|
||||
|
||||
type LearningSignalKind string
|
||||
type DiagnosisClass string
|
||||
type EpistemicStatus string
|
||||
type InterventionClass string
|
||||
type DecisionRegretKind string
|
||||
type CandidateState string
|
||||
|
||||
const (
|
||||
SignalVerifiedFailure LearningSignalKind = "VERIFIED_FAILURE"
|
||||
SignalUserCorrection LearningSignalKind = "USER_CORRECTION"
|
||||
SignalRepeatedMistake LearningSignalKind = "REPEATED_KNOWN_MISTAKE"
|
||||
SignalReflexDivergence LearningSignalKind = "REFLEX_DIVERGENCE"
|
||||
SignalSkillFallback LearningSignalKind = "SKILL_FALLBACK"
|
||||
SignalUnexpectedEscalation LearningSignalKind = "UNEXPECTED_ESCALATION"
|
||||
SignalOutcomeMismatch LearningSignalKind = "OUTCOME_MISMATCH"
|
||||
SignalRetryFallback LearningSignalKind = "RETRY_FALLBACK"
|
||||
SignalStaleDependency LearningSignalKind = "STALE_DEPENDENCY"
|
||||
SignalDecisionRegret LearningSignalKind = "DECISION_REGRET"
|
||||
SignalBetterSolution LearningSignalKind = "BETTER_SOLUTION"
|
||||
DiagnosisMemoryMissing DiagnosisClass = "MEMORY_MISSING"
|
||||
DiagnosisMemoryNotRetrieved DiagnosisClass = "MEMORY_NOT_RETRIEVED"
|
||||
DiagnosisWrongScope DiagnosisClass = "WRONG_SCOPE"
|
||||
DiagnosisStaleKnowledge DiagnosisClass = "STALE_KNOWLEDGE"
|
||||
DiagnosisContextSelection DiagnosisClass = "CONTEXT_SELECTION"
|
||||
DiagnosisReasoningFailure DiagnosisClass = "REASONING_FAILURE"
|
||||
DiagnosisSkillFailure DiagnosisClass = "SKILL_FAILURE"
|
||||
DiagnosisReflexFailure DiagnosisClass = "REFLEX_FAILURE"
|
||||
DiagnosisToolFailure DiagnosisClass = "TOOL_FAILURE"
|
||||
DiagnosisProviderFailure DiagnosisClass = "PROVIDER_FAILURE"
|
||||
DiagnosisOutcomeVerificationFailure DiagnosisClass = "OUTCOME_VERIFICATION_FAILURE"
|
||||
DiagnosisRoutingInefficiency DiagnosisClass = "ROUTING_INEFFICIENCY"
|
||||
DiagnosisUnknown DiagnosisClass = "UNKNOWN_CAUSE"
|
||||
EpistemicKnown EpistemicStatus = "KNOWN"
|
||||
EpistemicSupported EpistemicStatus = "SUPPORTED"
|
||||
EpistemicHypothesis EpistemicStatus = "HYPOTHESIS"
|
||||
EpistemicUnknown EpistemicStatus = "UNKNOWN"
|
||||
InterventionConfiguration InterventionClass = "CONFIGURATION"
|
||||
InterventionSkillReview InterventionClass = "SKILL_REVIEW"
|
||||
InterventionReflexReview InterventionClass = "REFLEX_REVIEW"
|
||||
InterventionRoutingReview InterventionClass = "ROUTING_REVIEW"
|
||||
InterventionProviderReview InterventionClass = "PROVIDER_REVIEW"
|
||||
InterventionContextReview InterventionClass = "CONTEXT_REVIEW"
|
||||
RegretUnavailableThen DecisionRegretKind = "INFORMATION_UNAVAILABLE_THEN"
|
||||
RegretAvailableUnused DecisionRegretKind = "INFORMATION_AVAILABLE_UNUSED"
|
||||
RegretIgnoredInContext DecisionRegretKind = "EVIDENCE_IGNORED_IN_CONTEXT"
|
||||
CandidateProposed CandidateState = "PROPOSED"
|
||||
CandidateEvaluating CandidateState = "EVALUATING"
|
||||
CandidateKept CandidateState = "KEPT"
|
||||
CandidateRejected CandidateState = "REJECTED"
|
||||
CandidateRolledBack CandidateState = "ROLLED_BACK"
|
||||
)
|
||||
|
||||
const (
|
||||
AssessmentCandidate AssessmentState = "CANDIDATE"
|
||||
AssessmentConfirmed AssessmentState = "CONFIRMED"
|
||||
@@ -37,6 +89,17 @@ type WeaknessPattern struct {
|
||||
DetectionEvidenceRefs []SemanticReference
|
||||
State AssessmentState
|
||||
RecordedAt int64
|
||||
TaskPattern string
|
||||
ScopeKey string
|
||||
EnvironmentVersion string
|
||||
DependencyRefs []SemanticReference
|
||||
Frequency int
|
||||
LastObservedAt int64
|
||||
Severity int
|
||||
AffectedComponent string
|
||||
AffectedStrategy string
|
||||
ConfidenceMillis int64
|
||||
EpistemicStatus EpistemicStatus
|
||||
}
|
||||
type DependencyAssessment struct {
|
||||
ID AssessmentID
|
||||
@@ -48,15 +111,83 @@ type DependencyAssessment struct {
|
||||
AssessedAt int64
|
||||
}
|
||||
type ImprovementNeed struct {
|
||||
ID AssessmentID
|
||||
PrincipalID string
|
||||
SubjectRef SemanticReference
|
||||
ProblemSummary string
|
||||
EvidenceRefs []SemanticReference
|
||||
ExpectedBenefit string
|
||||
Priority int
|
||||
State string
|
||||
CreatedAt int64
|
||||
ID AssessmentID
|
||||
PrincipalID string
|
||||
SubjectRef SemanticReference
|
||||
ProblemSummary string
|
||||
EvidenceRefs []SemanticReference
|
||||
ExpectedBenefit string
|
||||
Priority int
|
||||
State string
|
||||
CreatedAt int64
|
||||
Diagnosis DiagnosisClass
|
||||
DiagnosisStatus EpistemicStatus
|
||||
AffectedComponent string
|
||||
ScopeKey string
|
||||
Risk string
|
||||
Intervention InterventionClass
|
||||
Hypothesis string
|
||||
EvaluationPlan string
|
||||
MustNotWorsen string
|
||||
RollbackPlan string
|
||||
ResourceCost string
|
||||
}
|
||||
|
||||
type LearningSignal struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
Kind LearningSignalKind
|
||||
TaskPattern string
|
||||
ScopeKey string
|
||||
SubjectRef SemanticReference
|
||||
EvidenceRefs []SemanticReference
|
||||
ExpectedOutcome string
|
||||
ActualOutcome string
|
||||
DependencyRefs []SemanticReference
|
||||
EnvironmentVersion string
|
||||
ObservedAt int64
|
||||
Severity int
|
||||
UserImpact int
|
||||
CorrectionCount int
|
||||
DecisionRegret DecisionRegretKind
|
||||
}
|
||||
|
||||
type Diagnosis struct {
|
||||
Class DiagnosisClass
|
||||
Status EpistemicStatus
|
||||
Rationale string
|
||||
EvidenceRefs []SemanticReference
|
||||
ConfidenceMillis int64
|
||||
}
|
||||
|
||||
type ImprovementCandidate struct {
|
||||
ID string
|
||||
PrincipalID string
|
||||
NeedRef SemanticReference
|
||||
Hypothesis string
|
||||
ExpectedEffect string
|
||||
EvaluationPlan string
|
||||
MustNotWorsen []string
|
||||
RollbackPlan string
|
||||
ResourceCost string
|
||||
Creator string
|
||||
IndependentEvaluator string
|
||||
State CandidateState
|
||||
EvidenceRefs []SemanticReference
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type ImprovementEvaluation struct {
|
||||
CandidateRef SemanticReference
|
||||
Evaluator string
|
||||
EvidenceRefs []SemanticReference
|
||||
ExpectedQuality int64
|
||||
ActualQuality int64
|
||||
ExpectedLatency int64
|
||||
ActualLatency int64
|
||||
MustNotWorsenPass bool
|
||||
Decision CandidateState
|
||||
EvaluatedAt int64
|
||||
}
|
||||
type LearningLedgerEntry struct {
|
||||
ID LearningLedgerEntryID
|
||||
@@ -90,3 +221,24 @@ func (l LearningLedgerEntry) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s LearningSignal) Validate() error {
|
||||
if s.ID == "" || s.PrincipalID == "" || s.Kind == "" || s.TaskPattern == "" || s.ScopeKey == "" || !s.SubjectRef.Valid() || len(s.EvidenceRefs) == 0 || s.ObservedAt < 1 {
|
||||
return fmt.Errorf("learning signal is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Diagnosis) Validate() error {
|
||||
if d.Class == "" || d.Status == "" || len(d.EvidenceRefs) == 0 || d.ConfidenceMillis < 0 || d.ConfidenceMillis > 1000 {
|
||||
return fmt.Errorf("diagnosis requires bounded evidence")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ImprovementCandidate) Validate() error {
|
||||
if c.ID == "" || c.PrincipalID == "" || !c.NeedRef.Valid() || c.Hypothesis == "" || c.ExpectedEffect == "" || c.EvaluationPlan == "" || c.MustNotWorsen == nil || c.RollbackPlan == "" || c.Creator == "" || c.IndependentEvaluator == "" || c.Creator == c.IndependentEvaluator || len(c.EvidenceRefs) == 0 || c.CreatedAt < 1 {
|
||||
return fmt.Errorf("candidate lacks independent bounded evaluation")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE epistemics_role;
|
||||
|
||||
ALTER TABLE epistemics.experience_records
|
||||
ADD COLUMN IF NOT EXISTS principal_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS context_scope_ref TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS problem_statement TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS applicability_conditions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
ADD COLUMN IF NOT EXISTS why_succeeded_or_failed TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS experience_records_principal_scope_recorded_idx
|
||||
ON epistemics.experience_records (principal_id, context_scope_ref, recorded_at_unix_millis DESC);
|
||||
|
||||
RESET ROLE;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,79 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE capability_role;
|
||||
|
||||
ALTER TABLE capability.skill_profiles
|
||||
ADD COLUMN IF NOT EXISTS applicability JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS dependency_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS validation_evidence_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS independent_evaluation_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS known_failure_conditions TEXT[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS verified_outcome_count BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS must_not_worsen_satisfied BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE capability.skill_profile_heads DROP CONSTRAINT IF EXISTS skill_profile_heads_status_check;
|
||||
ALTER TABLE capability.skill_profile_heads
|
||||
ADD CONSTRAINT skill_profile_heads_status_check CHECK (status IN ('CANDIDATE','EVALUATING','VALIDATED','ACTIVE','DEGRADED','SUSPENDED','RETIRED'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability.reflex_revisions (
|
||||
reflex_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
principal_id TEXT NOT NULL,
|
||||
source_skill_id TEXT NOT NULL,
|
||||
source_skill_revision BIGINT NOT NULL CHECK (source_skill_revision > 0),
|
||||
kind TEXT NOT NULL,
|
||||
applicability JSONB NOT NULL,
|
||||
implementation_ref TEXT NOT NULL,
|
||||
implementation_version TEXT NOT NULL DEFAULT '',
|
||||
artifact_digest TEXT NOT NULL DEFAULT '',
|
||||
dependency_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
validation_evidence_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
known_failure_experience_refs TEXT[] NOT NULL DEFAULT '{}',
|
||||
shadow_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
revalidation_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_validated_at BIGINT NOT NULL DEFAULT 0,
|
||||
lifecycle TEXT NOT NULL,
|
||||
supersedes_revision BIGINT NOT NULL DEFAULT 0,
|
||||
execution_count BIGINT NOT NULL DEFAULT 0,
|
||||
failure_count BIGINT NOT NULL DEFAULT 0,
|
||||
divergence_count BIGINT NOT NULL DEFAULT 0,
|
||||
recorded_at BIGINT NOT NULL CHECK (recorded_at > 0),
|
||||
PRIMARY KEY (reflex_id, revision),
|
||||
CHECK (lifecycle IN ('CANDIDATE','VALIDATING','ACTIVE','DEGRADED','STALE','REVALIDATING','SUSPENDED','RETIRED'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.reflex_heads (
|
||||
reflex_id TEXT PRIMARY KEY,
|
||||
revision BIGINT NOT NULL,
|
||||
lifecycle TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
GRANT SELECT, INSERT, UPDATE ON capability.reflex_revisions, capability.reflex_heads TO capability_role;
|
||||
|
||||
RESET ROLE;
|
||||
SET ROLE self_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self.improvement_needs (
|
||||
improvement_need_id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
subject_ref JSONB NOT NULL,
|
||||
problem_summary TEXT NOT NULL,
|
||||
evidence_refs JSONB NOT NULL,
|
||||
expected_benefit TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL
|
||||
);
|
||||
ALTER TABLE self.learning_ledger
|
||||
ADD COLUMN IF NOT EXISTS improvement_need_ref JSONB,
|
||||
ADD COLUMN IF NOT EXISTS proposed_change_ref JSONB,
|
||||
ADD COLUMN IF NOT EXISTS expected_improvement TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS evaluation_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS development_gate_ref JSONB,
|
||||
ADD COLUMN IF NOT EXISTS before_version_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS after_version_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS side_effects JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS rollback_ref JSONB;
|
||||
GRANT SELECT, INSERT, UPDATE ON self.improvement_needs, self.learning_ledger TO self_role;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,74 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE self_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self.learning_signals (
|
||||
signal_id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
task_pattern TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
subject_ref JSONB NOT NULL,
|
||||
evidence_refs JSONB NOT NULL,
|
||||
expected_outcome TEXT NOT NULL DEFAULT '',
|
||||
actual_outcome TEXT NOT NULL DEFAULT '',
|
||||
dependency_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
environment_version TEXT NOT NULL DEFAULT '',
|
||||
observed_at_unix_millis BIGINT NOT NULL,
|
||||
severity INTEGER NOT NULL DEFAULT 0,
|
||||
user_impact INTEGER NOT NULL DEFAULT 0,
|
||||
correction_count INTEGER NOT NULL DEFAULT 0,
|
||||
decision_regret TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self.weakness_patterns (
|
||||
assessment_id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
pattern_key TEXT NOT NULL,
|
||||
task_pattern TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
environment_version TEXT NOT NULL DEFAULT '',
|
||||
dependency_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
failure_mode TEXT NOT NULL,
|
||||
detection_evidence_refs JSONB NOT NULL,
|
||||
frequency INTEGER NOT NULL,
|
||||
last_observed_at_unix_millis BIGINT NOT NULL,
|
||||
severity INTEGER NOT NULL,
|
||||
affected_component TEXT NOT NULL,
|
||||
affected_strategy TEXT NOT NULL DEFAULT '',
|
||||
confidence_millis BIGINT NOT NULL,
|
||||
epistemic_status TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self.improvement_candidates (
|
||||
candidate_id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
need_ref JSONB NOT NULL,
|
||||
hypothesis TEXT NOT NULL,
|
||||
expected_effect TEXT NOT NULL,
|
||||
evaluation_plan TEXT NOT NULL,
|
||||
must_not_worsen JSONB NOT NULL,
|
||||
rollback_plan TEXT NOT NULL,
|
||||
resource_cost TEXT NOT NULL,
|
||||
creator TEXT NOT NULL,
|
||||
independent_evaluator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
evidence_refs JSONB NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS self.personal_benchmark_cases (
|
||||
case_id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
task_class TEXT NOT NULL,
|
||||
evidence_refs JSONB NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON self.learning_signals, self.weakness_patterns, self.improvement_candidates, self.personal_benchmark_cases TO self_role;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,155 @@
|
||||
# This file deliberately owns only one inactive release slot. It joins the
|
||||
# stable project's shared network as an external resource, but has a distinct
|
||||
# Compose project name supplied by run-production-canary.sh. Do not add
|
||||
# durable services or stable services here.
|
||||
services:
|
||||
research-runtime:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/images/runtime.Dockerfile
|
||||
args: { TARGET: research-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
|
||||
restart: "no"
|
||||
environment:
|
||||
LISTEN_ADDR: :8087
|
||||
RESEARCH_SOURCE_ROOT: /var/lib/research-sources
|
||||
RESEARCH_HTTP_ALLOWED_HOSTS: assistant.cin.su,git.cin.su
|
||||
RESEARCH_SEARCH_URL_TEMPLATE: ${RESEARCH_SEARCH_URL_TEMPLATE:-https://www.bing.com/search?q=%s}
|
||||
RESEARCH_SEARCH_ALLOWED_HOSTS: ${RESEARCH_SEARCH_ALLOWED_HOSTS:-www.bing.com}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
|
||||
- assistant_research_sources:/var/lib/research-sources:ro
|
||||
networks:
|
||||
shared:
|
||||
aliases: [research-runtime-candidate]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8087/healthz >/dev/null && wget -qO- http://localhost:8087/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
|
||||
core-runtime:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/images/runtime.Dockerfile
|
||||
args: { TARGET: core-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
|
||||
restart: "no"
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
POSTGRES_USER: situation_role
|
||||
POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD}
|
||||
IDENTITY_POSTGRES_USER: identity_role
|
||||
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
|
||||
CREATOR_USERNAME: ${CREATOR_USERNAME}
|
||||
WORK_POSTGRES_USER: work_role
|
||||
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
|
||||
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1
|
||||
COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1}
|
||||
COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR: ${COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR:-60}
|
||||
COGNITIVE_ADMISSION_MAX_INPUT_BYTES: ${COGNITIVE_ADMISSION_MAX_INPUT_BYTES:-81920}
|
||||
COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES: ${COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES:-16384}
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker-candidate:8083
|
||||
RESEARCH_RUNTIME_URL: http://research-runtime-candidate:8087
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
networks:
|
||||
shared:
|
||||
aliases: [core-runtime-candidate]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 20
|
||||
|
||||
cognitive-worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/images/runtime.Dockerfile
|
||||
args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
|
||||
restart: "no"
|
||||
environment:
|
||||
LISTEN_ADDR: ":8083"
|
||||
CORE_RUNTIME_URL: http://core-runtime-candidate:8080
|
||||
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
|
||||
LOCAL_CPU_PROVIDER_API_KEY_FILE: ${LOCAL_CPU_PROVIDER_API_KEY_FILE:-}
|
||||
LOCAL_CPU_PROVIDER_CREDENTIAL_REF: ${LOCAL_CPU_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-cpu/provider-api-key/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-gateway:8080/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-/run/secrets/llm_local_gateway_api_key}
|
||||
LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1}
|
||||
LOCAL_GPU_PROVIDER_TIMEOUT: ${LOCAL_GPU_PROVIDER_TIMEOUT:-60s}
|
||||
LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1
|
||||
LOCAL_IMAGE_PROVIDER_KIND: ${LOCAL_IMAGE_PROVIDER_KIND:-comfyui}
|
||||
LOCAL_IMAGE_PROVIDER_URL: ${LOCAL_IMAGE_PROVIDER_URL:-http://comfyui:8188}
|
||||
LOCAL_IMAGE_MODEL: ${LOCAL_IMAGE_MODEL:-z_image_turbo_bf16.safetensors}
|
||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||
COGNITIVE_ALLOW_CANARY: "true"
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
networks:
|
||||
shared:
|
||||
aliases: [cognitive-worker-candidate]
|
||||
llm-local: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/healthz >/dev/null && wget -qO- http://localhost:8083/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 20
|
||||
|
||||
creator-web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/images/runtime.Dockerfile
|
||||
args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
|
||||
restart: "no"
|
||||
environment:
|
||||
LISTEN_ADDR: ":8084"
|
||||
CORE_RUNTIME_URL: http://core-runtime-candidate:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_RUNTIME_URL: http://commit-runtime:8082
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker-candidate:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
networks:
|
||||
shared:
|
||||
aliases: [creator-web-candidate]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
assistant_core_secrets:
|
||||
external: true
|
||||
assistant_control_plane_service_secrets:
|
||||
name: assistant_control_plane_service_secrets
|
||||
external: true
|
||||
assistant_research_sources:
|
||||
name: assistant_research_sources
|
||||
external: true
|
||||
|
||||
networks:
|
||||
shared:
|
||||
name: assistant_default
|
||||
external: true
|
||||
llm-local:
|
||||
name: llm-local_default
|
||||
external: true
|
||||
@@ -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
|
||||
@@ -83,6 +85,12 @@ services:
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 20
|
||||
|
||||
agent-canary:
|
||||
profiles: ["agent-canary"]
|
||||
@@ -201,6 +209,11 @@ services:
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
- assistant_research_sources:/var/lib/research-sources:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8087/healthz >/dev/null && wget -qO- http://localhost:8087/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
|
||||
continuity-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
@@ -235,8 +248,18 @@ services:
|
||||
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
|
||||
depends_on: [core-runtime, continuity-runtime, notification-receiver]
|
||||
volumes:
|
||||
- assistant_control_plane_service_secrets:/run/control-plane-secrets:ro
|
||||
- assistant_notification_writer_secret:/run/notification-writer:ro
|
||||
|
||||
secret-control-plane:
|
||||
profiles: ["secret-control-plane"]
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: secret-control-plane, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
user: "0:0"
|
||||
restart: "no"
|
||||
command: ["ensure"]
|
||||
volumes:
|
||||
- assistant_control_plane_service_secrets:/run/control-plane-secrets
|
||||
|
||||
cognitive-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -346,17 +369,14 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
creator-web-candidate:
|
||||
profiles: ["assistant-canary"]
|
||||
extends:
|
||||
service: creator-web
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
assistant_pg:
|
||||
assistant_nats:
|
||||
assistant_core_secrets:
|
||||
external: true
|
||||
assistant_control_plane_service_secrets:
|
||||
name: assistant_control_plane_service_secrets
|
||||
external: true
|
||||
assistant_notification_receipts:
|
||||
assistant_notification_writer_secret:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Stable-only production updater. Candidate slots are managed exclusively by
|
||||
# their isolated release command and Supervisor; this controller never starts
|
||||
# or reconciles a candidate service.
|
||||
BASE_DIR="${BASE_DIR:-/home/m/assistant-deploy}"
|
||||
REPO_DIR="${REPO_DIR:-$BASE_DIR/repo}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE_DIR/production-compose.env}"
|
||||
COMPOSE_FILE="$REPO_DIR/deploy/docker-compose.prod.yml"
|
||||
LOG_FILE="${LOG_FILE:-$BASE_DIR/deploy.log}"
|
||||
LOCK_FILE="${LOCK_FILE:-/tmp/assistant-stable-deploy.lock}"
|
||||
stable_services=(postgres core-runtime continuity-runtime commit-runtime cognitive-worker creator-web notification-receiver research-runtime agent-worker work-graph-runtime)
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || exit 0
|
||||
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$LOG_FILE"; }
|
||||
fail() { log "FAILED: $*"; exit 1; }
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
|
||||
cd "$REPO_DIR"
|
||||
git fetch --prune origin main >>"$LOG_FILE" 2>&1
|
||||
git merge --ff-only origin/main >>"$LOG_FILE" 2>&1 || fail "stable checkout is not a fast-forward to origin/main"
|
||||
revision="$(git rev-parse HEAD)"
|
||||
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || fail "invalid source revision"
|
||||
|
||||
if ! REPO_DIR="$REPO_DIR" ENV_FILE="$ENV_FILE" bash "$REPO_DIR/deploy/apply-migrations.sh" >>"$LOG_FILE" 2>&1; then
|
||||
fail "migration application failed"
|
||||
fi
|
||||
compose config >/dev/null 2>>"$LOG_FILE" || fail "stable compose config validation failed"
|
||||
|
||||
all_current=1
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="assistant-${service}-1"
|
||||
label="$(docker inspect "$container" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
|
||||
[[ "$label" == "$revision" ]] || all_current=0
|
||||
done
|
||||
if [[ "$all_current" == 1 ]]; then log "SKIP stable revision=$revision"; exit 0; fi
|
||||
|
||||
log "BUILD stable revision=$revision"
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose build --build-arg "SOURCE_REVISION=$revision" "${stable_services[@]}" >>"$LOG_FILE" 2>&1
|
||||
log "UP stable revision=$revision"
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose up -d --no-build "${stable_services[@]}" >>"$LOG_FILE" 2>&1
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -fsS --max-time 5 https://assistant.cin.su/healthz >/dev/null 2>&1; then break; fi
|
||||
[[ "$attempt" == 30 ]] && fail "public health failed"
|
||||
sleep 10
|
||||
done
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="assistant-${service}-1"
|
||||
label="$(docker inspect "$container" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
|
||||
[[ "$label" == "$revision" ]] || fail "artifact drift container=$container"
|
||||
done
|
||||
log "OK stable revision=$revision"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# This is the only production writer for the dedicated control-plane machine
|
||||
# credential volume. It never receives, prints, or exports a secret value.
|
||||
BASE_DIR="${BASE_DIR:-/home/m/assistant-deploy}"
|
||||
REPO_DIR="${REPO_DIR:-$BASE_DIR/repo}"
|
||||
ENV_FILE="${ENV_FILE:-$BASE_DIR/production-compose.env}"
|
||||
COMPOSE_FILE="$REPO_DIR/deploy/docker-compose.prod.yml"
|
||||
VOLUME="assistant_control_plane_service_secrets"
|
||||
ACTION="${1:-ensure}"
|
||||
|
||||
case "$ACTION" in
|
||||
ensure|rotate) ;;
|
||||
*) echo "unsupported machine credential action" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || { echo "production compose environment is missing" >&2; exit 1; }
|
||||
[[ -f "$COMPOSE_FILE" ]] || { echo "production compose file is missing" >&2; exit 1; }
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
|
||||
# `docker volume create` is idempotent and does not inspect or expose content.
|
||||
docker volume create "$VOLUME" >/dev/null
|
||||
revision="$(git -C "$REPO_DIR" rev-parse HEAD)"
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose build --build-arg "SOURCE_REVISION=$revision" secret-control-plane >/dev/null
|
||||
|
||||
if [[ "$ACTION" == "rotate" ]]; then
|
||||
# Only bounded, non-secret rotation reasons can reach the audit trail.
|
||||
reason="${ROTATION_REASON:-owner-requested-rotation}"
|
||||
case "$reason" in
|
||||
owner-requested-rotation|exposure-detected) ;;
|
||||
*) echo "invalid rotation reason" >&2; exit 2 ;;
|
||||
esac
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane -reason "$reason" rotate
|
||||
else
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane ensure
|
||||
fi
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Regression: the wrapper accepts only an operation name and never places a
|
||||
# plaintext credential on a Docker/Compose command line.
|
||||
script="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/provision-machine-credential.sh"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/bin" "$tmp/repo/deploy"
|
||||
cp "$script" "$tmp/repo/deploy/provision-machine-credential.sh"
|
||||
printf 'placeholder\n' > "$tmp/repo/deploy/docker-compose.prod.yml"
|
||||
printf 'placeholder\n' > "$tmp/env"
|
||||
cat > "$tmp/bin/docker" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "${CAPTURE:?}"
|
||||
SH
|
||||
cat > "$tmp/bin/git" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '0123456789abcdef0123456789abcdef01234567\n'
|
||||
SH
|
||||
chmod +x "$tmp/bin/docker" "$tmp/bin/git" "$tmp/repo/deploy/provision-machine-credential.sh"
|
||||
capture="$tmp/capture"
|
||||
PATH="$tmp/bin:$PATH" CAPTURE="$capture" REPO_DIR="$tmp/repo" ENV_FILE="$tmp/env" "$tmp/repo/deploy/provision-machine-credential.sh" ensure >/dev/null
|
||||
grep -qx 'volume create assistant_control_plane_service_secrets' "$capture"
|
||||
grep -q -- '--profile secret-control-plane run --rm --no-deps secret-control-plane ensure' "$capture"
|
||||
PATH="$tmp/bin:$PATH" CAPTURE="$capture" REPO_DIR="$tmp/repo" ENV_FILE="$tmp/env" ROTATION_REASON=owner-requested-rotation "$tmp/repo/deploy/provision-machine-credential.sh" rotate >/dev/null
|
||||
grep -q -- '--profile secret-control-plane run --rm --no-deps secret-control-plane -reason owner-requested-rotation rotate' "$capture"
|
||||
if grep -Eqi '(token=|password=|secret=|authorization:)' "$capture"; then
|
||||
echo "provisioning wrapper passed credential material" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,43 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Candidate services are never part of the stable Compose project. The slot
|
||||
# Compose file may use only declared external networks and durable resources.
|
||||
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||
CANDIDATE_FILE="$BASE_DIR/deploy/docker-compose.candidate-slot.yml"
|
||||
SUPERVISOR_CONTROL_URL="${SUPERVISOR_CONTROL_URL:-http://127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}}"
|
||||
REVISION="${1:?candidate revision is required}"
|
||||
SLOT="${CANDIDATE_SLOT:-b}"
|
||||
STABLE_PROJECT="${STABLE_PROJECT:-assistant}"
|
||||
CANDIDATE_PROJECT="${CANDIDATE_PROJECT:-assistant-slot-$SLOT}"
|
||||
HEALTH_ATTEMPTS="${HEALTH_ATTEMPTS:-360}"
|
||||
PROMOTE_AFTER_CANARY="${PROMOTE_AFTER_CANARY:-true}"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
[[ "$SLOT" =~ ^[a-z0-9-]+$ ]] || { echo "invalid candidate slot" >&2; exit 2; }
|
||||
[[ "$CANDIDATE_PROJECT" != "$STABLE_PROJECT" ]] || { echo "candidate and stable Compose projects must differ" >&2; exit 2; }
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
candidate_compose() { docker compose -p "$CANDIDATE_PROJECT" --env-file "$ENV_FILE" -f "$CANDIDATE_FILE" "$@"; }
|
||||
control() { curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL$1" "${@:2}" >/dev/null; }
|
||||
fail_rollback() {
|
||||
control /v1/traffic/rollback || true
|
||||
control /v1/releases/rollback || true
|
||||
echo "production canary failed; stable traffic rollback requested" >&2
|
||||
echo "isolated candidate ReleaseSet failed; stable ReleaseSet rollback requested" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
compose --profile assistant-canary build --build-arg "SOURCE_REVISION=$REVISION" creator-web-candidate
|
||||
compose --profile assistant-canary up -d --no-build creator-web-candidate
|
||||
for attempt in $(seq 1 30); do
|
||||
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' assistant-creator-web-candidate-1 2>/dev/null || true)"
|
||||
[[ "$status" == healthy ]] && break
|
||||
[[ "$attempt" == 30 ]] && fail_rollback
|
||||
sleep 2
|
||||
services=(creator-web core-runtime cognitive-worker research-runtime)
|
||||
stable_services=(creator-web core-runtime cognitive-worker research-runtime)
|
||||
stable_ids() {
|
||||
local service container id
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="${STABLE_PROJECT}-${service}-1"
|
||||
id=$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null || true)
|
||||
[[ -n "$id" ]] || { echo "stable container missing: $container" >&2; return 1; }
|
||||
printf '%s=%s\n' "$service" "$id"
|
||||
done
|
||||
}
|
||||
assert_stable_ids_unchanged() {
|
||||
local after
|
||||
after=$(stable_ids) || fail_rollback
|
||||
[[ "$after" == "$stable_ids_before" ]] || {
|
||||
echo "stable container IDs changed during isolated candidate operation" >&2
|
||||
diff -u <(printf '%s\n' "$stable_ids_before") <(printf '%s\n' "$after") >&2 || true
|
||||
fail_rollback
|
||||
}
|
||||
}
|
||||
candidate_container() { printf '%s-%s-1' "$CANDIDATE_PROJECT" "$1"; }
|
||||
wait_healthy() {
|
||||
local container="$1" status="" ready=""
|
||||
for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do
|
||||
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' "$container" 2>/dev/null || true)
|
||||
if [[ "$status" == healthy ]]; then ready=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[[ "$ready" == true ]] || fail_rollback
|
||||
}
|
||||
|
||||
stable_ids_before=$(stable_ids) || exit 1
|
||||
candidate_compose config >/dev/null
|
||||
candidate_compose build --build-arg "SOURCE_REVISION=$REVISION" "${services[@]}"
|
||||
assert_stable_ids_unchanged
|
||||
candidate_compose up -d --no-build --no-deps research-runtime core-runtime
|
||||
wait_healthy "$(candidate_container research-runtime)"
|
||||
wait_healthy "$(candidate_container core-runtime)"
|
||||
candidate_compose up -d --no-build --no-deps cognitive-worker
|
||||
wait_healthy "$(candidate_container cognitive-worker)"
|
||||
candidate_compose up -d --no-build --no-deps creator-web
|
||||
wait_healthy "$(candidate_container creator-web)"
|
||||
assert_stable_ids_unchanged
|
||||
for service in "${services[@]}"; do
|
||||
image_revision=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$(candidate_container "$service")" 2>/dev/null || true)
|
||||
[[ "$image_revision" == "$REVISION" ]] || fail_rollback
|
||||
done
|
||||
assert_stable_ids_unchanged
|
||||
if [[ "${PREPARE_ONLY:-false}" == true ]]; then
|
||||
echo "isolated candidate ReleaseSet prepared slot=$SLOT project=$CANDIDATE_PROJECT revision=$REVISION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
control /v1/releases/baseline -H 'Content-Type: application/json' --data "{\"id\":\"stable-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||
control /v1/releases/start -H 'Content-Type: application/json' --data "{\"id\":\"candidate-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||
control /v1/releases/observe -H 'Content-Type: application/json' --data "{\"release_id\":\"candidate-$REVISION\",\"ready\":true,\"reason\":\"candidate healthcheck passed\",\"observed_at_unix_millis\":$(date +%s%3N)}" || fail_rollback
|
||||
release_json=$(cat <<JSON
|
||||
{"id":"candidate-$SLOT-$REVISION","slot":"$SLOT","revision":"$REVISION","components":{"creator-web":{"name":"creator-web","revision":"$REVISION","upstream":"http://creator-web-candidate:8084","health_url":"http://creator-web-candidate:8084/healthz","ready_url":"http://creator-web-candidate:8084/healthz"},"core-runtime":{"name":"core-runtime","revision":"$REVISION","upstream":"http://core-runtime-candidate:8080","health_url":"http://core-runtime-candidate:8080/healthz","ready_url":"http://core-runtime-candidate:8080/readyz"},"cognitive-worker":{"name":"cognitive-worker","revision":"$REVISION","upstream":"http://cognitive-worker-candidate:8083","health_url":"http://cognitive-worker-candidate:8083/healthz","ready_url":"http://cognitive-worker-candidate:8083/readyz"},"research-runtime":{"name":"research-runtime","revision":"$REVISION","upstream":"http://research-runtime-candidate:8087","health_url":"http://research-runtime-candidate:8087/healthz","ready_url":"http://research-runtime-candidate:8087/readyz"}}}
|
||||
JSON
|
||||
)
|
||||
stable_json=$(curl -fsS --max-time 10 "$SUPERVISOR_CONTROL_URL/v1/state")
|
||||
stable_set=$(printf '%s' "$stable_json" | jq -c '.stable_release_set // empty')
|
||||
if [[ -z "$stable_set" ]]; then
|
||||
stable_revision=$(printf '%s' "$stable_json" | jq -r '.current.version // "unknown"')
|
||||
stable_set=$(cat <<JSON
|
||||
{"id":"stable-$stable_revision","slot":"legacy-stable","revision":"$stable_revision","components":{"creator-web":{"name":"creator-web","revision":"$stable_revision","upstream":"http://creator-web:8084","health_url":"http://creator-web:8084/healthz","ready_url":"http://creator-web:8084/healthz"},"core-runtime":{"name":"core-runtime","revision":"$stable_revision","upstream":"http://core-runtime:8080","health_url":"http://core-runtime:8080/healthz","ready_url":"http://core-runtime:8080/readyz"},"cognitive-worker":{"name":"cognitive-worker","revision":"$stable_revision","upstream":"http://cognitive-worker:8083","health_url":"http://cognitive-worker:8083/healthz","ready_url":"http://cognitive-worker:8083/readyz"},"research-runtime":{"name":"research-runtime","revision":"$stable_revision","upstream":"http://research-runtime:8087","health_url":"http://research-runtime:8087/healthz","ready_url":"http://research-runtime:8087/readyz"}}}
|
||||
JSON
|
||||
)
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/baseline-set" -H 'Content-Type: application/json' --data "$stable_set" >/dev/null || fail_rollback
|
||||
fi
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/start-set" -H 'Content-Type: application/json' --data "$release_json" >/dev/null || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
observation=$(printf '{"release_id":"candidate-%s-%s","ready":true,"reason":"isolated slot health and image revisions passed","observed_at_unix_millis":%s,"components":{"creator-web":{"revision":"%s","health":true,"ready":true},"core-runtime":{"revision":"%s","health":true,"ready":true},"cognitive-worker":{"revision":"%s","health":true,"ready":true},"research-runtime":{"revision":"%s","health":true,"ready":true}}}\n' "$SLOT" "$REVISION" "$(date +%s%3N)" "$REVISION" "$REVISION" "$REVISION" "$REVISION")
|
||||
curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL/v1/releases/observe-set" -H 'Content-Type: application/json' --data "$observation" >/dev/null || fail_rollback
|
||||
assert_stable_ids_unchanged
|
||||
control /v1/traffic/canary || fail_rollback
|
||||
|
||||
headers="$(mktemp)"
|
||||
assert_stable_ids_unchanged
|
||||
headers=$(mktemp)
|
||||
trap 'rm -f "$headers"' EXIT
|
||||
if ! curl -fsS --max-time 10 -D "$headers" -H 'X-Assistant-Canary: true' "https://assistant.cin.su/login" -o /dev/null; then fail_rollback; fi
|
||||
grep -qi '^X-Assistant-Traffic-Target: candidate' "$headers" || fail_rollback
|
||||
|
||||
if [[ "$PROMOTE_AFTER_CANARY" != true ]]; then
|
||||
echo "isolated candidate ReleaseSet canary ready slot=$SLOT project=$CANDIDATE_PROJECT revision=$REVISION"
|
||||
exit 0
|
||||
fi
|
||||
control /v1/releases/promote || fail_rollback
|
||||
echo "production canary promoted revision=$REVISION"
|
||||
assert_stable_ids_unchanged
|
||||
echo "isolated candidate ReleaseSet promoted slot=$SLOT project=$CANDIDATE_PROJECT revision=$REVISION"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||
stable_services=(creator-web core-runtime cognitive-worker research-runtime)
|
||||
stable_ids() {
|
||||
local service container id
|
||||
for service in "${stable_services[@]}"; do
|
||||
container="assistant-${service}-1"
|
||||
id=$(docker inspect -f '{{.Id}}' "$container" 2>/dev/null || true)
|
||||
[[ -n "$id" ]] || { echo "stable container missing: $container" >&2; return 1; }
|
||||
printf '%s=%s\n' "$service" "$id"
|
||||
done
|
||||
}
|
||||
|
||||
before=$(stable_ids)
|
||||
compose up -d --no-build --no-deps supervisor
|
||||
after=$(stable_ids)
|
||||
[[ "$before" == "$after" ]] || {
|
||||
echo "stable container IDs changed while updating supervisor" >&2
|
||||
diff -u <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$after"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Safe diagnostics only: artifact existence/readability and mount topology.
|
||||
# This script never reads or prints credential content.
|
||||
TOKEN_PATH="/run/control-plane-secrets/control_plane_service_token"
|
||||
VOLUME="assistant_control_plane_service_secrets"
|
||||
authorized=(assistant-core-runtime-1 assistant-commit-runtime-1)
|
||||
unauthorized=(assistant-creator-web-1 assistant-cognitive-worker-1 assistant-research-runtime-1 assistant-continuity-runtime-1 assistant-agent-worker-1)
|
||||
|
||||
for container in "${authorized[@]}"; do
|
||||
docker inspect "$container" >/dev/null
|
||||
docker exec "$container" sh -c "test -r '$TOKEN_PATH'"
|
||||
docker inspect -f '{{range .Mounts}}{{if eq .Name "assistant_control_plane_service_secrets"}}{{.Destination}} {{.RW}}{{end}}{{end}}' "$container" | grep -qx '/run/control-plane-secrets false'
|
||||
done
|
||||
|
||||
for container in "${unauthorized[@]}"; do
|
||||
docker inspect "$container" >/dev/null
|
||||
if docker inspect -f '{{range .Mounts}}{{println .Name}}{{end}}' "$container" | grep -qx "$VOLUME"; then
|
||||
echo "unauthorized runtime has the control-plane credential mount" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "machine credential mount topology verified"
|
||||
@@ -1 +1 @@
|
||||
{"task_file":"dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json","task_id":"GEN2-REM-CORE-M5-LOCAL-MEDIUM-001","task_revision":1}
|
||||
{"task_file":"dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json","task_id":"GEN2-SECRETS-001","task_revision":2}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-028",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -16,7 +16,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"docs/**",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-029",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -16,7 +16,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"database/migrations/*.sql",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"task_id": "GEN2-030",
|
||||
"task_revision": 1,
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
@@ -20,7 +20,7 @@
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"cmd/**",
|
||||
"adapters/**",
|
||||
"adapters/postgres/**",
|
||||
"runtime/**",
|
||||
"platform/**",
|
||||
"tests/**"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"task_id": "GEN2-COGNITIVE-002",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Восстановить доказанно рабочий production cognitive route для обычного household и memory-recall запроса через существующие Capability и Cognitive Gateway boundaries, без подмены primary route Lifeboat-ом.",
|
||||
"requirement_ids": ["REQ-GEN2-COGNITIVE-002-TRACE", "REQ-GEN2-COGNITIVE-002-ROUTING", "REQ-GEN2-COGNITIVE-002-RECOVERY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["capability", "cognitive-worker", "core-runtime", "interaction", "production deployment"],
|
||||
"paths": [
|
||||
"cmd/cognitive-worker/**",
|
||||
"cmd/core-runtime/**",
|
||||
"modules/interaction/domain/gen2_context.go",
|
||||
"modules/interaction/domain/gen2_context_test.go",
|
||||
"adapters/postgres/interaction_repository.go",
|
||||
"contracts/capability/**",
|
||||
"contracts/cognitive/**",
|
||||
"modules/capability/**",
|
||||
"adapters/models/**",
|
||||
"adapters/postgres/capability_repository.go",
|
||||
"deploy/run-production-canary.sh",
|
||||
"deploy/docker-compose.candidate-slot.yml",
|
||||
"tests/**",
|
||||
"docs/evidence/**",
|
||||
"docs/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "security/**", "database/**", "go.mod", "go.sum", ".gitea/workflows/**", "GEN2-028", "GEN2-029", "GEN2-030"]
|
||||
},
|
||||
"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 Capability binding lifecycle", "existing cognitive gateway/advisory contracts", "existing Supervisor ReleaseSet control plane"],
|
||||
"may_change": ["typed capability/provider selection and invocation wiring only within declared paths", "the existing Interaction Gen2 retrieval projection for bounded, scoped human assertions; no new Memory owner or reader"]
|
||||
},
|
||||
"semantic_owner": "Capability remains the sole owner of provider/model/binding lifecycle; cognitive-worker invokes providers through adapters; Core consumes the governed gateway; Supervisor alone controls traffic promotion and rollback.",
|
||||
"canonical_state": ["existing Capability provider/model/binding/evaluation evidence", "canonical Interaction human utterance history and existing typed ProjectReference read through the existing Gen2 retrieval projection", "no new canonical writer, owner, schema, role, or migration"],
|
||||
"state_transitions": ["trace stable and candidate loaded binding heads -> correct stale/divergent lifecycle or invocation defect -> governed repair/qualification -> scoped Interaction projection selects bounded current human assertions -> isolated candidate validation -> Supervisor canary -> promote or rollback"],
|
||||
"side_effects_and_privileges": ["production candidate deployment and Supervisor canary/promotion are explicitly authorized", "adapters/models/** may change only after trace proves a model adapter, provider endpoint, injection, timeout, rate-limit, context budget, or invocation defect there", "cmd/core-runtime/** may change only after trace proves Core-to-worker request construction, route selection, or response handling is causal", "modules/interaction/domain/gen2_context.go may change only to extend the existing bounded Gen2 read projection over canonical Interaction utterances with Principal hard filtering, current applicability and relevance selection; it must not write state, become a Memory owner, or promote PersonalMemory V1 to primary", "adapters/postgres/interaction_repository.go may only read the already-persisted typed interaction.ProjectReference records for the selected canonical Interaction utterances; it may not create schema, write state, or bypass Interaction ownership", "no direct SQL semantic mutation", "no credential, cookie, token, prompt, or user content read/emitted", "Lifeboat may be observed as fallback but cannot mask Local MEDIUM primary failure"],
|
||||
"persistence_and_migrations": ["no schema/migration change is authorized", "adapters/postgres/capability_repository.go may change only after trace proves durable Capability head/state loading diverges from the correct persisted state; reuse its existing repository boundary"],
|
||||
"failure_retry_restart": ["discovery health, invocation health, and availability remain distinct", "CPU/GPU gaps must not suppress a valid Local MEDIUM", "restart reloads a durable active/validated binding head", "failed candidate rolls traffic to Supervisor LKG without stable recreation"],
|
||||
"assumptions": ["isolated candidate slot B and Supervisor controls from GEN2-DEPLOY-003 are available", "an existing authenticated production session may be used only through ordinary runtime interaction", "GEN2-COMPAT-OBS-001 remains paused and V1 paths remain unchanged"],
|
||||
"acceptance_scenarios": [
|
||||
"COGNITIVE-002-SCN-01: factual stable/candidate trace records Core request, requirement, binding selection, loaded binding head, fleet availability, provider eligibility, invocation result, and response/error without sensitive content",
|
||||
"COGNITIVE-002-SCN-02: Local MEDIUM is shown ACTIVE, VALIDATED, AVAILABLE, and actually invoked for bounded OK, Russian household, and prepared-context recall requests",
|
||||
"COGNITIVE-002-SCN-03: health endpoint success alone is not used as inference proof; discovery and invocation failures are separately classified",
|
||||
"COGNITIVE-002-SCN-04: candidate four-component health/revision check, real canary household request, scoped bounded arbitrary-assertion memory write/multiple turns/recall, and candidate Core plus cognitive-worker restart/recovery pass",
|
||||
"COGNITIVE-002-SCN-05: only after candidate success, Supervisor promotes it; evidence retains LKG and rollback readiness",
|
||||
"COGNITIVE-002-EXIT: ordinary household and memory-recall response use Local MEDIUM through the governed route and remain usable after candidate restart"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: targeted go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./modules/capability/... ./adapters/models/... ./adapters/postgres/... only for changed packages",
|
||||
"FAST: targeted go vet for changed packages",
|
||||
"FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||
"FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||
"FAST independent: go run ./cmd/devctl/independent",
|
||||
"FAST whitespace: git diff --check gen2-integration --",
|
||||
"production: stable/candidate trace, isolated candidate health/canary, Local MEDIUM invocation, restart recovery, and Supervisor promotion/rollback evidence"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"task_id": "GEN2-COGNITIVE-003",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Restore bounded OpenAI-compatible Local MEDIUM invocation without changing canonical ownership or Core semantics.",
|
||||
"requirement_ids": ["REQ-GEN2-EVOLVE-004-LOCAL-MEDIUM-INVOCATION"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["cognitive-worker provider invocation", "existing local OpenAI-compatible adapter", "targeted evidence"],
|
||||
"paths": [
|
||||
"cmd/cognitive-worker/main.go",
|
||||
"cmd/cognitive-worker/advisory.go",
|
||||
"cmd/cognitive-worker/gateway_registry.go",
|
||||
"cmd/cognitive-worker/*_test.go",
|
||||
"adapters/models/openai_compatible_local.go",
|
||||
"adapters/models/openai_compatible_local_test.go",
|
||||
"cmd/core-runtime/learning_test.go",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json",
|
||||
"docs/evidence/**"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": [
|
||||
"cmd/core-runtime/capability_chat.go",
|
||||
"cmd/core-runtime/capability_chat_test.go",
|
||||
"cmd/core-runtime/chat_orchestrator.go",
|
||||
"cmd/core-runtime/chat_orchestrator_test.go",
|
||||
"cmd/core-runtime/cognitive_interpretation.go",
|
||||
"cmd/core-runtime/cognitive_interpretation_test.go",
|
||||
"cmd/core-runtime/development_gate.go",
|
||||
"cmd/core-runtime/development_gate_test.go",
|
||||
"cmd/core-runtime/household_scenario_test.go",
|
||||
"cmd/core-runtime/human_response_gate_test.go",
|
||||
"cmd/core-runtime/information_request_test.go",
|
||||
"cmd/core-runtime/learning.go",
|
||||
"cmd/core-runtime/main.go",
|
||||
"cmd/core-runtime/main_test.go",
|
||||
"cmd/core-runtime/research_client.go",
|
||||
"cmd/core-runtime/research_client_test.go",
|
||||
"adapters/postgres/**",
|
||||
"modules/**",
|
||||
"contracts/**",
|
||||
"architecture/**",
|
||||
"database/**",
|
||||
"security/**",
|
||||
"deploy/**",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"UI",
|
||||
"V1 retirement",
|
||||
"GEN2-EVOLVE-005",
|
||||
"Gen3/**"
|
||||
]
|
||||
},
|
||||
"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": ["existing cognitive advisory contract", "existing capability binding/profile metadata", "existing OpenAI-compatible local adapter"],
|
||||
"may_change": ["provider request normalization and typed immediate failure mapping only"]
|
||||
},
|
||||
"semantic_owner": "Cognitive gateway remains the sole owner of provider invocation and route selection.",
|
||||
"canonical_state": ["discovery is not invocation qualification", "optional provider parameters are capability-gated", "immediate provider rejection is not reported as timeout"],
|
||||
"state_transitions": ["binding selection -> normalized request -> provider invocation -> typed result"],
|
||||
"side_effects_and_privileges": ["no secrets or authorization headers in evidence", "no direct database access", "no provider calls outside the existing gateway boundary"],
|
||||
"persistence_and_migrations": ["no schema change"],
|
||||
"failure_retry_restart": ["bounded invocation; no timeout inflation; no infinite retry; preserve fail-closed/fallback semantics"],
|
||||
"assumptions": ["Local MEDIUM gateway remains the existing provider endpoint", "candidate slot B is available for isolated verification", "cmd/core-runtime/learning_test.go is a preserved carry-forward regression from the preceding runtime task"],
|
||||
"acceptance_scenarios": ["minimal invocation completes", "ordinary and bounded-context invocation complete", "immediate provider 4xx maps to typed rejection without timeout", "qualification requires bounded invocation evidence"],
|
||||
"required_tests": ["targeted go test", "targeted go vet", "preflight", "check-diff", "independent", "architecture checks", "git diff --check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "REQUIRES_MIGRATION_DECISION", "BLOCKED_BY_DEPLOYMENT_CONTROL"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Добавить bounded redacted production telemetry для оставшихся V1 compatibility paths без изменения их поведения, owners или canonical storage.",
|
||||
"requirement_ids": ["REQ-GEN2-COMPAT-OBS-001-TELEMETRY", "REQ-GEN2-COMPAT-OBS-001-OBSERVATION", "REQ-GEN2-COMPAT-OBS-001-ROLLBACK"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"platform/telemetry/**",
|
||||
"cmd/core-runtime/**",
|
||||
"cmd/research-runtime/**",
|
||||
"modules/interaction/domain/**",
|
||||
"modules/capability/domain/**",
|
||||
"adapters/postgres/**",
|
||||
"docs/**",
|
||||
"tests/**",
|
||||
"deploy/run-production-canary.sh",
|
||||
"deploy/update-supervisor.sh",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-COMPAT-OBS-001-production-compatibility-observation.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["security/**", "go.mod", "go.sum", "architecture/**", ".gitea/workflows/**", "database/**", "adapters/models/**"]
|
||||
},
|
||||
"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 runtime logs and capability usage observation mechanisms"],
|
||||
"may_change": ["telemetry event shape only; no semantic/domain contract change"]
|
||||
},
|
||||
"semantic_owner": "Migration/Governance; telemetry is an operational projection and never a canonical semantic owner.",
|
||||
"canonical_state": ["no change", "V1 compatibility behavior and Gen2 canonical writers remain unchanged"],
|
||||
"state_transitions": ["path invocation -> redacted telemetry event -> bounded aggregation/observation -> retirement classification"],
|
||||
"side_effects_and_privileges": ["stdout/stderr telemetry only; no external mutation, secret access, or user content logging"],
|
||||
"persistence_and_migrations": ["no schema or migration change; telemetry is emitted to existing process logs"],
|
||||
"failure_retry_restart": ["telemetry failure must never change or block the compatibility path", "rollback is code revert to the pre-instrumentation commit"],
|
||||
"assumptions": ["production Supervisor deployment is authorized by this stage", "observation window is bounded and redacted", "no credentials are read"],
|
||||
"acceptance_scenarios": [
|
||||
"COMPAT-OBS-001-SCN-01: each listed path has a typed redacted event shape",
|
||||
"COMPAT-OBS-001-SCN-02: events include invocation, component, read/write, reason, Gen2 availability, fallback/primary, outcome and safe correlation",
|
||||
"COMPAT-OBS-001-SCN-03: no event contains content, secrets, tokens or raw user identifiers",
|
||||
"COMPAT-OBS-001-SCN-04: FAST tests prove instrumentation is non-blocking and path behavior is unchanged",
|
||||
"COMPAT-OBS-001-SCN-05: deployed production smoke produces observable compatibility telemetry",
|
||||
"COMPAT-OBS-001-EXIT: production observation can classify each path ACTIVE_REQUIRED, ACTIVE_FALLBACK, SHADOW_ONLY, UNUSED or UNKNOWN without claiming retirement from absence of evidence"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: targeted go test for changed runtime/domain/adapter packages",
|
||||
"FAST: targeted go vet for changed packages",
|
||||
"FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||
"FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||
"FAST independent: go run ./cmd/devctl/independent",
|
||||
"FAST whitespace: git diff --check gen2-integration --",
|
||||
"production smoke: Supervisor candidate deployment and redacted log observation"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Расширить существующий Supervisor до безопасного управления единым candidate/stable ReleaseSet из creator-web, core-runtime, cognitive-worker и research-runtime без изменения Gen2 semantic ownership.",
|
||||
"requirement_ids": ["REQ-GEN2-DEPLOY-002-RELEASE-SET", "REQ-GEN2-DEPLOY-002-CANDIDATE-TOPOLOGY", "REQ-GEN2-DEPLOY-002-ROLLBACK", "REQ-GEN2-DEPLOY-002-COMPAT-OBS"],
|
||||
"allowed_change_scope": {"modules": ["runtime/supervisor"], "paths": ["runtime/supervisor/**", "cmd/supervisor/**", "deploy/**", "docs/**", "tests/**", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-DEPLOY-002-supervised-release-set.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "modules/**", "contracts/**", "security/**", "go.mod", "go.sum", ".gitea/workflows/**", "adapters/models/**"]},
|
||||
"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 Supervisor durable state and production compose", "existing canary header and stable rollback controls"], "may_change": ["Supervisor release control payloads", "deployment topology and candidate routing configuration"]},
|
||||
"semantic_owner": "Supervisor/deployment control plane; ReleaseSet is operational release state and not semantic canonical state.",
|
||||
"canonical_state": ["no semantic owner or schema change", "previous stable release and evidence remain retained"],
|
||||
"state_transitions": ["candidate ReleaseSet -> component health/revision checks -> canary -> observation -> promotion or whole-set rollback"],
|
||||
"side_effects_and_privileges": ["production deployment only through approved control plane; no direct stable replacement; no credential reads"],
|
||||
"persistence_and_migrations": ["durable Supervisor release state remains in existing external Supervisor volume; no database migration"],
|
||||
"assumptions": ["the existing production Supervisor and compose control plane remain authoritative for deployment", "candidate services may share existing durable infrastructure only under current ownership and compatibility rules", "no credentials are read by this task"],
|
||||
"failure_retry_restart": ["candidate component failure routes all traffic to previous stable ReleaseSet", "Supervisor restart reloads ReleaseSet state", "previous stable is retained until promotion is complete"],
|
||||
"acceptance_scenarios": ["DEPLOY-002-SCN-01: complete ReleaseSet has all four components and one revision", "DEPLOY-002-SCN-02: candidate dependencies resolve only to candidate services", "DEPLOY-002-SCN-03: health/readiness/revision checks cover all required components", "DEPLOY-002-SCN-04: canary and stable paths are isolated", "DEPLOY-002-SCN-05: forced candidate failure rolls back the complete ReleaseSet", "DEPLOY-002-SCN-06: Supervisor restart preserves stable/candidate/LKG state", "DEPLOY-002-SCN-07: production telemetry revision starts bounded observation after controlled deployment"],
|
||||
"required_tests": ["go test ./runtime/supervisor ./cmd/supervisor", "FAST: targeted supervisor/release-set tests", "FAST: compose config validation", "FAST: routing and revision consistency tests", "FAST: rollback/restart persistence tests", "FAST: targeted go vet", "FAST governance: preflight/check-diff/independent", "FAST whitespace: git diff --check", "PRODUCTION: candidate ReleaseSet drill and telemetry observation start"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"task_id": "GEN2-DEPLOY-003",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Изолировать stable и candidate ReleaseSet в независимые Compose deployment projects/slots, чтобы подготовка candidate не могла пересоздать stable containers, сохранив Supervisor как единственный traffic/promotion/rollback control plane.",
|
||||
"requirement_ids": ["REQ-GEN2-DEPLOY-003-SLOT-ISOLATION", "REQ-GEN2-DEPLOY-003-UPDATER-OWNERSHIP", "REQ-GEN2-DEPLOY-003-ROLLBACK"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["deployment control", "runtime supervisor"],
|
||||
"paths": [
|
||||
"deploy/**",
|
||||
"runtime/supervisor/**",
|
||||
"cmd/supervisor/**",
|
||||
"docs/evidence/**",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"tests/deployment/**",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-DEPLOY-003-isolated-release-slots.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "database/**", "modules/**", "contracts/**", "security/**", "adapters/**", "go.mod", "go.sum", ".gitea/workflows/**", "GEN2-028", "GEN2-029", "GEN2-030"]
|
||||
},
|
||||
"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 Supervisor durable ReleaseSet state", "existing production compose stack", "existing shared durable PostgreSQL/volumes"],
|
||||
"may_change": ["deployment slot topology", "deployment-controller scripts", "Supervisor operational ReleaseSet identity fields"]
|
||||
},
|
||||
"semantic_owner": "Operations/Supervisor; slot and ReleaseSet identity are runtime-only operational control-plane state, not semantic canonical state.",
|
||||
"canonical_state": ["no semantic owner, canonical writer, schema or data migration change", "Supervisor durable release evidence/LKG remains retained outside candidate containers"],
|
||||
"state_transitions": ["stable slot retained -> isolated candidate slot start -> four-component health/revision validation -> Supervisor canary -> promote or traffic rollback -> inactive slot retained for rollback"],
|
||||
"side_effects_and_privileges": ["production operations only through Docker/Compose and Supervisor control plane explicitly authorized for this remediation", "candidate preparation must never directly replace/recreate stable", "no credential reads or outputs", "external updater pause is allowed only as an explicit bounded operation with restore evidence"],
|
||||
"persistence_and_migrations": ["no database migration", "shared PostgreSQL, named volumes and durable services are attached as existing external resources and are never duplicated or deleted by candidate cleanup"],
|
||||
"failure_retry_restart": ["capture stable container IDs before every candidate operation and abort before canary when any changes", "candidate component failure returns traffic to LKG without container recreation", "Supervisor restart reloads ReleaseSet/slot/LKG evidence", "updater ownership must be project-scoped so it cannot reconcile the other slot"],
|
||||
"assumptions": ["current stable may remain in its existing project during the one-time transition; it must not be recreated merely to rename a slot", "external deployment controller can be changed only when its exact owner/process is evidenced", "GEN2-COMPAT-OBS-001, V1 retirement and Gen3 remain stopped until this task passes"],
|
||||
"acceptance_scenarios": [
|
||||
"DEPLOY-003-SCN-01: stable and candidate Compose projects, service/container identities and updater ownership are distinct",
|
||||
"DEPLOY-003-SCN-02: candidate dependencies resolve only to candidate services plus declared shared external resources",
|
||||
"DEPLOY-003-SCN-03: candidate operation leaves every captured stable container ID unchanged; deviation aborts before canary",
|
||||
"DEPLOY-003-SCN-04: all four candidate components report health/readiness and one revision",
|
||||
"DEPLOY-003-SCN-05: Supervisor records ReleaseSet slot identity/revision/LKG and canaries the candidate",
|
||||
"DEPLOY-003-SCN-06: forced candidate component failure rolls all traffic back to previous stable without stable recreate",
|
||||
"DEPLOY-003-SCN-07: Supervisor restart preserves LKG/rollback target and candidate recovery does not alter stable IDs",
|
||||
"DEPLOY-003-EXIT: isolated production ReleaseSet drill passes, after which GEN2-COMPAT-OBS-001 may resume without V1 retirement"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: compose config for stable and candidate slots",
|
||||
"FAST: targeted go test ./runtime/supervisor ./cmd/supervisor",
|
||||
"FAST: targeted go vet ./runtime/supervisor ./cmd/supervisor",
|
||||
"FAST: deployment shell regression for project/identity isolation and stable ID invariance",
|
||||
"FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
|
||||
"FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
|
||||
"FAST independent: go run ./cmd/devctl/independent",
|
||||
"FAST whitespace: git diff --check gen2-integration --",
|
||||
"PRODUCTION: isolated candidate ReleaseSet health, canary, forced rollback, Supervisor restart/LKG and stable-ID drill"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_DEPLOYMENT_CONTROL", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"task_id": "GEN2-EVOLVE-001",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Extend the existing Gen2 retrieval fabric with task-relative freshness, bounded salience, scoped positive/negative experience, contradiction candidates and rebuildable consolidation candidates without creating a Memory owner or retiring V1 compatibility.",
|
||||
"requirement_ids": ["REQ-GEN2-EVOLVE-001-ADAPTIVE-RETRIEVAL", "REQ-GEN2-EVOLVE-001-EXPERIENCE", "REQ-GEN2-EVOLVE-001-CONSOLIDATION"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["interaction", "epistemics", "core-runtime", "cognitive-context", "production evidence"],
|
||||
"paths": [
|
||||
"modules/interaction/domain/gen2_context.go",
|
||||
"modules/interaction/domain/gen2_context_test.go",
|
||||
"modules/epistemics/domain/freshness.go",
|
||||
"modules/epistemics/domain/freshness_test.go",
|
||||
"modules/epistemics/domain/experience.go",
|
||||
"modules/epistemics/domain/experience_test.go",
|
||||
"modules/epistemics/domain/adaptive_retrieval.go",
|
||||
"modules/epistemics/domain/adaptive_retrieval_test.go",
|
||||
"modules/epistemics/domain/consolidation.go",
|
||||
"modules/epistemics/domain/consolidation_test.go",
|
||||
"contracts/epistemics/freshness.go",
|
||||
"contracts/epistemics/types.go",
|
||||
"contracts/cognitive/context_compiler.go",
|
||||
"contracts/cognitive/context_compiler_v2_test.go",
|
||||
"adapters/postgres/epistemics_repository.go",
|
||||
"adapters/postgres/interaction_repository.go",
|
||||
"cmd/core-runtime/main.go",
|
||||
"cmd/core-runtime/learning.go",
|
||||
"cmd/core-runtime/cognitive_interpretation.go",
|
||||
"cmd/core-runtime/cognitive_interpretation_test.go",
|
||||
"database/migrations/053_gen2_adaptive_experience_scope.sql",
|
||||
"development/GEN2_MIGRATION_MAP.md",
|
||||
"docs/evidence/**",
|
||||
"docs/PROJECT_STATUS.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "security/**", "go.mod", "go.sum", ".gitea/workflows/**", "cmd/supervisor/**", "deploy/**", "GEN2-028", "GEN2-029", "GEN2-030", "PersonalMemory write paths", "Gen3/**"]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {
|
||||
"consume": ["existing Interaction utterance and ProjectReference projection", "existing Epistemics ExperienceRecord and Freshness contracts", "existing Cognitive Context Compiler V2"],
|
||||
"may_change": ["typed Epistemics retrieval and temporal fields", "typed Context Compiler lane and omission metadata", "no public or cross-owner contract"]
|
||||
},
|
||||
"semantic_owner": "Interaction remains canonical owner of human utterances and ProjectReference; Epistemics remains canonical owner of ExperienceRecord and task-relative freshness evidence; Context Compiler remains a rebuildable cognitive projection; Core only composes existing owner reads through established repository boundaries.",
|
||||
"canonical_state": ["Interaction utterance history and ProjectReference", "Epistemics ExperienceRecord and temporal applicability", "no new Memory canonical state", "no direct cross-owner SQL join or write"],
|
||||
"persistence_and_migrations": ["add only owner-local Epistemics columns for principal and ContextScope references required to retrieve ExperienceRecord without cross-owner joins", "existing rows remain historical and are not deleted", "migration must be additive and rollback-safe", "migration number 053 is assigned because global migrations 051 and 052 already exist on the task base; no existing migration is changed", "cmd/core-runtime/learning.go is included only to persist the existing authenticated principal into the additive Epistemics metadata written by this task"],
|
||||
"side_effects_and_privileges": ["no external mutation", "no provider override", "no autonomous Skill or Reflex activation", "no scheduler", "no V1 retirement", "no browser acceptance required"],
|
||||
"state_transitions": [
|
||||
"task-relative freshness uses observed/verified/validity/volatility/version/provenance inputs and never turns a stale assessment into permanent deletion",
|
||||
"current versus historical assertion resolution preserves historical lineage and evaluates applicability before recency",
|
||||
"ProjectReference and principal filtering precede semantic relevance ranking",
|
||||
"Epistemics positive and negative ExperienceRecord retrieval is separately lane-classified and preserves failure applicability conditions",
|
||||
"bounded contradiction candidates are retrieved after the primary candidates without confirmation-only selection",
|
||||
"Context Compiler receives bounded current-fact, experience, contradiction and historical-analogy lanes with explainable inclusions and omissions",
|
||||
"consolidation is a deterministic rebuildable projection from raw records to episode/proposition/experience/Skill-candidate evidence; it does not write or activate Skills"
|
||||
],
|
||||
"failure_retry_restart": ["bounded retrieval never loops", "missing or old Experience metadata remains historical or excluded rather than treated as current", "rebuild/restart recomputes projections from canonical utterance and Epistemics evidence", "no candidate failure changes stable production traffic"],
|
||||
"assumptions": ["GEN2-COGNITIVE-002 scoped Interaction retrieval remains the primary assertion reader", "Epistemics owns additive ExperienceRecord metadata", "COMPAT observation and V1 retirement remain deferred by owner", "no browser or production promotion is required for this task"],
|
||||
"acceptance_scenarios": [
|
||||
"EVOLVE-001-SCN-01 current versus historical value returns the applicable current value while retaining historical evidence",
|
||||
"EVOLVE-001-SCN-02 fast-aging and slow-aging inputs receive distinct task-relative freshness assessments",
|
||||
"EVOLVE-001-SCN-03 Company A and Company B are hard-isolated through typed scope references before ranking",
|
||||
"EVOLVE-001-SCN-04 relevant memory is selected and irrelevant memory is excluded under a bounded context budget with an explainable omission",
|
||||
"EVOLVE-001-SCN-05 similar work retrieves both successful and failed applicable ExperienceRecord lanes",
|
||||
"EVOLVE-001-SCN-06 contradiction retrieval emits a bounded opposing candidate without replacing canonical evidence",
|
||||
"EVOLVE-001-SCN-07 rebuildable consolidation retains source evidence references and survives repository reload without raw-evidence loss"
|
||||
],
|
||||
"required_tests": [
|
||||
"FAST: targeted go test and go vet for changed interaction, epistemics, cognitive context, core-runtime and postgres packages",
|
||||
"FAST: devctl preflight, check-diff, independent governance, git diff --check",
|
||||
"MILESTONE: changed owners plus migration consistency and focused restart/rebuild retrieval check; no browser or FULL suite"
|
||||
],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "GEN2-EVOLVE-002",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Wire one bounded cognitive memory-continuation round through the existing Interaction/Epistemics retrieval fabric and Context Compiler without a new Memory owner, V1 retirement, UI change, direct model storage access or an unrestricted memory tool.",
|
||||
"requirement_ids": ["REQ-GEN2-EVOLVE-002-ITERATIVE-RETRIEVAL"],
|
||||
"allowed_change_scope": {"modules": ["cognitive contracts", "core-runtime", "cognitive-worker", "Gen2 governance and evidence"], "paths": ["contracts/cognitive/context_compiler.go", "contracts/cognitive/context_compiler_v2_test.go", "contracts/cognitive/types.go", "cmd/core-runtime/cognitive_interpretation.go", "cmd/core-runtime/cognitive_interpretation_test.go", "cmd/core-runtime/main.go", "cmd/cognitive-worker/gateway.go", "cmd/cognitive-worker/gateway_registry_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json", "docs/evidence/**", "docs/PROJECT_STATUS.md"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "go.mod", "go.sum", "database/**", "deploy/**", "cmd/supervisor/**", "cmd/creator-web/**", "PersonalMemory write paths", "GEN2-028", "GEN2-029", "GEN2-030", "Gen3/**"]},
|
||||
"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": ["existing Interaction and Epistemics retrieval projections", "existing Context Compiler V2"], "may_change": ["Cognitive iterative-retrieval request, inherited continuation envelope and advisory response fields only"]},
|
||||
"semantic_owner": "Interaction remains canonical owner of utterance assertions and ProjectReference; Epistemics remains canonical owner of experience. Core validates and binds continuation identity/scope/policy before calling existing owner reads; Context Compiler remains the only context selector; cognitive-worker may emit only a typed retrieval intent and never reads storage.",
|
||||
"canonical_state": ["no new state is written", "no new Memory owner", "no direct model or cognitive-worker storage access", "no cross-owner SQL join"],
|
||||
"persistence_and_migrations": ["no schema or migration change"],
|
||||
"side_effects_and_privileges": ["no external mutation", "no provider override", "no UI/browser change", "no V1 retirement"],
|
||||
"state_transitions": ["initial Context Compiler context -> cognitive-worker may emit a typed retrieval intent -> Core binds inherited Principal/Scope/Focus/task/freshness/policy -> existing owner retrieval hard-filters scope before ranking -> Context Compiler compiles unique bounded additions -> one continuation advisory request", "no result -> explicit no-additional-evidence continuation", "contradiction or negative experience is lane-classified rather than suppressed"],
|
||||
"failure_retry_restart": ["one bounded continuation round only", "round/bytes/fragments/latency and duplicate budgets fail closed", "missing evidence remains unknown", "continuation state is request-scoped; durable Work/Investigation remains responsible for any restartable task"],
|
||||
"assumptions": ["the task branch starts at gen2-integration 850fe92", "UI/browser acceptance remains deferred by owner", "the traced existing IterativeRetrievalRequest lacks the required structured lane/reason and inherited runtime envelope, so this revision changes only those Cognitive contract fields"],
|
||||
"acceptance_scenarios": ["EVOLVE-002-SCN-01 insufficient initial context can obtain one additional current fact", "EVOLVE-002-SCN-02 negative and contradiction lanes are separately requested", "EVOLVE-002-SCN-03 inherited Company A scope rejects Company B regardless of request", "EVOLVE-002-SCN-04 duplicate excerpts and rounds/byte limits are rejected", "EVOLVE-002-SCN-05 no result preserves an explicit unknown/no-additional-evidence state"],
|
||||
"required_tests": ["FAST: targeted go test and go vet for changed cognitive contract, core-runtime and cognitive-worker packages", "FAST: devctl preflight, check-diff, independent governance, git diff --check", "MILESTONE: changed owner/runtime tests plus focused bounded continuation and restart-state check; no browser or FULL suite"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"task_id": "GEN2-EVOLVE-003",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement the bounded evidence-governed Experience to Skill to Reflex learning lifecycle using existing Epistemics, Capability and SELF owners: candidate, independent validation, applicable routing, bounded shadow/revalidation, downgrade/retirement and Learning Ledger evidence, without autonomous production mutation.",
|
||||
"requirement_ids": ["REQ-GEN2-EVOLVE-003-EXPERIENCE-SKILL-REFLEX"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["capability", "self", "core-runtime learning", "Gen2 evidence and governance"],
|
||||
"paths": [
|
||||
"contracts/capability/skill_v2.go", "contracts/capability/reflex_v2.go", "contracts/capability/resolution.go", "contracts/capability/intelligence.go", "contracts/self/types.go",
|
||||
"modules/capability/domain/skill.go", "modules/capability/domain/skill_test.go", "modules/capability/domain/reflex.go", "modules/capability/domain/reflex_test.go", "modules/capability/domain/intelligence_test.go",
|
||||
"modules/self/domain/self.go", "modules/self/domain/self_test.go",
|
||||
"adapters/postgres/capability_repository.go", "adapters/postgres/self_repository.go", "adapters/postgres/capability_repository_test.go", "adapters/postgres/self_repository_test.go",
|
||||
"database/migrations/054_experience_skill_reflex_learning_lifecycle.sql",
|
||||
"cmd/core-runtime/learning.go", "cmd/core-runtime/learning_test.go",
|
||||
"dev/ACTIVE_TASK.json", "dev/tasks/GEN2-EVOLVE-003-experience-skill-reflex-learning-lifecycle.json", "docs/evidence/**", "docs/PROJECT_STATUS.md"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "security/**", "go.mod", "go.sum", "deploy/**", "cmd/creator-web/**", "cmd/cognitive-worker/**", "adapters/models/**", "modules/epistemics/**", "adapters/postgres/epistemics_repository.go", "PersonalMemory write paths", "GEN2-028", "GEN2-029", "GEN2-030", "Gen3/**"]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {
|
||||
"consume": ["existing Epistemics ExperienceRecord/ReflectionCandidate", "existing Capability Skill V2/Reflex V2", "existing SELF LearningLedger/ImprovementNeed", "existing Intelligence Strategy"],
|
||||
"may_change": ["Capability and SELF typed lifecycle/evidence fields necessary for bounded candidate, validation, routing and revalidation semantics only"]
|
||||
},
|
||||
"semantic_owner": "Epistemics remains canonical owner of Experience and Outcome evidence; Capability remains sole canonical owner of Skill and Reflex lifecycle; SELF remains canonical owner of ImprovementNeed, competence/weakness assessments and Learning Ledger. Core Runtime only records evidence through owner repositories and never self-promotes production behavior.",
|
||||
"canonical_state": ["Experience/Outcome evidence is referenced, never copied as a new source of truth", "Capability writes Skill/Reflex revisions and heads; SELF writes Learning Ledger and ImprovementNeed", "candidate, validation, shadow and revalidation never grant independent production mutation authority", "no new Memory owner, parallel Skill registry or cross-owner SQL join"],
|
||||
"side_effects_and_privileges": ["No external mutation may bypass Authority -> Commit -> Executor -> verified Outcome.", "SELF may observe, evaluate and propose only; production promotion remains behind the existing governed development gates and independent evaluation.", "No provider invocation, privilege expansion, UI/browser change or V1 retirement is authorized."],
|
||||
"persistence_and_migrations": ["054 is the next verified global migration number after 053; it is additive and uses existing capability_role and self_role only", "revision 3 sets devctl's database-surface compatibility delta to one solely because devctl requires it for any migration; no database owner role is created", "persist Skill/Reflex revision/head and SELF learning evidence needed for restart-safe lifecycle state; existing historical records remain untouched", "rollback disables/refuses non-active Reflex selection and returns to existing Skill/reasoning path without deleting evidence"],
|
||||
"state_transitions": ["verified positive Experience with a bounded pattern and provenance -> Candidate Skill", "independent validation/replay evidence and must-not-worsen criteria -> Validated Skill -> Active eligible routing", "frequent stable Active Skill with deterministic bounded implementation and verifier -> Candidate Reflex -> Validated/Active Reflex", "dependency change, stale evidence, correction, failure trend or shadow divergence -> Stale/Suspicious -> revalidation request -> Active, rewrite proposal or Retired", "Reflex decline/failure -> Skill -> reasoning; no execution can bypass Authority/Commit/Executor"],
|
||||
"failure_retry_restart": ["one success never promotes Candidate directly to Active Skill or Reflex", "shadow is sampled/bounded and never delays ordinary low-risk response; divergence records evidence and ImprovementNeed rather than rewriting Reflex", "revalidation is bounded and never retries endlessly; real failures remain fail-closed and escalate", "durable revisions, validation evidence and Ledger survive restart; no direct provider invocation or process-local canonical state"],
|
||||
"assumptions": ["task branch starts at gen2-integration a07aba0", "UI/browser acceptance remains deferred by owner; V1 compatibility remains retained", "a verified inspection found 054 free, existing SkillProfile persistence, Reflex V2 contract without durable lifecycle persistence, and SELF typed ledger without a repository", "revision 2 adds only contracts/capability/resolution.go because the existing canonical SkillProfile is declared there and its durable evidence fields must be additive rather than creating a parallel Skill registry", "revision 3 satisfies devctl's migration compatibility gate without creating any role or privilege", "automatic arbitrary production code generation is not in scope; candidate executable strategies must use an existing bounded implementation reference and governed development remains the only production-promotion route"],
|
||||
"acceptance_scenarios": ["EVOLVE-003-SCN-01 verified outcome and pattern create Candidate Skill with source Experience/Outcome references", "EVOLVE-003-SCN-02 independent replay/evaluation evidence promotes Skill only after repeatability and must-not-worsen checks", "EVOLVE-003-SCN-03 applicable fresh Active Skill is routed; inapplicable, stale or weak Skill escalates to reasoning", "EVOLVE-003-SCN-04 sufficient stable evidence creates Candidate Reflex; verified bounded Reflex becomes Active and retains fallback", "EVOLVE-003-SCN-05 dependency change, correction, failure trend and stale validation trigger bounded revalidation/downgrade rather than silent use", "EVOLVE-003-SCN-06 sampled shadow divergence records ImprovementNeed/LearningLedger evidence and does not rewrite production Reflex", "EVOLVE-003-SCN-07 user correction links the affected Skill/Reflex revision and preserves conditional known failure evidence", "EVOLVE-003-SCN-08 retired Reflex is excluded from routing while all evidence/history remains queryable"],
|
||||
"required_tests": ["FAST: targeted go test and go vet for changed capability, self, core-runtime and postgres packages", "FAST: devctl preflight, check-diff, independent governance, architecture check, git diff --check", "MILESTONE: focused lifecycle/persistence/restart and escalation tests only; no browser, production canary or FULL suite"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"task_id": "GEN2-EVOLVE-004",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement a bounded evidence-only SELF learning loop: observed signals -> contextual weakness diagnosis -> ImprovementNeed -> candidate proposal -> independent evaluation plan -> Learning Ledger, without autonomous code generation, production promotion or a new owner.",
|
||||
"requirement_ids": ["REQ-GEN2-EVOLVE-004-SELF-IMPROVEMENT-LOOP"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["self", "self benchmark", "core-runtime learning evidence", "Gen2 evidence and governance"],
|
||||
"paths": [
|
||||
"contracts/self/types.go",
|
||||
"contracts/self/benchmark.go",
|
||||
"modules/self/domain/self.go",
|
||||
"modules/self/domain/self_test.go",
|
||||
"adapters/postgres/self_repository.go",
|
||||
"adapters/postgres/self_repository_test.go",
|
||||
"database/migrations/055_self_weakness_diagnosis_and_benchmark.sql",
|
||||
"cmd/core-runtime/learning.go",
|
||||
"cmd/core-runtime/learning_test.go",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/GEN2-EVOLVE-004-self-improvement-loop-weakness-diagnosis-and-governed-learning.json",
|
||||
"docs/evidence/**",
|
||||
"docs/PROJECT_STATUS.md"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "security/**", "go.mod", "go.sum", "deploy/**", "cmd/creator-web/**", "cmd/cognitive-worker/**", "adapters/models/**", "modules/capability/**", "modules/epistemics/**", "database/migrations/001-054", "PersonalMemory write paths", "GEN2-028", "GEN2-029", "GEN2-030", "Gen3/**"]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["existing Epistemics Experience/Outcome evidence", "existing Capability Skill/Reflex references", "existing SELF CompetenceAssessment/WeaknessPattern/ImprovementNeed/LearningLedger/PersonalBenchmark", "existing Work governed-evolution gates"], "may_change": ["SELF diagnosis, bounded candidate-proposal and benchmark case typed fields only"]},
|
||||
"semantic_owner": "SELF remains the sole owner of derived competence, weakness, ImprovementNeed, PersonalBenchmark and Learning Ledger state. Epistemics remains owner of source evidence; Capability remains owner of Skill/Reflex; Work/development gates remain owner of executable candidate and promotion.",
|
||||
"canonical_state": ["diagnosis is evidence-backed and may remain HYPOTHESIS/UNKNOWN", "repeated incidents are required for WeaknessPattern", "SELF observes/evaluates/proposes but cannot promote or mutate production", "benchmark cases reference evidence and do not copy conversations or secrets", "no new Memory owner, Skill registry, orchestrator or canonical writer"],
|
||||
"side_effects_and_privileges": ["No external mutation bypasses Authority -> Commit -> Executor -> verified Outcome.", "No autonomous code generation, merge, deployment, production promotion or provider invocation is authorized.", "Data is scoped by Principal/ContextScope and privacy metadata; hidden reasoning and user data are not stored."],
|
||||
"persistence_and_migrations": ["055 is the next free global migration after 054 and is additive under existing self_role only", "the positive new_database_owner_roles value is a devctl database-surface compatibility gate, not authorization for a new role; no new owner role is created", "persist contextual weakness incidents, ImprovementNeed diagnosis/candidate metadata, bounded benchmark references and Learning Ledger outcomes; preserve all historical evidence", "rollback disables proposal generation and leaves observations/evidence queryable"],
|
||||
"state_transitions": ["verified failure/correction/fallback/divergence signal -> scoped evidence", "repeated scoped evidence -> WeaknessPattern; one random failure remains an incident", "supported diagnosis -> ImprovementNeed with intervention class, risk and expected effect", "ImprovementNeed -> candidate proposal/evaluation plan only", "independent evaluation and existing governed development gates decide promotion; actual result is appended to Learning Ledger"],
|
||||
"failure_retry_restart": ["bounded diagnosis/candidate/evaluation budgets and deduplication prevent self-review loops", "provider outage is not competence loss; diagnosis distinguishes unavailable dependency", "stale/unknown/hypothesis diagnosis is never presented as established fact", "durable SELF state survives restart; no process-local canonical state"],
|
||||
"assumptions": ["task branch starts at gen2-integration dd04a67", "UI/browser acceptance remains deferred and V1 compatibility remains retained", "existing Work governed-evolution contract is consumed rather than duplicated", "automatic executable production changes remain a future governed task"],
|
||||
"acceptance_scenarios": ["EVOLVE-004-SCN-01 repeated user corrections attach to the affected scoped Skill/Reflex decision", "EVOLVE-004-SCN-02 repeated incidents create a WeaknessPattern while one random failure does not", "EVOLVE-004-SCN-03 diagnosis distinguishes retrieval/context/skill/reflex/provider/tool/outcome causes and preserves confidence/evidence", "EVOLVE-004-SCN-04 competence is contextual and evidence-derived, never self-declared", "EVOLVE-004-SCN-05 ImprovementNeed produces a bounded candidate proposal with risk, expected effect, evaluation and rollback", "EVOLVE-004-SCN-06 Creator and Evaluator are distinct; must-not-worsen can reject a candidate", "EVOLVE-004-SCN-07 Personal Benchmark cases reference confirmed evidence for memory, scope, failure avoidance, household, technical, continuity and authority/outcome classes", "EVOLVE-004-SCN-08 expected versus actual benefit is appended to Learning Ledger; no autonomous promotion occurs", "EVOLVE-004-SCN-09 diagnosis/candidate budgets and deduplication prevent infinite self-review"],
|
||||
"required_tests": ["FAST: targeted go test and go vet for changed self, core-runtime, contracts and postgres packages", "FAST: devctl preflight, check-diff, independent governance, architecture check, git diff --check", "MILESTONE: focused evidence attribution, diagnosis, deduplication, contextual competence, candidate/evaluation, benchmark and Learning Ledger tests; no FULL suite, deployment or browser acceptance"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"task_id": "GEN2-SECRETS-001",
|
||||
"task_revision": 2,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Provide an idempotent, local-only, cryptographically secure machine-credential provisioner and rotation mechanism on the existing protected Docker secret control plane, initially for the delegated control-plane service token, without creating a second semantic owner or exposing a secret value.",
|
||||
"requirement_ids": ["REQ-GEN2-SECRETS-001-LOCAL-MACHINE-CREDENTIALS", "REQ-GEN2-AUTHORITY-001-DELEGATED-CONTROL-PLANE"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["technical protected secret file backend", "production deployment/bootstrap secret provisioning", "targeted Core/Commit service-authentication mount wiring", "targeted tests and evidence"],
|
||||
"paths": ["platform/secrets/**", "contracts/secrets/types.go", "cmd/secret-control-plane/**", "cmd/core-runtime/main.go", "cmd/commit-runtime/control_plane_authority.go", "deploy/images/runtime.Dockerfile", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "deploy/provision-machine-credential.sh", "deploy/provision-machine-credential_test.sh", "deploy/verify-machine-credential.sh", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-SECRETS-001-local-machine-credential-provisioning.json"]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/authority/**", "modules/authority/**", "adapters/postgres/**", "database/**", "modules/identity/**", "modules/capability/**", "cmd/cognitive-worker/**", "cmd/creator-web/**", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**", "go.mod", "go.sum"]
|
||||
},
|
||||
"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 CredentialRef and FileResolver", "existing Docker protected-volume deployment mechanism"], "may_change": ["typed SecretPurpose/consumer metadata only if required for the provisioner"]},
|
||||
"semantic_owner": "No new semantic owner is introduced. platform/secrets remains a technical bootstrap/resolution mechanism; Identity, Authority, Capability and Configuration ownership remain unchanged.",
|
||||
"canonical_state": ["credential values live only in protected local artifacts, never in Configuration, Memory, context, PostgreSQL, Compose environment, Git, logs or evidence", "machine authentication is distinct from Authority grants", "metadata/audit is separate from the value and contains no plaintext or reversible material", "the control-plane token is mounted read-only only to Core and Commit runtimes"],
|
||||
"state_transitions": ["ABSENT -> ACTIVE version one via idempotent CSPRNG provisioning", "ACTIVE -> ROTATING -> ACTIVE new version after atomic artifact replacement and controlled verification", "ACTIVE -> COMPROMISED/REVOKED -> replacement version with audit retained"],
|
||||
"side_effects_and_privileges": ["a one-shot local control-plane container may create or rotate only its dedicated external Docker volume", "Core and Commit get read-only mounts; all other runtimes receive no mount", "no plaintext environment variable is added"],
|
||||
"persistence_and_migrations": ["no application database schema or canonical writer changes", "secret metadata and append-only audit reside separately from the secret value inside the protected control-plane volume", "rollback retains prior metadata and revokes only after a verified replacement"],
|
||||
"failure_retry_restart": ["restart is idempotent and preserves the active artifact", "partial writes are avoided by atomic replacement", "a missing or unreadable artifact fails service authentication closed", "rotation never emits a credential and leaves a valid active artifact on failure"],
|
||||
"assumptions": ["docker.cin.su permits creation of a dedicated external Docker volume by the existing production deployment control plane", "the runtime image can execute a one-shot local provisioner as the control-plane writer", "the existing protected secret volume pattern is the approved production mechanism"],
|
||||
"acceptance_scenarios": ["missing credential is generated once with crypto/rand and then reused across restart", "metadata and audit are separate from the value", "Core and Commit mounts are read-only and no other runtime mounts the dedicated token volume", "rotation accepts the new credential and rejects the old credential", "logs and diagnostics redact known secret headers/fields", "a valid service credential alone does not bypass the existing delegated Authority grant checks"],
|
||||
"required_tests": ["targeted go test ./platform/secrets ./cmd/secret-control-plane", "targeted go vet ./platform/secrets ./cmd/secret-control-plane", "compose config validation", "provisioner shell regression", "preflight", "check-diff", "independent", "git diff --check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
@@ -1641,7 +1641,7 @@ Historical migrations are evidence. Gen2 migration starts at the next available
|
||||
|
||||
# 41. New Gen2 schema migration groups
|
||||
|
||||
Exact migration numbers are assigned after current repository HEAD is fixed.
|
||||
Exact migration numbers are assigned after current repository HEAD is fixed. The repository uses one global sequence: `051_identity_principal_realms.sql` and `052_identity_creator_accounts.sql` occupy their numbers. GEN2-EVOLVE-001 therefore adds owner-local Epistemics metadata as additive migration `053_gen2_adaptive_experience_scope.sql`; it does not alter either existing migration.
|
||||
|
||||
Recommended groups:
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# DOMOVOY Gen2 — Task Index
|
||||
|
||||
## GEN2-SECRETS-001 — Local Machine Credential Provisioning & Rotation
|
||||
|
||||
Bounded post-M5 remediation. Provides the technical protected-secret control-plane mechanism required by `GEN2-AUTHORITY-001`; it does not change semantic ownership or grant Authority.
|
||||
|
||||
Это исполняемый индекс task packets. `ARCH-GEN2-000` соответствует разделу `GEN2-000` Master Plan: префикс `ARCH-` нужен текущему `devctl`, чтобы последующие packets могли ссылаться на него через `architecture_revision`.
|
||||
|
||||
| Порядок | Task | Milestone | Gate | Base ref | Зависимость | Файл |
|
||||
|
||||
@@ -21,6 +21,12 @@ 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.
|
||||
|
||||
Delegated non-interactive control-plane work is separately authenticated. The local `secret-control-plane` one-shot control-plane service provisions `control_plane_service_token` with `crypto/rand` into the dedicated external `assistant_control_plane_service_secrets` volume. The token artifact is mode `0400`; metadata and audit are separate protected files and contain no value or reversible fingerprint. Only `core-runtime` and the existing Authority/Commit runtime mount that volume read-only at `/run/control-plane-secrets`; no model, Skill, SELF, creator-web, cognitive-worker or research-runtime receives the mount. Compose carries only the non-secret credential reference `credential://domovoy/control-plane/service-token/v1`.
|
||||
|
||||
The provisioner is idempotent: restart reuses an existing safe active artifact. Rotation creates a new value by atomic replacement, records non-secret metadata/audit, and never accepts a plaintext environment variable. The service token is not a Creator session, cannot impersonate a human, and never grants Authority by itself. Removing or rotating the token disables/refreshes service authentication; revoking the durable Authority grant also blocks new delegated operations without deleting audit history.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Assistant project status
|
||||
|
||||
## Gen2 M6 checkpoint — 2026-08-30
|
||||
|
||||
- `GEN2-EVOLVE-004 = MILESTONE_ACCEPTED`: bounded SELF learning signals, evidence-backed attribution, contextual weakness/competence, ImprovementNeed, independently evaluated candidate, benchmark and expected-vs-actual Learning Ledger primitives are implemented with durable SELF persistence in migration 055. Evidence: `docs/evidence/gen2-evolve-004-milestone-2026-08-30.md`. No autonomous promotion, production deployment, new owner, UI work, V1 retirement or GEN2-EVOLVE-005 was started.
|
||||
|
||||
- `GEN2-EVOLVE-003 = MILESTONE_ACCEPTED`: Experience evidence now enters an evidence-governed Candidate Skill → independent validation → Active Skill → bounded Candidate Reflex lifecycle. Reflex divergence/revalidation records SELF proposals and Learning Ledger evidence; it cannot autonomously rewrite or promote production. Evidence: `docs/evidence/gen2-evolve-003-milestone-2026-08-30.md`. UI and V1 compatibility remain unchanged.
|
||||
|
||||
- `GEN2-EVOLVE-002 = MILESTONE_ACCEPTED`: one bounded typed cognitive memory-continuation round is available through Core and the existing retrieval fabric. Evidence: `docs/evidence/gen2-evolve-002-milestone-2026-08-30.md`. UI and V1 compatibility remain unchanged.
|
||||
|
||||
- `GEN2-EVOLVE-001 = MILESTONE_ACCEPTED`: bounded adaptive retrieval adds task-relative freshness, computed salience, positive/negative/contradiction experience lanes and provenance-preserving consolidation candidates. Evidence: `docs/evidence/gen2-evolve-001-milestone-2026-08-30.md`. No V1 path was retired and UI/browser acceptance remains deferred by owner.
|
||||
|
||||
- `GEN2-028 = COMPLETED_WITH_EVIDENCE`: V1 compatibility inventory is recorded in `docs/evidence/gen2-028-v1-compatibility-inventory-2026-08-30.json`. Current production callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; no path was proven `SAFE_TO_RETIRE`.
|
||||
- `GEN2-029 = COMPLETED_WITH_SAFE_NOOP`: no V1 runtime path or historical evidence was deleted or disabled. The decision and rollback point are recorded in `docs/evidence/gen2-029-v1-retirement-decision-2026-08-30.json`; `FULL_RETIREMENT` was not applicable because no irreversible retirement was performed.
|
||||
- `GEN2-030_STATUS = ACCEPTANCE_IN_PROGRESS`: this status is a documentation checkpoint, not a final Gen2 PASS. The final gate must confirm current production and repository evidence; the M6 retirement gap remains that active V1 compatibility callers have not yet been proven unused.
|
||||
- `UI_BROWSER_ACCEPTANCE = DEFERRED_BY_OWNER`; it is intentionally excluded from core/M6 acceptance and is neither counted as PASS nor as a blocker.
|
||||
- `PROVIDER_GAPS = CPU_FAST_UNAVAILABLE; GPU_UNAVAILABLE`; Local MEDIUM remains the primary route and Lifeboat remains fallback per existing M5 evidence.
|
||||
- `GEN2_M6_FINAL_STATUS = NOT_FINAL_PASS_RETIREMENT_GAP_REMAINS`; FULL_FINAL technical checks passed, but no final M6 baseline tag is created because active V1 compatibility callers were not proven safe to retire.
|
||||
- `GEN2_M6_FINAL_EVIDENCE = docs/evidence/gen2-030-final-acceptance-2026-08-30.json`.
|
||||
- `GEN2-COGNITIVE-002 = IMPLEMENTED / CORE_VERIFIED / BROWSER_ACCEPTANCE_DEFERRED_BY_OWNER`; non-browser evidence: `docs/evidence/gen2-cognitive-002-core-verified-2026-08-30.json`.
|
||||
- `GEN2-COMPAT-OBS-001 = OBSERVATION_DEFERRED_BY_OWNER`; instrumentation remains committed, but no production observation window or retirement decision is claimed. No V1 path is `SAFE_TO_RETIRE`; no retirement task was created.
|
||||
- `GEN2_FINAL_STATUS = GEN2_CORE_ACCEPTED / V1_COMPATIBILITY_RETAINED / BROWSER_ACCEPTANCE_DEFERRED / COMPAT_OBSERVATION_DEFERRED`.
|
||||
- `NEXT_EVOLUTION_QUEUE = instrument bounded compatibility usage, observe, then retire only individually proven paths under a future FULL_RETIREMENT gate; no Gen3 or new feature work is started.
|
||||
|
||||
## Current audit delta — 2026-08-24
|
||||
|
||||
- `CURRENT_MODEL_SCOPE = LOCAL_FIRST_V1`; one locally available Qwen3-8B or Gemma-4 conversational model is sufficient for the first stage, with the local Lifeboat as recovery fallback. External models remain supported as optional capability, but their absence must not block work: reflexes, skills and local executors provide degraded continuation. External/GPU qualification and promotion are intentionally deferred by DEV-000270.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"evidence_id": "gen2-028-v1-compatibility-inventory-2026-08-30",
|
||||
"task_id": "GEN2-028",
|
||||
"task_revision": 2,
|
||||
"source_revision": "93feb9bf2df8f2e2cfd4d0259d39c869b19cad08",
|
||||
"scope": "static repository inventory plus read-only production container and Supervisor observations; no code, schema, or runtime mutation",
|
||||
"canonical_writer_rule": "unchanged; no second Memory owner and no writer cutover performed",
|
||||
"production_observation": {
|
||||
"deployment_revision": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"core_container": "running, restart_count=0; /healthz=200",
|
||||
"cognitive_worker": "running, restart_count=0, health=healthy",
|
||||
"lifeboat": "running, restart_count=0, health=healthy",
|
||||
"supervisor": "running, restart_count=0, health=healthy",
|
||||
"traffic_mode": "CANDIDATE",
|
||||
"last_known_good": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"legacy_named_runtime_containers": "none observed"
|
||||
},
|
||||
"inventory": [
|
||||
{
|
||||
"id": "personal-memory-context-text",
|
||||
"paths": ["modules/interaction/domain/personal_memory.go", "modules/interaction/domain/gen2_context.go", "cmd/core-runtime/main.go"],
|
||||
"role": "V1 rebuildable context projection and deterministic preference recall",
|
||||
"runtime_status": "ACTIVE_COMPATIBILITY_PATH",
|
||||
"evidence": ["BuildScopedGen2Context is called from the production chat handler", "CompilePersonalMemory and ContextText remain in that call path", "empty Gen2 projection returns personal-memory-v1-fallback", "targeted gen2_context and personal_memory tests exist"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain the existing read fallback and revert the reader switch without deleting utterance history"
|
||||
},
|
||||
{
|
||||
"id": "active-project-focus",
|
||||
"paths": ["adapters/postgres/situation_repository.go", "cmd/core-runtime/main.go", "contracts/situation/types.go"],
|
||||
"role": "V1 project-centric focus compatibility projection",
|
||||
"runtime_status": "ACTIVE_READ_AND_WRITE_COMPATIBILITY_PATH",
|
||||
"evidence": ["core chat loads ActiveProjectFocus for each authenticated message", "core persists it after semantic interpretation", "PostgreSQL adapter reads and upserts situation.active_project_focus", "Focus V2 projection exists but does not remove these callers"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "keep the projection readable and restore the prior focus reader if a V2 reader is rolled back"
|
||||
},
|
||||
{
|
||||
"id": "truth-bundles",
|
||||
"paths": ["adapters/postgres/epistemics_repository.go", "modules/epistemics/application/persisted_research.go", "database/migrations/034_epistemics_truth_bundles.sql", "tests/research_contracts/truth_persistence_sql_test.go"],
|
||||
"role": "V1 denormalized epistemic compatibility/status and research persistence",
|
||||
"runtime_status": "ACTIVE_WRITER_AND_READ_COMPATIBILITY_PATH",
|
||||
"evidence": ["SaveTruthBundle remains an application repository contract used by persisted research", "TruthRuntimeStatus reads counts and grouped status from epistemics.truth_bundles", "migration 034 and its contract test are retained", "no production evidence permits declaring the reader unused"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain table and reader; revert normalized reader selection without deleting historical bundles"
|
||||
},
|
||||
{
|
||||
"id": "ordinary-env-configuration",
|
||||
"paths": ["cmd/core-runtime/main.go", "cmd/work-graph-runtime/main.go", "cmd/reflection-worker/main.go", "cmd/notification-receiver/main.go"],
|
||||
"role": "ENV configuration and secret/bootstrap resolution",
|
||||
"runtime_status": "ACTIVE_MIXED_BOOTSTRAP_AND_NON_BOOTSTRAP_READS",
|
||||
"evidence": ["production composition supplies runtime settings through process configuration", "source contains direct os.Getenv reads for listener, database, provider and runtime settings", "secret values were not read or emitted", "no complete production observation proves every non-bootstrap consumer is unused"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "restore the prior ENV fallback for a single setting while Configuration remains authoritative"
|
||||
},
|
||||
{
|
||||
"id": "skill-profile-v1",
|
||||
"paths": ["adapters/postgres/capability_repository.go", "cmd/core-runtime/learning.go", "contracts/capability/resolution.go"],
|
||||
"role": "V1 SkillProfile storage/read compatibility and FastPath learning",
|
||||
"runtime_status": "ACTIVE_READ_AND_WRITE_PATH",
|
||||
"evidence": ["core exposes RecentSkillProfiles and records FastPath decline learning", "learning.go loads and saves SkillProfile", "PostgreSQL adapter writes and joins capability.skill_profiles", "explicit Skill/Reflex types exist but do not prove these callers are dead"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "retain SkillProfile rows and reader; revert Skill V2 selection without data deletion"
|
||||
},
|
||||
{
|
||||
"id": "implicit-fast-path",
|
||||
"paths": ["modules/capability/domain/skill.go", "modules/capability/domain/reflex.go", "cmd/core-runtime/learning.go"],
|
||||
"role": "V1 FastPath behavior and fallback-to-thinking compatibility",
|
||||
"runtime_status": "ACTIVE_COMPATIBILITY_PATH",
|
||||
"evidence": ["FastPath decisions are implemented and tested", "core records FastPath decline experiences", "ReflexFallback explicitly maps escalation to skill->reasoning", "no runtime telemetry separates all FastPath invocations from explicit Reflex use"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "restore FastPath decline/escalation behavior; preserve evidence and Skill/Reflex revisions"
|
||||
},
|
||||
{
|
||||
"id": "migration-feature-switches",
|
||||
"paths": ["development/GEN2_MIGRATION_MAP.md", "runtime/**", "modules/**"],
|
||||
"role": "documented migration switch family and compatibility strategy",
|
||||
"runtime_status": "UNKNOWN_NO_DEDICATED_RUNTIME_TELEMETRY_FOUND",
|
||||
"evidence": ["migration map defines switch names such as Focus, Context, Epistemics, Config and Skill strategies", "repository scan found no complete operational switch registry/telemetry proving zero production use", "test-only legacy mode references are not sufficient production evidence"],
|
||||
"retirement": "KEEP_LONGER",
|
||||
"rollback": "do not remove a switch until production observation identifies its callers and a reversible disable path"
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"searched": ["modules", "contracts", "cmd", "adapters", "runtime", "platform", "tests", "docs", "database/migrations"],
|
||||
"terms": ["PersonalMemory", "CompilePersonalMemory", "ContextText", "ActiveProjectFocus", "truth_bundles", "skill_profiles", "FastPath", "os.Getenv", "LookupEnv", "compatibility", "legacy", "feature switch"],
|
||||
"limitations": ["No secret values were inspected", "Container logs and Supervisor state provide no per-path legacy usage counters", "A code reference is evidence of availability/callability, not a claim that every branch executed in the observation window"]
|
||||
},
|
||||
"retirement_list": [
|
||||
{"path": "personal-memory-context-text", "decision": "KEEP_LONGER", "reason": "active production chat fallback and no usage counter"},
|
||||
{"path": "active-project-focus", "decision": "KEEP_LONGER", "reason": "core read/write callers remain active"},
|
||||
{"path": "truth-bundles", "decision": "KEEP_LONGER", "reason": "research writer and status reader remain active"},
|
||||
{"path": "ordinary-env-configuration", "decision": "KEEP_LONGER", "reason": "mixed bootstrap/non-bootstrap consumers remain"},
|
||||
{"path": "skill-profile-v1", "decision": "KEEP_LONGER", "reason": "core learning and repository read/write callers remain active"},
|
||||
{"path": "implicit-fast-path", "decision": "KEEP_LONGER", "reason": "active learning/fallback path and no complete production telemetry"},
|
||||
{"path": "migration-feature-switches", "decision": "KEEP_LONGER", "reason": "no complete runtime observation evidence"}
|
||||
],
|
||||
"conclusion": "GEN2-028 EXIT: every inventoried legacy surface is classified ACTIVE_COMPATIBILITY_PATH, ACTIVE_READ_AND_WRITE_COMPATIBILITY_PATH, or UNKNOWN_NO_DEDICATED_RUNTIME_TELEMETRY_FOUND. No surface is SAFE_TO_RETIRE on current evidence; GEN2-029 must not perform irreversible deletion or disablement based on this inventory."
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"evidence_id": "gen2-029-v1-retirement-decision-2026-08-30",
|
||||
"task_id": "GEN2-029",
|
||||
"task_revision": 2,
|
||||
"source_revision": "59d32143bf4ee7b4a78cb714ec0cbd76d4d8bdf5",
|
||||
"decision": "NO_IRREVERSIBLE_RETIREMENT",
|
||||
"reason": "GEN2-028 found no SAFE_TO_RETIRE path. Active callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; migration switches lack complete production telemetry.",
|
||||
"changed_runtime_paths": [],
|
||||
"changed_schema_paths": [],
|
||||
"retired_paths": [],
|
||||
"preserved": [
|
||||
"human utterances and interaction history",
|
||||
"epistemic observations, claims, experiences, truth_bundles and audit lineage",
|
||||
"ActiveProjectFocus compatibility projection",
|
||||
"PersonalMemory read fallback",
|
||||
"SkillProfile/FastPath compatibility",
|
||||
"ordinary ENV fallback consumers until bootstrap/config observation is complete",
|
||||
"all existing Gen2 canonical writers and owner boundaries"
|
||||
],
|
||||
"full_retirement_gate": {
|
||||
"status": "NOT_APPLICABLE",
|
||||
"reason": "No irreversible V1 schema/data deletion or runtime disablement was authorized or performed; the execution contract requires the gate immediately before such a retirement.",
|
||||
"repository_full_test": "NOT_RUN_BY_DESIGN; no retirement change risk to justify expanding beyond the task's FAST/no-op decision"
|
||||
},
|
||||
"rollback": {
|
||||
"point": "59d32143bf4ee7b4a78cb714ec0cbd76d4d8bdf5",
|
||||
"method": "keep current compatibility paths and revert this evidence-only decision; no schema rollback or data deletion needed"
|
||||
},
|
||||
"conclusion": "No unsafe V1 path was removed. Historical evidence remains available, and there is no new dual writer. Retirement remains pending an observation window that can prove each path unused or a future approved cutover decision."
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"evidence_id": "gen2-030-final-acceptance-2026-08-30",
|
||||
"task_id": "GEN2-030",
|
||||
"task_revision": 2,
|
||||
"source_revision": "2a8e4e1b81226a1acedfd746ebc2aeb0ac11158f",
|
||||
"status": "NOT_FINAL_PASS_RETIREMENT_GAP_REMAINS",
|
||||
"full_final_gate": {
|
||||
"go_test_all": "PASS",
|
||||
"go_vet_all": "PASS",
|
||||
"architecture_checks": "PASS",
|
||||
"governance_preflight_check_diff_independent_whitespace": "PASS",
|
||||
"migration_consistency": "PASS; 52 files, unique range 001..052; no M6 schema changes",
|
||||
"production_core_smoke": "PASS; core /healthz=200, /readyz=200; cognitive-worker healthy; Lifeboat healthy",
|
||||
"supervisor_recovery_readiness": "PASS; current and last-known-good 3182092d5cc758f5a536ce10f5428b5d782bb961; rollback target retained"
|
||||
},
|
||||
"architecture_reconciliation": {
|
||||
"registry": "YAML/JSON mirrors agree; Gen2 owners and runtime topology are represented",
|
||||
"canonical_memory_owner": "none; retrieval/context remain projection fabric",
|
||||
"schema": "migrations 046..052 are present and numbered without duplicates; historical 001..045 retained",
|
||||
"production_revision": "3182092d5cc758f5a536ce10f5428b5d782bb961"
|
||||
},
|
||||
"m6_result": {
|
||||
"gen2_028": "completed inventory; no SAFE_TO_RETIRE path proven",
|
||||
"gen2_029": "completed safe no-op; no runtime/schema/data deletion",
|
||||
"gen2_030": "full final verification passed, but final M6 exit is not signed",
|
||||
"full_retirement": "NOT_APPLICABLE; no irreversible retirement was performed",
|
||||
"final_tag": "NOT_CREATED; acceptance is not a final PASS"
|
||||
},
|
||||
"ui_browser": "DEFERRED_BY_OWNER; intentionally excluded, neither PASS nor blocker",
|
||||
"provider_gaps": ["CPU_FAST unavailable", "GPU unavailable"],
|
||||
"remaining_gaps": [
|
||||
"Per-path production observation telemetry is missing for migration switches and several compatibility readers",
|
||||
"PersonalMemory, ActiveProjectFocus, truth_bundles, ordinary ENV, SkillProfile/FastPath still have active callers and require a future evidence-backed retirement/cutover task",
|
||||
"M6 cannot claim complete V1 retirement until those paths are proven unused or explicitly retained by an accepted architecture decision"
|
||||
],
|
||||
"next_evolution_queue": [
|
||||
"Instrument bounded, redacted usage observations for each remaining compatibility path",
|
||||
"Run an approved observation window and re-evaluate retirement candidates",
|
||||
"Retire only individually proven paths with FULL_RETIREMENT immediately before irreversible change",
|
||||
"Keep UI/browser acceptance deferred by owner",
|
||||
"Keep CPU_FAST/GPU provider qualification separate from Core acceptance"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"evidence_id": "gen2-cognitive-002-core-verified-2026-08-30",
|
||||
"task_id": "GEN2-COGNITIVE-002",
|
||||
"task_revision": 3,
|
||||
"commit": "00af176a200142bd3de67ba914e542b2299f43d5",
|
||||
"status": "IMPLEMENTED / CORE_VERIFIED / BROWSER_ACCEPTANCE_DEFERRED_BY_OWNER",
|
||||
"owner_amendment": {
|
||||
"browser_authenticated_acceptance": "DEFERRED_BY_OWNER",
|
||||
"compatibility_observation": "DEFERRED_BY_OWNER",
|
||||
"v1_retirement": "NOT_AUTHORIZED"
|
||||
},
|
||||
"non_browser_acceptance": {
|
||||
"targeted_tests": "PASS: cmd/cognitive-worker, cmd/core-runtime, modules/interaction/domain, adapters/postgres",
|
||||
"targeted_vet": "PASS: same packages",
|
||||
"governance": ["preflight PASS", "check-diff PASS", "independent PASS", "git diff --check PASS"],
|
||||
"scoped_retrieval": [
|
||||
"arbitrary assertion retrieval",
|
||||
"current value selected while historical value is retained",
|
||||
"ProjectReference hard scope isolation before relevance ranking",
|
||||
"irrelevant assertions excluded from bounded context"
|
||||
],
|
||||
"context_compiler": "PASS: bounded typed Interaction fragments are compiled before the cognitive gateway request",
|
||||
"candidate_slot_b": {
|
||||
"components": "creator-web, core-runtime, cognitive-worker, research-runtime healthy",
|
||||
"restart": "candidate core-runtime and cognitive-worker restarted; both health and ready endpoints recovered",
|
||||
"local_medium": "governed gateway advisory completed after restart"
|
||||
}
|
||||
},
|
||||
"preserved_invariants": [
|
||||
"Capability remains canonical owner of binding lifecycle",
|
||||
"Interaction remains canonical owner of utterances and ProjectReference",
|
||||
"no direct semantic SQL update",
|
||||
"PersonalMemory V1 is not restored as primary retrieval",
|
||||
"Lifeboat is fallback, not proof of Local MEDIUM"
|
||||
],
|
||||
"not_claimed": [
|
||||
"browser/authenticated production acceptance",
|
||||
"production promotion",
|
||||
"compatibility telemetry observation",
|
||||
"V1 retirement readiness"
|
||||
],
|
||||
"rollback": "Supervisor release rollback returns traffic to the stable last-known-good ReleaseSet; stable containers are not recreated."
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"evidence_id": "gen2-compat-obs-001-blocked-2026-08-30",
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 1,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"instrumentation_commit": "3efcbbd",
|
||||
"integration_commit": "346e0ac",
|
||||
"final_status": "GEN2_CORE_ACCEPTED / V1_COMPATIBILITY_RETAINED / UI_DEFERRED",
|
||||
"instrumented_paths": ["PersonalMemory fallback", "ActiveProjectFocus read", "ActiveProjectFocus write", "truth_bundles writer", "truth_bundles status reader", "non-bootstrap ENV consumers", "SkillProfile V1", "FastPath V1", "migration/compatibility switches"],
|
||||
"telemetry_contract": {
|
||||
"fields": ["invocation_count", "unique_request_key_hash", "unique_session_key_hash", "last_used_at", "caller_component", "read_or_write", "reason", "gen2_path_available", "fallback_or_primary", "success_or_failure", "correlation_id_hash"],
|
||||
"content_policy": "No memory content, secrets, or user data is emitted; identifiers are hashed immediately and telemetry is an operational projection, not a canonical owner."
|
||||
},
|
||||
"fast_checks": {
|
||||
"go_test": "PASS: targeted telemetry, core-runtime, interaction, capability, and postgres packages",
|
||||
"go_vet": "PASS: same targeted packages",
|
||||
"preflight": "PASS",
|
||||
"check_diff": "PASS",
|
||||
"independent_governance": "PASS",
|
||||
"git_diff_check": "PASS"
|
||||
},
|
||||
"production_observation": {"deployment": "NOT_RUN", "smoke": "NOT_RUN", "observation_window": "NOT_STARTED", "production_mutation": "NONE"},
|
||||
"blocker": {
|
||||
"supervisor_topology": "Supervisor has stable/candidate routing for creator-web only; core-runtime, cognitive-worker, and research-runtime have no candidate targets in the production compose/startup model.",
|
||||
"missing_capability": "An approved production deployment flow capable of deploying the instrumented core/cognitive/research candidate, such as configured Portainer automation or an approved candidate-capable deployment topology.",
|
||||
"unsafe_alternative_not_used": "Directly replacing core-runtime with compose would mutate the stable runtime outside the Supervisor candidate path and was not performed."
|
||||
},
|
||||
"retirement": {"safe_to_retire": [], "retirement_tasks_created": [], "classification": "UNKNOWN until production observation evidence exists", "rollback": "All existing V1 compatibility paths and historical evidence remain intact."},
|
||||
"scope": {"schema_or_migrations_changed": false, "canonical_owner_changed": false, "behavior_changed": false, "ui_changed": false, "gen3_started": false}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"task_revision": 2,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"observed_at_utc": "2026-08-30T10:13:06.5676592Z",
|
||||
"deployment_revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb",
|
||||
"deployment_scope": "candidate-only; no Supervisor traffic change or promotion",
|
||||
"stable_release_set": {
|
||||
"traffic_mode": "STABLE",
|
||||
"revision": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"container_ids_before_protected_operation": {
|
||||
"creator-web": "9578e7c7e0137ba36a7fc75ffaf9fce33be046e38531c9de99c0b75c2ce465f8",
|
||||
"core-runtime": "8281ba60b234825d6ee965887e8eef10e29a0783264c05d39cbe937f1b780530",
|
||||
"cognitive-worker": "987640770cad44a0b05a47dbf08a4b47a9225f8030f8bc81721fdc0834e2ee4c",
|
||||
"research-runtime": "7e7d6e5fc050b5bde5073f0939e5a9652a1f68be0a75104cc0b486ec0cd745a6"
|
||||
},
|
||||
"container_ids_after_protected_operation": {
|
||||
"creator-web": "9578e7c7e0137ba36a7fc75ffaf9fce33be046e38531c9de99c0b75c2ce465f8",
|
||||
"core-runtime": "8281ba60b234825d6ee965887e8eef10e29a0783264c05d39cbe937f1b780530",
|
||||
"cognitive-worker": "987640770cad44a0b05a47dbf08a4b47a9225f8030f8bc81721fdc0834e2ee4c",
|
||||
"research-runtime": "7e7d6e5fc050b5bde5073f0939e5a9652a1f68be0a75104cc0b486ec0cd745a6"
|
||||
},
|
||||
"stable_ids_unchanged": true
|
||||
},
|
||||
"candidate_components": {
|
||||
"creator-web": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"core-runtime": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"cognitive-worker": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"},
|
||||
"research-runtime": {"health": "healthy", "revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb"}
|
||||
},
|
||||
"auth_harness": {
|
||||
"approved_harness_found": false,
|
||||
"available_mechanisms": ["/api/session", "tests/creator_web/verify-remote.ps1", "tests/cognitive_advisory/verify-remote.ps1"],
|
||||
"gap": "available remote scripts require production password or bootstrap token; no sanctioned test principal/service harness without reading a credential was found",
|
||||
"browser_or_secret_bypass_used": false
|
||||
},
|
||||
"telemetry_observation": {
|
||||
"started": false,
|
||||
"reason": "no legitimate authenticated generation traffic; candidate was intentionally not promoted",
|
||||
"v1_paths_deleted": false
|
||||
},
|
||||
"deployment_incident": {
|
||||
"occurred": true,
|
||||
"cause": "first attempted checkout used stale local origin/gen2-integration ref and ran pre-guard script",
|
||||
"effect": "core stable container was recreated once; image revision remained 3182092 and stable traffic stayed active",
|
||||
"recovery": "subsequent protected candidate-only deployment used FETCH_HEAD 87ec7ef and proved stable IDs unchanged"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"task_id": "GEN2-COMPAT-OBS-001",
|
||||
"status": "BLOCKED_BY_DEPLOYMENT_CONTROL",
|
||||
"observed_at_utc": "2026-08-30T10:37:07.7219511Z",
|
||||
"browser_connector": {
|
||||
"authenticated_tab_visible": true,
|
||||
"stale_login_tab_also_present": true,
|
||||
"cookies_tokens_or_passwords_read": false
|
||||
},
|
||||
"requested_candidate_revision": "87ec7efd0fa7bdbed22c1c0c67d671907029d7bb",
|
||||
"candidate_prepare": "not_completed_as_a_safe_release_set_promotion",
|
||||
"failure": {
|
||||
"phase": "candidate creator startup after cognitive readiness",
|
||||
"result": "stable container IDs changed despite candidate-only --no-deps; Supervisor control became temporarily unavailable",
|
||||
"stable_ids_before": {
|
||||
"creator-web": "4df348752f1a6def05acb1934bfc23048902ad6f235a7da967806d481e9ce7b7",
|
||||
"core-runtime": "a739d2d5c713dde575f571b38e58ec33143994aa9e3bcc2f5d5fc765a50e1796",
|
||||
"cognitive-worker": "a33d61b32116711287a166e0cce6362dc10de321f920549ef8a344fe0b5efb2d",
|
||||
"research-runtime": "88cfade5cdce288576b1c73493f83c84d915ac82c3d16dc6d3fedb9579a4ba8c"
|
||||
},
|
||||
"stable_ids_after": {
|
||||
"creator-web": "5181e5c6b48eaed6c6ea85eff5668c0e675b72bb982eb9425d6f39f899378ec9",
|
||||
"core-runtime": "641e8f7ae8177e4638cf806d9be1bb6f8b5ac028deb0c0c410c6ff4139032bd5",
|
||||
"cognitive-worker": "2e6d46f5b533cfeda261ceafd65ad5b5873b8aa8166bffc4ae91033a6c994d0a",
|
||||
"research-runtime": "b15d9b0e116fbd18fa726e33541ef11fe1bafe4a85210ab5ee500466a20b400b"
|
||||
},
|
||||
"stable_ids_unchanged": false,
|
||||
"cause": "external production updater/Compose reconcile reacts to production checkout or candidate Compose operation; --no-deps alone does not isolate stable in this control plane"
|
||||
},
|
||||
"recovery": {
|
||||
"traffic_mode": "STABLE",
|
||||
"revision": "91f0c9e7999907a4ee7665b77742dd452c18b17b",
|
||||
"healthz": 200,
|
||||
"readyz": 200,
|
||||
"supervisor_baseline_aligned": true,
|
||||
"data_or_volumes_deleted": false
|
||||
},
|
||||
"observation": {
|
||||
"authenticated_workload_sent": false,
|
||||
"telemetry_promoted": false,
|
||||
"baseline_started": false,
|
||||
"tag_created": false,
|
||||
"v1_paths_deleted": false
|
||||
},
|
||||
"required_follow_up": "deployment control plane must isolate candidate Compose project or disable updater reconcile before safe ReleaseSet promotion"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"evidence_id": "gen2-deploy-002-blocked-2026-08-30",
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"implementation_checks": {
|
||||
"release_set_tests": "PASS",
|
||||
"supervisor_go_test": "PASS",
|
||||
"supervisor_go_vet": "PASS",
|
||||
"compose_config": "PASS",
|
||||
"canary_script_syntax": "PASS",
|
||||
"governance": "PASS",
|
||||
"git_diff_check": "PASS"
|
||||
},
|
||||
"implemented": [
|
||||
"durable Supervisor ReleaseSet state for creator-web, core-runtime, cognitive-worker, and research-runtime",
|
||||
"parallel candidate services with candidate-only core/cognitive/research dependency wiring",
|
||||
"whole-set health/readiness/revision observation, canary, promotion and rollback controls",
|
||||
"stable/candidate/LKG persistence across Supervisor restart",
|
||||
"core and research runtime compose healthchecks"
|
||||
],
|
||||
"production": {
|
||||
"deployment": "NOT_RUN",
|
||||
"candidate_release_set": "NOT_STARTED",
|
||||
"telemetry_observation": "NOT_STARTED",
|
||||
"stable_mutation": "NONE"
|
||||
},
|
||||
"capability_evidence": {
|
||||
"docker_and_compose_on_production_host": "AVAILABLE",
|
||||
"supervisor_control_endpoint": "REACHABLE",
|
||||
"portainer_api_key_in_current_execution_context": "NOT_CONFIGURED",
|
||||
"portainer_endpoint_metadata_in_current_execution_context": "NOT_CONFIGURED",
|
||||
"existing_production_candidate_services": ["creator-web-candidate"],
|
||||
"missing_capability": "Approved production deployment automation or candidate-capable control-plane access for the four-component ReleaseSet."
|
||||
},
|
||||
"unsafe_action_not_taken": "Stable containers were not replaced directly and no production compose mutation was attempted.",
|
||||
"compatibility": "No V1 compatibility path was removed or disabled; no schema, migration, semantic owner, or Gen3 change.",
|
||||
"next_action": "Owner must provide or enable the approved candidate-capable production deployment mechanism; then run the ReleaseSet drill and start bounded telemetry observation."
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"evidence_id": "gen2-deploy-002-production-drill-2026-08-30",
|
||||
"task_id": "GEN2-DEPLOY-002",
|
||||
"task_revision": 1,
|
||||
"status": "BLOCKED_BY_MISSING_CAPABILITY",
|
||||
"integration_revision": "7f8a05c",
|
||||
"candidate_revision": "7f8a05c",
|
||||
"stable_revision_after_rollback": "3182092d5cc758f5a536ce10f5428b5d782bb961",
|
||||
"release_set": ["creator-web", "core-runtime", "cognitive-worker", "research-runtime"],
|
||||
"drill": {
|
||||
"candidate_health_readiness_revision": "PASS; all four candidate containers healthy and image revision checked",
|
||||
"candidate_dependency_isolation": "PASS; creator->core candidate, core->research candidate, core/cognitive candidate routes verified in compose wiring",
|
||||
"canary_header": "PASS; Supervisor selected candidate for X-Assistant-Canary=true",
|
||||
"forced_component_failure": "PASS; research-runtime-candidate stopped, ReleaseSet observation rejected with HTTP 409",
|
||||
"whole_set_rollback": "PASS; Supervisor restored stable 3182092 and traffic mode STABLE",
|
||||
"promotion": "PASS; healthy candidate was promoted through Supervisor",
|
||||
"supervisor_restart": "PASS; promoted ReleaseSet and rollback target survived restart",
|
||||
"post_rollback_health": "PASS; public health/readiness both HTTP 200"
|
||||
},
|
||||
"telemetry": {
|
||||
"startup_events_observed": ["non-bootstrap-env-consumers", "migration-compatibility-switches"],
|
||||
"authenticated_path_observation": "NOT_COMPLETED",
|
||||
"observation_window": "NOT_STARTED",
|
||||
"reason": "The existing browser session was stale and production requested re-authentication before chat submission. No credentials were read or entered."
|
||||
},
|
||||
"final_production_state": {"traffic_mode": "STABLE", "revision": "3182092d5cc758f5a536ce10f5428b5d782bb961", "candidate_containers": "retained but not serving stable traffic"},
|
||||
"not_done": ["telemetry confirmation for all nine paths", "bounded observation window", "retirement tasks", "main merge", "milestone tag"],
|
||||
"scope": {"schema_or_migrations_changed": false, "v1_paths_removed": false, "gen3_started": false, "ui_changed": false}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"task": "GEN2-DEPLOY-003",
|
||||
"status": "PASS",
|
||||
"timestamp_utc": "2026-08-30T11:09:35Z",
|
||||
"root_cause": {
|
||||
"stable_and_candidate_project": "assistant",
|
||||
"stable_and_candidate_working_directory": "/home/m/assistant-deploy/repo/deploy",
|
||||
"updater": "/home/m/assistant-deploy/update.sh",
|
||||
"updater_schedule": "cron every five minutes",
|
||||
"unsafe_operation": "the updater ran full docker compose up for the stable project and invoked the old in-project candidate canary"
|
||||
},
|
||||
"remediation": {
|
||||
"stable_project": "assistant",
|
||||
"candidate_project": "assistant-slot-b",
|
||||
"candidate_file": "deploy/docker-compose.candidate-slot.yml",
|
||||
"shared_resources": ["assistant_default external network", "llm-local_default external network", "existing secret and research volumes"],
|
||||
"updater_ownership": "stable-only updater; candidate release command is not invoked by the updater",
|
||||
"supervisor_release_set_slot": "ReleaseSet.slot persisted in durable Supervisor state"
|
||||
},
|
||||
"candidate_revision": "216d76f1d8b4eeba6ee9427da4c87c3e5c728c02",
|
||||
"stable_revision": "2ee5da81145cdad2da1c92c24a91145ad4af61a5",
|
||||
"candidate_components": {
|
||||
"creator-web": "healthy",
|
||||
"core-runtime": "healthy",
|
||||
"cognitive-worker": "healthy",
|
||||
"research-runtime": "healthy"
|
||||
},
|
||||
"drill": {
|
||||
"stable_ids_captured_before_candidate": true,
|
||||
"stable_ids_unchanged_after_candidate_start": true,
|
||||
"header_canary": "candidate slot b",
|
||||
"forced_failure": "candidate cognitive-worker stopped",
|
||||
"rollback": "Supervisor release rollback returned traffic to legacy stable without recreating stable containers",
|
||||
"stable_ids_unchanged_after_rollback": true,
|
||||
"supervisor_restart": "last-known-good legacy stable release set preserved",
|
||||
"candidate_recovery": "candidate cognitive-worker restored healthy",
|
||||
"stable_ids_unchanged_after_candidate_recovery": true
|
||||
},
|
||||
"final_state": {
|
||||
"traffic_mode": "STABLE",
|
||||
"stable_upstream": "http://creator-web:8084",
|
||||
"last_known_good_slot": "legacy-stable",
|
||||
"public_health": 200,
|
||||
"public_ready": 200
|
||||
},
|
||||
"limits": [
|
||||
"The existing stable project remains legacy-stable during the non-disruptive transition; it was not recreated merely to rename it.",
|
||||
"Local MEDIUM discovery was slow/unavailable during candidate cognitive startup; it delayed startup but did not affect stable traffic or the isolated release-set drill.",
|
||||
"GEN2-COMPAT-OBS-001 has not resumed and no V1 path has been retired."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# GEN2-EVOLVE-001 — milestone evidence
|
||||
|
||||
- Task/revision: `GEN2-EVOLVE-001 r3`.
|
||||
- Base: `07f0f6e3807f0c40971a22f7bc31b844c0635857` (`gen2-integration`); task branch: `gen2/gen2-evolve-001`.
|
||||
- Migration numbering: global. Existing `051_identity_principal_realms.sql` and `052_identity_creator_accounts.sql` occupied the originally chosen number, so the task-owned additive Epistemics migration was renumbered to `053_gen2_adaptive_experience_scope.sql`. No historical/applied migration was edited.
|
||||
- Canonical ownership: Interaction remains the canonical owner of utterances and typed `ProjectReference`; Epistemics remains the canonical owner of Experience records and temporal evidence; Context Compiler remains rebuildable. No new Memory owner, cross-owner SQL join, canonical writer, privilege, dependency, provider override or V1 retirement was introduced.
|
||||
|
||||
## Milestone checks
|
||||
|
||||
Executed once on 2026-08-30:
|
||||
|
||||
```text
|
||||
go test -count=1 ./modules/interaction/domain ./modules/epistemics/domain ./contracts/cognitive ./contracts/epistemics ./adapters/postgres ./cmd/core-runtime
|
||||
go vet ./modules/interaction/domain ./modules/epistemics/domain ./contracts/cognitive ./contracts/epistemics ./adapters/postgres ./cmd/core-runtime
|
||||
go run ./cmd/devctl preflight -task dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json
|
||||
go run ./cmd/devctl check-diff -task dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json
|
||||
go run ./cmd/devctl/independent
|
||||
go run ./cmd/devctl check-architecture
|
||||
git diff --check
|
||||
```
|
||||
|
||||
All commands passed. The focused migration consistency check confirmed a unique global sequence through `053`, an additive owner-local migration, and required `BEGIN`, `SET ROLE epistemics_role`, `principal_id`, `context_scope_ref`, and `COMMIT` markers.
|
||||
|
||||
## Acceptance covered
|
||||
|
||||
- current versus historical assertion selection preserves historical lineage; an explicit correction precedes timestamp tie-breaking;
|
||||
- task-relative volatility/freshness uses validity, last verification, maximum age and optional version applicability;
|
||||
- Principal and typed `ProjectReference` scope filtering occur before relevance/salience ranking;
|
||||
- positive, negative and contradiction Experience lanes are independently retrieved with applicability conditions retained;
|
||||
- Context Compiler reserves bounded lane coverage, preserves omission/inclusion reasons in an internal trace, and keeps historical analogies opt-in;
|
||||
- consolidation candidates retain Epistemics experience references and are candidates only: no Skill is written or activated;
|
||||
- retrieval projections rebuild from canonical Interaction/Epistemics records; no process-local state is required.
|
||||
|
||||
## Deferred / next gap
|
||||
|
||||
The existing `IterativeRetrievalRequest` contract is present, but no established runtime continuation callback is available in this packet's scope. Iterative retrieval therefore remains a bounded future wiring task; no parallel runtime contract was invented here. Browser/UI acceptance, compatibility observation and V1 retirement remain deferred by owner decision.
|
||||
@@ -0,0 +1,14 @@
|
||||
# GEN2-EVOLVE-002 — Iterative Retrieval & Cognitive Memory Continuation
|
||||
|
||||
- Task/revision: `GEN2-EVOLVE-002 r2`; base `850fe92`.
|
||||
- Continuation path: Core initial Context Compiler -> Cognitive Gateway advisory -> typed retrieval intent -> Core-bound inherited scope/policy -> existing Interaction/Epistemics retrieval -> existing Context Compiler -> one final advisory.
|
||||
- Initiator: the cognitive strategy may emit a strict typed lane intent. Gateway validates it; Core, not the model, binds Principal, ContextScope, Focus, task label, freshness policy and local-only data policy.
|
||||
- Limits: one continuation round, two excerpts, 1024 added bytes, 1.5 second local retrieval window, lane-qualified duplicate suppression. The second advisory has continuation disabled.
|
||||
- No result returns a private `no additional evidence` excerpt for the final reasoning round. Negative experience and contradiction are separate lanes; scope is hard-filtered before ranking.
|
||||
- No new owner, schema, provider selector, direct cognitive-worker storage access, V1 retirement or UI change.
|
||||
|
||||
## Verification
|
||||
|
||||
MILESTONE gate: targeted `go test` and `go vet` for `contracts/cognitive`, `cmd/cognitive-worker`, and `cmd/core-runtime`; `devctl preflight`, `check-diff`, `independent`, `check-architecture`, and `git diff --check`.
|
||||
|
||||
Focused tests cover the second retrieval round, Company A/B isolation, negative and contradiction lanes, duplicate suppression, byte/round limits, and no-additional-evidence behavior. Continuation state is request-scoped; durable restart/resume remains with the existing Work/Investigation owner.
|
||||
@@ -0,0 +1,16 @@
|
||||
# GEN2-EVOLVE-003 — Experience to Skill to Reflex Learning Lifecycle
|
||||
|
||||
- Task/revision: `GEN2-EVOLVE-003 r3`; base `a07aba0`.
|
||||
- Canonical owners remain unchanged: Epistemics owns Experience/Outcome evidence; Capability owns the single SkillProfile lifecycle and Reflex revisions; SELF owns ImprovementNeed and Learning Ledger. Evidence is referenced by stable refs, not copied into another memory or Skill registry.
|
||||
- Skill lifecycle: verified positive evidence produces `CANDIDATE`; at least two verified outcomes plus an independent evaluation and must-not-worsen evidence are required for `VALIDATED`; a separate explicit transition makes it `ACTIVE`.
|
||||
- Routing: an ineligible Reflex falls through to an eligible Skill and then Local reasoning. A degraded, stale, suspended, retired or insufficient-confidence Skill cannot be fast-applied.
|
||||
- Reflex lifecycle: only an Active Skill with three verified outcomes and a bounded deterministic/rule/workflow implementation can become a Candidate. Independent evaluation promotes it to Active. Sampled shadow divergence marks it `STALE` and creates only a SELF revalidation proposal; it cannot rewrite or promote production code.
|
||||
- Revalidation triggers cover stale lifecycle, dependency/version change, correction count and validation age. Retired Reflex revisions remain durable history and cannot be selected.
|
||||
- Persistence: additive migration `054` extends the existing Capability SkillProfile record, adds Capability-owned Reflex revision/head tables, and adds SELF ImprovementNeed/Learning Ledger evidence fields using existing roles. Rollback excludes a non-Active Reflex and falls back to Skill/reasoning; no evidence is deleted.
|
||||
- Gap intentionally retained: automatic generation of executable production Reflex code is not implemented. It requires a separately authorized governed-development task with independent evaluation and promotion gates.
|
||||
|
||||
## Verification
|
||||
|
||||
MILESTONE: focused `go test`/`go vet` for Capability domain, SELF domain, PostgreSQL adapter, Core Runtime and Capability contracts; `devctl preflight`, `check-diff`, `independent`, `check-architecture`, migration consistency and `git diff --check`.
|
||||
|
||||
Focused regressions cover no one-run promotion, independent validation, Reflex candidate requirements, shadow divergence/revalidation rather than rewrite, Skill-to-reasoning escalation, SELF proposal-only behavior, and historical evidence preservation.
|
||||
@@ -0,0 +1,28 @@
|
||||
# GEN2-EVOLVE-004 milestone evidence
|
||||
|
||||
Status: MILESTONE_ACCEPTED for the bounded SELF evidence/proposal domain.
|
||||
|
||||
## Delivered
|
||||
|
||||
- Typed learning signals cover verified failures, corrections, repeated mistakes, Reflex divergence, Skill fallback, escalation, outcome mismatch, retry/fallback, stale dependencies, regret and better solutions.
|
||||
- Attribution returns a bounded evidence-backed `HYPOTHESIS`; provider outage is not converted into a competence loss.
|
||||
- Weakness patterns require two matching incidents in the same Principal/task/Scope and retain evidence, frequency, severity, affected component and confidence.
|
||||
- Competence assessment is contextual and requires evidence; SELF cannot self-declare trust.
|
||||
- ImprovementNeed and Candidate contain hypothesis, expected effect, evaluation, must-not-worsen, rollback, cost and independent evaluator fields.
|
||||
- Benchmark cases reference evidence rather than copying conversations. Learning Ledger appends expected and actual evaluation references and decision.
|
||||
- Candidate evaluation rejects latency/quality regression and creator/evaluator identity reuse. New model qualification creates a review candidate only.
|
||||
- PostgreSQL migration `055` adds SELF-owned durable signal, weakness, candidate and benchmark tables under existing `self_role`; no new role, owner or cross-owner relation is created.
|
||||
|
||||
## Checks
|
||||
|
||||
- `go test ./contracts/self ./modules/self/domain ./adapters/postgres ./cmd/core-runtime`
|
||||
- `go vet ./contracts/self ./modules/self/domain ./adapters/postgres ./cmd/core-runtime`
|
||||
- `go run ./cmd/devctl preflight -task dev/tasks/GEN2-EVOLVE-004-self-improvement-loop-weakness-diagnosis-and-governed-learning.json`
|
||||
- `go run ./cmd/devctl check-diff -task dev/tasks/GEN2-EVOLVE-004-self-improvement-loop-weakness-diagnosis-and-governed-learning.json`
|
||||
- `go run ./cmd/devctl/independent`
|
||||
- `go run ./cmd/devctl check-architecture`
|
||||
- `git diff --check`
|
||||
|
||||
## Boundaries
|
||||
|
||||
No autonomous code generation, merge, deployment, production promotion, new SELF owner, Skill/Reflex mutation, UI work, V1 retirement or Gen2-EVOLVE-005 was performed. Existing Work governed-evolution gates remain the promotion authority. Runtime recorder wiring through `cmd/core-runtime/main.go` was not added because it is outside the accepted packet scope; the durable repository and typed domain path are ready for a separately authorized integration task.
|
||||
@@ -0,0 +1,35 @@
|
||||
# GEN2-SECRETS-001 — local machine credential provisioning
|
||||
|
||||
Date: 2026-08-31
|
||||
|
||||
## Design evidence
|
||||
|
||||
`platform/secrets` is the existing technical protected-file mechanism. This
|
||||
task extends it without adding a semantic Secrets owner, a database table, or
|
||||
a Configuration/Memory writer.
|
||||
|
||||
The dedicated Docker volume is named
|
||||
`assistant_control_plane_service_secrets`. Its sole writer is the one-shot
|
||||
`secret-control-plane` control-plane container. Its only consumers are
|
||||
`core-runtime` and `commit-runtime`, mounted read-only at
|
||||
`/run/control-plane-secrets`. The metadata directory is distinct from the
|
||||
token artifact and records purpose, owner, version, consumers, status,
|
||||
generation method, and non-secret audit actions only.
|
||||
|
||||
The provisioner uses `crypto/rand`, writes via an atomic file replacement,
|
||||
and has no plaintext environment input. It reports only safe operation status.
|
||||
The data policy marks the artifact as not indexable, embeddable, retrievable,
|
||||
or model-visible.
|
||||
|
||||
## Local FAST evidence
|
||||
|
||||
The following checks completed successfully before production execution:
|
||||
|
||||
- targeted `go test ./platform/secrets ./cmd/secret-control-plane`;
|
||||
- targeted `go vet ./platform/secrets ./cmd/secret-control-plane`;
|
||||
- POSIX shell regression for the provisioning wrapper, executed under Git
|
||||
Bash because the Windows WSL shim cannot translate the mapped workspace;
|
||||
- static Docker Compose validation for production and candidate files.
|
||||
|
||||
No credential value, fingerprint, length, header, or plaintext environment
|
||||
value was read, printed, copied, committed, or included in this evidence.
|
||||
@@ -13,3 +13,13 @@ func TestStrategyUsesRiskAndCostSignals(t *testing.T) {
|
||||
t.Fatalf("%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableReflexFallsBackThroughSkillToReasoning(t *testing.T) {
|
||||
request := capability.StrategyRequest{PreferLowLatency: true, RequiredQuality: 700}
|
||||
if got := request.SelectEligible(capability.StrategyEligibility{SkillEligible: true}); got.Path != capability.PathSkill {
|
||||
t.Fatalf("expected Skill fallback, got %#v", got)
|
||||
}
|
||||
if got := request.SelectEligible(capability.StrategyEligibility{}); got.Path != capability.PathSmallLocal || !got.Escalate {
|
||||
t.Fatalf("expected reasoning fallback, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,91 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
var ErrInsufficientReflexEvidence = errors.New("insufficient reflex evidence")
|
||||
|
||||
func ValidateReflexRevision(v capability.ReflexRevision) error {
|
||||
if v.ReflexID == "" || v.SourceSkillRevision < 1 || v.Kind == "" || v.ImplementationRef == "" || v.Lifecycle == "" || v.RecordedAt < 1 {
|
||||
return fmt.Errorf("invalid reflex revision")
|
||||
}
|
||||
if v.Lifecycle == capability.ReflexActive && len(v.ValidationEvidenceRefs) == 0 {
|
||||
if (v.Lifecycle == capability.ReflexActive || v.Lifecycle == capability.ReflexValidating) && len(v.ValidationEvidenceRefs) == 0 {
|
||||
return fmt.Errorf("active reflex requires evidence")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CandidateReflex admits only a bounded, deterministic implementation from an
|
||||
// already active Skill with multiple verified results. It never activates it.
|
||||
func CandidateReflex(skill capability.SkillProfile, value capability.ReflexRevision, verifierEvidence capability.SkillEvidenceRef, recordedAt int64) (capability.ReflexRevision, error) {
|
||||
if err := ValidateSkillProfile(skill); err != nil || skill.Status != capability.SkillActive || skill.VerifiedOutcomeCount < 3 || verifierEvidence == "" || recordedAt < 1 {
|
||||
return capability.ReflexRevision{}, ErrInsufficientReflexEvidence
|
||||
}
|
||||
if value.Kind != capability.ReflexDeterministicCode && value.Kind != capability.ReflexRule && value.Kind != capability.ReflexWorkflow && value.Kind != capability.ReflexBoundedAdapter {
|
||||
return capability.ReflexRevision{}, ErrInsufficientReflexEvidence
|
||||
}
|
||||
value.SourceSkillRevision = skill.Revision
|
||||
value.Lifecycle = capability.ReflexCandidate
|
||||
value.ValidationEvidenceRefs = appendEvidence(value.ValidationEvidenceRefs, verifierEvidence)
|
||||
value.LastValidatedAt = 0
|
||||
value.RecordedAt = recordedAt
|
||||
if err := ValidateReflexRevision(value); err != nil {
|
||||
return capability.ReflexRevision{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func ValidateAndActivateReflex(value capability.ReflexRevision, independentEvidence capability.SkillEvidenceRef, mustNotWorsen bool, recordedAt int64) (capability.ReflexRevision, error) {
|
||||
if err := ValidateReflexRevision(value); err != nil || value.Lifecycle != capability.ReflexCandidate || independentEvidence == "" || !mustNotWorsen || recordedAt < 1 {
|
||||
return capability.ReflexRevision{}, ErrInsufficientReflexEvidence
|
||||
}
|
||||
next := value
|
||||
next.Revision++
|
||||
next.Lifecycle = capability.ReflexActive
|
||||
next.ValidationEvidenceRefs = appendEvidence(next.ValidationEvidenceRefs, independentEvidence)
|
||||
next.LastValidatedAt = recordedAt
|
||||
next.RecordedAt = recordedAt
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// ObserveShadow keeps normal execution non-blocking. Divergence marks the
|
||||
// revision stale for governed revalidation; it cannot replace implementation.
|
||||
func ObserveShadow(value capability.ReflexRevision, comparison capability.ShadowComparison) (capability.ReflexRevision, bool, error) {
|
||||
if err := ValidateReflexRevision(value); err != nil || comparison.ReflexID != value.ReflexID || comparison.ReflexRevision != value.Revision || comparison.ObservedAt < 1 {
|
||||
return capability.ReflexRevision{}, false, ErrInsufficientReflexEvidence
|
||||
}
|
||||
next := value
|
||||
next.ExecutionCount++
|
||||
if comparison.Diverged || !comparison.VerifierPassed {
|
||||
next.DivergenceCount++
|
||||
next.Lifecycle = capability.ReflexStale
|
||||
next.RecordedAt = comparison.ObservedAt
|
||||
return next, true, nil
|
||||
}
|
||||
return next, false, nil
|
||||
}
|
||||
|
||||
func RevalidationRequired(value capability.ReflexRevision, dependencyChanged bool, correctionCount int64, now int64) bool {
|
||||
if value.Lifecycle == capability.ReflexRetired || value.Lifecycle == capability.ReflexStale || value.Lifecycle == capability.ReflexDegraded {
|
||||
return true
|
||||
}
|
||||
if dependencyChanged || (value.Revalidation.RevalidateAfterCorrectionCount != nil && correctionCount >= *value.Revalidation.RevalidateAfterCorrectionCount) {
|
||||
return true
|
||||
}
|
||||
return value.Revalidation.MaxAgeMillis != nil && value.LastValidatedAt > 0 && now-value.LastValidatedAt >= *value.Revalidation.MaxAgeMillis
|
||||
}
|
||||
|
||||
func appendEvidence(values []capability.SkillEvidenceRef, value capability.SkillEvidenceRef) []capability.SkillEvidenceRef {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
func ReflexFallback(d capability.FastPathDecision) string {
|
||||
if d.Decision == capability.FastPathApplied {
|
||||
return "reflex"
|
||||
|
||||
@@ -19,3 +19,19 @@ func TestReflexActiveRequiresEvidenceAndFallbacks(t *testing.T) {
|
||||
t.Fatal("missing escalation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReflexRequiresStableSkillAndShadowDivergenceOnlyRequestsReview(t *testing.T) {
|
||||
skill := capability.SkillProfile{ID: "s", Revision: 4, Name: "bounded", Description: "bounded", Status: capability.SkillActive, ConfidenceMillis: 900, RecordedAtUnixMillis: 1, VerifiedOutcomeCount: 3}
|
||||
candidate, err := domain.CandidateReflex(skill, capability.ReflexRevision{ReflexID: "r", Revision: 1, Kind: capability.ReflexRule, ImplementationRef: "rule", RecordedAt: 1}, "verifier:success", 2)
|
||||
if err != nil || candidate.Lifecycle != capability.ReflexCandidate {
|
||||
t.Fatalf("candidate=%#v err=%v", candidate, err)
|
||||
}
|
||||
active, err := domain.ValidateAndActivateReflex(candidate, "evaluation:independent", true, 3)
|
||||
if err != nil || active.Lifecycle != capability.ReflexActive {
|
||||
t.Fatalf("active=%#v err=%v", active, err)
|
||||
}
|
||||
next, review, err := domain.ObserveShadow(active, capability.ShadowComparison{ReflexID: "r", ReflexRevision: active.Revision, EvidenceRefs: []capability.SkillEvidenceRef{"shadow:1"}, VerifierPassed: false, Diverged: true, ObservedAt: 4})
|
||||
if err != nil || !review || next.Lifecycle != capability.ReflexStale {
|
||||
t.Fatalf("next=%#v review=%t err=%v", next, review, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
var ErrInvalidSkillProfile = errors.New("invalid skill profile")
|
||||
var ErrInvalidFastPathInput = errors.New("invalid fast path input")
|
||||
var ErrInsufficientSkillEvidence = errors.New("insufficient independent skill evidence")
|
||||
|
||||
func ValidateSkillProfile(value capability.SkillProfile) error {
|
||||
if value.ID == "" || value.Revision < 1 || strings.TrimSpace(value.Name) == "" ||
|
||||
@@ -17,13 +18,59 @@ func ValidateSkillProfile(value capability.SkillProfile) error {
|
||||
return ErrInvalidSkillProfile
|
||||
}
|
||||
switch value.Status {
|
||||
case capability.SkillCandidate, capability.SkillEvaluating, capability.SkillValidated, capability.SkillDegraded, capability.SkillSuspended, capability.SkillRetired:
|
||||
case capability.SkillCandidate, capability.SkillEvaluating, capability.SkillValidated, capability.SkillActive, capability.SkillDegraded, capability.SkillSuspended, capability.SkillRetired:
|
||||
default:
|
||||
return ErrInvalidSkillProfile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CandidateFromVerifiedExperience starts a reusable approach but deliberately
|
||||
// does not validate or activate it. Evidence stays in Epistemics by reference.
|
||||
func CandidateFromVerifiedExperience(profile capability.SkillProfile, experience capability.SkillEvidenceRef, verified bool, recordedAt int64) (capability.SkillProfile, error) {
|
||||
if err := ValidateSkillProfile(profile); err != nil || experience == "" || !verified || recordedAt < 1 {
|
||||
return capability.SkillProfile{}, ErrInvalidSkillProfile
|
||||
}
|
||||
next := profile
|
||||
next.Revision++
|
||||
next.Status = capability.SkillCandidate
|
||||
next.SupportingExperienceRefs = appendEvidenceRef(next.SupportingExperienceRefs, experience)
|
||||
next.VerifiedOutcomeCount++
|
||||
next.RecordedAtUnixMillis = recordedAt
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// ValidateSkill requires repeatable verified outcomes plus evidence produced by
|
||||
// an evaluator other than the candidate creator. It never activates a Skill.
|
||||
func ValidateSkill(profile capability.SkillProfile, evaluation capability.SkillEvidenceRef, independent bool, mustNotWorsen bool, recordedAt int64) (capability.SkillProfile, error) {
|
||||
if err := ValidateSkillProfile(profile); err != nil || evaluation == "" || !independent || !mustNotWorsen || recordedAt < 1 {
|
||||
return capability.SkillProfile{}, ErrInsufficientSkillEvidence
|
||||
}
|
||||
if profile.VerifiedOutcomeCount < 2 || len(profile.SupportingExperienceRefs) < 2 {
|
||||
return capability.SkillProfile{}, ErrInsufficientSkillEvidence
|
||||
}
|
||||
next := profile
|
||||
next.Revision++
|
||||
next.Status = capability.SkillValidated
|
||||
next.ValidationEvidenceRefs = appendEvidenceRef(next.ValidationEvidenceRefs, evaluation)
|
||||
next.IndependentEvaluationRefs = appendEvidenceRef(next.IndependentEvaluationRefs, evaluation)
|
||||
next.MustNotWorsenSatisfied = true
|
||||
next.LastValidatedUnixMillis = recordedAt
|
||||
next.RecordedAtUnixMillis = recordedAt
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ActivateSkill(profile capability.SkillProfile, recordedAt int64) (capability.SkillProfile, error) {
|
||||
if err := ValidateSkillProfile(profile); err != nil || profile.Status != capability.SkillValidated || len(profile.IndependentEvaluationRefs) == 0 || !profile.MustNotWorsenSatisfied || recordedAt < 1 {
|
||||
return capability.SkillProfile{}, ErrInsufficientSkillEvidence
|
||||
}
|
||||
next := profile
|
||||
next.Revision++
|
||||
next.Status = capability.SkillActive
|
||||
next.RecordedAtUnixMillis = recordedAt
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ObserveSkillOutcome(profile capability.SkillProfile, observation capability.SkillOutcomeObservation) (capability.SkillProfile, error) {
|
||||
if err := ValidateSkillProfile(profile); err != nil {
|
||||
return capability.SkillProfile{}, err
|
||||
@@ -37,10 +84,6 @@ func ObserveSkillOutcome(profile capability.SkillProfile, observation capability
|
||||
switch observation.Outcome {
|
||||
case capability.UsageSucceeded:
|
||||
next.SupportingExperienceRefs = appendEvidenceRef(next.SupportingExperienceRefs, observation.ExperienceRef)
|
||||
if next.Status == capability.SkillCandidate || next.Status == capability.SkillEvaluating {
|
||||
next.Status = capability.SkillValidated
|
||||
}
|
||||
next.LastValidatedUnixMillis = observation.ObservedAtUnixMillis
|
||||
next.ConfidenceMillis = clampConfidence(next.ConfidenceMillis + 25)
|
||||
case capability.UsageFailed:
|
||||
next.KnownFailureExperienceRefs = appendEvidenceRef(next.KnownFailureExperienceRefs, observation.ExperienceRef)
|
||||
@@ -57,7 +100,7 @@ func ObserveSkillOutcome(profile capability.SkillProfile, observation capability
|
||||
next.KnownFailureExperienceRefs = appendEvidenceRef(next.KnownFailureExperienceRefs, observation.ExperienceRef)
|
||||
next.ConfidenceMillis = clampConfidence(next.ConfidenceMillis - 200)
|
||||
}
|
||||
if next.Status == capability.SkillValidated && (next.ObservedFailureCount >= 3 || next.ObservedCorrectionCount >= 2 || next.ConfidenceMillis < 500) {
|
||||
if (next.Status == capability.SkillValidated || next.Status == capability.SkillActive) && (next.ObservedFailureCount >= 3 || next.ObservedCorrectionCount >= 2 || next.ConfidenceMillis < 500) {
|
||||
next.Status = capability.SkillDegraded
|
||||
}
|
||||
if next.Status == capability.SkillDegraded && next.ConfidenceMillis < 250 {
|
||||
@@ -80,7 +123,7 @@ func DecideFastPath(profile capability.SkillProfile, supported bool, confidenceM
|
||||
ObservedAtUnixMillis: observedAtUnixMillis,
|
||||
}
|
||||
switch {
|
||||
case profile.Status == capability.SkillSuspended || profile.Status == capability.SkillRetired:
|
||||
case profile.Status == capability.SkillSuspended || profile.Status == capability.SkillRetired || profile.Status == capability.SkillDegraded:
|
||||
return decline(decision, capability.FastPathStale, "skill profile is not eligible for fast-path use"), nil
|
||||
case environmentVersionChanged:
|
||||
return decline(decision, capability.FastPathStale, "environment or implementation version changed since validation"), nil
|
||||
@@ -90,7 +133,7 @@ func DecideFastPath(profile capability.SkillProfile, supported bool, confidenceM
|
||||
return decline(decision, capability.FastPathOutOfDistribution, "input is outside supported semantics"), nil
|
||||
case confidenceMillis < 700 || profile.ConfidenceMillis < 700:
|
||||
return decline(decision, capability.FastPathLowConfidence, "confidence is too low for optimized path"), nil
|
||||
case profile.Status != capability.SkillValidated:
|
||||
case profile.Status != capability.SkillActive:
|
||||
return decline(decision, capability.FastPathNotApplicable, "skill is not validated"), nil
|
||||
default:
|
||||
return decision, nil
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestFastPathDeclinesUnsupportedInputAndEscalatesToThinking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedSkillCanApplyFastPathWhenFreshAndConfident(t *testing.T) {
|
||||
func TestActiveSkillCanApplyFastPathWhenFreshAndConfident(t *testing.T) {
|
||||
decision, err := DecideFastPath(skillProfile(), true, 950, false, false, 2000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -58,18 +58,43 @@ func TestUnknownAndSkippedOutcomesDoNotBecomeSkillFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateNeedsIndependentRepeatableValidationBeforeActive(t *testing.T) {
|
||||
profile := skillProfile()
|
||||
profile.Status = capability.SkillCandidate
|
||||
profile.VerifiedOutcomeCount = 1
|
||||
profile.SupportingExperienceRefs = []capability.SkillEvidenceRef{"experience:1"}
|
||||
if _, err := ValidateSkill(profile, "evaluation:independent", true, true, 2000); err == nil {
|
||||
t.Fatal("one success promoted a skill")
|
||||
}
|
||||
profile.VerifiedOutcomeCount = 2
|
||||
profile.SupportingExperienceRefs = append(profile.SupportingExperienceRefs, "experience:2")
|
||||
validated, err := ValidateSkill(profile, "evaluation:independent", true, true, 2000)
|
||||
if err != nil || validated.Status != capability.SkillValidated {
|
||||
t.Fatalf("validation=%#v err=%v", validated, err)
|
||||
}
|
||||
active, err := ActivateSkill(validated, 2100)
|
||||
if err != nil || active.Status != capability.SkillActive {
|
||||
t.Fatalf("activation=%#v err=%v", active, err)
|
||||
}
|
||||
}
|
||||
|
||||
func skillProfile() capability.SkillProfile {
|
||||
return capability.SkillProfile{
|
||||
ID: "skill-obvious-reminder-response-v1",
|
||||
Revision: 1,
|
||||
Name: "Resolve obvious reminder continuation",
|
||||
Description: "Use a recent unambiguous reminder/attention target for complete/snooze/cancel without cognition.",
|
||||
ApplicableContext: []string{"continuity:reminder-response"},
|
||||
ImplementationRef: "continuity:human-response-target-resolver-v1",
|
||||
ImplementationVersion: "1",
|
||||
Status: capability.SkillValidated,
|
||||
ConfidenceMillis: 850,
|
||||
LastValidatedUnixMillis: 1000,
|
||||
RecordedAtUnixMillis: 1000,
|
||||
ID: "skill-obvious-reminder-response-v1",
|
||||
Revision: 1,
|
||||
Name: "Resolve obvious reminder continuation",
|
||||
Description: "Use a recent unambiguous reminder/attention target for complete/snooze/cancel without cognition.",
|
||||
ApplicableContext: []string{"continuity:reminder-response"},
|
||||
ImplementationRef: "continuity:human-response-target-resolver-v1",
|
||||
ImplementationVersion: "1",
|
||||
Status: capability.SkillActive,
|
||||
ConfidenceMillis: 850,
|
||||
LastValidatedUnixMillis: 1000,
|
||||
RecordedAtUnixMillis: 1000,
|
||||
SupportingExperienceRefs: []capability.SkillEvidenceRef{"experience:1", "experience:2"},
|
||||
IndependentEvaluationRefs: []capability.SkillEvidenceRef{"evaluation:independent"},
|
||||
ValidationEvidenceRefs: []capability.SkillEvidenceRef{"evaluation:independent"},
|
||||
VerifiedOutcomeCount: 2,
|
||||
MustNotWorsenSatisfied: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
type ExperienceRetrieval struct {
|
||||
Positive []epistemics.ExperienceRecord
|
||||
Negative []epistemics.ExperienceRecord
|
||||
Contradictions []epistemics.ExperienceRecord
|
||||
}
|
||||
|
||||
// SalienceInput is transient retrieval policy. It deliberately is not stored
|
||||
// as a permanent importance score on canonical evidence.
|
||||
type SalienceInput struct {
|
||||
Relevance int
|
||||
FocusMatch bool
|
||||
GoalOrTaskMatch bool
|
||||
UserIntentStrength int
|
||||
Importance int
|
||||
PreviousUsefulness int
|
||||
CommitmentRelevant bool
|
||||
Freshness epistemics.FreshnessAssessmentState
|
||||
Negative bool
|
||||
ContextCostBytes int
|
||||
RecordedAtUnixMillis int64
|
||||
NowUnixMillis int64
|
||||
}
|
||||
|
||||
// ComputeSalience keeps ranking bounded and explainable. Scope and principal
|
||||
// filtering happen before this function is ever used.
|
||||
func ComputeSalience(input SalienceInput) int {
|
||||
score := input.Relevance*100 + input.Importance + input.PreviousUsefulness + input.UserIntentStrength
|
||||
if input.FocusMatch {
|
||||
score += 80
|
||||
}
|
||||
if input.GoalOrTaskMatch {
|
||||
score += 60
|
||||
}
|
||||
if input.CommitmentRelevant {
|
||||
score += 40
|
||||
}
|
||||
if input.Negative {
|
||||
score += 30
|
||||
}
|
||||
switch input.Freshness {
|
||||
case epistemics.FreshnessAssessmentCurrent:
|
||||
score += 40
|
||||
case epistemics.FreshnessAssessmentAging, epistemics.FreshnessAssessmentAcceptable:
|
||||
score += 10
|
||||
case epistemics.FreshnessAssessmentStale, epistemics.FreshnessAssessmentHistoricalOnly, epistemics.FreshnessAssessmentVersionMismatch:
|
||||
score -= 80
|
||||
}
|
||||
if input.NowUnixMillis > input.RecordedAtUnixMillis && input.NowUnixMillis-input.RecordedAtUnixMillis < 24*60*60*1000 {
|
||||
score += 20
|
||||
}
|
||||
return score - input.ContextCostBytes/32
|
||||
}
|
||||
|
||||
// RetrieveExperience filters principal and scope before text relevance. It is
|
||||
// a derived view over append-only Epistemics records, not a Memory owner.
|
||||
func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scopeRef, query string, limit int) ExperienceRetrieval {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
queryTokens := retrievalTokens(query)
|
||||
eligible := make([]epistemics.ExperienceRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if principalID != "" && record.PrincipalID != principalID {
|
||||
continue
|
||||
}
|
||||
if scopeRef != "" && record.ContextScopeRef != scopeRef {
|
||||
continue
|
||||
}
|
||||
if len(queryTokens) > 0 && overlap(queryTokens, retrievalTokens(record.ProblemStatement+" "+record.OutcomeDetail+" "+record.WhySucceededOrFailed)) == 0 {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, record)
|
||||
}
|
||||
sort.SliceStable(eligible, func(i, j int) bool {
|
||||
left := experienceSalience(eligible[i], queryTokens)
|
||||
right := experienceSalience(eligible[j], queryTokens)
|
||||
if left != right {
|
||||
return left > right
|
||||
}
|
||||
return eligible[i].RecordedAtUnixMillis > eligible[j].RecordedAtUnixMillis
|
||||
})
|
||||
result := ExperienceRetrieval{}
|
||||
for _, record := range eligible {
|
||||
if len(result.Positive)+len(result.Negative) >= limit {
|
||||
break
|
||||
}
|
||||
switch record.ActualOutcome {
|
||||
case epistemics.OutcomeSuccess, epistemics.OutcomePartialSuccess:
|
||||
result.Positive = append(result.Positive, record)
|
||||
default:
|
||||
result.Negative = append(result.Negative, record)
|
||||
if record.ActualOutcome == epistemics.OutcomeConflict || record.ActualOutcome == epistemics.OutcomeUserCorrected || record.ActualOutcome == epistemics.OutcomeStale {
|
||||
result.Contradictions = append(result.Contradictions, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func experienceSalience(record epistemics.ExperienceRecord, queryTokens []string) int {
|
||||
text := record.ProblemStatement + " " + record.OutcomeDetail + " " + record.WhySucceededOrFailed + " " + strings.Join(record.ApplicabilityConditions, " ")
|
||||
negative := record.ActualOutcome != epistemics.OutcomeSuccess && record.ActualOutcome != epistemics.OutcomePartialSuccess
|
||||
return ComputeSalience(SalienceInput{
|
||||
Relevance: overlap(queryTokens, retrievalTokens(text)),
|
||||
GoalOrTaskMatch: record.GoalRef != "" || record.TaskRef != "",
|
||||
Importance: int(record.ConfidenceMillis / 10),
|
||||
CommitmentRelevant: record.ExpectationID != "",
|
||||
Negative: negative,
|
||||
ContextCostBytes: len([]byte(text)),
|
||||
RecordedAtUnixMillis: record.RecordedAtUnixMillis,
|
||||
NowUnixMillis: record.RecordedAtUnixMillis,
|
||||
})
|
||||
}
|
||||
|
||||
func BuildConsolidationCandidates(records []epistemics.ExperienceRecord) []epistemics.ConsolidationCandidate {
|
||||
byProblem := map[string][]epistemics.ExperienceRecord{}
|
||||
for _, record := range records {
|
||||
key := strings.TrimSpace(strings.ToLower(record.ProblemStatement))
|
||||
if key != "" {
|
||||
byProblem[key] = append(byProblem[key], record)
|
||||
}
|
||||
}
|
||||
result := make([]epistemics.ConsolidationCandidate, 0, len(byProblem))
|
||||
for problem, grouped := range byProblem {
|
||||
refs := make([]epistemics.ExperienceRecordID, 0, len(grouped))
|
||||
for _, record := range grouped {
|
||||
refs = append(refs, record.ID)
|
||||
}
|
||||
result = append(result, epistemics.ConsolidationCandidate{EpisodeRef: "derived:experience:" + problem, ExperienceRefs: refs, PropositionText: problem, SkillCandidate: len(grouped) > 1})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func retrievalTokens(value string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { return r < '0' || r > 'z' && (r < 'а' || r > 'я') })
|
||||
}
|
||||
func overlap(left, right []string) int {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range left {
|
||||
seen[v] = true
|
||||
}
|
||||
n := 0
|
||||
for _, v := range right {
|
||||
if seen[v] {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRetrieveExperienceFiltersScopeBeforeRelevanceAndKeepsFailures(t *testing.T) {
|
||||
records := []epistemics.ExperienceRecord{
|
||||
{ID: "success-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 2},
|
||||
{ID: "failed-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeFailed, OutcomeDetail: "old connector failed", RecordedAtUnixMillis: 1},
|
||||
{ID: "success-b", PrincipalID: "p", ContextScopeRef: "company-b", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 3},
|
||||
}
|
||||
got := RetrieveExperience(records, "p", "company-a", "sync catalog", 4)
|
||||
if len(got.Positive) != 1 || got.Positive[0].ID != "success-a" || len(got.Negative) != 1 || got.Negative[0].ID != "failed-a" {
|
||||
t.Fatalf("retrieval=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolidationPreservesEvidenceWithoutSkillActivation(t *testing.T) {
|
||||
got := BuildConsolidationCandidates([]epistemics.ExperienceRecord{{ID: "a", ProblemStatement: "sync catalog"}, {ID: "b", ProblemStatement: "sync catalog"}})
|
||||
if len(got) != 1 || len(got[0].ExperienceRefs) != 2 || !got[0].SkillCandidate {
|
||||
t.Fatalf("candidates=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSalienceIsComputedAndDoesNotPersistImportance(t *testing.T) {
|
||||
current := ComputeSalience(SalienceInput{Relevance: 1, FocusMatch: true, Freshness: epistemics.FreshnessAssessmentCurrent, ContextCostBytes: 32})
|
||||
stale := ComputeSalience(SalienceInput{Relevance: 1, Freshness: epistemics.FreshnessAssessmentStale, ContextCostBytes: 32})
|
||||
if current <= stale {
|
||||
t.Fatalf("current salience=%d stale salience=%d", current, stale)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,27 @@ import "git.cin.su/m/Assistant/contracts/epistemics"
|
||||
// AssessFreshness receives the Work-owned requirement as primitive policy
|
||||
// inputs, keeping the Epistemics owner independent of Work package types.
|
||||
func AssessFreshness(applicability epistemics.TemporalApplicability, mode string, maximumAgeMillis *int64, nowMillis int64) epistemics.FreshnessAssessment {
|
||||
age := nowMillis - applicability.ObservedAtUnixMillis
|
||||
return AssessFreshnessForRequirement(applicability, epistemics.FreshnessRequirement{Mode: mode, MaximumAgeMillis: maximumAgeMillis}, nowMillis)
|
||||
}
|
||||
|
||||
// AssessFreshnessForRequirement evaluates evidence for the task at hand. It
|
||||
// never writes a permanent stale marker: the same evidence may be current for
|
||||
// one task and historical-only for another.
|
||||
func AssessFreshnessForRequirement(applicability epistemics.TemporalApplicability, requirement epistemics.FreshnessRequirement, nowMillis int64) epistemics.FreshnessAssessment {
|
||||
basis := applicability.ObservedAtUnixMillis
|
||||
if applicability.LastVerifiedAtUnixMillis != nil && *applicability.LastVerifiedAtUnixMillis > basis {
|
||||
basis = *applicability.LastVerifiedAtUnixMillis
|
||||
}
|
||||
age := nowMillis - basis
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
state := epistemics.FreshnessAssessmentCurrent
|
||||
reason := "observation is within applicability window"
|
||||
reason := "evidence is within task applicability window"
|
||||
versionState := "NOT_REQUIRED"
|
||||
if applicability.ValidFromUnixMillis != nil && nowMillis < *applicability.ValidFromUnixMillis {
|
||||
state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "valid-from has not started"
|
||||
}
|
||||
if applicability.ValidUntilUnixMillis != nil && nowMillis > *applicability.ValidUntilUnixMillis {
|
||||
state, reason = epistemics.FreshnessAssessmentStale, "valid-until elapsed"
|
||||
}
|
||||
@@ -20,11 +35,38 @@ func AssessFreshness(applicability epistemics.TemporalApplicability, mode string
|
||||
if applicability.Volatility.RevalidateAfterMillis != nil && age > *applicability.Volatility.RevalidateAfterMillis && state == epistemics.FreshnessAssessmentCurrent {
|
||||
state, reason = epistemics.FreshnessAssessmentAging, "revalidation window elapsed"
|
||||
}
|
||||
if maximumAgeMillis != nil && age > *maximumAgeMillis {
|
||||
if requirement.MaximumAgeMillis != nil && age > *requirement.MaximumAgeMillis {
|
||||
state, reason = epistemics.FreshnessAssessmentStale, "task maximum age exceeded"
|
||||
}
|
||||
if mode == "LIVE_REQUIRED" {
|
||||
if len(requirement.RequiredVersionRefs) > 0 {
|
||||
versionState = versionMatchState(applicability, requirement.RequiredVersionRefs)
|
||||
if versionState == "MISMATCH" {
|
||||
state, reason = epistemics.FreshnessAssessmentVersionMismatch, "task version does not match evidence applicability"
|
||||
} else if versionState == "UNKNOWN" && state == epistemics.FreshnessAssessmentCurrent {
|
||||
state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "task version applicability is unknown"
|
||||
}
|
||||
}
|
||||
if requirement.Mode == "LIVE_REQUIRED" {
|
||||
state, reason = epistemics.FreshnessAssessmentHistoricalOnly, "live source required"
|
||||
}
|
||||
return epistemics.FreshnessAssessment{SourceKnowledgeRef: "knowledge", TaskRef: "task", State: state, Reason: reason, AgeMillis: age, AssessedAtUnixMillis: nowMillis}
|
||||
return epistemics.FreshnessAssessment{SourceKnowledgeRef: "knowledge", TaskRef: "task", State: state, Reason: reason, AgeMillis: age, VersionMatchState: versionState, AssessedAtUnixMillis: nowMillis}
|
||||
}
|
||||
|
||||
func versionMatchState(applicability epistemics.TemporalApplicability, required []epistemics.SemanticReference) string {
|
||||
if len(applicability.VersionBindings) == 0 {
|
||||
return "UNKNOWN"
|
||||
}
|
||||
for _, target := range required {
|
||||
matched := false
|
||||
for _, binding := range applicability.VersionBindings {
|
||||
if binding.VersionRef == target {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return "MISMATCH"
|
||||
}
|
||||
}
|
||||
return "MATCH"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package domain_test
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -15,3 +16,23 @@ func TestFreshnessIsTaskRelative(t *testing.T) {
|
||||
t.Fatalf("got %v and %v", current.State, historical.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshnessUsesVerificationAndVersionForTheCurrentTask(t *testing.T) {
|
||||
verified := int64(90)
|
||||
fast := int64(20)
|
||||
slow := int64(500)
|
||||
fastEvidence := epistemics.TemporalApplicability{ObservedAtUnixMillis: 1, LastVerifiedAtUnixMillis: &verified, Volatility: epistemics.VolatilitySpec{Mode: epistemics.VolatilityCustom, HardStaleAfterMillis: &fast}}
|
||||
server := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "server", StableID: "server-1"}
|
||||
v2 := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "version", StableID: "v2"}
|
||||
v3 := ids.SemanticRef{OwnerNamespace: "world", SemanticKind: "version", StableID: "v3"}
|
||||
slowEvidence := epistemics.WithExactVersionBinding(epistemics.TemporalApplicability{ObservedAtUnixMillis: 1, LastVerifiedAtUnixMillis: &verified, Volatility: epistemics.VolatilitySpec{Mode: epistemics.VolatilityCustom, HardStaleAfterMillis: &slow}}, server, v2)
|
||||
if got := domain.AssessFreshnessForRequirement(fastEvidence, epistemics.FreshnessRequirement{}, 120); got.State != epistemics.FreshnessAssessmentStale {
|
||||
t.Fatalf("fast-changing evidence=%#v", got)
|
||||
}
|
||||
if got := domain.AssessFreshnessForRequirement(slowEvidence, epistemics.FreshnessRequirement{RequiredVersionRefs: []epistemics.SemanticReference{v2}}, 120); got.State != epistemics.FreshnessAssessmentCurrent || got.VersionMatchState != "MATCH" {
|
||||
t.Fatalf("slow version-matched evidence=%#v", got)
|
||||
}
|
||||
if got := domain.AssessFreshnessForRequirement(slowEvidence, epistemics.FreshnessRequirement{RequiredVersionRefs: []epistemics.SemanticReference{v3}}, 120); got.State != epistemics.FreshnessAssessmentVersionMismatch {
|
||||
t.Fatalf("version mismatch=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
package domain
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/interaction"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
const (
|
||||
gen2ContextMaxCandidates = 4
|
||||
gen2ContextMaxBytes = 2048
|
||||
)
|
||||
|
||||
type ContextFragment struct {
|
||||
UtteranceID interaction.HumanUtteranceID
|
||||
Text string
|
||||
ScopeRef string
|
||||
Relevance int
|
||||
Historical bool
|
||||
}
|
||||
|
||||
type ContextProjection struct {
|
||||
Text string
|
||||
Source string
|
||||
UsedPersonalMemoryFallback bool
|
||||
Fragments []ContextFragment
|
||||
Historical []ContextFragment
|
||||
}
|
||||
|
||||
// BuildGen2Context is a read-side projection over canonical utterance history.
|
||||
@@ -19,6 +39,14 @@ func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
|
||||
// filter prevents accidental cross-principal projection when a mixed source
|
||||
// is passed during degraded operation.
|
||||
func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection {
|
||||
return BuildScopedGen2ContextForQuery(history, nil, principalID, "", "")
|
||||
}
|
||||
|
||||
// BuildScopedGen2ContextForQuery projects bounded, explicit human assertions
|
||||
// from the canonical Interaction history. Principal and typed project refs are
|
||||
// hard filters before relevance ranking. Older values remain lineage in
|
||||
// Historical and are never represented as the current value.
|
||||
func BuildScopedGen2ContextForQuery(history []interaction.HumanUtterance, references []interaction.ProjectReference, principalID, focusProjectID, query string) ContextProjection {
|
||||
if principalID != "" {
|
||||
scoped := make([]interaction.HumanUtterance, 0, len(history))
|
||||
for _, item := range history {
|
||||
@@ -28,9 +56,197 @@ func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID st
|
||||
}
|
||||
history = scoped
|
||||
}
|
||||
projectRefs := make(map[interaction.HumanUtteranceID][]interaction.ProjectReference, len(references))
|
||||
for _, ref := range references {
|
||||
projectRefs[ref.UtteranceID] = append(projectRefs[ref.UtteranceID], ref)
|
||||
}
|
||||
assertions := explicitAssertionCandidates(history, projectRefs, focusProjectID, query)
|
||||
if len(assertions) > 0 {
|
||||
current, historical := selectCurrentAssertions(assertions)
|
||||
return contextProjectionFromAssertions(current, historical)
|
||||
}
|
||||
text := CompilePersonalMemory(history).ContextText()
|
||||
if text != "" {
|
||||
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
|
||||
}
|
||||
return ContextProjection{Text: text, Source: "personal-memory-v1-fallback", UsedPersonalMemoryFallback: true}
|
||||
}
|
||||
|
||||
type assertionCandidate struct {
|
||||
fragment ContextFragment
|
||||
subject string
|
||||
observed int64
|
||||
corrects interaction.HumanUtteranceID
|
||||
}
|
||||
|
||||
func explicitAssertionCandidates(history []interaction.HumanUtterance, projectRefs map[interaction.HumanUtteranceID][]interaction.ProjectReference, focusProjectID, query string) []assertionCandidate {
|
||||
queryTokens := significantContextTokens(query)
|
||||
result := make([]assertionCandidate, 0)
|
||||
for _, utterance := range history {
|
||||
subject, value, ok := explicitAssertion(utterance.Content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
refs := projectRefs[utterance.ID]
|
||||
scopeRef := ""
|
||||
if focusProjectID != "" {
|
||||
matched := false
|
||||
for _, ref := range refs {
|
||||
if string(ref.ProjectID) == focusProjectID {
|
||||
matched = true
|
||||
scopeRef = focusProjectID
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
} else if len(refs) == 1 {
|
||||
scopeRef = string(refs[0].ProjectID)
|
||||
}
|
||||
relevance := contextRelevance(subject, queryTokens)
|
||||
if len(queryTokens) > 0 && relevance == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, assertionCandidate{
|
||||
fragment: ContextFragment{UtteranceID: utterance.ID, Text: subject + " = " + value, ScopeRef: scopeRef, Relevance: relevance},
|
||||
subject: normalizedContextSubject(subject),
|
||||
observed: utterance.ReceivedAtUnixMillis,
|
||||
corrects: utterance.CorrectsUtteranceID,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func selectCurrentAssertions(candidates []assertionCandidate) ([]ContextFragment, []ContextFragment) {
|
||||
latest := make(map[string]assertionCandidate)
|
||||
for _, candidate := range candidates {
|
||||
current, exists := latest[candidate.subject]
|
||||
if !exists || supersedesAssertion(candidate, current) {
|
||||
latest[candidate.subject] = candidate
|
||||
}
|
||||
}
|
||||
current := make([]ContextFragment, 0, len(latest))
|
||||
historical := make([]ContextFragment, 0, len(candidates)-len(latest))
|
||||
for _, candidate := range candidates {
|
||||
selected := latest[candidate.subject]
|
||||
if candidate.fragment.UtteranceID == selected.fragment.UtteranceID {
|
||||
current = append(current, candidate.fragment)
|
||||
continue
|
||||
}
|
||||
candidate.fragment.Historical = true
|
||||
historical = append(historical, candidate.fragment)
|
||||
}
|
||||
sort.SliceStable(current, func(i, j int) bool {
|
||||
if current[i].Relevance != current[j].Relevance {
|
||||
return current[i].Relevance > current[j].Relevance
|
||||
}
|
||||
return current[i].UtteranceID > current[j].UtteranceID
|
||||
})
|
||||
return current, historical
|
||||
}
|
||||
|
||||
// supersedesAssertion first respects an explicit correction relation, then
|
||||
// uses observation time only as a deterministic tie-breaker. Applicability is
|
||||
// already established by principal and typed Scope filtering before this step.
|
||||
func supersedesAssertion(candidate, current assertionCandidate) bool {
|
||||
if candidate.corrects == current.fragment.UtteranceID {
|
||||
return true
|
||||
}
|
||||
if current.corrects == candidate.fragment.UtteranceID {
|
||||
return false
|
||||
}
|
||||
return candidate.observed > current.observed || candidate.observed == current.observed && candidate.fragment.UtteranceID > current.fragment.UtteranceID
|
||||
}
|
||||
|
||||
func contextProjectionFromAssertions(current, historical []ContextFragment) ContextProjection {
|
||||
selected := make([]ContextFragment, 0, len(current))
|
||||
text := make([]string, 0, len(current))
|
||||
usedBytes := 0
|
||||
for _, candidate := range current {
|
||||
if len(selected) == gen2ContextMaxCandidates {
|
||||
break
|
||||
}
|
||||
bytes := len([]byte(candidate.Text))
|
||||
if bytes == 0 || usedBytes+bytes > gen2ContextMaxBytes {
|
||||
continue
|
||||
}
|
||||
usedBytes += bytes
|
||||
selected = append(selected, candidate)
|
||||
text = append(text, candidate.Text)
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return ContextProjection{Source: "retrieval:interaction-assertions", Historical: historical}
|
||||
}
|
||||
return ContextProjection{Text: strings.Join(text, "\n"), Source: "retrieval:interaction-assertions", Fragments: selected, Historical: historical}
|
||||
}
|
||||
|
||||
func explicitAssertion(message string) (string, string, bool) {
|
||||
text := strings.TrimSpace(StripAssistantVocative(message))
|
||||
if text == "" || strings.Contains(text, "?") {
|
||||
return "", "", false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if index := strings.Index(lower, "запомни:"); index >= 0 {
|
||||
text = strings.TrimSpace(text[index+len("запомни:"):])
|
||||
lower = strings.ToLower(text)
|
||||
}
|
||||
for _, prefix := range []string{"запомни:", "запомни", "помни:"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
text = strings.TrimSpace(text[len(prefix):])
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, separator := range []string{"=", "—"} {
|
||||
subject, value, found := strings.Cut(text, separator)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
subject, value = strings.TrimSpace(subject), strings.TrimSpace(value)
|
||||
if subject != "" && value != "" && len([]rune(subject)) <= 160 && len([]rune(value)) <= 512 {
|
||||
return subject, value, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func normalizedContextSubject(subject string) string {
|
||||
return strings.Join(significantContextTokens(subject), " ")
|
||||
}
|
||||
|
||||
func significantContextTokens(value string) []string {
|
||||
ignored := map[string]struct{}{"какой": {}, "какая": {}, "какое": {}, "какие": {}, "текущий": {}, "текущая": {}, "текущее": {}, "я": {}, "ты": {}, "назвал": {}, "назвала": {}, "сообщил": {}, "сообщила": {}}
|
||||
result := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, token := range semanticTokens(value) {
|
||||
if len([]rune(token)) < 3 {
|
||||
continue
|
||||
}
|
||||
if _, skip := ignored[token]; skip {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[token]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
result = append(result, token)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func contextRelevance(subject string, queryTokens []string) int {
|
||||
if len(queryTokens) == 0 {
|
||||
return 1
|
||||
}
|
||||
subjectTokens := significantContextTokens(subject)
|
||||
matched := 0
|
||||
for _, query := range queryTokens {
|
||||
for _, token := range subjectTokens {
|
||||
if query == token {
|
||||
matched++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
@@ -32,7 +32,73 @@ func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
|
||||
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
|
||||
}, "principal-1")
|
||||
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
|
||||
if got.Source != "retrieval:interaction-assertions" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет = синий") {
|
||||
t.Fatalf("remembered assertion was not projected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextSelectsArbitraryRelevantCurrentAssertion(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{ID: "assertion", PrincipalID: "principal-1", Content: "контрольный код = ORBIT-731", ReceivedAtUnixMillis: 1},
|
||||
{ID: "turn-1", PrincipalID: "principal-1", Content: "Спасибо", ReceivedAtUnixMillis: 2},
|
||||
{ID: "turn-2", PrincipalID: "principal-1", Content: "Продолжим", ReceivedAtUnixMillis: 3},
|
||||
}
|
||||
got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код я назвал?")
|
||||
if got.Source != "retrieval:interaction-assertions" || !strings.Contains(got.Text, "ORBIT-731") || len(got.Fragments) != 1 {
|
||||
t.Fatalf("arbitrary assertion was not selected: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextKeepsHistoricalAssertionOutOfCurrentProjection(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{ID: "old", PrincipalID: "principal-1", Content: "контрольный код = OLD-111", ReceivedAtUnixMillis: 1},
|
||||
{ID: "new", PrincipalID: "principal-1", Content: "контрольный код = NEW-222", ReceivedAtUnixMillis: 2},
|
||||
}
|
||||
got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?")
|
||||
if strings.Contains(got.Text, "OLD-111") || !strings.Contains(got.Text, "NEW-222") || len(got.Historical) != 1 || !strings.Contains(got.Historical[0].Text, "OLD-111") {
|
||||
t.Fatalf("current/historical assertion separation failed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextCorrectionSupersedesBeforeTimestampTieBreaker(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{ID: "observed-later", PrincipalID: "principal-1", Content: "контрольный код = LATER", ReceivedAtUnixMillis: 9},
|
||||
{ID: "original", PrincipalID: "principal-1", Content: "контрольный код = ORIGINAL", ReceivedAtUnixMillis: 1},
|
||||
{ID: "correction", PrincipalID: "principal-1", Content: "контрольный код = CORRECTED", ReceivedAtUnixMillis: 2, CorrectsUtteranceID: "observed-later"},
|
||||
}
|
||||
got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?")
|
||||
if !strings.Contains(got.Text, "CORRECTED") || strings.Contains(got.Text, "LATER") {
|
||||
t.Fatalf("explicit correction did not supersede: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextHardFiltersProjectReferencesBeforeRanking(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{ID: "a", PrincipalID: "principal-1", Content: "контрольный код = ALPHA", ReceivedAtUnixMillis: 1},
|
||||
{ID: "b", PrincipalID: "principal-1", Content: "контрольный код = BETA", ReceivedAtUnixMillis: 2},
|
||||
}
|
||||
references := []interaction.ProjectReference{
|
||||
{ID: "ref-a", UtteranceID: "a", ProjectID: "company-a"},
|
||||
{ID: "ref-b", UtteranceID: "b", ProjectID: "company-b"},
|
||||
}
|
||||
for projectID, expected := range map[string]struct{ want, absent string }{
|
||||
"company-a": {want: "ALPHA", absent: "BETA"},
|
||||
"company-b": {want: "BETA", absent: "ALPHA"},
|
||||
} {
|
||||
got := domain.BuildScopedGen2ContextForQuery(history, references, "principal-1", projectID, "Какой контрольный код?")
|
||||
if !strings.Contains(got.Text, expected.want) || strings.Contains(got.Text, expected.absent) || len(got.Fragments) != 1 || got.Fragments[0].ScopeRef != projectID {
|
||||
t.Fatalf("scope %s leaked context: %#v", projectID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGen2ContextExcludesIrrelevantAssertionsFromBoundedProjection(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{ID: "code", PrincipalID: "principal-1", Content: "контрольный код = ORBIT-731", ReceivedAtUnixMillis: 1},
|
||||
{ID: "city", PrincipalID: "principal-1", Content: "город встречи = Казань", ReceivedAtUnixMillis: 2},
|
||||
}
|
||||
got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?")
|
||||
if strings.Contains(got.Text, "Казань") || len(got.Fragments) != 1 {
|
||||
t.Fatalf("irrelevant assertion entered bounded projection: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,191 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/self"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
const (
|
||||
MinWeaknessIncidents = 2
|
||||
MaxDiagnosisEvidence = 8
|
||||
MaxCandidateEvidence = 12
|
||||
)
|
||||
|
||||
func ValidateCompetence(v self.CompetenceAssessment) error { return v.Validate() }
|
||||
func ValidateLearning(v self.LearningLedgerEntry) error { return v.Validate() }
|
||||
|
||||
func AttributeFailure(signal self.LearningSignal) self.Diagnosis {
|
||||
class := self.DiagnosisUnknown
|
||||
switch signal.Kind {
|
||||
case self.SignalSkillFallback:
|
||||
class = self.DiagnosisSkillFailure
|
||||
case self.SignalReflexDivergence:
|
||||
class = self.DiagnosisReflexFailure
|
||||
case self.SignalStaleDependency:
|
||||
class = self.DiagnosisProviderFailure
|
||||
case self.SignalRetryFallback:
|
||||
class = self.DiagnosisRoutingInefficiency
|
||||
case self.SignalOutcomeMismatch:
|
||||
class = self.DiagnosisOutcomeVerificationFailure
|
||||
}
|
||||
return self.Diagnosis{Class: class, Status: self.EpistemicHypothesis, Rationale: "classified from bounded signal; independent confirmation required", EvidenceRefs: uniqueRefs(signal.EvidenceRefs, MaxDiagnosisEvidence), ConfidenceMillis: 250}
|
||||
}
|
||||
|
||||
func BuildWeaknessPattern(principal, taskPattern, scopeKey, environment string, incidents []self.LearningSignal, affectedComponent, strategy string) (self.WeaknessPattern, bool, error) {
|
||||
if principal == "" || taskPattern == "" || scopeKey == "" || affectedComponent == "" || len(incidents) < MinWeaknessIncidents {
|
||||
return self.WeaknessPattern{}, false, nil
|
||||
}
|
||||
refs := []self.SemanticReference{}
|
||||
severity, last := 0, int64(0)
|
||||
for _, incident := range incidents {
|
||||
if err := incident.Validate(); err != nil || incident.PrincipalID != principal || incident.TaskPattern != taskPattern || incident.ScopeKey != scopeKey {
|
||||
return self.WeaknessPattern{}, false, fmt.Errorf("weakness incidents are not one bounded context")
|
||||
}
|
||||
refs = append(refs, incident.EvidenceRefs...)
|
||||
if incident.Severity > severity {
|
||||
severity = incident.Severity
|
||||
}
|
||||
if incident.UserImpact > severity {
|
||||
severity = incident.UserImpact
|
||||
}
|
||||
if incident.ObservedAt > last {
|
||||
last = incident.ObservedAt
|
||||
}
|
||||
}
|
||||
return self.WeaknessPattern{ID: self.AssessmentID("weakness:" + principal + ":" + taskPattern + ":" + scopeKey), PrincipalID: principal, Revision: 1, PatternKey: taskPattern + ":" + scopeKey, TaskPattern: taskPattern, ScopeKey: scopeKey, EnvironmentVersion: environment, FailureMode: string(AttributeFailure(incidents[len(incidents)-1]).Class), DetectionEvidenceRefs: uniqueRefs(refs, MaxDiagnosisEvidence), State: self.AssessmentCandidate, RecordedAt: last, Frequency: len(incidents), LastObservedAt: last, Severity: severity, AffectedComponent: affectedComponent, AffectedStrategy: strategy, ConfidenceMillis: int64(min(1000, 250+len(incidents)*150)), EpistemicStatus: self.EpistemicSupported}, true, nil
|
||||
}
|
||||
|
||||
func AssessContextualCompetence(principal string, subject self.SemanticReference, context []string, evidence []self.SemanticReference, quality, reliability, confidence, assessedAt int64) (self.CompetenceAssessment, error) {
|
||||
v := self.CompetenceAssessment{ID: self.AssessmentID("competence:" + principal + ":" + string(subject.StableID)), PrincipalID: principal, SubjectRef: subject, Revision: 1, ContextMatcher: append([]string(nil), context...), QualityMillis: quality, ReliabilityMillis: reliability, ConfidenceMillis: confidence, EvidenceRefs: uniqueRefs(evidence, MaxDiagnosisEvidence), AssessedAt: assessedAt, LastValidatedAt: assessedAt}
|
||||
if err := v.Validate(); err != nil {
|
||||
return self.CompetenceAssessment{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func BuildImprovementNeed(signal self.LearningSignal, diagnosis self.Diagnosis, component string) (self.ImprovementNeed, error) {
|
||||
if err := signal.Validate(); err != nil {
|
||||
return self.ImprovementNeed{}, err
|
||||
}
|
||||
if err := diagnosis.Validate(); err != nil {
|
||||
return self.ImprovementNeed{}, err
|
||||
}
|
||||
if component == "" {
|
||||
return self.ImprovementNeed{}, fmt.Errorf("affected component is required")
|
||||
}
|
||||
priority := signal.Severity*2 + signal.UserImpact + signal.CorrectionCount
|
||||
if priority < 1 {
|
||||
priority = 1
|
||||
}
|
||||
return self.ImprovementNeed{ID: self.AssessmentID("need:" + signal.ID), PrincipalID: signal.PrincipalID, SubjectRef: signal.SubjectRef, ProblemSummary: "bounded evidence indicates an improvement opportunity", EvidenceRefs: diagnosis.EvidenceRefs, ExpectedBenefit: "reduce recurrence without weakening verified behavior", Priority: priority, State: "OPEN", CreatedAt: signal.ObservedAt, Diagnosis: diagnosis.Class, DiagnosisStatus: diagnosis.Status, AffectedComponent: component, ScopeKey: signal.ScopeKey, Risk: "regression in established behavior", Intervention: interventionFor(diagnosis.Class), Hypothesis: diagnosis.Rationale, EvaluationPlan: "independent benchmark and must-not-worsen evaluation", MustNotWorsen: "quality and verified outcome rate", RollbackPlan: "disable candidate and restore last-known-good", ResourceCost: "bounded review"}, nil
|
||||
}
|
||||
|
||||
func BuildCandidate(need self.ImprovementNeed, creator, evaluator string, evidence []self.SemanticReference, at int64) (self.ImprovementCandidate, error) {
|
||||
ref := self.SemanticReference{OwnerNamespace: "self", SemanticKind: "improvement_need", StableID: ids.StableID(need.ID)}
|
||||
c := self.ImprovementCandidate{ID: "candidate:" + string(need.ID), PrincipalID: need.PrincipalID, NeedRef: ref, Hypothesis: need.Hypothesis, ExpectedEffect: need.ExpectedBenefit, EvaluationPlan: need.EvaluationPlan, MustNotWorsen: []string{need.MustNotWorsen}, RollbackPlan: need.RollbackPlan, ResourceCost: need.ResourceCost, Creator: creator, IndependentEvaluator: evaluator, State: self.CandidateProposed, EvidenceRefs: uniqueRefs(evidence, MaxCandidateEvidence), CreatedAt: at}
|
||||
if err := c.Validate(); err != nil {
|
||||
return self.ImprovementCandidate{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func EvaluateCandidate(candidate self.ImprovementCandidate, evaluation self.ImprovementEvaluation) (self.CandidateState, error) {
|
||||
if evaluation.Evaluator == "" || evaluation.Evaluator == candidate.Creator || !evaluation.MustNotWorsenPass || evaluation.ActualQuality < evaluation.ExpectedQuality || (evaluation.ExpectedLatency > 0 && evaluation.ActualLatency > evaluation.ExpectedLatency*2) {
|
||||
return self.CandidateRejected, nil
|
||||
}
|
||||
return self.CandidateKept, nil
|
||||
}
|
||||
|
||||
func ClassifyDecisionRegret(signal self.LearningSignal) self.DecisionRegretKind {
|
||||
if signal.DecisionRegret != "" {
|
||||
return signal.DecisionRegret
|
||||
}
|
||||
switch signal.ActualOutcome {
|
||||
case "information unavailable":
|
||||
return self.RegretUnavailableThen
|
||||
case "available but unused":
|
||||
return self.RegretAvailableUnused
|
||||
case "ignored in context":
|
||||
return self.RegretIgnoredInContext
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildBenchmarkCase(principal, class string, evidence []self.SemanticReference, at int64) (self.BenchmarkCase, error) {
|
||||
c := self.BenchmarkCase{ID: "benchmark:" + principal + ":" + class, PrincipalID: principal, TaskClass: class, EvidenceRefs: uniqueRefs(evidence, MaxCandidateEvidence), RecordedAt: at}
|
||||
if !c.Valid() {
|
||||
return self.BenchmarkCase{}, fmt.Errorf("benchmark requires evidence")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func AppendLedgerOutcome(need self.ImprovementNeed, candidate self.ImprovementCandidate, evaluation self.ImprovementEvaluation, decision self.CandidateState, at int64) (self.LearningLedgerEntry, error) {
|
||||
ref := self.SemanticReference{OwnerNamespace: "self", SemanticKind: "improvement_need", StableID: ids.StableID(need.ID)}
|
||||
refs := append(append([]self.SemanticReference{}, need.EvidenceRefs...), evaluation.EvidenceRefs...)
|
||||
e := self.LearningLedgerEntry{ID: self.LearningLedgerEntryID("ledger:" + candidate.ID + ":" + fmt.Sprint(at)), PrincipalID: need.PrincipalID, ImprovementNeedRef: &ref, ObservedProblem: need.ProblemSummary, EvidenceRefs: uniqueRefs(refs, MaxCandidateEvidence), Diagnosis: string(need.Diagnosis), Hypothesis: need.Hypothesis, ProposedChangeRef: candidate.NeedRef, ExpectedImprovement: need.ExpectedBenefit, ActualEvaluationRefs: evaluation.EvidenceRefs, Decision: string(decision), RecordedAt: at}
|
||||
if err := e.Validate(); err != nil {
|
||||
return self.LearningLedgerEntry{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func ReviewCandidateForNewModel(principal string, model self.SemanticReference, evidence []self.SemanticReference, at int64) (self.ImprovementCandidate, error) {
|
||||
n := self.ImprovementNeed{ID: self.AssessmentID("need:model-review:" + string(model.StableID)), PrincipalID: principal, SubjectRef: model, ProblemSummary: "qualified model is available for governed review", EvidenceRefs: evidence, ExpectedBenefit: "compare model quality under existing safety constraints", Priority: 1, State: "OPEN", CreatedAt: at, Intervention: self.InterventionProviderReview, Hypothesis: "model may improve quality", EvaluationPlan: "independent benchmark", MustNotWorsen: "safety, latency and verified outcomes", RollbackPlan: "retain current route", ResourceCost: "bounded evaluation"}
|
||||
return BuildCandidate(n, "qualification", "independent-evaluator", evidence, at)
|
||||
}
|
||||
|
||||
func interventionFor(c self.DiagnosisClass) self.InterventionClass {
|
||||
switch c {
|
||||
case self.DiagnosisSkillFailure:
|
||||
return self.InterventionSkillReview
|
||||
case self.DiagnosisReflexFailure:
|
||||
return self.InterventionReflexReview
|
||||
case self.DiagnosisProviderFailure:
|
||||
return self.InterventionProviderReview
|
||||
case self.DiagnosisRoutingInefficiency:
|
||||
return self.InterventionRoutingReview
|
||||
case self.DiagnosisContextSelection, self.DiagnosisMemoryNotRetrieved:
|
||||
return self.InterventionContextReview
|
||||
}
|
||||
return self.InterventionConfiguration
|
||||
}
|
||||
func uniqueRefs(refs []self.SemanticReference, max int) []self.SemanticReference {
|
||||
seen := map[string]bool{}
|
||||
out := make([]self.SemanticReference, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
if !r.Valid() || seen[string(r.StableID)] {
|
||||
continue
|
||||
}
|
||||
seen[string(r.StableID)] = true
|
||||
out = append(out, r)
|
||||
if len(out) == max {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return string(out[i].StableID) < string(out[j].StableID) })
|
||||
return out
|
||||
}
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func ImprovementForReflexDivergence(principal string, reflexRef self.SemanticReference, evidence []self.SemanticReference, at int64) (self.ImprovementNeed, self.LearningLedgerEntry, error) {
|
||||
s := self.LearningSignal{ID: "signal:reflex:" + string(reflexRef.StableID), PrincipalID: principal, Kind: self.SignalReflexDivergence, TaskPattern: "reflex", ScopeKey: "default", SubjectRef: reflexRef, EvidenceRefs: evidence, ObservedAt: at, Severity: 1}
|
||||
d := AttributeFailure(s)
|
||||
n, err := BuildImprovementNeed(s, d, "reflex")
|
||||
if err != nil {
|
||||
return self.ImprovementNeed{}, self.LearningLedgerEntry{}, err
|
||||
}
|
||||
ref := self.SemanticReference{OwnerNamespace: "self", SemanticKind: "improvement_need", StableID: ids.StableID(n.ID)}
|
||||
l := self.LearningLedgerEntry{ID: self.LearningLedgerEntryID("ledger:" + string(reflexRef.StableID) + ":" + fmt.Sprint(at)), PrincipalID: principal, ImprovementNeedRef: &ref, ObservedProblem: n.ProblemSummary, EvidenceRefs: evidence, Diagnosis: string(d.Class), Hypothesis: d.Rationale, ProposedChangeRef: reflexRef, ExpectedImprovement: n.ExpectedBenefit, Decision: "PROPOSE_REVALIDATION", RecordedAt: at}
|
||||
if err := l.Validate(); err != nil {
|
||||
return self.ImprovementNeed{}, self.LearningLedgerEntry{}, err
|
||||
}
|
||||
return n, l, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package domain_test
|
||||
import (
|
||||
selfcontract "git.cin.su/m/Assistant/contracts/self"
|
||||
"git.cin.su/m/Assistant/modules/self/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -16,3 +17,83 @@ func TestCompetenceCannotBeSelfDeclaredWithoutEvidence(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowDivergenceCreatesProposalNotPromotion(t *testing.T) {
|
||||
reflex := selfcontract.SemanticReference{OwnerNamespace: "capability", SemanticKind: "reflex", StableID: "r"}
|
||||
evidence := []selfcontract.SemanticReference{{OwnerNamespace: "epistemics", SemanticKind: "experience", StableID: "e"}}
|
||||
need, ledger, err := domain.ImprovementForReflexDivergence("p", reflex, evidence, 1)
|
||||
if err != nil || need.State != "OPEN" || ledger.Decision != "PROPOSE_REVALIDATION" {
|
||||
t.Fatalf("need=%#v ledger=%#v err=%v", need, ledger, err)
|
||||
}
|
||||
}
|
||||
|
||||
func signal(kind selfcontract.LearningSignalKind, id, scope string, at int64) selfcontract.LearningSignal {
|
||||
return selfcontract.LearningSignal{ID: id, PrincipalID: "p", Kind: kind, TaskPattern: "recall", ScopeKey: scope, SubjectRef: selfcontract.SemanticReference{OwnerNamespace: "interaction", SemanticKind: "assertion", StableID: ids.StableID("x")}, EvidenceRefs: []selfcontract.SemanticReference{{OwnerNamespace: "epistemics", SemanticKind: "experience", StableID: ids.StableID(id)}}, ObservedAt: at, Severity: 2, UserImpact: 1}
|
||||
}
|
||||
|
||||
func TestRepeatedSignalsCreateScopedWeaknessButSingleDoesNot(t *testing.T) {
|
||||
a, b := signal(selfcontract.SignalUserCorrection, "e1", "company-a", 1), signal(selfcontract.SignalUserCorrection, "e2", "company-a", 2)
|
||||
if _, created, err := domain.BuildWeaknessPattern("p", "recall", "company-a", "v1", []selfcontract.LearningSignal{a}, "context", "strategy"); err != nil || created {
|
||||
t.Fatalf("single incident created weakness: %v %v", created, err)
|
||||
}
|
||||
w, created, err := domain.BuildWeaknessPattern("p", "recall", "company-a", "v1", []selfcontract.LearningSignal{a, b}, "context", "strategy")
|
||||
if err != nil || !created || w.Frequency != 2 || w.ScopeKey != "company-a" {
|
||||
t.Fatalf("weakness=%#v created=%v err=%v", w, created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisCandidateAndMustNotWorsen(t *testing.T) {
|
||||
s := signal(selfcontract.SignalReflexDivergence, "e3", "company-a", 3)
|
||||
d := domain.AttributeFailure(s)
|
||||
if d.Class != selfcontract.DiagnosisReflexFailure || d.Status != selfcontract.EpistemicHypothesis {
|
||||
t.Fatalf("diagnosis=%#v", d)
|
||||
}
|
||||
n, err := domain.BuildImprovementNeed(s, d, "reflex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := domain.BuildCandidate(n, "creator", "evaluator", s.EvidenceRefs, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decision, err := domain.EvaluateCandidate(c, selfcontract.ImprovementEvaluation{Evaluator: "evaluator", EvidenceRefs: s.EvidenceRefs, ExpectedQuality: 800, ActualQuality: 800, ExpectedLatency: 100, ActualLatency: 250, MustNotWorsenPass: true})
|
||||
if err != nil || decision != selfcontract.CandidateRejected {
|
||||
t.Fatalf("decision=%v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedEvidenceIsDeduplicatedAndBounded(t *testing.T) {
|
||||
s := signal(selfcontract.SignalSkillFallback, "e4", "company-a", 4)
|
||||
d := domain.AttributeFailure(s)
|
||||
n, err := domain.BuildImprovementNeed(s, d, "skill")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = domain.BuildCandidate(n, "creator", "evaluator", append(s.EvidenceRefs, s.EvidenceRefs...), 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := domain.ClassifyDecisionRegret(selfcontract.LearningSignal{DecisionRegret: selfcontract.RegretIgnoredInContext}); got != selfcontract.RegretIgnoredInContext {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkAndLedgerReferenceEvidence(t *testing.T) {
|
||||
s := signal(selfcontract.SignalOutcomeMismatch, "e5", "company-a", 5)
|
||||
caseRef, err := domain.BuildBenchmarkCase("p", "memory-recall", s.EvidenceRefs, 5)
|
||||
if err != nil || !caseRef.Valid() {
|
||||
t.Fatalf("benchmark=%#v err=%v", caseRef, err)
|
||||
}
|
||||
d := domain.AttributeFailure(s)
|
||||
n, err := domain.BuildImprovementNeed(s, d, "verification")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := domain.BuildCandidate(n, "creator", "evaluator", s.EvidenceRefs, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := domain.AppendLedgerOutcome(n, c, selfcontract.ImprovementEvaluation{Evaluator: "evaluator", EvidenceRefs: s.EvidenceRefs}, selfcontract.CandidateRejected, 6)
|
||||
if err != nil || l.Decision != string(selfcontract.CandidateRejected) || len(l.EvidenceRefs) == 0 {
|
||||
t.Fatalf("ledger=%#v err=%v", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
ControlPlaneServiceTokenID = "control_plane_service_token"
|
||||
MachineCredentialOwner = "platform.secrets.local-control-plane"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedMachineCredential = errors.New("unsupported machine credential")
|
||||
ErrCredentialArtifactUnsafe = errors.New("machine credential artifact is unsafe")
|
||||
)
|
||||
|
||||
// MachineCredentialKind describes technical credential material. It conveys no
|
||||
// authority and no secret value.
|
||||
type MachineCredentialKind string
|
||||
|
||||
const (
|
||||
MachineCredentialServiceToken MachineCredentialKind = "SERVICE_TO_SERVICE_TOKEN"
|
||||
MachineCredentialDatabaseRole MachineCredentialKind = "DATABASE_ROLE_PASSWORD"
|
||||
MachineCredentialInternalAPI MachineCredentialKind = "INTERNAL_API_CREDENTIAL"
|
||||
)
|
||||
|
||||
type MachineCredentialStatus string
|
||||
|
||||
const (
|
||||
MachineCredentialActive MachineCredentialStatus = "ACTIVE"
|
||||
MachineCredentialRotating MachineCredentialStatus = "ROTATING"
|
||||
MachineCredentialCompromised MachineCredentialStatus = "COMPROMISED"
|
||||
MachineCredentialRevoked MachineCredentialStatus = "REVOKED"
|
||||
)
|
||||
|
||||
type MachineCredentialSpec struct {
|
||||
ID string
|
||||
Kind MachineCredentialKind
|
||||
Purpose secretcontracts.SecretPurpose
|
||||
Consumers []secretcontracts.RuntimeConsumer
|
||||
Bytes int
|
||||
}
|
||||
|
||||
type MachineCredentialMetadata struct {
|
||||
SecretID string `json:"secret_id"`
|
||||
Kind MachineCredentialKind `json:"kind"`
|
||||
Purpose secretcontracts.SecretPurpose `json:"purpose"`
|
||||
Owner string `json:"owner"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RotatedAt time.Time `json:"rotated_at,omitempty"`
|
||||
Consumers []secretcontracts.RuntimeConsumer `json:"consumers"`
|
||||
Status MachineCredentialStatus `json:"status"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
GenerationMethod string `json:"generation_method"`
|
||||
DataPolicy MachineCredentialDataPolicy `json:"data_policy"`
|
||||
}
|
||||
|
||||
// MachineCredentialDataPolicy is explicit technical handling policy. It is
|
||||
// metadata only; it never contains or resolves a credential value.
|
||||
type MachineCredentialDataPolicy struct {
|
||||
Indexable bool `json:"indexable"`
|
||||
Embeddable bool `json:"embeddable"`
|
||||
Retrievable bool `json:"retrievable"`
|
||||
ModelVisible bool `json:"model_visible"`
|
||||
}
|
||||
|
||||
type MachineCredentialAudit struct {
|
||||
SecretID string `json:"secret_id"`
|
||||
Version int `json:"version"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type MachineCredentialResult struct {
|
||||
Metadata MachineCredentialMetadata
|
||||
Created bool
|
||||
Rotated bool
|
||||
}
|
||||
|
||||
// LocalMachineCredentialStore is the one writer for a dedicated protected
|
||||
// volume. The value and metadata always have separate files.
|
||||
type LocalMachineCredentialStore struct {
|
||||
root string
|
||||
metadata string
|
||||
runtimeUID int
|
||||
runtimeGID int
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
}
|
||||
|
||||
func NewLocalMachineCredentialStore(root string, runtimeUID, runtimeGID int, now func() time.Time) (*LocalMachineCredentialStore, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("machine credential root: %w", ErrPathOutsideRoot)
|
||||
}
|
||||
clean, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("machine credential root: %w", err)
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &LocalMachineCredentialStore{root: clean, metadata: filepath.Join(clean, ".metadata"), runtimeUID: runtimeUID, runtimeGID: runtimeGID, now: now, random: rand.Reader}, nil
|
||||
}
|
||||
|
||||
func ControlPlaneServiceTokenSpec() MachineCredentialSpec {
|
||||
return MachineCredentialSpec{
|
||||
ID: ControlPlaneServiceTokenID,
|
||||
Kind: MachineCredentialServiceToken,
|
||||
Purpose: secretcontracts.SecretPurposeServiceAuthentication,
|
||||
Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore, secretcontracts.RuntimeConsumerCommit},
|
||||
Bytes: 48,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) Ensure(spec MachineCredentialSpec) (MachineCredentialResult, error) {
|
||||
if err := validateMachineCredentialSpec(spec); err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
if err := s.prepareDirectories(); err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
if info, err := os.Lstat(s.artifactPath(spec)); err == nil {
|
||||
if !safeArtifact(info) {
|
||||
return MachineCredentialResult{}, ErrCredentialArtifactUnsafe
|
||||
}
|
||||
metadata, err := s.ensureMetadata(spec, false, "")
|
||||
return MachineCredentialResult{Metadata: metadata}, err
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
metadata, err := s.createVersion(spec, 1, "CREATED", "")
|
||||
if err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
return MachineCredentialResult{Metadata: metadata, Created: true}, nil
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) Rotate(spec MachineCredentialSpec, reason string) (MachineCredentialResult, error) {
|
||||
if err := validateMachineCredentialSpec(spec); err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
if err := s.prepareDirectories(); err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
current, err := s.readMetadata(spec)
|
||||
if err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
if current.Status != MachineCredentialActive {
|
||||
return MachineCredentialResult{}, fmt.Errorf("machine credential status %s cannot rotate", current.Status)
|
||||
}
|
||||
rotating := current
|
||||
rotating.Status = MachineCredentialRotating
|
||||
if err := s.writeMetadata(spec, rotating); err != nil {
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: current.Version, Action: "ROTATION_STARTED", Reason: strings.TrimSpace(reason), At: s.now().UTC()}); err != nil {
|
||||
_ = s.writeMetadata(spec, current)
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
metadata, err := s.createVersion(spec, current.Version+1, "ROTATED", strings.TrimSpace(reason))
|
||||
if err != nil {
|
||||
_ = s.writeMetadata(spec, current)
|
||||
_ = s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: current.Version, Action: "ROTATION_FAILED", At: s.now().UTC()})
|
||||
return MachineCredentialResult{}, err
|
||||
}
|
||||
return MachineCredentialResult{Metadata: metadata, Rotated: true}, nil
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) createVersion(spec MachineCredentialSpec, version int, action, reason string) (MachineCredentialMetadata, error) {
|
||||
value, err := randomCredential(s.random, spec.Bytes)
|
||||
if err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
if err := s.atomicArtifact(spec, value); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
metadata := MachineCredentialMetadata{SecretID: spec.ID, Kind: spec.Kind, Purpose: spec.Purpose, Owner: MachineCredentialOwner, Version: version, CreatedAt: now, Consumers: append([]secretcontracts.RuntimeConsumer(nil), spec.Consumers...), Status: MachineCredentialActive, Algorithm: "CSPRNG", GenerationMethod: "crypto/rand + base64url", DataPolicy: MachineCredentialDataPolicy{Indexable: false, Embeddable: false, Retrievable: false, ModelVisible: false}}
|
||||
if version > 1 {
|
||||
metadata.RotatedAt = now
|
||||
}
|
||||
if err := s.writeMetadata(spec, metadata); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: version, Action: action, Reason: reason, At: now}); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) ensureMetadata(spec MachineCredentialSpec, rotated bool, reason string) (MachineCredentialMetadata, error) {
|
||||
metadata, err := s.readMetadata(spec)
|
||||
if err == nil {
|
||||
return metadata, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
metadata = MachineCredentialMetadata{SecretID: spec.ID, Kind: spec.Kind, Purpose: spec.Purpose, Owner: MachineCredentialOwner, Version: 1, CreatedAt: now, Consumers: append([]secretcontracts.RuntimeConsumer(nil), spec.Consumers...), Status: MachineCredentialActive, Algorithm: "CSPRNG", GenerationMethod: "preexisting protected artifact", DataPolicy: MachineCredentialDataPolicy{Indexable: false, Embeddable: false, Retrievable: false, ModelVisible: false}}
|
||||
if rotated {
|
||||
metadata.RotatedAt = now
|
||||
}
|
||||
if err := s.writeMetadata(spec, metadata); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
if err := s.appendAudit(MachineCredentialAudit{SecretID: spec.ID, Version: metadata.Version, Action: "ADOPTED", Reason: reason, At: now}); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) prepareDirectories() error {
|
||||
if err := os.MkdirAll(s.root, 0o710); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(s.root, 0o710); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.runtimeGID >= 0 && os.Geteuid() == 0 {
|
||||
if err := os.Chown(s.root, 0, s.runtimeGID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(s.metadata, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(s.metadata, 0o700)
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) atomicArtifact(spec MachineCredentialSpec, value string) error {
|
||||
temp, err := os.CreateTemp(s.root, ".credential-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
if err := temp.Chmod(0o400); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if s.runtimeUID >= 0 && s.runtimeGID >= 0 && os.Geteuid() == 0 {
|
||||
if err := temp.Chown(s.runtimeUID, s.runtimeGID); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := temp.WriteString(value + "\n"); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
target := s.artifactPath(spec)
|
||||
// Windows cannot replace a read-only file. On Linux the owning runtime can
|
||||
// still read this temporary mode; the final replacement remains atomic.
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
if err := os.Chmod(target, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempPath, target)
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) readMetadata(spec MachineCredentialSpec) (MachineCredentialMetadata, error) {
|
||||
contents, err := os.ReadFile(s.metadataPath(spec))
|
||||
if err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
var metadata MachineCredentialMetadata
|
||||
if err := json.Unmarshal(contents, &metadata); err != nil {
|
||||
return MachineCredentialMetadata{}, err
|
||||
}
|
||||
if metadata.SecretID != spec.ID || metadata.Version < 1 || metadata.Purpose != spec.Purpose || metadata.Owner != MachineCredentialOwner {
|
||||
return MachineCredentialMetadata{}, ErrCredentialArtifactUnsafe
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) writeMetadata(spec MachineCredentialSpec, metadata MachineCredentialMetadata) error {
|
||||
contents, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicPrivateFile(s.metadataPath(spec), contents)
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) appendAudit(audit MachineCredentialAudit) error {
|
||||
contents, err := json.Marshal(audit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(filepath.Join(s.metadata, audit.SecretID+".audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(contents, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func (s *LocalMachineCredentialStore) artifactPath(spec MachineCredentialSpec) string {
|
||||
return filepath.Join(s.root, spec.ID)
|
||||
}
|
||||
func (s *LocalMachineCredentialStore) metadataPath(spec MachineCredentialSpec) string {
|
||||
return filepath.Join(s.metadata, spec.ID+".json")
|
||||
}
|
||||
|
||||
func randomCredential(source io.Reader, bytes int) (string, error) {
|
||||
if bytes < 32 {
|
||||
return "", fmt.Errorf("machine credential entropy must be at least 32 bytes")
|
||||
}
|
||||
value := make([]byte, bytes)
|
||||
if _, err := io.ReadFull(source, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func validateMachineCredentialSpec(spec MachineCredentialSpec) error {
|
||||
if strings.TrimSpace(spec.ID) == "" || strings.Contains(spec.ID, "/") || spec.Bytes < 32 || len(spec.Consumers) == 0 {
|
||||
return ErrUnsupportedMachineCredential
|
||||
}
|
||||
switch spec.Kind {
|
||||
case MachineCredentialServiceToken:
|
||||
if spec.Purpose != secretcontracts.SecretPurposeServiceAuthentication {
|
||||
return ErrUnsupportedMachineCredential
|
||||
}
|
||||
case MachineCredentialDatabaseRole:
|
||||
if spec.Purpose != secretcontracts.SecretPurposeDatabasePassword {
|
||||
return ErrUnsupportedMachineCredential
|
||||
}
|
||||
case MachineCredentialInternalAPI:
|
||||
if spec.Purpose != secretcontracts.SecretPurposeServiceAuthentication && spec.Purpose != secretcontracts.SecretPurposeProviderAPIKey {
|
||||
return ErrUnsupportedMachineCredential
|
||||
}
|
||||
default:
|
||||
return ErrUnsupportedMachineCredential
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeArtifact(info os.FileInfo) bool {
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 {
|
||||
return false
|
||||
}
|
||||
// Windows does not preserve POSIX permissions on a temporary test volume.
|
||||
return runtime.GOOS == "windows" || info.Mode().Perm() == 0o400
|
||||
}
|
||||
|
||||
func atomicPrivateFile(path string, contents []byte) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), ".metadata-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
defer os.Remove(tempPath)
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(contents); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
)
|
||||
|
||||
func TestMachineCredentialEnsureIsIdempotentAndKeepsValueOutOfMetadata(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
store, err := NewLocalMachineCredentialStore(t.TempDir(), -1, -1, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := store.Ensure(ControlPlaneServiceTokenSpec())
|
||||
if err != nil || !first.Created || first.Metadata.Version != 1 {
|
||||
t.Fatalf("first provision = %#v, %v", first, err)
|
||||
}
|
||||
second, err := store.Ensure(ControlPlaneServiceTokenSpec())
|
||||
if err != nil || second.Created || second.Metadata.Version != 1 {
|
||||
t.Fatalf("second provision = %#v, %v", second, err)
|
||||
}
|
||||
metadata, err := os.ReadFile(filepath.Join(store.metadata, ControlPlaneServiceTokenID+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := os.ReadFile(filepath.Join(store.root, ControlPlaneServiceTokenID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(metadata), strings.TrimSpace(string(artifact))) {
|
||||
t.Fatal("metadata unexpectedly contains a credential value")
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(store.root, ControlPlaneServiceTokenID))
|
||||
if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o400) {
|
||||
t.Fatalf("artifact mode = %v, %v", info.Mode(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineCredentialSpecSupportsMachineCredentialKinds(t *testing.T) {
|
||||
cases := []MachineCredentialSpec{
|
||||
ControlPlaneServiceTokenSpec(),
|
||||
{ID: "database_role_password", Kind: MachineCredentialDatabaseRole, Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore}, Bytes: 32},
|
||||
{ID: "internal_api_credential", Kind: MachineCredentialInternalAPI, Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCommit}, Bytes: 32},
|
||||
}
|
||||
for _, value := range cases {
|
||||
if err := validateMachineCredentialSpec(value); err != nil {
|
||||
t.Fatalf("credential kind %s is unsupported: %v", value.Kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineCredentialRotateAtomicallyReplacesArtifactAndAdvancesVersion(t *testing.T) {
|
||||
store, err := NewLocalMachineCredentialStore(t.TempDir(), -1, -1, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Ensure(ControlPlaneServiceTokenSpec()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.root, ControlPlaneServiceTokenID)
|
||||
oldValue, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := store.Rotate(ControlPlaneServiceTokenSpec(), "exposure detected")
|
||||
if err != nil || !rotated.Rotated || rotated.Metadata.Version != 2 {
|
||||
t.Fatalf("rotation = %#v, %v", rotated, err)
|
||||
}
|
||||
newValue, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(oldValue) == string(newValue) {
|
||||
t.Fatal("rotation retained old credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineCredentialRejectsUnsafeExistingArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, ControlPlaneServiceTokenID), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := NewLocalMachineCredentialStore(root, -1, -1, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Ensure(ControlPlaneServiceTokenSpec()); err != ErrCredentialArtifactUnsafe {
|
||||
t.Fatalf("unsafe artifact error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package secrets
|
||||
|
||||
import "strings"
|
||||
|
||||
const Redacted = "REDACTED"
|
||||
|
||||
// RedactDiagnosticFields removes known credential/header values before a
|
||||
// diagnostics boundary. Callers must still avoid passing raw values at all.
|
||||
func RedactDiagnosticFields(fields map[string]string) map[string]string {
|
||||
redacted := make(map[string]string, len(fields))
|
||||
for key, value := range fields {
|
||||
if isSensitiveDiagnosticField(key) {
|
||||
redacted[key] = Redacted
|
||||
continue
|
||||
}
|
||||
redacted[key] = value
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func isSensitiveDiagnosticField(key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
return key == "authorization" || key == "proxy-authorization" || strings.Contains(key, "secret") || strings.Contains(key, "token") || strings.Contains(key, "password") || strings.Contains(key, "credential")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package secrets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedactDiagnosticFieldsNeverReturnsKnownCredentialFields(t *testing.T) {
|
||||
fields := RedactDiagnosticFields(map[string]string{"Authorization": "Bearer value", "control_plane_service_token": "value", "status": "PASS"})
|
||||
if fields["Authorization"] != Redacted || fields["control_plane_service_token"] != Redacted || fields["status"] != "PASS" {
|
||||
t.Fatalf("unexpected diagnostic redaction: %#v", fields)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package telemetry contains operational projections that do not own domain
|
||||
// state. Compatibility events are intentionally redacted and best-effort.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CompatibilityEvent is one bounded invocation observation. Aggregators can
|
||||
// derive counts and unique request/session counts from the redacted keys.
|
||||
// It deliberately contains no content, secret, or user identifier.
|
||||
type CompatibilityEvent struct {
|
||||
Event string `json:"event"`
|
||||
Path string `json:"path"`
|
||||
Component string `json:"component"`
|
||||
Access string `json:"access"`
|
||||
Reason string `json:"reason"`
|
||||
Gen2PathAvailable bool `json:"gen2_path_available"`
|
||||
Fallback bool `json:"fallback"`
|
||||
Primary bool `json:"primary"`
|
||||
Outcome string `json:"outcome"`
|
||||
InvocationCount int `json:"invocation_count"`
|
||||
UniqueRequestKey string `json:"unique_request_key"`
|
||||
UniqueSessionKey string `json:"unique_session_key"`
|
||||
SafeCorrelationID string `json:"correlation_id"`
|
||||
LastUsedAt string `json:"last_used_at"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
// NewCompatibilityEvent creates a redacted event. requestRef/sessionRef are
|
||||
// hashed immediately; callers must pass identifiers only, never content.
|
||||
func NewCompatibilityEvent(path, component, access, reason string, gen2PathAvailable, fallback, primary bool, outcome, requestRef, sessionRef string, now time.Time) CompatibilityEvent {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
requestKey := digest(requestRef)
|
||||
sessionKey := digest(sessionRef)
|
||||
correlationInput := requestRef + "\x00" + sessionRef + "\x00" + path + "\x00" + strconv.FormatInt(now.UnixMilli(), 10)
|
||||
return CompatibilityEvent{
|
||||
Event: "compatibility_observation",
|
||||
Path: path,
|
||||
Component: component,
|
||||
Access: access,
|
||||
Reason: reason,
|
||||
Gen2PathAvailable: gen2PathAvailable,
|
||||
Fallback: fallback,
|
||||
Primary: primary,
|
||||
Outcome: outcome,
|
||||
InvocationCount: 1,
|
||||
UniqueRequestKey: requestKey,
|
||||
UniqueSessionKey: sessionKey,
|
||||
SafeCorrelationID: digest(correlationInput),
|
||||
LastUsedAt: now.UTC().Format(time.RFC3339Nano),
|
||||
ObservedAtUnixMillis: now.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordCompatibility emits a structured redacted event. Telemetry is
|
||||
// best-effort and cannot change the compatibility path's result.
|
||||
func RecordCompatibility(path, component, access, reason string, gen2PathAvailable, fallback, primary bool, outcome, requestRef, sessionRef string, now time.Time) {
|
||||
event := NewCompatibilityEvent(path, component, access, reason, gen2PathAvailable, fallback, primary, outcome, requestRef, sessionRef, now)
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("%s", payload)
|
||||
}
|
||||
|
||||
func digest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompatibilityEventRedactsRequestAndSessionReferences(t *testing.T) {
|
||||
event := NewCompatibilityEvent("personal-memory-fallback", "core-runtime", "read", "empty-gen2-projection", true, true, false, "SUCCESS", "request-secret-looking", "session-secret-looking", time.UnixMilli(42))
|
||||
if event.InvocationCount != 1 || event.ObservedAtUnixMillis != 42 || event.LastUsedAt == "" {
|
||||
t.Fatalf("event metadata incomplete: %+v", event)
|
||||
}
|
||||
if strings.Contains(event.UniqueRequestKey, "request-secret-looking") || strings.Contains(event.UniqueSessionKey, "session-secret-looking") || strings.Contains(event.SafeCorrelationID, "secret-looking") {
|
||||
t.Fatalf("event leaked reference: %+v", event)
|
||||
}
|
||||
if event.Path != "personal-memory-fallback" || !event.Gen2PathAvailable || !event.Fallback || event.Primary {
|
||||
t.Fatalf("event classification incorrect: %+v", event)
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,24 @@ func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) StartSet(candidate ReleaseSet) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.StartSet(candidate) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) EstablishBaseline(release Release) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaseline(release) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) EstablishBaselineSet(release ReleaseSet) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaselineSet(release) })
|
||||
}
|
||||
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) RecordSet(observation ReleaseSetObservation) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.RecordSet(observation) })
|
||||
}
|
||||
func (p *PersistentLifeboat) Rollback() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.Rollback() })
|
||||
}
|
||||
@@ -105,6 +117,10 @@ func (p *PersistentLifeboat) SetTraffic(mode TrafficMode, stableUpstream, candid
|
||||
return p.transition(func(value *Lifeboat) bool { return value.SetTraffic(mode, stableUpstream, candidateUpstream) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) SetTrafficForSets(mode TrafficMode) (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.SetTrafficForSets(mode) })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) Promote() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool {
|
||||
if value.Current.State != ReleaseHealthy || value.Current.ID == "" {
|
||||
@@ -114,6 +130,10 @@ func (p *PersistentLifeboat) Promote() (bool, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) PromoteSet() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.PromoteSet() })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) RollbackTraffic() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool {
|
||||
if !value.Rollback() {
|
||||
@@ -123,6 +143,10 @@ func (p *PersistentLifeboat) RollbackTraffic() (bool, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) RollbackSet() (bool, error) {
|
||||
return p.transition(func(value *Lifeboat) bool { return value.RollbackSet() })
|
||||
}
|
||||
|
||||
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package supervisor
|
||||
|
||||
import "sort"
|
||||
|
||||
type ReleaseState string
|
||||
|
||||
const (
|
||||
@@ -22,21 +24,215 @@ type Release struct {
|
||||
Version string `json:"version"`
|
||||
State ReleaseState `json:"state"`
|
||||
LastKnownGood bool `json:"last_known_good"`
|
||||
Set *ReleaseSet `json:"release_set,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
ComponentCreatorWeb = "creator-web"
|
||||
ComponentCoreRuntime = "core-runtime"
|
||||
ComponentCognitiveWorker = "cognitive-worker"
|
||||
ComponentResearchRuntime = "research-runtime"
|
||||
)
|
||||
|
||||
// ComponentRelease is operational deployment evidence. It is deliberately
|
||||
// separate from semantic/domain state and contains no credentials.
|
||||
type ComponentRelease struct {
|
||||
Name string `json:"name"`
|
||||
Revision string `json:"revision"`
|
||||
Upstream string `json:"upstream"`
|
||||
HealthURL string `json:"health_url"`
|
||||
ReadyURL string `json:"ready_url"`
|
||||
}
|
||||
|
||||
// ReleaseSet is the atomic deployment unit supervised by the control plane.
|
||||
type ReleaseSet struct {
|
||||
ID string `json:"id"`
|
||||
// Slot identifies the independently managed deployment slot. An empty value
|
||||
// remains readable for release state recorded before slot isolation.
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Revision string `json:"revision"`
|
||||
Components map[string]ComponentRelease `json:"components"`
|
||||
}
|
||||
|
||||
var requiredComponents = []string{ComponentCreatorWeb, ComponentCoreRuntime, ComponentCognitiveWorker, ComponentResearchRuntime}
|
||||
|
||||
func (s ReleaseSet) Validate() bool {
|
||||
if s.ID == "" || s.Revision == "" || len(s.Components) != len(requiredComponents) {
|
||||
return false
|
||||
}
|
||||
for _, name := range requiredComponents {
|
||||
component, ok := s.Components[name]
|
||||
if !ok || component.Name != name || component.Revision != s.Revision || component.Upstream == "" || component.HealthURL == "" || component.ReadyURL == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s ReleaseSet) ComponentNames() []string {
|
||||
names := make([]string, 0, len(s.Components))
|
||||
for name := range s.Components {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
type ReleaseSetObservation struct {
|
||||
ReleaseID string `json:"release_id"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Components map[string]ComponentObservation `json:"components"`
|
||||
}
|
||||
|
||||
type ComponentObservation struct {
|
||||
Revision string `json:"revision"`
|
||||
Health bool `json:"health"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
type Lifeboat struct {
|
||||
Current Release `json:"current"`
|
||||
LastKnownGood Release `json:"last_known_good"`
|
||||
RollbackTarget Release `json:"rollback_target"`
|
||||
Observations []Observation `json:"observations"`
|
||||
TrafficMode TrafficMode `json:"traffic_mode"`
|
||||
StableUpstream string `json:"stable_upstream"`
|
||||
CandidateUpstream string `json:"candidate_upstream"`
|
||||
Current Release `json:"current"`
|
||||
LastKnownGood Release `json:"last_known_good"`
|
||||
RollbackTarget Release `json:"rollback_target"`
|
||||
Observations []Observation `json:"observations"`
|
||||
TrafficMode TrafficMode `json:"traffic_mode"`
|
||||
StableUpstream string `json:"stable_upstream"`
|
||||
CandidateUpstream string `json:"candidate_upstream"`
|
||||
StableReleaseSet *ReleaseSet `json:"stable_release_set,omitempty"`
|
||||
CandidateReleaseSet *ReleaseSet `json:"candidate_release_set,omitempty"`
|
||||
LastKnownGoodReleaseSet *ReleaseSet `json:"last_known_good_release_set,omitempty"`
|
||||
RollbackTargetReleaseSet *ReleaseSet `json:"rollback_target_release_set,omitempty"`
|
||||
}
|
||||
|
||||
func (l *Lifeboat) StartSet(candidate ReleaseSet) bool {
|
||||
if !candidate.Validate() {
|
||||
return false
|
||||
}
|
||||
copySet := candidate
|
||||
if l.LastKnownGoodReleaseSet != nil {
|
||||
previous := *l.LastKnownGoodReleaseSet
|
||||
l.RollbackTargetReleaseSet = &previous
|
||||
}
|
||||
l.CandidateReleaseSet = ©Set
|
||||
l.Current = Release{ID: candidate.ID, Version: candidate.Revision, State: ReleaseCandidate, Set: ©Set}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) EstablishBaselineSet(stable ReleaseSet) bool {
|
||||
if !stable.Validate() {
|
||||
return false
|
||||
}
|
||||
copySet := stable
|
||||
l.StableReleaseSet = ©Set
|
||||
l.LastKnownGoodReleaseSet = ©Set
|
||||
l.Current = Release{ID: stable.ID, Version: stable.Revision, State: ReleaseHealthy, LastKnownGood: true, Set: ©Set}
|
||||
l.LastKnownGood = l.Current
|
||||
l.RollbackTarget = Release{}
|
||||
l.RollbackTargetReleaseSet = nil
|
||||
l.TrafficMode = TrafficStable
|
||||
return l.setSetUpstreams(©Set, nil)
|
||||
}
|
||||
|
||||
func (l *Lifeboat) setSetUpstreams(stable, candidate *ReleaseSet) bool {
|
||||
if stable == nil {
|
||||
return false
|
||||
}
|
||||
stableWeb, ok := stable.Components[ComponentCreatorWeb]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
l.StableUpstream = stableWeb.Upstream
|
||||
if candidate != nil {
|
||||
candidateWeb, exists := candidate.Components[ComponentCreatorWeb]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
l.CandidateUpstream = candidateWeb.Upstream
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) RecordSet(o ReleaseSetObservation) bool {
|
||||
if l.CandidateReleaseSet == nil || o.ReleaseID == "" || o.ReleaseID != l.CandidateReleaseSet.ID || !o.Ready {
|
||||
l.Current.State = ReleaseFailed
|
||||
return false
|
||||
}
|
||||
if len(o.Components) != len(requiredComponents) {
|
||||
l.Current.State = ReleaseFailed
|
||||
return false
|
||||
}
|
||||
for _, name := range requiredComponents {
|
||||
component, ok := o.Components[name]
|
||||
expected := l.CandidateReleaseSet.Components[name]
|
||||
if !ok || !component.Health || !component.Ready || component.Revision != expected.Revision {
|
||||
l.Current.State = ReleaseFailed
|
||||
return false
|
||||
}
|
||||
}
|
||||
l.Current.State = ReleaseHealthy
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) SetTrafficForSets(mode TrafficMode) bool {
|
||||
if l.StableReleaseSet == nil || l.CandidateReleaseSet == nil {
|
||||
return false
|
||||
}
|
||||
if !l.setSetUpstreams(l.StableReleaseSet, l.CandidateReleaseSet) {
|
||||
return false
|
||||
}
|
||||
l.TrafficMode = mode
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *Lifeboat) PromoteSet() bool {
|
||||
if l.CandidateReleaseSet == nil || l.Current.State != ReleaseHealthy {
|
||||
return false
|
||||
}
|
||||
if l.StableReleaseSet != nil {
|
||||
previous := *l.StableReleaseSet
|
||||
l.RollbackTargetReleaseSet = &previous
|
||||
l.RollbackTarget = l.LastKnownGood
|
||||
}
|
||||
set := *l.CandidateReleaseSet
|
||||
l.StableReleaseSet = &set
|
||||
l.LastKnownGoodReleaseSet = &set
|
||||
l.LastKnownGood = l.Current
|
||||
l.LastKnownGood.Set = &set
|
||||
l.LastKnownGood.LastKnownGood = true
|
||||
l.Current.Set = &set
|
||||
l.Current.LastKnownGood = true
|
||||
l.CandidateReleaseSet = nil
|
||||
// The promoted ReleaseSet is now the stable target. Leaving header-canary
|
||||
// mode active would keep an obsolete candidate route in the public proxy.
|
||||
l.TrafficMode = TrafficStable
|
||||
return l.setSetUpstreams(&set, nil)
|
||||
}
|
||||
|
||||
func (l *Lifeboat) RollbackSet() bool {
|
||||
if l.RollbackTargetReleaseSet == nil && l.LastKnownGoodReleaseSet == nil {
|
||||
return false
|
||||
}
|
||||
target := l.RollbackTargetReleaseSet
|
||||
if target == nil {
|
||||
target = l.LastKnownGoodReleaseSet
|
||||
}
|
||||
copySet := *target
|
||||
l.StableReleaseSet = ©Set
|
||||
l.Current = Release{ID: copySet.ID, Version: copySet.Revision, State: ReleaseRolledBack, LastKnownGood: true, Set: ©Set}
|
||||
l.LastKnownGood = l.Current
|
||||
l.LastKnownGoodReleaseSet = ©Set
|
||||
l.CandidateReleaseSet = nil
|
||||
l.RollbackTargetReleaseSet = nil
|
||||
l.TrafficMode = TrafficStable
|
||||
return l.setSetUpstreams(©Set, nil)
|
||||
}
|
||||
|
||||
func (l *Lifeboat) Start(candidate Release) bool {
|
||||
|
||||
@@ -82,3 +82,84 @@ func TestPersistentSupervisorRestoresTrafficModeAfterRestart(t *testing.T) {
|
||||
t.Fatalf("rollback state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func testReleaseSet(id, revision, web, core, cognitive, research string) supervisor.ReleaseSet {
|
||||
return supervisor.ReleaseSet{ID: id, Revision: revision, Components: map[string]supervisor.ComponentRelease{
|
||||
supervisor.ComponentCreatorWeb: {Name: supervisor.ComponentCreatorWeb, Revision: revision, Upstream: web, HealthURL: web + "/healthz", ReadyURL: web + "/readyz"},
|
||||
supervisor.ComponentCoreRuntime: {Name: supervisor.ComponentCoreRuntime, Revision: revision, Upstream: core, HealthURL: core + "/healthz", ReadyURL: core + "/readyz"},
|
||||
supervisor.ComponentCognitiveWorker: {Name: supervisor.ComponentCognitiveWorker, Revision: revision, Upstream: cognitive, HealthURL: cognitive + "/healthz", ReadyURL: cognitive + "/readyz"},
|
||||
supervisor.ComponentResearchRuntime: {Name: supervisor.ComponentResearchRuntime, Revision: revision, Upstream: research, HealthURL: research + "/healthz", ReadyURL: research + "/readyz"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestReleaseSetRejectsMixedRevisionsAndMissingComponents(t *testing.T) {
|
||||
set := testReleaseSet("candidate", "r1", "http://web", "http://core", "http://cognitive", "http://research")
|
||||
if !set.Validate() {
|
||||
t.Fatal("complete release set should validate")
|
||||
}
|
||||
set.Components[supervisor.ComponentCoreRuntime] = supervisor.ComponentRelease{Name: supervisor.ComponentCoreRuntime, Revision: "r0", Upstream: "http://core", HealthURL: "http://core/healthz", ReadyURL: "http://core/readyz"}
|
||||
if set.Validate() {
|
||||
t.Fatal("mixed revision release set must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentReleaseSetRollbackPreservesWholeLastKnownGood(t *testing.T) {
|
||||
store, err := supervisor.NewDurableStore(filepath.Join(t.TempDir(), "control", "release-set.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := supervisor.NewPersistentLifeboat(store)
|
||||
good := testReleaseSet("stable-r1", "r1", "http://web", "http://core", "http://cognitive", "http://research")
|
||||
good.Slot = "a"
|
||||
candidate := testReleaseSet("candidate-r2", "r2", "http://web-c", "http://core-c", "http://cognitive-c", "http://research-c")
|
||||
candidate.Slot = "b"
|
||||
if ok, err := first.EstablishBaselineSet(good); err != nil || !ok {
|
||||
t.Fatalf("baseline: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := first.StartSet(candidate); err != nil || !ok {
|
||||
t.Fatalf("start: %v %v", ok, err)
|
||||
}
|
||||
observation := supervisor.ReleaseSetObservation{ReleaseID: candidate.ID, Ready: true, Components: map[string]supervisor.ComponentObservation{}}
|
||||
for name := range candidate.Components {
|
||||
observation.Components[name] = supervisor.ComponentObservation{Revision: candidate.Revision, Health: true, Ready: true}
|
||||
}
|
||||
if ok, err := first.RecordSet(observation); err != nil || !ok {
|
||||
t.Fatalf("observe: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := first.SetTrafficForSets(supervisor.TrafficCanaryHeader); err != nil || !ok {
|
||||
t.Fatalf("canary: %v %v", ok, err)
|
||||
}
|
||||
second := supervisor.NewPersistentLifeboat(store)
|
||||
if ok, err := second.RollbackSet(); err != nil || !ok {
|
||||
t.Fatalf("rollback: %v %v", ok, err)
|
||||
}
|
||||
state, err := second.Snapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.StableReleaseSet == nil || state.StableReleaseSet.Revision != "r1" || state.StableReleaseSet.Slot != "a" || state.TrafficMode != supervisor.TrafficStable {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSetPromotionRetainsPreviousStableForRollback(t *testing.T) {
|
||||
good := testReleaseSet("stable-r1", "r1", "http://web", "http://core", "http://cognitive", "http://research")
|
||||
candidate := testReleaseSet("candidate-r2", "r2", "http://web-c", "http://core-c", "http://cognitive-c", "http://research-c")
|
||||
l := supervisor.Lifeboat{}
|
||||
if !l.EstablishBaselineSet(good) || !l.StartSet(candidate) {
|
||||
t.Fatal("release set setup failed")
|
||||
}
|
||||
observation := supervisor.ReleaseSetObservation{ReleaseID: candidate.ID, Ready: true, Components: map[string]supervisor.ComponentObservation{}}
|
||||
for name := range candidate.Components {
|
||||
observation.Components[name] = supervisor.ComponentObservation{Revision: candidate.Revision, Health: true, Ready: true}
|
||||
}
|
||||
if !l.RecordSet(observation) || !l.PromoteSet() {
|
||||
t.Fatal("promotion failed")
|
||||
}
|
||||
if l.TrafficMode != supervisor.TrafficStable {
|
||||
t.Fatalf("promoted ReleaseSet traffic=%s, want stable", l.TrafficMode)
|
||||
}
|
||||
if l.RollbackTargetReleaseSet == nil || l.RollbackTargetReleaseSet.Revision != "r1" {
|
||||
t.Fatalf("rollback target=%+v", l.RollbackTargetReleaseSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
canary="$root/deploy/run-production-canary.sh"
|
||||
supervisor="$root/deploy/update-supervisor.sh"
|
||||
candidate_compose="$root/deploy/docker-compose.candidate-slot.yml"
|
||||
stable_compose="$root/deploy/docker-compose.prod.yml"
|
||||
stable_updater="$root/deploy/production-stable-updater.sh"
|
||||
|
||||
grep -Fq 'CANDIDATE_PROJECT="${CANDIDATE_PROJECT:-assistant-slot-$SLOT}"' "$canary"
|
||||
grep -Fq '[[ "$CANDIDATE_PROJECT" != "$STABLE_PROJECT" ]]' "$canary"
|
||||
grep -Fq 'docker compose -p "$CANDIDATE_PROJECT"' "$canary"
|
||||
grep -Fq 'up -d --no-build --no-deps research-runtime core-runtime' "$canary"
|
||||
grep -Fq 'up -d --no-build --no-deps cognitive-worker' "$canary"
|
||||
grep -Fq 'up -d --no-build --no-deps creator-web' "$canary"
|
||||
grep -Fq 'compose up -d --no-build --no-deps supervisor' "$supervisor"
|
||||
grep -Fq 'stable_ids_before=$(stable_ids)' "$canary"
|
||||
grep -Fq 'assert_stable_ids_unchanged' "$canary"
|
||||
grep -Fq 'before=$(stable_ids)' "$supervisor"
|
||||
grep -Fq 'after=$(stable_ids)' "$supervisor"
|
||||
grep -Fq 'stable container IDs changed' "$supervisor"
|
||||
grep -Fq 'HEALTH_ATTEMPTS="${HEALTH_ATTEMPTS:-360}"' "$canary"
|
||||
grep -Fq 'seq 1 "$HEALTH_ATTEMPTS"' "$canary"
|
||||
grep -Fq 'PROMOTE_AFTER_CANARY="${PROMOTE_AFTER_CANARY:-true}"' "$canary"
|
||||
grep -Fq 'if [[ "$PROMOTE_AFTER_CANARY" != true ]]' "$canary"
|
||||
grep -Fq 'name: assistant_default' "$candidate_compose"
|
||||
grep -Fq 'external: true' "$candidate_compose"
|
||||
! grep -Fq 'candidate:' "$stable_compose"
|
||||
! grep -Fq 'run-production-canary.sh' "$stable_updater"
|
||||
grep -Fq 'compose up -d --no-build "${stable_services[@]}"' "$stable_updater"
|
||||
echo 'stable release-set guard: PASS'
|
||||
Reference in New Issue
Block a user