Compare commits
223
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f16ec75f5 | ||
|
|
c0ffc59666 | ||
|
|
aa5e33778c | ||
|
|
870392dc09 | ||
|
|
005d244950 | ||
|
|
c44dd9e0be | ||
|
|
2f639c9d6c | ||
|
|
ec7c4fef66 | ||
|
|
c831ee1260 | ||
|
|
81ce8dfdb2 | ||
|
|
750c87c926 | ||
|
|
e6e051d59f | ||
|
|
5a9714645f | ||
|
|
cf818cfc47 | ||
|
|
e21eae7a99 | ||
|
|
dec19e873d | ||
|
|
d1710907ec | ||
|
|
2f085858db | ||
|
|
a8be998cee | ||
|
|
f42a94ee7a | ||
|
|
35666c9923 | ||
|
|
da83248ac9 | ||
|
|
db139e6800 | ||
|
|
b4335d6735 | ||
|
|
44541b1ba3 | ||
|
|
9c42b18850 | ||
|
|
3a8c6d4827 | ||
|
|
8b1d75f337 | ||
|
|
1c05619899 | ||
|
|
a6b0fcd768 | ||
|
|
dd9bca3ace | ||
|
|
9d94a86300 | ||
|
|
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 | ||
|
|
df8a275eb1 | ||
|
|
8aa5179d47 | ||
|
|
7b62781087 | ||
|
|
5c95916648 | ||
|
|
8caa49f429 | ||
|
|
5b5875d0f4 | ||
|
|
f7ffaee10d | ||
|
|
550738f3ca | ||
|
|
e8b4c4fdab | ||
|
|
39f6fdee7b | ||
|
|
711a734fee | ||
|
|
ad851a98aa | ||
|
|
1e3f29b791 | ||
|
|
62fd265bb7 | ||
|
|
2a52306f90 | ||
|
|
e1c2e5f59c | ||
|
|
49b3165caa | ||
|
|
41b5c57d05 | ||
|
|
31dd628519 | ||
|
|
d0103bbf8e | ||
|
|
29217ca935 | ||
|
|
e9c3df26ae | ||
|
|
70ece6ed5c | ||
|
|
916b93517a | ||
|
|
cf68786d0c | ||
|
|
44ac0203a9 | ||
|
|
8571e3fe59 | ||
|
|
0af6a9b9a5 | ||
|
|
6b9d5be299 | ||
|
|
8b3d6f9176 | ||
|
|
b4ab280950 | ||
|
|
9cb69d1663 | ||
|
|
5387010a15 | ||
|
|
66fd4bfc75 | ||
|
|
8ac4525569 | ||
|
|
40c610945f | ||
|
|
99458a6ced | ||
|
|
25c62e38fc | ||
|
|
5c0c6b3e42 | ||
|
|
788137b4ba | ||
|
|
2ef639eba9 | ||
|
|
d4ed85dec2 | ||
|
|
c1c2110322 | ||
|
|
d83fcb2176 | ||
|
|
041fb1556d | ||
|
|
a3baade40b | ||
|
|
12104210a3 | ||
|
|
40dcc36b06 | ||
|
|
433cb2de2c | ||
|
|
b49ff6cb71 | ||
|
|
4bb6728dfe | ||
|
|
b00130ac15 | ||
|
|
fc356eb0a3 | ||
|
|
0f880896fe | ||
|
|
460a82e020 | ||
|
|
e27ead1a75 | ||
|
|
64bc8bca25 | ||
|
|
67b585af94 | ||
|
|
e47ea7b48e | ||
|
|
75042e090f | ||
|
|
1168c00caf | ||
|
|
6e873ae3af | ||
|
|
b7210dc0ad | ||
|
|
c0704dd28e | ||
|
|
198f6cfe58 | ||
|
|
49608599c4 | ||
|
|
4f6d205a60 | ||
|
|
bcb833f15e | ||
|
|
4f6f2a1f48 | ||
|
|
f709c026d0 | ||
|
|
92ad3516ea | ||
|
|
e1beed0391 | ||
|
|
6b297c7749 | ||
|
|
91b8da2240 | ||
|
|
8924f5a48a | ||
|
|
636fb904f2 | ||
|
|
598a97cd1a | ||
|
|
e9ef635933 | ||
|
|
c516754d63 | ||
|
|
5c965ef69c | ||
|
|
eab7d11bdd | ||
|
|
51a49ba0ef | ||
|
|
60ff22882c | ||
|
|
9896e4f716 | ||
|
|
ff0d0e585b | ||
|
|
13f3165ba0 | ||
|
|
55f2f9affa | ||
|
|
e47e033dda | ||
|
|
2dc5587fd1 | ||
|
|
992c84240b | ||
|
|
cfeb6aeba7 | ||
|
|
f0d45ca9f1 | ||
|
|
e20c9fdfc0 | ||
|
|
cb50a120e5 | ||
|
|
b30eb38eca | ||
|
|
2083096aed | ||
|
|
671ecb4968 | ||
|
|
af9d5809e9 | ||
|
|
990928618d | ||
|
|
9fac8284f5 | ||
|
|
7d9a5edee5 | ||
|
|
32d3f318a5 | ||
|
|
3a563a29ee | ||
|
|
734db5d779 | ||
|
|
36fc8ec8c6 | ||
|
|
b8c001be4c | ||
|
|
eeeba6b7c0 | ||
|
|
fce3ec569c | ||
|
|
a925d787dc | ||
|
|
43556e1603 | ||
|
|
dd00dabf32 |
@@ -25,6 +25,15 @@ type OpenAICompatibleLocalAdapter struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// compatibleDefaultContextBytes is a conservative bound used when an
|
||||
// OpenAI-compatible local endpoint omits n_ctx from /v1/models. The adapter
|
||||
// is a chat-completions adapter, so advisory capability is known from the
|
||||
// adapter contract; quality and activation still require governed
|
||||
// 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)
|
||||
}
|
||||
@@ -58,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"`
|
||||
@@ -87,14 +99,36 @@ 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
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
if contextBytes <= 0 {
|
||||
contextBytes = compatibleDefaultContextBytes
|
||||
}
|
||||
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
|
||||
}
|
||||
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"`
|
||||
@@ -125,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 {
|
||||
@@ -226,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
|
||||
}
|
||||
|
||||
@@ -18,6 +18,65 @@ type captureRoundTripper struct {
|
||||
body []byte
|
||||
}
|
||||
|
||||
type modelsMetadataRoundTripper struct{}
|
||||
|
||||
func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"medium-model","owned_by":"llama.cpp","meta":{}}]}`)),
|
||||
Header: make(http.Header),
|
||||
Request: request,
|
||||
}, 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models, probe := adapter.Discover(context.Background())
|
||||
if probe.Health != capability.HealthAvailable || len(models) != 1 {
|
||||
t.Fatalf("unexpected discovery result: probe=%+v models=%+v", probe, models)
|
||||
}
|
||||
if models[0].MaxContextBytes != compatibleDefaultContextBytes {
|
||||
t.Fatalf("context bound=%d, want %d", models[0].MaxContextBytes, compatibleDefaultContextBytes)
|
||||
}
|
||||
if len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != capability.CapabilityAdvisory {
|
||||
t.Fatalf("capabilities=%v, want advisory", models[0].Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
|
||||
if err != nil {
|
||||
@@ -81,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"`
|
||||
@@ -88,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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package multimodal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func NormalizeIngress(id string, modality interaction.IngressModality, sourceRef, deviceRef, locationRef, principalID, contentType string, data []byte, response capability.MultimodalResponse, at int64) (interaction.IngressEnvelope, artifact.MediaArtifact) {
|
||||
hash := sha256.Sum256(data)
|
||||
artifactValue := artifact.MediaArtifact{ID: artifact.ArtifactID("artifact:media:" + hex.EncodeToString(hash[:8])), ContentType: contentType, SizeBytes: int64(len(data)), ContentDigest: hex.EncodeToString(hash[:]), SourceRef: sourceRef, RecordedAtUnixMillis: at}
|
||||
if len(data) == 0 {
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, Degraded: true, RecordedAtUnixMillis: at}, artifact.MediaArtifact{}
|
||||
}
|
||||
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, ArtifactRef: string(artifactValue.ID), ObservationRef: response.EvidenceRef, ProviderRef: response.Provider, ModelRef: response.Model, ModelVersion: response.ModelVersion, ConfidenceMillis: response.ConfidenceMillis, RecordedAtUnixMillis: at}, artifactValue
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package multimodal_test
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/adapters/multimodal"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIngressNormalizesMediaArtifactAndObservationProvenance(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressImage, "camera:street", "device:cam", "world:street", "principal", "image/png", []byte("image"), capability.MultimodalResponse{Provider: "local", Model: "vision", ModelVersion: "1", EvidenceRef: "observation-1", ConfidenceMillis: 800}, 1)
|
||||
if !e.Valid() || !a.Valid() || e.ArtifactRef == "" || e.ObservationRef != "observation-1" || e.DeviceRef == "" || e.LocationRef == "" {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableIngressDegradesWithoutFabricatingObservation(t *testing.T) {
|
||||
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressAudio, "mic", "device:mic", "", "principal", "audio/wav", nil, capability.MultimodalResponse{}, 1)
|
||||
if !e.Degraded || e.ObservationRef != "" || a.Valid() {
|
||||
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,12 @@ type DeliveryRequest struct {
|
||||
}
|
||||
|
||||
type Receipt struct {
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"`
|
||||
RequestDigest string `json:"request_digest"`
|
||||
AcceptedAt int64 `json:"accepted_at_unix_millis"`
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"`
|
||||
RequestDigest string `json:"request_digest"`
|
||||
AcceptedAt int64 `json:"accepted_at_unix_millis"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
ExternalDeliveryAttempted bool `json:"external_delivery_attempted"`
|
||||
}
|
||||
|
||||
func Digest(payload Payload) (string, error) {
|
||||
@@ -76,11 +78,27 @@ func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error)
|
||||
|
||||
type Executor struct {
|
||||
binding capability.Binding
|
||||
candidate *capability.CandidateBinding
|
||||
secret string
|
||||
client *http.Client
|
||||
executeAllowed bool
|
||||
}
|
||||
|
||||
// NewShadow accepts only a Capability-owned SHADOW binding. Its caller does
|
||||
// not supply an endpoint; the target remains inside the resolved binding.
|
||||
func NewShadow(binding capability.CandidateBinding, secret string, client *http.Client) (*Executor, error) {
|
||||
if capabilitydomain.ValidateCandidateBinding(binding) != nil || binding.Context.Mode != capability.CandidateBindingModeShadow || binding.Status != capability.CandidateBindingActive {
|
||||
return nil, ErrInvalidNotification
|
||||
}
|
||||
stable := capability.Binding{ID: binding.BaseActiveBindingID, Revision: binding.BaseActiveBindingRevision, OwnerPrincipalID: binding.Context.PrincipalID}
|
||||
value, err := newExecutor(stable, secret, client, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.candidate = &binding
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func New(binding capability.Binding, secret string, client *http.Client) (*Executor, error) {
|
||||
if err := capabilitydomain.UsableBinding(binding); err != nil {
|
||||
return nil, ErrInvalidNotification
|
||||
@@ -116,7 +134,7 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
endpoint, err := e.endpoint(e.binding.DeliveryPath, string(action.Proposal.IntentID))
|
||||
endpoint, err := e.endpoint(e.deliveryPath(), string(action.Proposal.IntentID))
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
@@ -127,6 +145,10 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi
|
||||
request.Header.Set("Authorization", "Bearer "+e.secret)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if e.candidate != nil {
|
||||
request.Header.Set("X-Candidate-Release", e.candidate.Context.ReleaseSetID)
|
||||
request.Header.Set("X-Candidate-Task", e.candidate.Context.TaskID)
|
||||
}
|
||||
response, err := e.client.Do(request)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{}
|
||||
@@ -139,10 +161,14 @@ func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.Executi
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
receipt, err := decodeReceipt(response.Body)
|
||||
if err != nil || !matchingReceipt(receipt, action) {
|
||||
if err != nil || !matchingReceipt(receipt, action) || (e.candidate != nil && (receipt.DeliveryMode != "SHADOW" || receipt.ExternalDeliveryAttempted)) {
|
||||
return ports.ExecutionResult{}
|
||||
}
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
result := ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
if e.candidate != nil {
|
||||
result.Receipt = &commitcontract.ExecutionReceipt{ID: receipt.ReceiptID, ExecutorImplementationRef: "notification-shadow-receiver-v1", ExternalOperationRef: "SHADOW_CAPTURED", ExternalRequestID: string(receipt.ActionIntentID), RequestDigest: receipt.RequestDigest, DispatchAtUnixMillis: receipt.AcceptedAt, ExecutorResult: commitcontract.ExecutorAccepted, ReconciliationKey: receipt.ReceiptID, DeliveryMode: "SHADOW", ExternalDeliveryAttempted: false, ReleaseSetID: e.candidate.Context.ReleaseSetID, TaskID: e.candidate.Context.TaskID, TaskRevision: e.candidate.Context.TaskRevision, CapabilityBindingID: string(e.candidate.ID), CapabilityBindingRevision: e.candidate.Revision, TargetReference: e.candidate.Context.TargetRef}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Executor) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
@@ -186,7 +212,11 @@ func (e *Executor) requestFor(action commitcontract.AuthorizedAction) (Payload,
|
||||
}
|
||||
|
||||
func (e *Executor) endpoint(path, intent string) (string, error) {
|
||||
base, err := url.Parse(e.binding.HTTPSOrigin)
|
||||
origin := e.binding.HTTPSOrigin
|
||||
if e.candidate != nil {
|
||||
origin = e.candidate.TargetOrigin
|
||||
}
|
||||
base, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -195,6 +225,13 @@ func (e *Executor) endpoint(path, intent string) (string, error) {
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func (e *Executor) deliveryPath() string {
|
||||
if e.candidate != nil {
|
||||
return e.candidate.DeliveryPath
|
||||
}
|
||||
return e.binding.DeliveryPath
|
||||
}
|
||||
|
||||
func decodeReceipt(reader io.Reader) (Receipt, error) {
|
||||
var receipt Receipt
|
||||
decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
func testAction(t *testing.T) commitcontract.AuthorizedAction {
|
||||
@@ -55,6 +56,35 @@ func TestExecutorPutAndReconcileMatchReceipt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowExecutorRequiresShadowReceipt(t *testing.T) {
|
||||
action := testAction(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Candidate-Release") != "assistant-slot-b" || r.Header.Get("X-Candidate-Task") != "GEN2-CAPABILITY-001" {
|
||||
t.Fatalf("missing candidate context")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "shadow-receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1, DeliveryMode: "SHADOW", ExternalDeliveryAttempted: false})
|
||||
}))
|
||||
defer server.Close()
|
||||
binding := capability.CandidateBinding{ID: "candidate-binding-1", Revision: 1, BaseActiveBindingID: "binding-1", BaseActiveBindingRevision: 1,
|
||||
Context: capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: "owner-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant"},
|
||||
TargetOrigin: server.URL, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive}
|
||||
// The httptest address is intentionally not the production candidate target;
|
||||
// its digest is recomputed locally only for this bounded test binding.
|
||||
digest, err := capabilitydomain.CandidateTargetDigest(binding)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding.Context.TargetDigest = digest
|
||||
executor, err := NewShadow(binding, "shadow-secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := executor.Execute(action)
|
||||
if !result.Known || !result.Succeeded || result.Receipt == nil || result.Receipt.DeliveryMode != "SHADOW" || result.Receipt.ExternalDeliveryAttempted {
|
||||
t.Fatalf("shadow result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAmbiguousAndIntegrityOutcomes(t *testing.T) {
|
||||
action := testAction(t)
|
||||
for name, handler := range map[string]http.HandlerFunc{
|
||||
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -27,3 +28,28 @@ func (r *AuthorityRepository) LoadGrant(ctx context.Context, id authority.GrantI
|
||||
}
|
||||
return grant, scope, nil
|
||||
}
|
||||
|
||||
func (r *AuthorityRepository) LoadDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID) (authority.DelegatedControlPlaneGrant, error) {
|
||||
var grant authority.DelegatedControlPlaneGrant
|
||||
err := r.pool.QueryRow(ctx, `SELECT grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,COALESCE(binding_id,''),COALESCE(provider_instance_id,''),COALESCE(model_descriptor_id,''),candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status FROM authority.delegated_control_plane_grants WHERE grant_id=$1`, id).Scan(&grant.ID, &grant.ServiceActorID, &grant.ServicePrincipalID, &grant.IssuerPrincipalID, &grant.TaskID, &grant.TaskRevision, &grant.Operations, &grant.Resource.BindingID, &grant.Resource.ProviderInstanceID, &grant.Resource.ModelDescriptorID, &grant.CandidateResource.ReleaseSetID, &grant.CandidateResource.Slot, &grant.CandidateResource.CapabilityID, &grant.CandidateResource.ResourceScope, &grant.CandidateResource.Mode, &grant.CandidateResource.TargetKind, &grant.CandidateResource.TargetRef, &grant.CandidateResource.TargetDigest, &grant.IssuedAtUnixMillis, &grant.ExpiresAtUnixMillis, &grant.ArchitectureRevision, &grant.Nonce, &grant.Revision, &grant.Status)
|
||||
if err != nil {
|
||||
return authority.DelegatedControlPlaneGrant{}, fmt.Errorf("load delegated control-plane grant: %w", err)
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (r *AuthorityRepository) RecordDelegatedControlPlaneAudit(ctx context.Context, audit authority.DelegatedControlPlaneAudit) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO authority.delegated_control_plane_audit (audit_id,grant_id,service_actor_id,service_principal_id,task_id,task_revision,operation,binding_id,provider_instance_id,model_descriptor_id,candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,decision,outcome,recorded_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, audit.AuditID, audit.GrantID, audit.ServiceActorID, audit.ServicePrincipalID, audit.TaskID, audit.TaskRevision, audit.Operation, audit.Resource.BindingID, audit.Resource.ProviderInstanceID, audit.Resource.ModelDescriptorID, audit.CandidateResource.ReleaseSetID, audit.CandidateResource.Slot, audit.CandidateResource.CapabilityID, audit.CandidateResource.ResourceScope, audit.CandidateResource.Mode, audit.CandidateResource.TargetKind, audit.CandidateResource.TargetRef, audit.CandidateResource.TargetDigest, audit.Decision, audit.Outcome, audit.RecordedAtUnixMillis)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record delegated control-plane audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AuthorityRepository) RevokeDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID, issuer string) (bool, error) {
|
||||
result, err := r.pool.Exec(ctx, `UPDATE authority.delegated_control_plane_grants SET status='REVOKED',revision=revision+1,revoked_at_unix_millis=$3 WHERE grant_id=$1 AND issuer_principal_id=$2 AND status='ACTIVE'`, id, issuer, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("revoke delegated control-plane grant: %w", err)
|
||||
}
|
||||
return result.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -181,10 +183,6 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -309,12 +307,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
|
||||
}
|
||||
@@ -333,13 +333,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)
|
||||
}
|
||||
@@ -353,7 +356,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)
|
||||
@@ -367,7 +371,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)
|
||||
@@ -375,6 +381,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,
|
||||
@@ -403,6 +443,93 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveCandidateBinding(ctx context.Context, binding capability.CandidateBinding) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
result, err := tx.Exec(ctx, `INSERT INTO capability.release_candidate_bindings (
|
||||
binding_id,revision,base_active_binding_id,base_active_binding_revision,release_set_id,slot,task_id,task_revision,principal_id,capability_id,resource_scope,mode,target_kind,target_ref,target_digest,authority_grant_id,
|
||||
target_origin,delivery_path,receipt_path,protocol,created_at_unix_millis,expires_at_unix_millis,status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) ON CONFLICT DO NOTHING`,
|
||||
binding.ID, binding.Revision, binding.BaseActiveBindingID, binding.BaseActiveBindingRevision, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID,
|
||||
binding.Context.TaskRevision, binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.Context.TargetKind, binding.Context.TargetRef, binding.Context.TargetDigest, binding.Context.AuthorityGrantID,
|
||||
binding.TargetOrigin, binding.DeliveryPath, binding.ReceiptPath, binding.Protocol, binding.CreatedAtUnixMillis, binding.ExpiresAtUnixMillis, binding.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrCapabilityBindingConflict
|
||||
}
|
||||
if binding.Status == capability.CandidateBindingActive {
|
||||
result, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_heads (
|
||||
release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode,binding_id,revision)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode) DO UPDATE SET binding_id=EXCLUDED.binding_id,revision=EXCLUDED.revision
|
||||
WHERE capability.release_candidate_binding_heads.revision < EXCLUDED.revision`, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID,
|
||||
binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.ID, binding.Revision)
|
||||
if err != nil || result.RowsAffected() != 1 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrCapabilityBindingConflict
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'CREATED','CAPABILITY_OWNED_CANDIDATE_BINDING',$8)`, binding.ID, binding.Revision, binding.Context.ReleaseSetID, binding.Context.TaskID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) ResolveCandidateBinding(ctx context.Context, request capability.CandidateReleaseContext, nowUnixMillis int64) (capability.CandidateBindingResolution, error) {
|
||||
var binding capability.CandidateBinding
|
||||
err := r.pool.QueryRow(ctx, `SELECT b.binding_id,b.revision,b.base_active_binding_id,b.base_active_binding_revision,b.release_set_id,b.slot,b.task_id,b.task_revision,b.principal_id,b.capability_id,b.resource_scope,b.mode,b.target_kind,b.target_ref,b.target_digest,b.authority_grant_id,b.target_origin,b.delivery_path,b.receipt_path,b.protocol,b.created_at_unix_millis,b.expires_at_unix_millis,b.status
|
||||
FROM capability.release_candidate_binding_heads h JOIN capability.release_candidate_bindings b ON b.binding_id=h.binding_id AND b.revision=h.revision
|
||||
WHERE h.release_set_id=$1 AND h.slot=$2 AND h.task_id=$3 AND h.principal_id=$4 AND h.capability_id=$5 AND h.resource_scope=$6 AND h.mode=$7`,
|
||||
request.ReleaseSetID, request.Slot, request.TaskID, request.PrincipalID, request.CapabilityID, request.ResourceScope, request.Mode).
|
||||
Scan(&binding.ID, &binding.Revision, &binding.BaseActiveBindingID, &binding.BaseActiveBindingRevision, &binding.Context.ReleaseSetID, &binding.Context.Slot, &binding.Context.TaskID, &binding.Context.TaskRevision, &binding.Context.PrincipalID, &binding.Context.CapabilityID, &binding.Context.ResourceScope, &binding.Context.Mode, &binding.Context.TargetKind, &binding.Context.TargetRef, &binding.Context.TargetDigest, &binding.Context.AuthorityGrantID, &binding.TargetOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.CreatedAtUnixMillis, &binding.ExpiresAtUnixMillis, &binding.Status)
|
||||
if err != nil {
|
||||
return capability.CandidateBindingResolution{}, fmt.Errorf("candidate binding required: %w", err)
|
||||
}
|
||||
if capabilitydomain.CandidateBindingInvocable(binding, request, nowUnixMillis) != nil {
|
||||
return capability.CandidateBindingResolution{}, ErrCapabilityBindingNotFound
|
||||
}
|
||||
_, _ = r.pool.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'RESOLVED','EXACT_CONTEXT',$8)`, binding.ID, binding.Revision, request.ReleaseSetID, request.TaskID, request.CapabilityID, request.ResourceScope, request.Mode, nowUnixMillis)
|
||||
return capability.CandidateBindingResolution{Binding: binding, Mode: binding.Context.Mode, Reason: "EXACT_CONTEXT"}, nil
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) RevokeCandidateBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64, nowUnixMillis int64) (bool, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var binding capability.CandidateBinding
|
||||
err = tx.QueryRow(ctx, `UPDATE capability.release_candidate_bindings SET status='REVOKED' WHERE binding_id=$1 AND revision=$2 AND status='ACTIVE'
|
||||
RETURNING binding_id,revision,base_active_binding_id,base_active_binding_revision,release_set_id,slot,task_id,task_revision,principal_id,capability_id,resource_scope,mode,target_kind,target_ref,target_digest,authority_grant_id,target_origin,delivery_path,receipt_path,protocol,created_at_unix_millis,expires_at_unix_millis,status`, id, revision).
|
||||
Scan(&binding.ID, &binding.Revision, &binding.BaseActiveBindingID, &binding.BaseActiveBindingRevision, &binding.Context.ReleaseSetID, &binding.Context.Slot, &binding.Context.TaskID, &binding.Context.TaskRevision, &binding.Context.PrincipalID, &binding.Context.CapabilityID, &binding.Context.ResourceScope, &binding.Context.Mode, &binding.Context.TargetKind, &binding.Context.TargetRef, &binding.Context.TargetDigest, &binding.Context.AuthorityGrantID, &binding.TargetOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.CreatedAtUnixMillis, &binding.ExpiresAtUnixMillis, &binding.Status)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `DELETE FROM capability.release_candidate_binding_heads WHERE release_set_id=$1 AND slot=$2 AND task_id=$3 AND principal_id=$4 AND capability_id=$5 AND resource_scope=$6 AND mode=$7 AND binding_id=$8 AND revision=$9`, binding.Context.ReleaseSetID, binding.Context.Slot, binding.Context.TaskID, binding.Context.PrincipalID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.release_candidate_binding_audit (binding_id,revision,release_set_id,task_id,capability_id,resource_scope,mode,decision,reason,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'REVOKED','EXPLICIT_REVOKE',$8)`, binding.ID, binding.Revision, binding.Context.ReleaseSetID, binding.Context.TaskID, binding.Context.CapabilityID, binding.Context.ResourceScope, binding.Context.Mode, nowUnixMillis)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances
|
||||
(provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros)
|
||||
@@ -437,6 +564,14 @@ func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Con
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidatesForEvaluation(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, evaluationID capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
var profileID capability.TaskEvaluationProfileID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT task_profile_id FROM capability.cognitive_task_evaluations WHERE evaluation_id=$1 AND capability_id=$2`, evaluationID, capabilityID).Scan(&profileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var capabilityID capability.CapabilityID
|
||||
var owner identity.PrincipalID
|
||||
@@ -564,7 +699,12 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
|
||||
// Discovery may advance a head, but must never roll back a governed
|
||||
// CANARY/ACTIVE revision to an older discovered snapshot after restart.
|
||||
// Discovery may advance an inactive head even when its deterministic
|
||||
// revision number is lower, but it must never overwrite a governed canary
|
||||
// or active head during restart/startup refresh.
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT (binding_id) DO UPDATE SET revision=EXCLUDED.revision,updated_at=CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM capability.cognitive_bindings current_binding WHERE current_binding.binding_id=capability.cognitive_binding_revision_heads.binding_id AND current_binding.revision=capability.cognitive_binding_revision_heads.revision AND current_binding.activation IN ('CANARY','ACTIVE'))`, binding.ID, binding.Revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,6 +111,29 @@ func (r *CommitRepository) FinishAttempt(ctx context.Context, a commitcontract.A
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CommitRepository) SaveExecutionReceipt(ctx context.Context, receipt commitcontract.ExecutionReceipt) error {
|
||||
if !receipt.Valid() {
|
||||
return ErrActionAttemptConflict
|
||||
}
|
||||
completed := int64(0)
|
||||
if receipt.CompletedAtUnixMillis != nil {
|
||||
completed = *receipt.CompletedAtUnixMillis
|
||||
}
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO commit.execution_receipts (
|
||||
receipt_id,action_attempt_id,executor_implementation_ref,external_operation_ref,external_request_id,request_digest,response_digest,dispatch_at_unix_millis,completed_at_unix_millis,
|
||||
executor_result,reconciliation_key,raw_artifact_ref,delivery_mode,external_delivery_attempted,release_set_id,task_id,task_revision,capability_binding_id,capability_binding_revision,target_reference)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT (receipt_id) DO NOTHING`,
|
||||
receipt.ID, receipt.ActionAttemptRef, receipt.ExecutorImplementationRef, receipt.ExternalOperationRef, receipt.ExternalRequestID, receipt.RequestDigest, receipt.ResponseDigest, receipt.DispatchAtUnixMillis, completed,
|
||||
receipt.ExecutorResult, receipt.ReconciliationKey, receipt.RawArtifactRef, receipt.DeliveryMode, receipt.ExternalDeliveryAttempted, receipt.ReleaseSetID, receipt.TaskID, receipt.TaskRevision, receipt.CapabilityBindingID, receipt.CapabilityBindingRevision, receipt.TargetReference)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrActionAttemptConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CommitRepository) LoadAuthorized(ctx context.Context, id commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) {
|
||||
return loadAuthorized(ctx, r.pool, id)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -26,3 +26,49 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) {
|
||||
var account identity.CreatorAccount
|
||||
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username).
|
||||
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
|
||||
if err != nil {
|
||||
return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) {
|
||||
var account identity.CreatorAccount
|
||||
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal).
|
||||
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
|
||||
if err != nil {
|
||||
return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error {
|
||||
result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update creator password: %w", err)
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("update creator password: revision conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *IdentityRepository) LoadServicePrincipal(ctx context.Context, principal identity.PrincipalID) (identity.ServicePrincipal, error) {
|
||||
var value identity.ServicePrincipal
|
||||
err := r.pool.QueryRow(ctx, `SELECT actor_id, principal_id, kind, purpose, synthetic_scope, active, revision FROM identity.service_principals WHERE principal_id=$1`, principal).
|
||||
Scan(&value.ActorID, &value.PrincipalID, &value.Kind, &value.Purpose, &value.SyntheticScope, &value.Active, &value.Revision)
|
||||
if err != nil {
|
||||
return identity.ServicePrincipal{}, fmt.Errorf("load service principal: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -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,170 @@
|
||||
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 }
|
||||
|
||||
type AcceptanceSummary struct {
|
||||
LearningSignals int `json:"learning_signals"`
|
||||
ImprovementNeeds int `json:"improvement_needs"`
|
||||
LearningLedger int `json:"learning_ledger"`
|
||||
SyntheticPrincipal string `json:"synthetic_principal"`
|
||||
}
|
||||
|
||||
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) AcceptanceSummary(ctx context.Context, principalID string) (AcceptanceSummary, error) {
|
||||
var value AcceptanceSummary
|
||||
value.SyntheticPrincipal = principalID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.learning_signals WHERE principal_id=$1`, principalID).Scan(&value.LearningSignals); err != nil {
|
||||
return AcceptanceSummary{}, err
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.improvement_needs WHERE principal_id=$1`, principalID).Scan(&value.ImprovementNeeds); err != nil {
|
||||
return AcceptanceSummary{}, err
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.learning_ledger WHERE principal_id=$1`, principalID).Scan(&value.LearningLedger); err != nil {
|
||||
return AcceptanceSummary{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-0021 — Delegated Non-Interactive Control-Plane Authority
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-31
|
||||
Accepted under: owner decision for GEN2-AUTHORITY-001
|
||||
|
||||
## Context
|
||||
|
||||
The existing Creator session endpoints correctly require a human principal for
|
||||
governed Cognitive CANARY and ACTIVE transitions. A bounded background task
|
||||
cannot use those endpoints without either a browser session, impersonation, or
|
||||
a direct Capability write. All three alternatives are invalid.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Identity remains the sole owner of Actors, Principals and their bindings. A
|
||||
service principal is a separately identified Actor/Principal binding; it is
|
||||
neither a Human Principal nor an impersonation of one.
|
||||
2. Authority remains the sole owner of delegated task grants, validation,
|
||||
revocation and safe action audit. This extends `AuthorityGrant`; it does not
|
||||
introduce a robot-authority owner or a second authorization registry.
|
||||
3. A delegated grant is typed and narrow: service principal, task id and
|
||||
revision, operation capability, exact Cognitive binding resource/revision,
|
||||
issuer, issue/expiry times, grant revision and lifecycle status. It is
|
||||
default-deny and cannot authorize grant creation, extension, policy change,
|
||||
or administration.
|
||||
4. A control-plane request is authorized in the existing `commit-runtime`, the
|
||||
runtime topology location of Authority. It must pass two independent checks: service credential
|
||||
authentication and a current delegated-grant authorization for its exact
|
||||
task/operation/resource. Credential possession never expands authority.
|
||||
5. Long-lived credential material remains only in the existing protected secret
|
||||
backend and is addressed by a `CredentialRef` plus bootstrap file reference.
|
||||
The runtime never logs, persists, or returns its token. Rotation changes the
|
||||
protected artifact; revocation of a grant blocks new actions immediately.
|
||||
6. Capability remains the only owner of Cognitive binding/evaluation lifecycle.
|
||||
The delegated path invokes the existing owner validation and promotion
|
||||
builders; it cannot synthesize an evaluation or mark a binding active.
|
||||
7. Control-plane actions receive durable Authority audit records containing
|
||||
identity, task, operation, resource, decision and safe result reference.
|
||||
Secret values, request payloads, user memory and hidden reasoning are never
|
||||
stored in that audit.
|
||||
8. External mutations continue to use Commit/Executor/Receipt/Outcome where
|
||||
the existing action is an external effect. Capability's owner-local binding
|
||||
lifecycle remains an owner-local governed state transition, not a shortcut
|
||||
around an external executor.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Background work can execute only an explicitly delegated, time-bounded task.
|
||||
- Restart reconstructs authorization from Identity/Authority durable state; no
|
||||
process-local permission cache is authoritative.
|
||||
- Each later task must receive a new delegated grant. A grant for
|
||||
GEN2-COGNITIVE-003 cannot be reused for SELF development or code promotion.
|
||||
- The first implementation deliberately covers only the Cognitive lifecycle
|
||||
operations required by GEN2-COGNITIVE-003. Broader control-plane actions
|
||||
need their own task-scoped authority and acceptance evidence.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable or revoke the delegated grant and route no new non-interactive requests
|
||||
to the endpoint. Existing Authority audit and Capability history remain
|
||||
append-only evidence; no binding state is rolled back by deleting history.
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
const tokenPath = "/run/secrets/acceptance_harness_service_token"
|
||||
|
||||
type client struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := strings.TrimRight(os.Getenv("CORE_RUNTIME_URL"), "/")
|
||||
if base == "" {
|
||||
base = "http://core-runtime-candidate:8080"
|
||||
}
|
||||
token, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
fail("acceptance credential unavailable")
|
||||
}
|
||||
c := client{base: base, token: strings.TrimSpace(string(token)), http: &http.Client{Timeout: 90 * time.Second}}
|
||||
if strings.TrimSpace(c.token) == "" {
|
||||
fail("acceptance credential unavailable")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
var issued struct {
|
||||
Token string
|
||||
Session identity.CreatorSession
|
||||
}
|
||||
if err := c.post(ctx, "/v1/acceptance/sessions", nil, &issued); err != nil {
|
||||
fail("acceptance authentication failed: " + err.Error())
|
||||
}
|
||||
if issued.Token == "" || issued.Session.PrincipalKind != identity.PrincipalKindService {
|
||||
fail("acceptance principal was not authenticated as service")
|
||||
}
|
||||
c.token = issued.Token
|
||||
var first interaction.ChatMessageResponse
|
||||
if err := c.post(ctx, "/v1/creator/chat/messages", interaction.ChatMessageRequest{Message: "synthetic acceptance statement", CorrelationID: "acceptance-harness", SyntheticAcceptance: true, SyntheticScopeID: "synthetic-acceptance-scope"}, &first); err != nil {
|
||||
fail("synthetic interaction failed: " + err.Error())
|
||||
}
|
||||
var corrected interaction.ChatMessageResponse
|
||||
if err := c.post(ctx, "/v1/creator/chat/messages", interaction.ChatMessageRequest{ConversationID: first.ConversationID, Message: "synthetic acceptance correction", CorrelationID: "acceptance-harness-correction", CorrectsUtteranceID: first.UtteranceID, SyntheticAcceptance: true, SyntheticScopeID: "synthetic-acceptance-scope"}, &corrected); err != nil {
|
||||
fail("synthetic correction failed: " + err.Error())
|
||||
}
|
||||
var summary struct {
|
||||
LearningSignals int `json:"learning_signals"`
|
||||
ImprovementNeeds int `json:"improvement_needs"`
|
||||
LearningLedger int `json:"learning_ledger"`
|
||||
}
|
||||
if err := c.get(ctx, "/v1/acceptance/self/summary", &summary); err != nil {
|
||||
fail("acceptance SELF summary unavailable: " + err.Error())
|
||||
}
|
||||
fmt.Printf("acceptance_pass learning_signals=%d improvement_needs=%d learning_ledger=%d\n", summary.LearningSignals, summary.ImprovementNeeds, summary.LearningLedger)
|
||||
}
|
||||
|
||||
func (c client) post(ctx context.Context, path string, body any, out any) error {
|
||||
return c.request(ctx, http.MethodPost, path, body, out)
|
||||
}
|
||||
func (c client) get(ctx context.Context, path string, out any) error {
|
||||
return c.request(ctx, http.MethodGet, path, nil, out)
|
||||
}
|
||||
func (c client) request(ctx context.Context, method, path string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("acceptance request denied with status %d", resp.StatusCode)
|
||||
}
|
||||
if out != nil && json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) != nil {
|
||||
return errors.New("invalid acceptance response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func fail(message string) { fmt.Fprintln(os.Stderr, message); os.Exit(1) }
|
||||
@@ -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"`
|
||||
|
||||
@@ -20,6 +20,7 @@ type fleetEndpoint struct {
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Client *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type fleetStatusResponse struct {
|
||||
@@ -63,7 +64,11 @@ func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, end
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
return member
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
|
||||
timeout := endpoint.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,3 +68,21 @@ func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessUsesProviderSpecificTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierMedium, []fleetEndpoint{{
|
||||
ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium,
|
||||
URL: server.URL, Client: server.Client(), Timeout: 100 * time.Millisecond,
|
||||
}})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"MEDIUM"`) {
|
||||
t.Fatalf("provider-specific timeout was not honored: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -71,7 +71,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 120*time.Second)
|
||||
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Fatal(adapterErr)
|
||||
@@ -113,11 +113,16 @@ 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}
|
||||
mediumReadinessClient := &http.Client{Timeout: 10 * 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}
|
||||
preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierFast)))
|
||||
@@ -131,14 +136,21 @@ func main() {
|
||||
// readiness budget short; their detailed fleet state is reported separately.
|
||||
cpuHealthProbe.Timeout = 2 * time.Second
|
||||
mediumEndpoint := ""
|
||||
mediumHealthEndpoint := ""
|
||||
mediumHeaders := map[string]string{}
|
||||
mediumHealthProbe := health.Probe{}
|
||||
if mediumURL != "" {
|
||||
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
|
||||
mediumHealthEndpoint = strings.TrimSuffix(strings.TrimRight(mediumURL, "/"), "/v1") + "/health"
|
||||
if mediumAPIKey != "" {
|
||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||
}
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||
// Readiness reports provider discovery health quickly; fleet readiness
|
||||
// separately reports authenticated model availability on /v1/models.
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(readinessClient, mediumHealthEndpoint, mediumHeaders)
|
||||
// The local gateway may need several seconds to enumerate a loaded model.
|
||||
// Keep this bounded, but do not classify a healthy MEDIUM provider as
|
||||
// unavailable before its authenticated discovery response can arrive.
|
||||
mediumHealthProbe.Timeout = 2 * time.Second
|
||||
}
|
||||
readinessProbes := []health.Probe{
|
||||
@@ -157,8 +169,8 @@ func main() {
|
||||
}
|
||||
|
||||
fleetEndpoints := []fleetEndpoint{
|
||||
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}},
|
||||
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient},
|
||||
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}, Timeout: 2 * time.Second},
|
||||
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient, Timeout: mediumReadinessTimeout},
|
||||
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
|
||||
}
|
||||
if gpuConfigured {
|
||||
@@ -191,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)
|
||||
@@ -217,7 +228,9 @@ func durationEnv(name string, fallback time.Duration) time.Duration {
|
||||
|
||||
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
|
||||
for name := range service.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
// Keep startup discovery bounded while allowing the authenticated local
|
||||
// gateway enough time to enumerate a loaded MEDIUM model.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
registration, ok := service.providers[name]
|
||||
if !ok || registration.Discovery == nil {
|
||||
cancel()
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -53,8 +56,83 @@ type modelQualificationResponse struct {
|
||||
Results []modelQualificationResult `json:"results"`
|
||||
}
|
||||
|
||||
const qualificationControlPlaneTimeout = 45 * time.Second
|
||||
|
||||
// qualificationCoordinator bounds duplicate control-plane probes within one
|
||||
// cognitive-worker process. It is deliberately not canonical state: the
|
||||
// Capability owner remains the sole owner of qualification evidence and
|
||||
// activation. A restart may repeat an incomplete probe, but cannot create a
|
||||
// second semantic writer or bypass independent evaluation.
|
||||
type qualificationCoordinator struct {
|
||||
mu sync.Mutex
|
||||
flights map[string]*qualificationFlight
|
||||
}
|
||||
|
||||
type qualificationFlight struct {
|
||||
done chan struct{}
|
||||
response modelQualificationResponse
|
||||
err error
|
||||
waiters int
|
||||
}
|
||||
|
||||
func (c *qualificationCoordinator) do(ctx context.Context, key string, operation func() (modelQualificationResponse, error)) (modelQualificationResponse, bool, error) {
|
||||
c.mu.Lock()
|
||||
if c.flights == nil {
|
||||
c.flights = make(map[string]*qualificationFlight)
|
||||
}
|
||||
if current, exists := c.flights[key]; exists {
|
||||
current.waiters++
|
||||
c.mu.Unlock()
|
||||
select {
|
||||
case <-current.done:
|
||||
return current.response, false, current.err
|
||||
case <-ctx.Done():
|
||||
return modelQualificationResponse{}, false, ctx.Err()
|
||||
}
|
||||
}
|
||||
current := &qualificationFlight{done: make(chan struct{})}
|
||||
c.flights[key] = current
|
||||
c.mu.Unlock()
|
||||
|
||||
current.response, current.err = operation()
|
||||
close(current.done)
|
||||
c.mu.Lock()
|
||||
delete(c.flights, key)
|
||||
c.mu.Unlock()
|
||||
return current.response, true, current.err
|
||||
}
|
||||
|
||||
type qualificationPhaseTrace struct {
|
||||
correlationID string
|
||||
started time.Time
|
||||
}
|
||||
|
||||
func newQualificationPhaseTrace() qualificationPhaseTrace {
|
||||
correlationID, err := ids.New()
|
||||
if err != nil {
|
||||
correlationID = ids.ID(fmt.Sprintf("qualification-%d", time.Now().UnixNano()))
|
||||
}
|
||||
return qualificationPhaseTrace{correlationID: string(correlationID), started: time.Now()}
|
||||
}
|
||||
|
||||
func (t qualificationPhaseTrace) phase(name string, fields ...string) {
|
||||
log.Printf("qualification_phase correlation_id=%s phase=%s elapsed_ms=%d %s", t.correlationID, name, time.Since(t.started).Milliseconds(), strings.Join(fields, " "))
|
||||
}
|
||||
|
||||
func qualificationFlightKey(provider string, modelIDs []capability.ProviderModelID) string {
|
||||
ids := make([]string, 0, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
ids = append(ids, string(modelID))
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return provider + "|" + strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
func modelQualificationHandler(providers map[string]providerRegistration, owner string, observers ...qualificationObserver) http.Handler {
|
||||
coordinator := &qualificationCoordinator{}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
trace := newQualificationPhaseTrace()
|
||||
trace.phase("Q0", "event=request_accepted")
|
||||
name := r.PathValue("provider")
|
||||
registration, ok := providers[name]
|
||||
if !ok || registration.Discovery == nil || registration.Provider == nil {
|
||||
@@ -72,32 +150,65 @@ func modelQualificationHandler(providers map[string]providerRegistration, owner
|
||||
http.Error(w, "max_output_bytes exceeds qualification bound", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
modelsFound, probe := registration.Discovery.Discover(r.Context())
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
http.Error(w, "provider unavailable for qualification", http.StatusServiceUnavailable)
|
||||
return
|
||||
controlCtx, cancel := context.WithTimeout(r.Context(), qualificationControlPlaneTimeout)
|
||||
defer cancel()
|
||||
response, leader, err := coordinator.do(controlCtx, qualificationFlightKey(name, request.ModelIDs), func() (modelQualificationResponse, error) {
|
||||
trace.phase("Q1", "event=binding_candidate_loaded")
|
||||
modelsFound, probe := registration.Discovery.Discover(controlCtx)
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
return modelQualificationResponse{}, fmt.Errorf("provider unavailable for qualification")
|
||||
}
|
||||
if len(request.ModelIDs) > 0 {
|
||||
modelsFound = slices.DeleteFunc(modelsFound, func(model models.DiscoveredModel) bool {
|
||||
return !slices.Contains(request.ModelIDs, model.ProviderModelID)
|
||||
})
|
||||
}
|
||||
if len(modelsFound) == 0 {
|
||||
return modelQualificationResponse{}, fmt.Errorf("requested model not discovered")
|
||||
}
|
||||
if len(modelsFound) > 16 {
|
||||
modelsFound = modelsFound[:16]
|
||||
}
|
||||
trace.phase("Q2", fmt.Sprintf("event=provider_model_resolved model_count=%d", len(modelsFound)))
|
||||
trace.phase("Q3", "event=bounded_invocation_started")
|
||||
results := qualifyModels(controlCtx, registration, owner, request.Prompt, request.MaxOutputBytes, modelsFound)
|
||||
trace.phase("Q4", "event=bounded_invocation_completed")
|
||||
trace.phase("Q5", fmt.Sprintf("event=invocation_evidence_built result_count=%d", len(results)))
|
||||
if len(observers) > 0 && observers[0] != nil {
|
||||
trace.phase("Q6", "event=observation_persistence_started")
|
||||
if err := persistQualificationResults(controlCtx, observers[0], registration, results); err != nil {
|
||||
return modelQualificationResponse{}, fmt.Errorf("persist model qualification observation: %w", err)
|
||||
}
|
||||
trace.phase("Q7", "event=observation_persistence_completed")
|
||||
}
|
||||
// Q8-Q17 are intentionally outside this worker's authority. Capability
|
||||
// owns independent evaluation and governed promotion; this trace makes
|
||||
// that hand-off explicit instead of disguising it as activation.
|
||||
trace.phase("Q8", "event=independent_evaluation_not_started owner=capability")
|
||||
return modelQualificationResponse{Provider: registration.ProviderInstanceID, ObservedAtUnixMillis: time.Now().UnixMilli(), Results: results}, nil
|
||||
})
|
||||
if !leader {
|
||||
trace.phase("Q0", "event=joined_in_progress_qualification")
|
||||
}
|
||||
if len(request.ModelIDs) > 0 {
|
||||
modelsFound = slices.DeleteFunc(modelsFound, func(model models.DiscoveredModel) bool {
|
||||
return !slices.Contains(request.ModelIDs, model.ProviderModelID)
|
||||
})
|
||||
}
|
||||
if len(modelsFound) == 0 {
|
||||
http.Error(w, "requested model not discovered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if len(modelsFound) > 16 {
|
||||
modelsFound = modelsFound[:16]
|
||||
}
|
||||
results := qualifyModels(r.Context(), registration, owner, request.Prompt, request.MaxOutputBytes, modelsFound)
|
||||
if len(observers) > 0 && observers[0] != nil {
|
||||
if err := persistQualificationResults(r.Context(), observers[0], registration, results); err != nil {
|
||||
http.Error(w, "persist model qualification observation", http.StatusBadGateway)
|
||||
if err != nil {
|
||||
if controlCtx.Err() != nil {
|
||||
http.Error(w, "qualification timeout", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "requested model not discovered") {
|
||||
http.Error(w, "requested model not discovered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "provider unavailable") {
|
||||
http.Error(w, "provider unavailable for qualification", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
http.Error(w, "persist model qualification observation", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
trace.phase("Q17", "event=qualification_response_returned activation=not_attempted")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = jsonEncode(w, modelQualificationResponse{Provider: registration.ProviderInstanceID, ObservedAtUnixMillis: time.Now().UnixMilli(), Results: results})
|
||||
_ = jsonEncode(w, response)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -17,6 +18,7 @@ type qualificationProvider struct{}
|
||||
func (qualificationProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
|
||||
func (qualificationProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "model-a"}, {ProviderModelID: "model-b"}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
@@ -83,3 +85,63 @@ func TestModelQualificationPersistsReachabilityWithoutQuality(t *testing.T) {
|
||||
t.Fatalf("invalid qualification observation: %+v err=%v", observer.observations[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelQualificationDeduplicatesSameProviderModelFlight(t *testing.T) {
|
||||
coordinator := &qualificationCoordinator{}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
firstDone := make(chan struct{})
|
||||
secondDone := make(chan struct{})
|
||||
result := modelQualificationResponse{Provider: "medium"}
|
||||
go func() {
|
||||
_, _, err := coordinator.do(context.Background(), "medium|model-a", func() (modelQualificationResponse, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return result, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("leader qualification failed: %v", err)
|
||||
}
|
||||
close(firstDone)
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("qualification invocation did not start")
|
||||
}
|
||||
go func() {
|
||||
actual, leader, err := coordinator.do(context.Background(), "medium|model-a", func() (modelQualificationResponse, error) {
|
||||
t.Error("duplicate qualification operation ran")
|
||||
return modelQualificationResponse{}, nil
|
||||
})
|
||||
if err != nil || leader || actual.Provider != result.Provider {
|
||||
t.Errorf("joined qualification response=%+v leader=%t err=%v", actual, leader, err)
|
||||
}
|
||||
close(secondDone)
|
||||
}()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
coordinator.mu.Lock()
|
||||
flight := coordinator.flights["medium|model-a"]
|
||||
waiters := 0
|
||||
if flight != nil {
|
||||
waiters = flight.waiters
|
||||
}
|
||||
coordinator.mu.Unlock()
|
||||
if waiters == 1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("second qualification did not join the in-progress flight")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
for _, done := range []chan struct{}{firstDone, secondDone} {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("qualification flight did not complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, reques
|
||||
go func(index int, item capability.TaskEvaluationCase) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, Locale: "en-US", MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
response, invokeErr := g.invokeAdmitted(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
|
||||
latency := time.Since(started).Microseconds()
|
||||
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -57,6 +58,30 @@ func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type localeCheckingProvider struct{ stubAdvisoryProvider }
|
||||
|
||||
func (p *localeCheckingProvider) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if request.Locale == "" {
|
||||
return cognitive.AdvisoryResponse{}, fmt.Errorf("task evaluation omitted required locale")
|
||||
}
|
||||
return p.stubAdvisoryProvider.Advise(ctx, request)
|
||||
}
|
||||
|
||||
func TestTaskEvaluationSuppliesLocaleToProviderAdapter(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
provider := &localeCheckingProvider{stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}}
|
||||
registry := &candidateRegistryStub{selected: selected}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}}}, now: time.Now}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-locale", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "locale-v1", RequiredCaseIDs: []string{"case-locale"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: selected.Binding.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-locale", Prompt: "one", EvidenceArtifactRef: "artifact:locale"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type taskEvaluationFailoverRegistry struct {
|
||||
primary, fallback capability.ResolvedCognitiveBinding
|
||||
resolveCalls int
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
// candidateReleaseContextFromRuntime accepts only deployment-owned metadata.
|
||||
// It never derives a candidate slot from an HTTP request or notification body.
|
||||
func candidateReleaseContextFromRuntime() (*capability.CandidateReleaseContext, error) {
|
||||
const prefix = "CANDIDATE_"
|
||||
release := os.Getenv(prefix + "RELEASE_SET_ID")
|
||||
if release == "" {
|
||||
return nil, nil
|
||||
}
|
||||
context := &capability.CandidateReleaseContext{
|
||||
ReleaseSetID: release,
|
||||
Slot: os.Getenv(prefix + "SLOT"),
|
||||
TaskID: os.Getenv(prefix + "TASK_ID"),
|
||||
PrincipalID: identity.PrincipalID(os.Getenv(prefix + "PRINCIPAL_ID")),
|
||||
CapabilityID: capability.CapabilityID(os.Getenv(prefix + "CAPABILITY_ID")),
|
||||
ResourceScope: os.Getenv(prefix + "RESOURCE_SCOPE"),
|
||||
Mode: capability.CandidateBindingMode(os.Getenv(prefix + "BINDING_MODE")),
|
||||
TargetKind: os.Getenv(prefix + "TARGET_KIND"),
|
||||
TargetRef: os.Getenv(prefix + "TARGET_REF"),
|
||||
TargetDigest: os.Getenv(prefix + "TARGET_DIGEST"),
|
||||
AuthorityGrantID: os.Getenv(prefix + "AUTHORITY_GRANT_ID"),
|
||||
}
|
||||
var revision int64
|
||||
if _, err := fmt.Sscan(os.Getenv(prefix+"TASK_REVISION"), &revision); err != nil {
|
||||
return nil, fmt.Errorf("candidate task revision: %w", err)
|
||||
}
|
||||
context.TaskRevision = revision
|
||||
if context.Slot == "" || context.TaskID == "" || context.TaskRevision < 1 || context.PrincipalID == "" || context.CapabilityID == "" || context.ResourceScope == "" || context.Mode == "" || context.TargetKind == "" || context.TargetRef == "" || context.TargetDigest == "" || context.AuthorityGrantID == "" {
|
||||
return nil, fmt.Errorf("incomplete candidate release metadata")
|
||||
}
|
||||
return context, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCandidateReleaseContextRequiresDeploymentMetadata(t *testing.T) {
|
||||
t.Setenv("CANDIDATE_RELEASE_SET_ID", "assistant-slot-b")
|
||||
if _, err := candidateReleaseContextFromRuntime(); err == nil {
|
||||
t.Fatal("partial candidate metadata was accepted")
|
||||
}
|
||||
t.Setenv("CANDIDATE_SLOT", "b")
|
||||
t.Setenv("CANDIDATE_TASK_ID", "GEN2-CAPABILITY-001")
|
||||
t.Setenv("CANDIDATE_TASK_REVISION", "7")
|
||||
t.Setenv("CANDIDATE_PRINCIPAL_ID", "domovoy-creator-principal")
|
||||
t.Setenv("CANDIDATE_CAPABILITY_ID", "prospective-notification-delivery")
|
||||
t.Setenv("CANDIDATE_RESOURCE_SCOPE", "synthetic-shadow-notification-v1")
|
||||
t.Setenv("CANDIDATE_BINDING_MODE", "SHADOW")
|
||||
t.Setenv("CANDIDATE_TARGET_KIND", "NOTIFICATION_SHADOW_RECEIVER")
|
||||
t.Setenv("CANDIDATE_TARGET_REF", "notification-receiver-candidate")
|
||||
t.Setenv("CANDIDATE_TARGET_DIGEST", "f9f9fa49a0727444c280c092d5bb340f3a8f33cf325c5396f5ae177a6666da35")
|
||||
t.Setenv("CANDIDATE_AUTHORITY_GRANT_ID", "grant")
|
||||
context, err := candidateReleaseContextFromRuntime()
|
||||
if err != nil || context.ReleaseSetID != "assistant-slot-b" || context.TaskRevision != 7 {
|
||||
t.Fatalf("context=%+v err=%v", context, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
authoritydomain "git.cin.su/m/Assistant/modules/authority/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
delegatedControlPlaneActor identity.ActorID = "domovoy-control-plane-actor"
|
||||
delegatedControlPlanePrincipal identity.PrincipalID = "domovoy-control-plane-principal"
|
||||
delegatedControlPlaneTokenFile = "control_plane_service_token"
|
||||
)
|
||||
|
||||
type delegatedControlPlaneAuthoritySource interface {
|
||||
LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error)
|
||||
RecordDelegatedControlPlaneAudit(context.Context, authority.DelegatedControlPlaneAudit) error
|
||||
RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error)
|
||||
}
|
||||
|
||||
type delegatedControlPlaneCore interface {
|
||||
Session(context.Context, string) (identity.CreatorSession, error)
|
||||
Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error)
|
||||
}
|
||||
|
||||
type delegatedControlPlaneHandler struct {
|
||||
authority delegatedControlPlaneAuthoritySource
|
||||
core delegatedControlPlaneCore
|
||||
credential func() (string, error)
|
||||
now func() time.Time
|
||||
newID func() (ids.ID, error)
|
||||
}
|
||||
|
||||
func registerDelegatedControlPlaneRoutes(mux *http.ServeMux, handler delegatedControlPlaneHandler) {
|
||||
mux.HandleFunc("POST /v1/internal/control-plane/authorize", handler.authorize)
|
||||
mux.HandleFunc("POST /v1/internal/control-plane/completions", handler.complete)
|
||||
mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", handler.revoke)
|
||||
}
|
||||
|
||||
func (h delegatedControlPlaneHandler) authorize(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.serviceAuthenticated(r) {
|
||||
http.Error(w, "service principal denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var request authority.DelegatedControlPlaneRequest
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid delegated control-plane request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), request.GrantID)
|
||||
if err != nil {
|
||||
http.Error(w, "delegated authority denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
binding, err := h.core.Binding(r.Context(), delegatedControlPlaneActor, delegatedControlPlanePrincipal)
|
||||
if err != nil || authoritydomain.ValidateDelegatedControlPlaneGrant(h.now().UnixMilli(), binding, grant, request) != nil {
|
||||
_ = h.audit(r.Context(), grant, request, "DENY", "FAILED")
|
||||
http.Error(w, "delegated authority denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := h.audit(r.Context(), grant, request, "ALLOW", "PENDING"); err != nil {
|
||||
http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h delegatedControlPlaneHandler) complete(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.serviceAuthenticated(r) {
|
||||
http.Error(w, "service principal denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var value authority.DelegatedControlPlaneCompletion
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || (value.Outcome != "COMPLETED" && value.Outcome != "FAILED") {
|
||||
http.Error(w, "invalid delegated completion", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), value.Request.GrantID)
|
||||
if err != nil {
|
||||
http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !authoritydomain.RequestMatchesDelegatedControlPlaneGrant(grant, value.Request) {
|
||||
http.Error(w, "delegated completion denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := h.audit(r.Context(), grant, value.Request, "ALLOW", value.Outcome); err != nil {
|
||||
http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h delegatedControlPlaneHandler) revoke(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.core.Session(r.Context(), r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), authority.GrantID(r.PathValue("grant")))
|
||||
if err != nil || !authoritydomain.CanIssuerRevokeDelegatedGrant(session.PrincipalID, grant) {
|
||||
http.Error(w, "delegated grant revoke denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ok, err := h.authority.RevokeDelegatedControlPlaneGrant(r.Context(), grant.ID, string(session.PrincipalID))
|
||||
if err != nil || !ok {
|
||||
http.Error(w, "delegated grant revoke denied", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationRevokeControlPlaneGrant, Resource: grant.Resource}
|
||||
if err := h.audit(r.Context(), grant, request, "REVOKE", "REVOKED"); err != nil {
|
||||
http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h delegatedControlPlaneHandler) serviceAuthenticated(r *http.Request) bool {
|
||||
if h.credential == nil {
|
||||
return false
|
||||
}
|
||||
expected, err := h.credential()
|
||||
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return err == nil && expected != "" && presented != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1
|
||||
}
|
||||
|
||||
func (h delegatedControlPlaneHandler) audit(ctx context.Context, grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest, decision, outcome string) error {
|
||||
if h.newID == nil {
|
||||
return errors.New("delegated audit id unavailable")
|
||||
}
|
||||
id, err := h.newID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.authority.RecordDelegatedControlPlaneAudit(ctx, authority.DelegatedControlPlaneAudit{AuditID: id, GrantID: grant.ID, ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, TaskID: request.TaskID, TaskRevision: request.TaskRevision, Operation: request.Operation, Resource: request.Resource, Decision: decision, Outcome: outcome, RecordedAtUnixMillis: h.now().UnixMilli()})
|
||||
}
|
||||
|
||||
func delegatedControlPlaneCredential() (string, error) {
|
||||
ref := os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF")
|
||||
if ref == "" {
|
||||
return "", errors.New("control-plane service credential ref is required")
|
||||
}
|
||||
root := os.Getenv("CONTROL_PLANE_SERVICE_SECRET_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/control-plane-secrets"
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCommit, Path: filepath.Join(root, delegatedControlPlaneTokenFile)})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type delegatedAuthorityFake struct {
|
||||
grant authority.DelegatedControlPlaneGrant
|
||||
audits []authority.DelegatedControlPlaneAudit
|
||||
revoked bool
|
||||
}
|
||||
|
||||
func TestCandidateBindingResolutionUsesServiceCredential(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/internal/capability-bindings/candidate/resolve" || r.Header.Get("Authorization") != "Bearer service-token" {
|
||||
t.Fatalf("unexpected candidate resolution request: %s %q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"reason":"EXACT_CONTEXT"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newCreatorCoreClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.serviceCredential = func() (string, error) { return "service-token", nil }
|
||||
if _, err := client.ResolveCandidateCapabilityBinding(context.Background(), capability.CandidateReleaseContext{}); err != nil {
|
||||
t.Fatalf("resolve candidate binding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegatedAuthorityCredentialRotationRejectsOldToken(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tokenPath := filepath.Join(root, "control_plane_service_token")
|
||||
if err := os.WriteFile(tokenPath, []byte("old-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF", "credential://domovoy/control-plane/service-token/v1")
|
||||
t.Setenv("CONTROL_PLANE_SERVICE_SECRET_ROOT", root)
|
||||
h := delegatedControlPlaneHandler{credential: delegatedControlPlaneCredential}
|
||||
oldRequest := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", nil)
|
||||
oldRequest.Header.Set("Authorization", "Bearer old-token")
|
||||
if !h.serviceAuthenticated(oldRequest) {
|
||||
t.Fatal("active credential was rejected before rotation")
|
||||
}
|
||||
if err := os.WriteFile(tokenPath, []byte("new-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.serviceAuthenticated(oldRequest) {
|
||||
t.Fatal("old credential remained valid after rotation")
|
||||
}
|
||||
newRequest := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", nil)
|
||||
newRequest.Header.Set("Authorization", "Bearer new-token")
|
||||
if !h.serviceAuthenticated(newRequest) {
|
||||
t.Fatal("new credential was rejected after rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *delegatedAuthorityFake) LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) {
|
||||
if f.grant.ID == "" {
|
||||
return authority.DelegatedControlPlaneGrant{}, errors.New("missing")
|
||||
}
|
||||
return f.grant, nil
|
||||
}
|
||||
func (f *delegatedAuthorityFake) RecordDelegatedControlPlaneAudit(_ context.Context, audit authority.DelegatedControlPlaneAudit) error {
|
||||
f.audits = append(f.audits, audit)
|
||||
return nil
|
||||
}
|
||||
func (f *delegatedAuthorityFake) RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error) {
|
||||
f.revoked = true
|
||||
f.grant.Status = authority.DelegatedGrantRevoked
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type delegatedCoreFake struct{}
|
||||
|
||||
func (delegatedCoreFake) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) {
|
||||
return identity.PrincipalBinding{ActorID: delegatedControlPlaneActor, PrincipalID: delegatedControlPlanePrincipal, Active: true}, nil
|
||||
}
|
||||
func (delegatedCoreFake) Session(context.Context, string) (identity.CreatorSession, error) {
|
||||
return identity.CreatorSession{PrincipalID: "owner"}, nil
|
||||
}
|
||||
|
||||
func TestDelegatedAuthorityRejectsWrongResourceAndSurvivesRestart(t *testing.T) {
|
||||
resource := authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"}
|
||||
fake := &delegatedAuthorityFake{grant: authority.DelegatedControlPlaneGrant{ID: "grant", ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, IssuerPrincipalID: "owner", TaskID: "GEN2-COGNITIVE-003", TaskRevision: 4, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationCanaryCognitiveBinding}, Resource: resource, IssuedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive}}
|
||||
h := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: func() (string, error) { return "service-token", nil }, now: func() time.Time { return time.UnixMilli(10) }, newID: func() (ids.ID, error) { return "audit", nil }}
|
||||
mux := http.NewServeMux()
|
||||
registerDelegatedControlPlaneRoutes(mux, h)
|
||||
valid := `{"GrantID":"grant","TaskID":"GEN2-COGNITIVE-003","TaskRevision":4,"Operation":"CANARY_COGNITIVE_BINDING","Resource":{"BindingID":"binding","ProviderInstanceID":"provider","ModelDescriptorID":"model"}}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid))
|
||||
request.Header.Set("Authorization", "Bearer service-token")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("valid delegation status=%d", response.Code)
|
||||
}
|
||||
wrong := strings.Replace(valid, `"binding"`, `"other"`, 1)
|
||||
request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(wrong))
|
||||
request.Header.Set("Authorization", "Bearer service-token")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong resource status=%d", response.Code)
|
||||
}
|
||||
// A fresh handler reads the same durable grant source; no process-local allow survives restart.
|
||||
request = httptest.NewRequest(http.MethodPost, "/v1/creator/control-plane/grants/grant/revoke", nil)
|
||||
request.Header.Set("Authorization", "Bearer creator-session")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNoContent || !fake.revoked {
|
||||
t.Fatalf("revoke status=%d revoked=%v", response.Code, fake.revoked)
|
||||
}
|
||||
restarted := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: h.credential, now: h.now, newID: h.newID}
|
||||
mux = http.NewServeMux()
|
||||
registerDelegatedControlPlaneRoutes(mux, restarted)
|
||||
request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid))
|
||||
request.Header.Set("Authorization", "Bearer service-token")
|
||||
response = httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("revoked grant accepted after restart: %d", response.Code)
|
||||
}
|
||||
if len(fake.audits) < 3 {
|
||||
t.Fatalf("audit trail missing: %#v", fake.audits)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ var (
|
||||
)
|
||||
|
||||
type creatorCoreClient struct {
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
baseURL *url.URL
|
||||
client *http.Client
|
||||
serviceCredential func() (string, error)
|
||||
}
|
||||
|
||||
type creatorContinuityClient struct {
|
||||
@@ -121,6 +122,58 @@ func (c creatorCoreClient) CapabilityBinding(ctx context.Context, id capability.
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (c creatorCoreClient) ResolveCandidateCapabilityBinding(ctx context.Context, context capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) {
|
||||
var resolved capability.CandidateBindingResolution
|
||||
status, err := c.postServiceAuthenticated(ctx, "/v1/internal/capability-bindings/candidate/resolve", context, &resolved)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return capability.CandidateBindingResolution{}, errCreatorQueryUnavailable
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (c creatorCoreClient) postServiceAuthenticated(ctx context.Context, path string, value any, destination any) (int, error) {
|
||||
if c.serviceCredential == nil {
|
||||
return 0, errCreatorQueryUnavailable
|
||||
}
|
||||
credential, err := c.serviceCredential()
|
||||
if err != nil || credential == "" {
|
||||
return 0, errCreatorQueryUnavailable
|
||||
}
|
||||
return c.postWithAuthorization(ctx, path, value, destination, "Bearer "+credential)
|
||||
}
|
||||
|
||||
func (c creatorCoreClient) post(ctx context.Context, path string, value any, destination any) (int, error) {
|
||||
return c.postWithAuthorization(ctx, path, value, destination, "")
|
||||
}
|
||||
|
||||
func (c creatorCoreClient) postWithAuthorization(ctx context.Context, path string, value any, destination any, authorization string) (int, error) {
|
||||
target := *c.baseURL
|
||||
target.Path, target.RawQuery = path, ""
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if authorization != "" {
|
||||
request.Header.Set("Authorization", authorization)
|
||||
}
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK && destination != nil {
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(destination); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c creatorCoreClient) get(ctx context.Context, path, authorization string, destination any) (int, error) {
|
||||
target := *c.baseURL
|
||||
target.Path = path
|
||||
|
||||
@@ -24,6 +24,7 @@ type executionRepository interface {
|
||||
LoadLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, error)
|
||||
BeginAttempt(context.Context, commitcontract.ActionAttempt) error
|
||||
FinishAttempt(context.Context, commitcontract.ActionAttempt) error
|
||||
SaveExecutionReceipt(context.Context, commitcontract.ExecutionReceipt) error
|
||||
}
|
||||
|
||||
type attemptRequest struct {
|
||||
@@ -94,10 +95,17 @@ func executeAuthorized(ctx context.Context, repository executionRepository, exec
|
||||
if err := repository.BeginAttempt(ctx, begun); err != nil {
|
||||
return commitcontract.ActionAttempt{}, err
|
||||
}
|
||||
attempt, operationErr := commitdomain.Execute(action, attemptID, executor)
|
||||
attempt, result, operationErr := commitdomain.ExecuteWithResult(action, attemptID, executor)
|
||||
if err := repository.FinishAttempt(ctx, attempt); err != nil {
|
||||
return commitcontract.ActionAttempt{}, err
|
||||
}
|
||||
if result.Receipt != nil {
|
||||
receipt := *result.Receipt
|
||||
receipt.ActionAttemptRef = attempt.ID
|
||||
if err := repository.SaveExecutionReceipt(ctx, receipt); err != nil {
|
||||
return commitcontract.ActionAttempt{}, err
|
||||
}
|
||||
}
|
||||
return attempt, operationErr
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import (
|
||||
type fakeExecutionRepository struct {
|
||||
action commitcontract.AuthorizedAction
|
||||
attempts []commitcontract.ActionAttempt
|
||||
receipt commitcontract.ExecutionReceipt
|
||||
}
|
||||
|
||||
func (f *fakeExecutionRepository) SaveExecutionReceipt(_ context.Context, receipt commitcontract.ExecutionReceipt) error {
|
||||
f.receipt = receipt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeExecutionRepository) LoadAuthorized(context.Context, commitcontract.AuthorizedActionID) (commitcontract.AuthorizedAction, error) {
|
||||
@@ -47,7 +53,8 @@ type knownExecutor struct{ calls int }
|
||||
|
||||
func (e *knownExecutor) Execute(commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
e.calls++
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
completed := int64(1)
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true, Receipt: &commitcontract.ExecutionReceipt{ID: "receipt-1", ExecutorImplementationRef: "test-executor", ExternalOperationRef: "SHADOW_CAPTURED", RequestDigest: "request", ExecutorResult: commitcontract.ExecutorReportedSuccess, ReconciliationKey: "receipt-1", DispatchAtUnixMillis: 1, CompletedAtUnixMillis: &completed}}
|
||||
}
|
||||
func (e *knownExecutor) Reconcile(commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
return ports.ExecutionResult{Known: true, Succeeded: true}
|
||||
@@ -66,6 +73,9 @@ func TestExecutionIsDurablyBegunAndCannotBeBlindlyRepeated(t *testing.T) {
|
||||
if result.Code != http.StatusOK || executor.calls != 1 || repository.attempts[0].State != commitcontract.ActionSucceeded {
|
||||
t.Fatalf("status=%d calls=%d attempts=%#v", result.Code, executor.calls, repository.attempts)
|
||||
}
|
||||
if repository.receipt.ActionAttemptRef != repository.attempts[0].ID {
|
||||
t.Fatalf("receipt attempt reference=%q want %q", repository.receipt.ActionAttemptRef, repository.attempts[0].ID)
|
||||
}
|
||||
|
||||
repeated := httptest.NewRequest(http.MethodPost, "/v1/internal/authorized-actions/authorized-1/execute", strings.NewReader(`{"AttemptID":"attempt-2"}`))
|
||||
repeated.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
@@ -73,15 +73,18 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE")
|
||||
candidateContext, err := candidateReleaseContextFromRuntime()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF")
|
||||
if notificationSecretRef == "" {
|
||||
log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required")
|
||||
notificationSecretRef, notificationSecret, err := notificationCredentialsFromRuntime(candidateContext != nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
|
||||
if candidateContext != nil {
|
||||
creatorCore.serviceCredential = delegatedControlPlaneCredential
|
||||
}
|
||||
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient, candidateContext: candidateContext}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
@@ -100,6 +103,9 @@ func main() {
|
||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||
executor: dispatcher, newID: ids.New, now: time.Now,
|
||||
}, executorToken)
|
||||
registerDelegatedControlPlaneRoutes(mux, delegatedControlPlaneHandler{
|
||||
authority: authorityRepository, core: creatorCore, credential: delegatedControlPlaneCredential, now: time.Now, newID: ids.New,
|
||||
})
|
||||
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request authorizationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
@@ -159,6 +165,24 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// notificationCredentialsFromRuntime keeps candidate shadow execution
|
||||
// physically separate from the production notification credential mount.
|
||||
func notificationCredentialsFromRuntime(candidate bool) (string, string, error) {
|
||||
fileName, refName := "NOTIFICATION_WRITE_TOKEN_FILE", "NOTIFICATION_WRITE_SECRET_REF"
|
||||
if candidate {
|
||||
fileName, refName = "CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", "CANDIDATE_NOTIFICATION_WRITE_SECRET_REF"
|
||||
}
|
||||
secret, err := readCommitSecretFile(fileName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
return "", "", fmt.Errorf("%s is required", refName)
|
||||
}
|
||||
return ref, secret, nil
|
||||
}
|
||||
|
||||
func readCommitSecretFile(name string) (string, error) {
|
||||
path := os.Getenv(name)
|
||||
if path == "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type notificationBindingSource interface {
|
||||
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
|
||||
CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error)
|
||||
ResolveCandidateCapabilityBinding(context.Context, capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error)
|
||||
}
|
||||
|
||||
type operationDispatcher struct {
|
||||
@@ -23,6 +24,7 @@ type operationDispatcher struct {
|
||||
secretRef string
|
||||
secret string
|
||||
client *http.Client
|
||||
candidateContext *capability.CandidateReleaseContext
|
||||
}
|
||||
|
||||
func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult {
|
||||
@@ -30,6 +32,20 @@ func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) por
|
||||
return d.continuityExecutor.Execute(action)
|
||||
}
|
||||
subject := action.Proposal.NotificationSubject
|
||||
if d.candidateContext != nil {
|
||||
resolved, err := d.bindings.ResolveCandidateCapabilityBinding(context.Background(), *d.candidateContext)
|
||||
if err != nil || resolved.Mode != capability.CandidateBindingModeShadow || resolved.Binding.Context != *d.candidateContext {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
if subject.BindingID != resolved.Binding.BaseActiveBindingID || subject.BindingRevision != resolved.Binding.BaseActiveBindingRevision {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
executor, err := notificationexecutor.NewShadow(resolved.Binding, d.secret, d.client)
|
||||
if err != nil {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
return executor.Execute(action)
|
||||
}
|
||||
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
|
||||
if err != nil || exact.SecretRef != d.secretRef {
|
||||
return ports.ExecutionResult{Known: true}
|
||||
@@ -49,6 +65,11 @@ func (d operationDispatcher) Reconcile(action commitcontract.AuthorizedAction) p
|
||||
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
|
||||
return d.continuityReconciler.Reconcile(action)
|
||||
}
|
||||
if d.candidateContext != nil {
|
||||
// A candidate shadow delivery is never reconciled against the ACTIVE
|
||||
// production target. Its typed receipt is the sole candidate evidence.
|
||||
return ports.ExecutionResult{Known: true}
|
||||
}
|
||||
subject := action.Proposal.NotificationSubject
|
||||
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
|
||||
if err != nil || exact.SecretRef != d.secretRef {
|
||||
|
||||
@@ -3,21 +3,43 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
func TestCandidateNotificationCredentialsDoNotFallbackToProductionCredential(t *testing.T) {
|
||||
path := t.TempDir() + "/candidate-token"
|
||||
if err := os.WriteFile(path, []byte("candidate-only-token"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", path)
|
||||
t.Setenv("CANDIDATE_NOTIFICATION_WRITE_SECRET_REF", "credential://domovoy/candidate/notification-write/v1")
|
||||
ref, secret, err := notificationCredentialsFromRuntime(true)
|
||||
if err != nil || ref == "" || secret != "candidate-only-token" {
|
||||
t.Fatalf("candidate credential resolution failed: ref=%q err=%v", ref, err)
|
||||
}
|
||||
t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", "")
|
||||
if _, _, err := notificationCredentialsFromRuntime(true); err == nil {
|
||||
t.Fatal("candidate path fell back to production notification credential")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNotificationBindings struct {
|
||||
exact capability.Binding
|
||||
active capability.Binding
|
||||
exactErr error
|
||||
activeErr error
|
||||
exact capability.Binding
|
||||
active capability.Binding
|
||||
exactErr error
|
||||
activeErr error
|
||||
candidate capability.CandidateBindingResolution
|
||||
candidateErr error
|
||||
}
|
||||
|
||||
func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) {
|
||||
@@ -26,6 +48,9 @@ func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.
|
||||
func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) {
|
||||
return f.active, f.activeErr
|
||||
}
|
||||
func (f fakeNotificationBindings) ResolveCandidateCapabilityBinding(context.Context, capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) {
|
||||
return f.candidate, f.candidateErr
|
||||
}
|
||||
|
||||
func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction {
|
||||
t.Helper()
|
||||
@@ -81,3 +106,28 @@ func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) {
|
||||
t.Fatalf("reconcile=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateDispatcherUsesShadowBindingWithoutActiveFallback(t *testing.T) {
|
||||
var action commitcontract.AuthorizedAction
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Candidate-Release") != "assistant-slot-b" || r.Header.Get("X-Candidate-Task") != "GEN2-CAPABILITY-001" {
|
||||
t.Fatalf("candidate headers missing")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "shadow-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1, DeliveryMode: "SHADOW"})
|
||||
}))
|
||||
defer server.Close()
|
||||
stable := dispatcherBinding("https://stable.invalid")
|
||||
action = dispatcherAction(t, stable)
|
||||
context := capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: stable.OwnerPrincipalID, CapabilityID: stable.CapabilityID, ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant"}
|
||||
candidate := capability.CandidateBinding{ID: "candidate-1", Revision: 1, BaseActiveBindingID: stable.ID, BaseActiveBindingRevision: stable.Revision, Context: context, TargetOrigin: server.URL, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive}
|
||||
digest, err := capabilitydomain.CandidateTargetDigest(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
context.TargetDigest, candidate.Context.TargetDigest = digest, digest
|
||||
dispatcher := operationDispatcher{continuityExecutor: &knownExecutor{}, continuityReconciler: &knownExecutor{}, bindings: fakeNotificationBindings{activeErr: errors.New("stable fallback must not run"), candidate: capability.CandidateBindingResolution{Binding: candidate, Mode: capability.CandidateBindingModeShadow}}, secret: "shadow-secret", client: server.Client(), candidateContext: &context}
|
||||
result := dispatcher.Execute(action)
|
||||
if !result.Known || !result.Succeeded || result.Receipt == nil || result.Receipt.DeliveryMode != "SHADOW" {
|
||||
t.Fatalf("candidate result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,154 @@ 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
|
||||
// spending the whole request budget generating unnecessary prose.
|
||||
responseReviewMaxOutputBytes = 512
|
||||
stableKnowledgeMaxOutputBytes = 512
|
||||
semanticInterpretMaxOutputBytes = 1024
|
||||
)
|
||||
|
||||
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
|
||||
// into a natural answer. The draft remains the safe fallback when cognition is
|
||||
// unavailable; the model does not mutate canonical state or execute actions.
|
||||
@@ -47,7 +197,7 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
|
||||
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: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Context: context, Locale: "ru", MaxOutputBytes: responseReviewMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{
|
||||
SpeechAct: "RESPONSE_REVIEW",
|
||||
CapabilityHints: meaning.CapabilityHints,
|
||||
@@ -86,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: 1024, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
|
||||
})
|
||||
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: stableKnowledgeMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
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)
|
||||
@@ -130,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 == "" {
|
||||
@@ -294,7 +481,7 @@ func buildGatewayInterpretationRequest(session identity.CreatorSession, utteranc
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"),
|
||||
Message: utterance.Content,
|
||||
Locale: "ru",
|
||||
MaxOutputBytes: 4096,
|
||||
MaxOutputBytes: semanticInterpretMaxOutputBytes,
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
DeterministicSpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
DeterministicSecondary: string(semantic.Interpretation.SecondarySpeechAct),
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
)
|
||||
|
||||
var ErrControlPlaneUnavailable = errors.New("delegated control-plane authority unavailable")
|
||||
|
||||
type controlPlaneAuthority struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
credential func() (string, error)
|
||||
}
|
||||
|
||||
func (a controlPlaneAuthority) authorize(ctx context.Context, request authority.DelegatedControlPlaneRequest) error {
|
||||
return a.post(ctx, "/v1/internal/control-plane/authorize", request)
|
||||
}
|
||||
|
||||
func (a controlPlaneAuthority) completed(ctx context.Context, request authority.DelegatedControlPlaneRequest, success bool) {
|
||||
outcome := "FAILED"
|
||||
if success {
|
||||
outcome = "COMPLETED"
|
||||
}
|
||||
_ = a.post(ctx, "/v1/internal/control-plane/completions", authority.DelegatedControlPlaneCompletion{Request: request, Outcome: outcome})
|
||||
}
|
||||
|
||||
func (a controlPlaneAuthority) revoke(ctx context.Context, creatorAuthorization string, grantID authority.GrantID) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+"/v1/creator/control-plane/grants/"+string(grantID)+"/revoke", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", creatorAuthorization)
|
||||
response, err := a.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("delegated grant revoke status %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a controlPlaneAuthority) post(ctx context.Context, path string, value any) error {
|
||||
credential, err := a.credential()
|
||||
if err != nil || credential == "" {
|
||||
return ErrControlPlaneUnavailable
|
||||
}
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+credential)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := a.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("delegated control-plane status %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
)
|
||||
|
||||
func TestControlPlaneClientSendsOnlyServiceCredentialToAuthorityRuntime(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/internal/control-plane/authorize" || r.Header.Get("Authorization") != "Bearer service-token" {
|
||||
t.Fatalf("unexpected delegated request: %s %q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := controlPlaneAuthority{baseURL: server.URL, client: server.Client(), credential: func() (string, error) { return "service-token", nil }}
|
||||
request := authority.DelegatedControlPlaneRequest{GrantID: "grant", TaskID: "task", TaskRevision: 1, Operation: authority.DelegatedOperationCanaryCognitiveBinding, Resource: authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"}}
|
||||
if err := client.authorize(context.Background(), request); err != nil {
|
||||
t.Fatalf("authorize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneCredentialUsesDedicatedSecretRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, controlPlaneServiceTokenFile), []byte("test-machine-token\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF", "credential://domovoy/control-plane/service-token/v1")
|
||||
t.Setenv("CONTROL_PLANE_SERVICE_SECRET_ROOT", root)
|
||||
if got, err := readControlPlaneServiceCredential(); err != nil || got != "test-machine-token" {
|
||||
t.Fatalf("control-plane credential resolution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateControlPlaneAuthenticationRejectsMissingOrWrongCredential(t *testing.T) {
|
||||
credential := func() (string, error) { return "service-token", nil }
|
||||
for _, authorization := range []string{"", "Bearer other-token", "Basic service-token"} {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/capability-bindings/candidate/resolve", nil)
|
||||
request.Header.Set("Authorization", authorization)
|
||||
if candidateControlPlaneAuthenticated(request, credential) {
|
||||
t.Fatalf("authorization %q was accepted", authorization)
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/internal/capability-bindings/candidate/resolve", nil)
|
||||
request.Header.Set("Authorization", "Bearer service-token")
|
||||
if !candidateControlPlaneAuthenticated(request, credential) {
|
||||
t.Fatal("expected service credential to be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateReleaseContextRequiresCompleteRuntimeMetadata(t *testing.T) {
|
||||
t.Setenv("CANDIDATE_RELEASE_SET_ID", "assistant-slot-b")
|
||||
if _, err := candidateReleaseContextFromRuntime(); err == nil {
|
||||
t.Fatal("partial candidate runtime metadata was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldInvokeHumanResponseSkipsDeterministicFastPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stableConversational bool
|
||||
identityReply string
|
||||
memoryInstructionReply string
|
||||
want bool
|
||||
}{
|
||||
{name: "stable conversational", stableConversational: true},
|
||||
{name: "identity", identityReply: "Меня зовут Домовой."},
|
||||
{name: "memory instruction", memoryInstructionReply: "Запомнил."},
|
||||
{name: "ordinary continuity", want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := shouldInvokeHumanResponse(test.stableConversational, test.identityReply, test.memoryInstructionReply); got != test.want {
|
||||
t.Fatalf("shouldInvokeHumanResponse() = %t, want %t", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReviewResponseSkipsStableAnswers(t *testing.T) {
|
||||
if shouldReviewResponse("Здравствуйте.", false, true, "", "", false) {
|
||||
t.Fatal("stable conversational reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Меня зовут Домовой.", false, false, "Меня зовут Домовой.", "", false) {
|
||||
t.Fatal("identity reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Запомнил.", false, false, "", "Запомнил.", false) {
|
||||
t.Fatal("memory instruction reply must not wait for response review")
|
||||
}
|
||||
if shouldReviewResponse("Вы называли тестовый цвет: синий.", false, false, "", "", true) {
|
||||
t.Fatal("deterministic memory reply must not wait for response review")
|
||||
}
|
||||
if !shouldReviewResponse("Ответ", false, false, "", "", false) {
|
||||
t.Fatal("non-conversational answer remains reviewable")
|
||||
}
|
||||
if shouldReviewResponse("Ответ", true, false, "", "", false) {
|
||||
t.Fatal("stable knowledge answer must not be reviewed twice")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+612
-66
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,12 +25,14 @@ 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"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -37,6 +40,71 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
creatorPasswordPath = "/run/secrets/creator_password"
|
||||
creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token"
|
||||
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"
|
||||
acceptanceHarnessTokenPath = "/run/secrets/acceptance_harness_service_token"
|
||||
controlPlaneServiceTokenFile = "control_plane_service_token"
|
||||
)
|
||||
|
||||
func candidateAuthorityResource(context capability.CandidateReleaseContext, origin string) authority.CandidateCapabilityBindingResource {
|
||||
digest := context.TargetDigest
|
||||
if origin != "" {
|
||||
calculated, err := capabilitydomain.CandidateTargetDigest(capability.CandidateBinding{Context: context, TargetOrigin: origin, Protocol: capability.ProtocolIdempotentReceiptV1})
|
||||
if err != nil {
|
||||
digest = ""
|
||||
} else {
|
||||
digest = calculated
|
||||
}
|
||||
}
|
||||
return authority.CandidateCapabilityBindingResource{ReleaseSetID: context.ReleaseSetID, Slot: context.Slot, CapabilityID: string(context.CapabilityID), ResourceScope: context.ResourceScope, Mode: string(context.Mode), TargetKind: context.TargetKind, TargetRef: context.TargetRef, TargetDigest: digest}
|
||||
}
|
||||
|
||||
// candidateReleaseContextFromRuntime accepts only deployment-owned metadata.
|
||||
// Candidate HTTP callers cannot choose a release identity for this runtime.
|
||||
func candidateReleaseContextFromRuntime() (*capability.CandidateReleaseContext, error) {
|
||||
const prefix = "CANDIDATE_"
|
||||
release := os.Getenv(prefix + "RELEASE_SET_ID")
|
||||
if release == "" {
|
||||
return nil, nil
|
||||
}
|
||||
context := &capability.CandidateReleaseContext{
|
||||
ReleaseSetID: release,
|
||||
Slot: os.Getenv(prefix + "SLOT"),
|
||||
TaskID: os.Getenv(prefix + "TASK_ID"),
|
||||
PrincipalID: identity.PrincipalID(os.Getenv(prefix + "PRINCIPAL_ID")),
|
||||
CapabilityID: capability.CapabilityID(os.Getenv(prefix + "CAPABILITY_ID")),
|
||||
ResourceScope: os.Getenv(prefix + "RESOURCE_SCOPE"),
|
||||
Mode: capability.CandidateBindingMode(os.Getenv(prefix + "BINDING_MODE")),
|
||||
TargetKind: os.Getenv(prefix + "TARGET_KIND"),
|
||||
TargetRef: os.Getenv(prefix + "TARGET_REF"),
|
||||
TargetDigest: os.Getenv(prefix + "TARGET_DIGEST"),
|
||||
AuthorityGrantID: os.Getenv(prefix + "AUTHORITY_GRANT_ID"),
|
||||
}
|
||||
revision, err := strconv.ParseInt(os.Getenv(prefix+"TASK_REVISION"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("candidate task revision: %w", err)
|
||||
}
|
||||
context.TaskRevision = revision
|
||||
if context.Slot == "" || context.TaskID == "" || context.TaskRevision < 1 || context.PrincipalID == "" || context.CapabilityID == "" || context.ResourceScope == "" || context.Mode == "" || context.TargetKind == "" || context.TargetRef == "" || context.TargetDigest == "" || context.AuthorityGrantID == "" {
|
||||
return nil, errors.New("incomplete candidate release metadata")
|
||||
}
|
||||
return context, nil
|
||||
}
|
||||
|
||||
func candidateControlPlaneAuthenticated(r *http.Request, credential func() (string, error)) bool {
|
||||
if credential == nil {
|
||||
return false
|
||||
}
|
||||
expected, err := credential()
|
||||
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
return err == nil && expected != "" && presented != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1
|
||||
}
|
||||
|
||||
const maxCreatorCredentialsBytes = 4 << 10
|
||||
|
||||
func main() {
|
||||
@@ -44,20 +112,21 @@ func main() {
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
|
||||
bootstrapToken, err := readRequiredSecretFile("CREATOR_BOOTSTRAP_TOKEN", creatorBootstrapTokenPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
|
||||
sessionKeyValue, err := readRequiredSecretFile("CREATOR_SESSION_SIGNING_KEY", creatorSessionKeyPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sessionKey := []byte(sessionKeyValue)
|
||||
creatorUsername := os.Getenv("CREATOR_USERNAME")
|
||||
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)
|
||||
@@ -73,7 +142,7 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer capabilityRepository.Close()
|
||||
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF", epistemicsDBPasswordPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -91,7 +160,22 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer identityRepository.Close()
|
||||
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF")
|
||||
if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
acceptanceServiceToken := ""
|
||||
var acceptancePrincipal identity.ServicePrincipal
|
||||
if os.Getenv("ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF") != "" {
|
||||
acceptanceServiceToken, err = readCredentialRefSecretPurpose("ACCEPTANCE_HARNESS_SERVICE_TOKEN", "ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF", acceptanceHarnessTokenPath, secretcontracts.SecretPurposeServiceAuthentication)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
acceptancePrincipal, err = identityRepository.LoadServicePrincipal(context.Background(), identity.PrincipalID("domovoy-acceptance-harness-principal"))
|
||||
if err != nil || !acceptancePrincipal.Active {
|
||||
log.Fatal("acceptance service principal is unavailable")
|
||||
}
|
||||
}
|
||||
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -104,6 +188,37 @@ 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")
|
||||
}
|
||||
var delegatedAuthority *controlPlaneAuthority
|
||||
if os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") != "" {
|
||||
delegatedAuthority = &controlPlaneAuthority{baseURL: envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), client: &http.Client{Timeout: 5 * time.Second}, credential: func() (string, error) {
|
||||
return readControlPlaneServiceCredential()
|
||||
}}
|
||||
} else {
|
||||
log.Printf("delegated control-plane authority is not configured; existing human control remains available")
|
||||
}
|
||||
candidateContext, err := candidateReleaseContextFromRuntime()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -124,16 +239,36 @@ 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())
|
||||
if err != nil {
|
||||
if err != nil || (session.PrincipalKind != "" && session.PrincipalKind != identity.PrincipalKindHuman) {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
authenticateAcceptance := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||
if acceptanceServiceToken == "" || acceptancePrincipal.Kind != identity.PrincipalKindService || !acceptancePrincipal.Active || !identitydomain.BootstrapMatches(acceptanceServiceToken, bearerToken(r)) {
|
||||
http.Error(w, "acceptance authentication denied", http.StatusUnauthorized)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
_, session, err := identitydomain.IssueServiceSession(sessionKey, acceptancePrincipal, time.Now(), 30*time.Minute)
|
||||
if err != nil {
|
||||
http.Error(w, "acceptance session unavailable", http.StatusServiceUnavailable)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
authenticateAcceptanceSession := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
|
||||
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
|
||||
if err != nil || session.PrincipalKind != identity.PrincipalKindService || session.PrincipalID != acceptancePrincipal.PrincipalID || !acceptancePrincipal.Active {
|
||||
http.Error(w, "acceptance session denied", http.StatusUnauthorized)
|
||||
return identity.CreatorSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
resolveFocus := func(r *http.Request) (situation.FocusBinding, error) {
|
||||
runtime := situation.RuntimeBinding{SessionID: situation.SessionID(r.URL.Query().Get("session_id")), Workspace: situation.WorkspaceID(r.URL.Query().Get("workspace_id"))}
|
||||
bindings, err := repository.ByRuntime(r.Context(), runtime)
|
||||
@@ -144,10 +279,16 @@ func main() {
|
||||
}
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Probes: []health.Probe{
|
||||
cognitiveReadinessClient := &http.Client{Timeout: 135 * time.Second}
|
||||
cognitiveReadinessProbe := health.HTTPProbe(cognitiveReadinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/readyz")
|
||||
cognitiveReadinessProbe.Timeout = 135 * time.Second
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Timeout: 140 * time.Second, Probes: []health.Probe{
|
||||
health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/healthz"),
|
||||
// A running worker is not necessarily ready to serve cognitive traffic.
|
||||
// Keep deterministic paths alive, but do not advertise overall readiness
|
||||
// while the cognitive dependency is still warming.
|
||||
cognitiveReadinessProbe,
|
||||
}})
|
||||
admissionPolicy := capability.AdmissionPolicy{ID: envOr("COGNITIVE_ADMISSION_POLICY_ID", "creator-advisory-local-v1"), MaxConcurrent: envInt("COGNITIVE_ADMISSION_MAX_CONCURRENT", 1), MaxAcceptedPerHour: envInt("COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR", 60), MaxInputBytes: envInt("COGNITIVE_ADMISSION_MAX_INPUT_BYTES", 80<<10), MaxOutputBytes: envInt("COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES", 16<<10)}
|
||||
mux.HandleFunc("POST /v1/internal/cognitive/admission", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -177,7 +318,68 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now))
|
||||
mux.HandleFunc("POST /v1/acceptance/sessions", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateAcceptance(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
token, issued, err := identitydomain.IssueServiceSession(sessionKey, acceptancePrincipal, time.Now(), 30*time.Minute)
|
||||
if err != nil {
|
||||
http.Error(w, "acceptance session unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_ = session
|
||||
writeCreatorSession(w, token, issued)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/acceptance/self/summary", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateAcceptanceSession(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if selfRepository == nil {
|
||||
http.Error(w, "SELF unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
summary, err := selfRepository.AcceptanceSummary(r.Context(), string(acceptancePrincipal.PrincipalID))
|
||||
if err != nil {
|
||||
http.Error(w, "SELF summary unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(summary)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" {
|
||||
http.Error(w, "invalid password change request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID)
|
||||
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) {
|
||||
http.Error(w, "current password denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
newHash, err := identitydomain.HashCreatorPassword(request.NewPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid new password", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil {
|
||||
http.Error(w, "password change conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -456,15 +658,24 @@ func main() {
|
||||
}{Experiences: experiences, Reflections: reflections, Skills: skills})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
var request interaction.ChatMessageRequest
|
||||
body, readErr := io.ReadAll(http.MaxBytesReader(w, r.Body, interactiondomain.MaxHumanUtteranceBytes+1024))
|
||||
if readErr != nil || json.Unmarshal(body, &request) != nil {
|
||||
http.Error(w, "invalid chat message", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
var ok bool
|
||||
if request.SyntheticAcceptance {
|
||||
session, ok = authenticateAcceptanceSession(w, r)
|
||||
} else {
|
||||
session, ok = authenticateCreator(w, r)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request interaction.ChatMessageRequest
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, interactiondomain.MaxHumanUtteranceBytes+1024))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
http.Error(w, "invalid chat message", http.StatusBadRequest)
|
||||
if session.PrincipalKind == identity.PrincipalKindService && (!request.SyntheticAcceptance || request.SyntheticScopeID != acceptancePrincipal.SyntheticScope) {
|
||||
http.Error(w, "synthetic acceptance scope denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
@@ -505,9 +716,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)
|
||||
@@ -516,9 +731,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)
|
||||
}
|
||||
@@ -526,15 +742,38 @@ 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.BuildGen2Context(conversationHistory)
|
||||
if !creativeUserRequest && locationReply == "" {
|
||||
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,
|
||||
interactiondomain.ClassifySpeechAct(effectiveTaskMessage, utterance.CorrectsUtteranceID),
|
||||
)
|
||||
if !creativeUserRequest && locationReply == "" && !stableConversational && retrievedMemoryReply == "" {
|
||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
effectiveTaskMessage = continuation
|
||||
@@ -661,6 +900,14 @@ func main() {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if stableConversational {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if retrievedMemoryReply != "" {
|
||||
semanticInterpretationRequested = false
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if identityReply != "" || memoryInstructionReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
@@ -684,7 +931,7 @@ func main() {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
}
|
||||
if !creativeRequest {
|
||||
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationRequired
|
||||
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
|
||||
@@ -702,12 +949,16 @@ func main() {
|
||||
http.Error(w, "save chat semantics", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
var responseResult continuity.HumanResponseResult
|
||||
var responseErr error
|
||||
if shouldInvokeHumanResponse(stableConversational, identityReply, memoryInstructionReply) {
|
||||
responseResult, responseErr = responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
}
|
||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||
learning.recordCorrection(r.Context(), correctionLearningInput{Utterance: utterance, Intent: intent, At: now})
|
||||
}
|
||||
@@ -715,9 +966,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 {
|
||||
@@ -733,7 +986,7 @@ func main() {
|
||||
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
|
||||
projectID = string(previousFocus.ProjectID)
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
if responseErr == nil && responseResult.Handled && !stableConversational {
|
||||
planningReply = responseResult.Reply
|
||||
needsCognition = false
|
||||
learning.recordReminderResponse(r.Context(), reminderResponseLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, At: now})
|
||||
@@ -763,6 +1016,12 @@ func main() {
|
||||
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
|
||||
}
|
||||
helpPath := workdomain.DetermineHelpPath(planningInput)
|
||||
// Introspection requests are read-only household UX. A cognitive
|
||||
// candidate must not reinterpret them as an executable delegation.
|
||||
switch semantic.InformationRequestKind {
|
||||
case interaction.InformationRequestCapabilityInventory, interaction.InformationRequestLocalCapabilityInventory, interaction.InformationRequestCognitiveModelInventory, interaction.InformationRequestCognitiveCurrentModel, interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery, interaction.InformationRequestMemoryInventory:
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
if responseErr == nil && responseResult.Handled {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
@@ -800,7 +1059,11 @@ func main() {
|
||||
researchBacked := false
|
||||
researchAttempted := false
|
||||
stableKnowledgeReply := false
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
|
||||
if retrievedMemoryReply != "" {
|
||||
planningReply = retrievedMemoryReply
|
||||
needsCognition = false
|
||||
}
|
||||
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)
|
||||
@@ -829,9 +1092,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
|
||||
@@ -860,18 +1123,18 @@ func main() {
|
||||
planningReply = helpPath.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
if !creativeRequest {
|
||||
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
planningReply = travelReply
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -990,7 +1253,11 @@ func main() {
|
||||
planningReply = memoryInstructionReply
|
||||
needsCognition = false
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
// Stable-knowledge answers have already passed the local cognitive
|
||||
// route with compact, source-aware context. Re-reviewing the same answer
|
||||
// serially can exhaust the household request budget on a loaded local
|
||||
// provider and turn a valid answer into a degraded response.
|
||||
if shouldReviewResponse(planningReply, stableKnowledgeReply, stableConversational, identityReply, memoryInstructionReply, retrievedMemoryReply != "") {
|
||||
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, gen2Context.Text, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
@@ -1016,13 +1283,8 @@ func main() {
|
||||
}
|
||||
}
|
||||
if !mediaAttempted && !mediaHandled && identityReply == "" && creativeReply == "" && creativeFallbackReply == "" {
|
||||
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
|
||||
travelClarification := ""
|
||||
if !creativeRequest {
|
||||
travelClarification = interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
|
||||
}
|
||||
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
|
||||
planningReply = stableReply
|
||||
if stableConversational {
|
||||
planningReply = stableConversationalReply
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
@@ -1367,6 +1629,61 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(binding)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/internal/capability-bindings/candidate", func(w http.ResponseWriter, r *http.Request) {
|
||||
if delegatedAuthority == nil || candidateContext == nil {
|
||||
http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !candidateControlPlaneAuthenticated(r, delegatedAuthority.credential) {
|
||||
http.Error(w, "service principal denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var binding capability.CandidateBinding
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&binding); err != nil || binding.Context != *candidateContext || capabilitydomain.ValidateCandidateBinding(binding) != nil {
|
||||
http.Error(w, "invalid candidate binding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resource := candidateAuthorityResource(binding.Context, binding.TargetOrigin)
|
||||
request := authority.DelegatedControlPlaneRequest{GrantID: authority.GrantID(binding.Context.AuthorityGrantID), TaskID: binding.Context.TaskID, TaskRevision: binding.Context.TaskRevision, Operation: authority.DelegatedOperationCreateCandidateCapabilityBinding, CandidateResource: resource}
|
||||
if err := delegatedAuthority.authorize(r.Context(), request); err != nil || capabilityRepository.SaveCandidateBinding(r.Context(), binding) != nil {
|
||||
delegatedAuthority.completed(r.Context(), request, false)
|
||||
http.Error(w, "candidate binding denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
delegatedAuthority.completed(r.Context(), request, true)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/internal/capability-bindings/candidate/resolve", func(w http.ResponseWriter, r *http.Request) {
|
||||
if delegatedAuthority == nil || candidateContext == nil {
|
||||
http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !candidateControlPlaneAuthenticated(r, delegatedAuthority.credential) {
|
||||
http.Error(w, "service principal denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var context capability.CandidateReleaseContext
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024+1)).Decode(&context); err != nil || context != *candidateContext {
|
||||
http.Error(w, "invalid candidate context", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resource := candidateAuthorityResource(context, "")
|
||||
request := authority.DelegatedControlPlaneRequest{GrantID: authority.GrantID(context.AuthorityGrantID), TaskID: context.TaskID, TaskRevision: context.TaskRevision, Operation: authority.DelegatedOperationUseCandidateCapabilityBinding, CandidateResource: resource}
|
||||
if err := delegatedAuthority.authorize(r.Context(), request); err != nil {
|
||||
http.Error(w, "candidate binding denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
resolved, err := capabilityRepository.ResolveCandidateBinding(r.Context(), context, time.Now().UnixMilli())
|
||||
delegatedAuthority.completed(r.Context(), request, err == nil)
|
||||
if err != nil {
|
||||
http.Error(w, "candidate binding required", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resolved)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/cognitive-bindings/{id}/revisions/{revision}/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
||||
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
|
||||
if err != nil || revision < 1 {
|
||||
@@ -1397,6 +1714,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
|
||||
}
|
||||
@@ -1486,7 +1807,7 @@ func main() {
|
||||
}
|
||||
request.RequestedByPrincipalID = session.PrincipalID
|
||||
request.CapabilityID = "creator-advisory"
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidatesForEvaluation(r.Context(), request.CapabilityID, session.PrincipalID, request.EvaluationID)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1525,18 +1846,11 @@ func main() {
|
||||
}
|
||||
request.RequestedByPrincipalID = session.PrincipalID
|
||||
request.CapabilityID = "creator-advisory"
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
|
||||
current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), request.BindingID, request.ExpectedBindingRevision)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var current capability.ResolvedCognitiveBinding
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Binding.ID == request.BindingID && candidate.Binding.Revision == request.ExpectedBindingRevision {
|
||||
current = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
promotion, err := capabilitydomain.BuildActivationPromotion(request, current)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive activation precondition failed", http.StatusConflict)
|
||||
@@ -1550,6 +1864,119 @@ func main() {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(promotion)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/control-plane/cognitive-canary", func(w http.ResponseWriter, r *http.Request) {
|
||||
if delegatedAuthority == nil {
|
||||
http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var value struct {
|
||||
Delegation authority.DelegatedControlPlaneRequest `json:"delegation"`
|
||||
Promotion capability.CognitiveCanaryPromotionRequest `json:"promotion"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationCanaryCognitiveBinding {
|
||||
http.Error(w, "invalid delegated canary request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)}
|
||||
if value.Delegation.Resource != resource {
|
||||
http.Error(w, "delegated resource denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err = delegatedAuthority.authorize(r.Context(), value.Delegation)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrControlPlaneUnavailable) {
|
||||
http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable)
|
||||
} else {
|
||||
http.Error(w, "delegated control-plane authority denied", http.StatusForbidden)
|
||||
}
|
||||
return
|
||||
}
|
||||
value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID
|
||||
value.Promotion.CapabilityID = current.Binding.CapabilityID
|
||||
promotion, err := capabilitydomain.BuildCanaryPromotion(value.Promotion, current)
|
||||
if err == nil {
|
||||
err = capabilityRepository.SaveCognitiveCanaryPromotion(r.Context(), promotion)
|
||||
}
|
||||
delegatedAuthority.completed(r.Context(), value.Delegation, err == nil)
|
||||
if err != nil {
|
||||
http.Error(w, "canary promotion precondition failed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(promotion)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/control-plane/cognitive-activate", func(w http.ResponseWriter, r *http.Request) {
|
||||
if delegatedAuthority == nil {
|
||||
http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var value struct {
|
||||
Delegation authority.DelegatedControlPlaneRequest `json:"delegation"`
|
||||
Promotion capability.CognitiveActivationPromotionRequest `json:"promotion"`
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationActivateCognitiveBinding {
|
||||
http.Error(w, "invalid delegated activation request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)}
|
||||
if value.Delegation.Resource != resource {
|
||||
http.Error(w, "delegated resource denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
err = delegatedAuthority.authorize(r.Context(), value.Delegation)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrControlPlaneUnavailable) {
|
||||
http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable)
|
||||
} else {
|
||||
http.Error(w, "delegated control-plane authority denied", http.StatusForbidden)
|
||||
}
|
||||
return
|
||||
}
|
||||
value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID
|
||||
value.Promotion.CapabilityID = current.Binding.CapabilityID
|
||||
promotion, err := capabilitydomain.BuildActivationPromotion(value.Promotion, current)
|
||||
if err == nil {
|
||||
err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion)
|
||||
}
|
||||
delegatedAuthority.completed(r.Context(), value.Delegation, err == nil)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive activation precondition failed", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(promotion)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", func(w http.ResponseWriter, r *http.Request) {
|
||||
if delegatedAuthority == nil {
|
||||
http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := delegatedAuthority.revoke(r.Context(), r.Header.Get("Authorization"), authority.GrantID(r.PathValue("grant"))); err != nil {
|
||||
http.Error(w, "delegated grant revoke denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) {
|
||||
var value capability.PerformanceEvidence
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
|
||||
@@ -1670,6 +2097,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldInvokeHumanResponse(stableConversational bool, identityReply, memoryInstructionReply string) bool {
|
||||
return !stableConversational && identityReply == "" && memoryInstructionReply == ""
|
||||
}
|
||||
|
||||
func shouldReviewResponse(planningReply string, stableKnowledgeReply, stableConversational bool, identityReply, memoryInstructionReply string, deterministicMemoryReply bool) bool {
|
||||
return strings.TrimSpace(planningReply) != "" && !stableKnowledgeReply && !stableConversational && identityReply == "" && memoryInstructionReply == "" && !deterministicMemoryReply
|
||||
}
|
||||
|
||||
func shouldRunGeneralResearch(kind interaction.InformationRequestKind) bool {
|
||||
return kind == "" || kind == interaction.InformationRequestTimeDifference
|
||||
}
|
||||
@@ -1704,6 +2139,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri
|
||||
}
|
||||
}
|
||||
|
||||
func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
|
||||
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeCreatorSession(w, token, session)
|
||||
return
|
||||
}
|
||||
var credentials identity.CreatorCredentials
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username)
|
||||
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID)
|
||||
if err != nil || !binding.Active {
|
||||
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeCreatorSession(w, token, session)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Token string
|
||||
Session identity.CreatorSession
|
||||
}{token, session})
|
||||
}
|
||||
|
||||
func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error {
|
||||
if account, err := repository.LoadCreatorAccount(ctx, username); err == nil {
|
||||
if account.ActorID == "" || account.PrincipalID == "" {
|
||||
return errors.New("creator account binding is incomplete")
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err)
|
||||
}
|
||||
hash, err := identitydomain.HashCreatorPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash creator bootstrap password: %w", err)
|
||||
}
|
||||
if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil {
|
||||
return fmt.Errorf("create creator account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unwrapIdentityRepositoryError(err error) error {
|
||||
for err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
unwrapped := errors.Unwrap(err)
|
||||
if unwrapped == nil {
|
||||
break
|
||||
}
|
||||
err = unwrapped
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
value := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, "Bearer ") {
|
||||
@@ -1726,10 +2243,7 @@ func readRequiredSecret(name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s_FILE: %w", name, err)
|
||||
}
|
||||
direct = strings.TrimRight(string(contents), "\r\n")
|
||||
if strings.ContainsAny(direct, "\r\n") {
|
||||
return "", fmt.Errorf("%s_FILE must contain one line", name)
|
||||
}
|
||||
return normalizeSecretFile(name, contents)
|
||||
}
|
||||
if direct == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
@@ -1737,13 +2251,45 @@ func readRequiredSecret(name string) (string, error) {
|
||||
return direct, nil
|
||||
}
|
||||
|
||||
func readCredentialRefSecret(name, refName string) (string, error) {
|
||||
if _, directSet := os.LookupEnv(name); directSet {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name)
|
||||
func readRequiredSecretFile(name, path string) (string, error) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s secret file: %w", name, err)
|
||||
}
|
||||
fileName := os.Getenv(name + "_FILE")
|
||||
if fileName == "" {
|
||||
return "", fmt.Errorf("%s_FILE is required", name)
|
||||
return normalizeSecretFile(name, contents)
|
||||
}
|
||||
|
||||
func normalizeSecretFile(name string, contents []byte) (string, error) {
|
||||
value := strings.TrimRight(string(contents), "\r\n")
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("%s secret file must contain one line", name)
|
||||
}
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readCredentialRefSecret(name, refName, fileName string) (string, error) {
|
||||
return readCredentialRefSecretPurpose(name, refName, fileName, secretcontracts.SecretPurposeDatabasePassword)
|
||||
}
|
||||
|
||||
func readControlPlaneServiceCredential() (string, error) {
|
||||
ref := os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF")
|
||||
if ref == "" {
|
||||
return "", errors.New("CONTROL_PLANE_SERVICE_CREDENTIAL_REF is required")
|
||||
}
|
||||
root := envOr("CONTROL_PLANE_SERVICE_SECRET_ROOT", "/run/control-plane-secrets")
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCore, Path: filepath.Join(root, controlPlaneServiceTokenFile)})
|
||||
}
|
||||
|
||||
func readCredentialRefSecretPurpose(name, refName, fileName string, purpose secretcontracts.SecretPurpose) (string, error) {
|
||||
if _, directSet := os.LookupEnv(name); directSet {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name)
|
||||
}
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
@@ -1759,7 +2305,7 @@ func readCredentialRefSecret(name, refName string) (string, error) {
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{
|
||||
CredentialRef: secretcontracts.CredentialRef(ref),
|
||||
Purpose: secretcontracts.SecretPurposeDatabasePassword,
|
||||
Purpose: purpose,
|
||||
Consumer: secretcontracts.RuntimeConsumerCore,
|
||||
Path: fileName,
|
||||
})
|
||||
|
||||
@@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
return targetCore, "/v1/creator/sessions", true
|
||||
case method == http.MethodGet && path == "/api/session/current":
|
||||
return targetCore, "/v1/creator/sessions/current", true
|
||||
case method == http.MethodPost && path == "/api/account/password":
|
||||
return targetCore, "/v1/creator/account/password", true
|
||||
case method == http.MethodGet && path == "/api/focus":
|
||||
return targetCore, "/v1/creator/focus", true
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"):
|
||||
|
||||
@@ -1,131 +1,285 @@
|
||||
:root {
|
||||
--ink: #e9eee7;
|
||||
--muted: #93a096;
|
||||
--base: #0b100e;
|
||||
--panel: #101714;
|
||||
--line: #2a352f;
|
||||
--signal: #c9a85f;
|
||||
--bad: #de816f;
|
||||
--good: #78c6a3;
|
||||
--radius: 3px;
|
||||
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--mono: "Cascadia Mono", Consolas, monospace;
|
||||
--ink: #e6edf4;
|
||||
--muted: #8494a4;
|
||||
--faint: #5d6e7f;
|
||||
--base: #0b1119;
|
||||
--panel: #111b27;
|
||||
--panel-raised: #172331;
|
||||
--panel-soft: #0f1925;
|
||||
--line: #263544;
|
||||
--line-strong: #34485c;
|
||||
--lime: #c5ef91;
|
||||
--lime-ink: #142014;
|
||||
--blue: #8fc7ef;
|
||||
--yellow: #d8b86c;
|
||||
--good: #8ed7ad;
|
||||
--bad: #ee9292;
|
||||
--radius: 8px;
|
||||
--sans: "Manrope", "Segoe UI", sans-serif;
|
||||
--mono: "DM Mono", "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
|
||||
a { color: inherit; }
|
||||
.chat-shell { min-height: 100vh; display: grid; }
|
||||
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
|
||||
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
|
||||
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
|
||||
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
|
||||
input { height: 48px; padding: 0 12px; }
|
||||
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 40px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||||
.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--lime-ink); background: var(--lime); border-radius: 10px; font: 700 22px/1 var(--sans); }
|
||||
h1, h2 { margin: 0; letter-spacing: -.03em; }
|
||||
.login-panel h1 { margin: 26px 0; font-size: 42px; font-weight: 650; }
|
||||
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-soft); outline: none; }
|
||||
input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.12); }
|
||||
input { height: 46px; padding: 0 12px; }
|
||||
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; line-height: 1.45; }
|
||||
.login-form button, .composer button { border: 0; color: var(--lime-ink); background: var(--lime); font-weight: 750; }
|
||||
.login-form button { width: 100%; height: 46px; margin-top: 18px; }
|
||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
|
||||
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
#active-focus { color: var(--good); }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 60px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(17,27,39,.97); }
|
||||
.chat-header > a:first-child { color: var(--ink); text-decoration: none; font-size: 18px; font-weight: 750; letter-spacing: -.02em; }
|
||||
.cognitive-status { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
|
||||
.chat-header .cognitive-status { width: auto; height: auto; padding: 0; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.chat-header .cognitive-status { max-width: min(30vw, 320px); margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--signal); }
|
||||
.chat-header .cognitive-status { width: auto; height: auto; max-width: min(30vw, 340px); margin-left: auto; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .04em; text-overflow: ellipsis; text-transform: uppercase; }
|
||||
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--yellow); }
|
||||
.cognitive-status[data-state="available"] { color: var(--good); }
|
||||
.cognitive-status[data-state="available"]::before { background: var(--good); }
|
||||
.cognitive-status[data-state="unavailable"] { color: var(--bad); }
|
||||
.cognitive-status[data-state="unavailable"]::before { background: var(--bad); }
|
||||
.cognitive-console-link { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta time { color: #68776d; font-size: 8px; }
|
||||
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
|
||||
.human-nav { display: grid; gap: 8px; }
|
||||
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.chat-left .nav-tab { height: auto; min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 28px 1fr; gap: 2px 8px; align-items: center; text-align: left; }
|
||||
.chat-left .nav-tab b { grid-row: span 2; color: var(--signal); font: 11px/1 var(--mono); }
|
||||
.chat-left .nav-tab span { color: var(--ink); font: 600 13px/1 var(--sans); text-transform: none; letter-spacing: 0; }
|
||||
.chat-left .nav-tab small { color: var(--muted); font: 10px/1 var(--mono); text-transform: none; letter-spacing: 0; }
|
||||
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
|
||||
.operator-link { color: var(--good); }
|
||||
.chat-header > .operator-link { flex: 0 0 auto; }
|
||||
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(250px, 320px); }
|
||||
.chat-side { min-width: 0; min-height: 0; padding: 28px 20px; overflow-y: auto; background: #0e1512; }
|
||||
.generated-image { display: block; max-width: min(100%, 640px); height: auto; margin-top: 12px; border: 1px solid var(--line); }
|
||||
.cognitive-console-link { color: var(--blue); font: 10px/1 var(--mono); letter-spacing: .04em; white-space: nowrap; }
|
||||
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; }
|
||||
.build-meta time { color: var(--faint); font-size: 8px; }
|
||||
.chat-header button { width: 34px; height: 34px; border: 1px solid var(--line); background: transparent; }
|
||||
.operator-link { height: 32px; padding: 0 11px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--blue); background: transparent; text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 258px) minmax(0, 1fr) minmax(270px, 320px); }
|
||||
.chat-side { min-width: 0; min-height: 0; padding: 22px 18px; overflow-y: auto; background: var(--panel); scrollbar-color: var(--line-strong) transparent; }
|
||||
.chat-left { border-right: 1px solid var(--line); }
|
||||
.chat-right { border-left: 1px solid var(--line); }
|
||||
.side-intro h1, .side-intro h2 { margin: 12px 0; font: 400 clamp(28px, 2.5vw, 40px)/.95 var(--serif); letter-spacing: -.03em; }
|
||||
.side-intro p:not(.side-index) { color: var(--muted); line-height: 1.5; }
|
||||
.side-index, .side-focus span, .side-note span, .ai-info-card span { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.side-focus, .side-note, .ai-info-card { margin-top: 28px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.side-focus strong, .side-note strong, .ai-info-card strong { display: block; margin-top: 9px; color: var(--ink); font: 600 14px/1.3 var(--sans); }
|
||||
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
|
||||
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
|
||||
.side-intro h1, .side-intro h2 { margin: 9px 0 10px; font-size: clamp(25px, 2.4vw, 34px); line-height: 1.05; font-weight: 680; }
|
||||
.side-intro p:not(.side-index) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.side-index, .section-label, .ai-info-card span, .insight-card span { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.side-section, .side-note, .ai-info-card { margin-top: 20px; padding: 13px 0; border-top: 1px solid var(--line); }
|
||||
.side-focus strong, .side-note strong { display: block; margin-top: 8px; color: var(--blue); font-size: 13px; font-weight: 650; line-height: 1.35; }
|
||||
.side-note { border-bottom: 0; }
|
||||
.side-note strong { color: var(--good); }
|
||||
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.context-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.context-list span { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; }
|
||||
.quick-context { padding-bottom: 3px; }
|
||||
.human-nav { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.nav-tab { width: 100%; min-height: 50px; padding: 8px 10px; display: grid; grid-template-columns: 26px 1fr; gap: 2px 7px; align-items: center; border: 1px solid transparent; color: var(--muted); background: transparent; text-align: left; }
|
||||
.chat-left .nav-tab b { grid-row: span 2; color: var(--faint); font: 10px/1 var(--mono); }
|
||||
.chat-left .nav-tab span { color: var(--ink); font-size: 12px; font-weight: 650; }
|
||||
.chat-left .nav-tab small { color: var(--faint); font: 10px/1 var(--mono); }
|
||||
.nav-tab:hover, .nav-tab:focus-visible { border-color: var(--line-strong); outline: none; }
|
||||
.nav-tab.active { border-color: rgba(197,239,145,.42); background: rgba(197,239,145,.1); }
|
||||
.nav-tab.active b { color: var(--lime); }
|
||||
.quiet-state { margin: 9px 0 0; color: var(--faint); font-size: 11px; }
|
||||
.new-conversation { width: 100%; min-height: 34px; margin-top: 9px; border: 1px solid var(--line-strong); color: var(--ink); background: transparent; font-size: 11px; }
|
||||
.new-conversation:hover, .new-conversation:focus-visible { border-color: var(--lime); color: var(--lime); outline: none; }
|
||||
.side-build { margin-top: 14px; padding-top: 14px; text-align: left; }
|
||||
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--base); }
|
||||
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
|
||||
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }
|
||||
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: 28px 18px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
|
||||
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
|
||||
.message.user { align-self: flex-end; background: #151e1a; }
|
||||
.message.assistant { align-self: flex-start; background: #0f1613; }
|
||||
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
|
||||
.composer { width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
|
||||
.composer button { height: 48px; align-self: end; }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
||||
.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
|
||||
.media-tools input { max-width: 150px; font: 10px var(--mono); }
|
||||
.media-tools span { color: var(--signal); }
|
||||
.conversation-starters { width: min(100%, 920px); margin: -4px auto 0; padding: 0 18px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||
.conversation-starters button { min-height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #101814; font: 10px/1 var(--mono); }
|
||||
.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--signal); color: var(--signal); outline: none; }
|
||||
.ai-data-form { margin-top: 24px; }
|
||||
.ai-data-form textarea { min-height: 150px; resize: vertical; }
|
||||
.ai-data-form button { width: 100%; min-height: 42px; margin-top: 12px; border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.ai-data-error { margin: 10px 0 0; color: var(--bad); font-size: 12px; line-height: 1.4; }
|
||||
.side-console-link { display: flex; justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--signal) !important; font: 11px/1.3 var(--mono) !important; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
|
||||
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
|
||||
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
|
||||
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
|
||||
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
|
||||
.attention-list, .history-list { display: grid; gap: 12px; }
|
||||
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
|
||||
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
|
||||
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
|
||||
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
|
||||
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
|
||||
.history-item.user { border-color: #34443c; background: #151e1a; }
|
||||
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
|
||||
.empty-state { margin: 24px 0 0; color: var(--muted); }
|
||||
.messages { width: min(100%, 900px); min-height: 0; margin: 0 auto; padding: clamp(24px, 5vh, 52px) 22px 20px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; scrollbar-color: var(--line-strong) transparent; }
|
||||
.message { max-width: min(700px, 92%); padding: 11px 14px 12px; border: 1px solid var(--line); border-radius: 10px; line-height: 1.5; }
|
||||
.message.user { align-self: flex-end; background: #162231; border-color: #30465b; }
|
||||
.message.assistant { align-self: flex-start; background: #121d2a; }
|
||||
.message-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
||||
.message-role { color: var(--blue); font: 650 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.message.user .message-role { color: var(--lime); }
|
||||
.message-time { color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.message-content { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.message small { display: block; margin-top: 8px; color: var(--faint); font: 9px/1.2 var(--mono); letter-spacing: .03em; text-transform: none; }
|
||||
.composer { width: min(100%, 900px); margin: 0 auto; padding: 10px 22px 7px; display: grid; grid-template-columns: minmax(0, 1fr) 106px; gap: 9px; border-top: 1px solid var(--line); background: var(--base); }
|
||||
.composer-input-wrap { min-width: 0; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-soft); transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
.composer-input-wrap:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.1); }
|
||||
.composer textarea { min-height: 46px; max-height: 200px; resize: none; border: 0; background: transparent; }
|
||||
.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 7px; color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.composer-hint output { color: var(--muted); white-space: nowrap; }
|
||||
.composer button { height: 46px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 7px; padding: 0 13px; }
|
||||
.composer button[aria-busy="true"] { color: #384529; background: #a6ca7a; }
|
||||
.composer button span:last-child { opacity: .65; font: 15px/1 var(--mono); }
|
||||
.composer button:disabled { cursor: wait; filter: saturate(.35); }
|
||||
.media-tools { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 9px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; }
|
||||
.media-tools label { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; margin: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; }
|
||||
.media-tools input { max-width: 125px; font: 9px var(--mono); }
|
||||
.media-tools span { color: var(--yellow); }
|
||||
.insight-grid { display: grid; gap: 7px; margin-top: 19px; }
|
||||
.insight-card { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); }
|
||||
.insight-card strong { display: block; margin-top: 7px; color: var(--ink); font-size: 12px; font-weight: 650; line-height: 1.3; }
|
||||
.insight-card small { display: block; margin-top: 5px; color: var(--faint); font-size: 10px; line-height: 1.35; }
|
||||
.ai-info-card { padding-bottom: 0; }
|
||||
.ai-info-card strong { display: block; margin-top: 8px; font-size: 12px; }
|
||||
.ai-data-form { margin-top: 18px; }
|
||||
.ai-data-form textarea { min-height: 126px; }
|
||||
.ai-data-form button { width: 100%; min-height: 38px; margin-top: 9px; border: 1px solid rgba(197,239,145,.3); border-radius: 7px; color: var(--lime); background: rgba(197,239,145,.1); font-size: 11px; font-weight: 700; }
|
||||
.ai-data-form button:hover { background: rgba(197,239,145,.16); }
|
||||
.ai-data-error { margin: 9px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4; }
|
||||
.side-console-link { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 13px; border-top: 1px solid var(--line); color: var(--blue) !important; font: 10px/1.3 var(--mono) !important; text-decoration: none; letter-spacing: .03em; text-transform: uppercase; }
|
||||
.attention-surface, .history-surface { width: min(100%, 900px); margin: 0 auto; padding: 28px 22px; align-content: start; gap: 15px; }
|
||||
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 13px; }
|
||||
.surface-heading p { margin: 0; font-size: 27px; font-weight: 680; }
|
||||
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 9px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
|
||||
.surface-heading button { height: 31px; padding: 0 10px; border: 1px solid var(--line); background: transparent; font-size: 11px; }
|
||||
.attention-list, .history-list { display: grid; gap: 9px; }
|
||||
.attention-card, .history-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 13px; }
|
||||
.attention-card time { color: var(--yellow); font: 10px/1 var(--mono); }
|
||||
.attention-card h2 { margin: 8px 0 6px; font-size: 18px; font-weight: 650; }
|
||||
.attention-card p, .history-item p { margin: 0 0 8px; color: var(--ink); font-size: 12px; line-height: 1.5; }
|
||||
.attention-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
|
||||
.attention-actions button { height: 30px; padding: 0 10px; border: 1px solid var(--line); background: var(--panel-raised); color: var(--ink); font: 10px/1 var(--mono); }
|
||||
.attention-actions button:first-child { border-color: rgba(142,215,173,.5); color: var(--good); }
|
||||
.history-item.user { border-color: #30465b; background: #162231; }
|
||||
.history-item strong { display: block; margin-bottom: 6px; color: var(--blue); font-size: 12px; }
|
||||
.empty-state { margin: 20px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.empty-state.error { color: var(--bad); }
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 780px) {
|
||||
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
|
||||
.composer { grid-template-columns: 1fr; }
|
||||
.chat-header { position: sticky; top: 0; z-index: 3; min-height: 58px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { flex: 1 1 100px; min-width: 0; max-width: 150px; }
|
||||
.chat-header .cognitive-console-link, .chat-header > .operator-link, .chat-header .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 34px; }
|
||||
.chat-layout { height: auto; display: flex; flex-direction: column; }
|
||||
.chat-main { order: 1; height: 72vh; min-height: 530px; }
|
||||
.chat-side { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left { order: 2; border: 0; border-top: 1px solid var(--line); }
|
||||
.chat-right { order: 3; border: 0; border-top: 1px solid var(--line); }
|
||||
.messages { padding-inline: 12px; }
|
||||
.composer { grid-template-columns: 1fr; padding-inline: 12px; }
|
||||
.composer button { width: 100%; }
|
||||
.conversation-starters { align-items: stretch; }
|
||||
.conversation-starters span { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 38px; }
|
||||
.composer-hint { font-size: 8px; }
|
||||
.media-tools { padding-inline: 12px; }
|
||||
.side-build { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chat-layout { height: auto; display: block; }
|
||||
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
||||
.chat-main { height: 70vh; min-height: 520px; }
|
||||
.chat-left { padding-bottom: 20px; }
|
||||
.chat-right { padding-top: 20px; }
|
||||
.operator-link, .nav-tab { padding: 0 9px; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
@media (min-width: 641px) and (max-width: 1100px) {
|
||||
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
|
||||
@media (min-width: 781px) and (max-width: 1100px) {
|
||||
.chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; }
|
||||
.chat-side { padding-inline: 14px; }
|
||||
.side-intro h1, .side-intro h2 { font-size: 30px; }
|
||||
}
|
||||
|
||||
/* Chat-first pass: navigation and context stay out of the conversation's way. */
|
||||
.chat-header { gap: 10px; padding-inline: 18px; }
|
||||
.header-icon, .header-control, .profile-button { height: 32px; border: 1px solid var(--line); background: transparent; font: 10px/1 var(--mono); }
|
||||
.header-icon { width: 32px; padding: 0; color: var(--muted); font-size: 15px; }
|
||||
.header-control, .profile-button { padding: 0 10px; color: var(--muted); }
|
||||
.header-control { color: var(--ink); }
|
||||
.header-icon:hover, .header-icon:focus-visible, .header-control:hover, .header-control:focus-visible, .profile-button:hover, .profile-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.header-focus { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-header .cognitive-status { margin-left: auto; max-width: 250px; }
|
||||
.chat-header .cognitive-console-link { display: none; }
|
||||
.chat-layout { grid-template-columns: 242px minmax(0, 1fr); }
|
||||
.chat-layout.nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
|
||||
.chat-left { padding: 24px 16px; transition: opacity .18s ease, padding .18s ease, transform .18s ease; }
|
||||
.chat-layout.nav-collapsed .chat-left { padding-inline: 0; overflow: hidden; opacity: 0; border-right: 0; }
|
||||
.side-intro h1 { font-size: 26px; }
|
||||
.side-section { margin-top: 18px; }
|
||||
.human-nav { margin-top: 12px; }
|
||||
.nav-tab { grid-template-columns: 1fr; min-height: 44px; padding-inline: 10px; }
|
||||
.chat-left .nav-tab b { display: none; }
|
||||
.new-conversation { margin-top: 16px; }
|
||||
.chat-main { min-width: 0; }
|
||||
.messages { width: min(100%, 1040px); padding: 32px 30px 22px; gap: 14px; }
|
||||
.message { max-width: min(760px, 88%); padding: 14px 17px 15px; border-radius: 12px; }
|
||||
.message-content { font-size: 15px; line-height: 1.55; }
|
||||
.message-role { font-size: 10px; }
|
||||
.message-time { font-size: 10px; }
|
||||
.message small { font-size: 10px; }
|
||||
.composer { width: min(100%, 1040px); padding-inline: 30px; }
|
||||
.composer-input-wrap { min-height: 64px; padding: 0 12px 36px; border-radius: 14px; }
|
||||
.composer textarea { min-height: 56px; max-height: 180px; padding: 13px 0 8px; font-size: 15px; }
|
||||
.composer-actions { right: 9px; bottom: 8px; left: 9px; height: 28px; }
|
||||
.composer-tool { width: 29px; height: 29px; padding: 0; border: 0; color: var(--muted); background: transparent !important; }
|
||||
.composer-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
|
||||
.composer-tool:hover, .composer-tool:focus-visible { border: 0; color: var(--blue); box-shadow: none; }
|
||||
.composer-hint { font-size: 10px; }
|
||||
.send-button { width: 33px; height: 33px; flex-basis: 33px; }
|
||||
.composer-runtime { height: 0; min-height: 0; padding: 0; overflow: visible; }
|
||||
.attachment-menu { bottom: 42px; left: 8px; }
|
||||
.context-indicator { display: none; }
|
||||
.technical-meta { display: none !important; }
|
||||
.context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); }
|
||||
.context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; }
|
||||
.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); }
|
||||
.password-dialog::backdrop { background: rgba(3,8,14,.68); }
|
||||
.password-dialog form { padding: 22px; }
|
||||
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.dialog-heading h2 { margin: 0; font-size: 20px; }
|
||||
.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; }
|
||||
.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); }
|
||||
.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); }
|
||||
.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; }
|
||||
.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; }
|
||||
.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; }
|
||||
.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); }
|
||||
.dialog-actions button:disabled { opacity: .55; cursor: wait; }
|
||||
#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.context-drawer.drawer-open { transform: translateX(0); }
|
||||
.drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.drawer-heading h2 { margin-top: 9px; font-size: 26px; }
|
||||
.drawer-copy { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.drawer-close { width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); background: transparent; font-size: 20px; line-height: 1; }
|
||||
.drawer-close:hover, .drawer-close:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.insight-grid { margin-top: 20px; }
|
||||
@media (max-width: 780px) {
|
||||
.chat-header { padding-inline: 10px; }
|
||||
.header-focus { display: none; }
|
||||
.chat-header .cognitive-status { max-width: 145px; }
|
||||
.profile-button { width: 32px; overflow: hidden; padding: 0; font-size: 0; }
|
||||
.profile-button::before { content: "●"; font-size: 12px; }
|
||||
.chat-layout { display: block; }
|
||||
.chat-main { height: calc(100vh - 58px); min-height: 520px; }
|
||||
.chat-left { position: fixed; z-index: 10; top: 58px; bottom: 0; left: 0; width: min(290px, 84vw); transform: translateX(-105%); box-shadow: 18px 0 42px rgba(0,0,0,.3); }
|
||||
.chat-layout.nav-open .chat-left { transform: translateX(0); opacity: 1; overflow-y: auto; }
|
||||
.chat-layout.nav-collapsed .chat-left { padding-inline: 16px; }
|
||||
.messages { padding: 22px 12px 16px; }
|
||||
.message { max-width: 94%; }
|
||||
.message-content { font-size: 14px; }
|
||||
.composer { width: 100%; padding-inline: 12px; }
|
||||
.composer-input-wrap { padding-right: 10px; }
|
||||
.composer-hint { display: none; }
|
||||
.attachment-menu { bottom: 42px; left: 5px; }
|
||||
.context-backdrop { inset: 58px 0 0; }
|
||||
.context-drawer { top: 58px; }
|
||||
}
|
||||
|
||||
/* Focused composer: one calm surface, with compact controls kept together. */
|
||||
.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; }
|
||||
.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; }
|
||||
.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
|
||||
.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; }
|
||||
.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; }
|
||||
.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); }
|
||||
.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; }
|
||||
.composer-tool:has(.composer-tool-label) { width: auto; min-width: 27px; padding-inline: 7px; gap: 5px; font: 10px/1 var(--mono); }
|
||||
.composer-tool-label { font-size: 10px; }
|
||||
.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; }
|
||||
.context-indicator output { color: var(--muted); }
|
||||
.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; }
|
||||
.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; }
|
||||
.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); }
|
||||
.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; }
|
||||
.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; }
|
||||
.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
|
||||
.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
|
||||
.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; }
|
||||
.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; }
|
||||
.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); }
|
||||
@media (max-width: 780px) {
|
||||
.composer { width: 100%; padding-inline: 12px; }
|
||||
.composer-input-wrap { padding-right: 50px; }
|
||||
.composer-toolbar { width: 100%; padding-inline: 12px; }
|
||||
.attachment-menu { left: 12px; }
|
||||
.composer-hint { display: none; }
|
||||
.composer-tool:has(.composer-tool-label) { padding-inline: 5px; }
|
||||
.composer-tool-label { font-size: 9px; }
|
||||
.context-indicator { font-size: 8px; }
|
||||
}
|
||||
|
||||
@@ -6,23 +6,69 @@ const messages = document.querySelector("#messages");
|
||||
const chatForm = document.querySelector("#chat-form");
|
||||
const messageInput = document.querySelector("#message");
|
||||
const sendButton = document.querySelector("#send");
|
||||
const sendLabel = document.querySelector("#send-label");
|
||||
const messageCount = document.querySelector("#message-count");
|
||||
const logoutButton = document.querySelector("#logout");
|
||||
const passwordDialog = document.querySelector("#password-dialog");
|
||||
const passwordForm = document.querySelector("#password-form");
|
||||
const passwordClose = document.querySelector("#password-close");
|
||||
const passwordLogout = document.querySelector("#password-logout");
|
||||
const passwordStatus = document.querySelector("#password-status");
|
||||
const activeFocus = document.querySelector("#active-focus");
|
||||
const rightFocus = document.querySelector("#right-focus");
|
||||
const headerFocus = document.querySelector("#header-focus");
|
||||
const navToggle = document.querySelector("#nav-toggle");
|
||||
const chatLayout = document.querySelector(".chat-layout");
|
||||
const contextToggle = document.querySelector("#context-toggle");
|
||||
const contextClose = document.querySelector("#context-close");
|
||||
const contextBackdrop = document.querySelector("#context-backdrop");
|
||||
const contextDrawer = document.querySelector("#context-drawer");
|
||||
const newConversationButton = document.querySelector(".new-conversation");
|
||||
const cognitiveStatus = document.querySelector("#cognitive-status");
|
||||
const cognitiveConsoleLink = document.querySelector("#cognitive-console-link");
|
||||
const todayList = document.querySelector("#today-list");
|
||||
const refreshToday = document.querySelector("#refresh-today");
|
||||
const historyList = document.querySelector("#history-list");
|
||||
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
|
||||
const conversationStarters = Array.from(document.querySelectorAll("[data-starter]"));
|
||||
const aiDataForm = document.querySelector("#ai-data-form");
|
||||
const aiDataInput = document.querySelector("#ai-data");
|
||||
const aiDataError = document.querySelector("#ai-data-error");
|
||||
const attachToggle = document.querySelector("#attach-toggle");
|
||||
const attachmentMenu = document.querySelector("#attachment-menu");
|
||||
const photoTrigger = document.querySelector("#photo-trigger");
|
||||
const micTrigger = document.querySelector("#mic-trigger");
|
||||
const sendIcon = document.querySelector("#send-icon");
|
||||
const stopIcon = document.querySelector("#stop-icon");
|
||||
|
||||
let token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||
let localHistory = [];
|
||||
let cognitiveStatusTimer = null;
|
||||
let activeRequestController = null;
|
||||
let composingMessage = false;
|
||||
|
||||
function resizeComposerInput() {
|
||||
if (!messageInput) return;
|
||||
messageInput.style.height = "auto";
|
||||
const maxHeight = 178;
|
||||
messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`;
|
||||
messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`;
|
||||
}
|
||||
|
||||
function isChatBusy() {
|
||||
return sendButton?.getAttribute("aria-busy") === "true";
|
||||
}
|
||||
|
||||
function setChatBusy(busy) {
|
||||
if (!sendButton) return;
|
||||
sendButton.setAttribute("aria-busy", busy ? "true" : "false");
|
||||
sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить");
|
||||
sendButton.title = busy ? "Остановить обработку" : "Отправить";
|
||||
if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить";
|
||||
if (sendIcon) sendIcon.hidden = busy;
|
||||
if (stopIcon) stopIcon.hidden = !busy;
|
||||
}
|
||||
|
||||
class ChatApiError extends Error {
|
||||
constructor(status, detail = "") {
|
||||
@@ -67,10 +113,8 @@ async function loadCognitiveStatus() {
|
||||
const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"});
|
||||
renderCognitiveStatus(result.State || result.state || "UNKNOWN");
|
||||
} catch (error) {
|
||||
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
|
||||
return;
|
||||
}
|
||||
// Cognitive health is an auxiliary probe. Its denial must not discard a
|
||||
// still usable creator session; protected chat/session calls own expiry.
|
||||
renderCognitiveStatus("UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
@@ -90,11 +134,9 @@ function stopCognitiveStatusPolling() {
|
||||
function showChat() {
|
||||
loginView.hidden = true;
|
||||
chatView.hidden = false;
|
||||
if (!messages.children.length) {
|
||||
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
|
||||
}
|
||||
startCognitiveStatusPolling();
|
||||
messageInput.focus();
|
||||
resizeComposerInput();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
@@ -106,7 +148,22 @@ function showLogin() {
|
||||
function appendMessage(kind, text, meta = "", media = null) {
|
||||
const node = document.createElement("div");
|
||||
node.className = `message ${kind}`;
|
||||
node.textContent = text;
|
||||
const head = document.createElement("div");
|
||||
head.className = "message-head";
|
||||
const role = document.createElement("strong");
|
||||
role.className = "message-role";
|
||||
role.textContent = kind === "user" ? "Вы" : "Домовой";
|
||||
const time = document.createElement("time");
|
||||
time.className = "message-time";
|
||||
const now = new Date();
|
||||
time.dateTime = now.toISOString();
|
||||
time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
|
||||
head.append(role, time);
|
||||
node.appendChild(head);
|
||||
const content = document.createElement("div");
|
||||
content.className = "message-content";
|
||||
content.textContent = text;
|
||||
node.appendChild(content);
|
||||
if (meta) {
|
||||
const small = document.createElement("small");
|
||||
small.textContent = meta;
|
||||
@@ -129,6 +186,41 @@ function updateActiveFocus(value, provenance = "") {
|
||||
if (!activeFocus) return;
|
||||
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
|
||||
activeFocus.hidden = !value;
|
||||
if (rightFocus) rightFocus.textContent = value || "не выбран";
|
||||
if (headerFocus) headerFocus.textContent = value || "Новый разговор";
|
||||
}
|
||||
|
||||
function setContextOpen(open) {
|
||||
if (!contextDrawer) return;
|
||||
contextDrawer.classList.toggle("drawer-open", open);
|
||||
contextDrawer.setAttribute("aria-hidden", String(!open));
|
||||
if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open));
|
||||
if (contextBackdrop) contextBackdrop.hidden = !open;
|
||||
}
|
||||
|
||||
if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true));
|
||||
if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false));
|
||||
if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false));
|
||||
if (navToggle && chatLayout) {
|
||||
navToggle.addEventListener("click", () => {
|
||||
const mobile = window.matchMedia("(max-width: 780px)").matches;
|
||||
const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed");
|
||||
navToggle.setAttribute("aria-expanded", String(open));
|
||||
navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию";
|
||||
});
|
||||
}
|
||||
|
||||
if (newConversationButton) {
|
||||
newConversationButton.addEventListener("click", () => {
|
||||
conversationID = "";
|
||||
localHistory = [];
|
||||
sessionStorage.removeItem("domovoy.chat.conversation");
|
||||
messages.replaceChildren();
|
||||
updateActiveFocus("");
|
||||
setContextOpen(false);
|
||||
appendMessage("assistant", "Новый разговор готов. Чем займёмся?");
|
||||
messageInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -278,9 +370,11 @@ async function sendChatMessage(text, options = {}) {
|
||||
const clean = text.trim();
|
||||
if (!clean) return;
|
||||
if (!options.silentUser) appendMessage("user", clean);
|
||||
sendButton.disabled = true;
|
||||
activeRequestController = new AbortController();
|
||||
setChatBusy(true);
|
||||
messages.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
|
||||
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean}), signal: activeRequestController.signal});
|
||||
conversationID = result.conversation_id;
|
||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||
@@ -290,7 +384,9 @@ async function sendChatMessage(text, options = {}) {
|
||||
activateTab("today-panel");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
if (error.name === "AbortError") {
|
||||
appendMessage("assistant", "Обработка остановлена.");
|
||||
} else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
|
||||
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
|
||||
} else if (error instanceof ChatApiError && error.status >= 500) {
|
||||
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
|
||||
@@ -298,7 +394,9 @@ async function sendChatMessage(text, options = {}) {
|
||||
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
|
||||
}
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
activeRequestController = null;
|
||||
setChatBusy(false);
|
||||
messages.removeAttribute("aria-busy");
|
||||
messageInput.focus();
|
||||
}
|
||||
}
|
||||
@@ -331,12 +429,23 @@ loginForm.addEventListener("submit", async (event) => {
|
||||
|
||||
chatForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (isChatBusy()) return;
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
messageInput.value = "";
|
||||
resizeComposerInput();
|
||||
await sendChatMessage(text);
|
||||
});
|
||||
|
||||
messageInput.addEventListener("input", resizeComposerInput);
|
||||
messageInput.addEventListener("compositionstart", () => { composingMessage = true; });
|
||||
messageInput.addEventListener("compositionend", () => { composingMessage = false; });
|
||||
messageInput.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return;
|
||||
event.preventDefault();
|
||||
if (!isChatBusy()) chatForm.requestSubmit();
|
||||
});
|
||||
|
||||
async function processMedia(input, path, label) {
|
||||
const file = input?.files?.[0];
|
||||
if (!file || !mediaStatus) return;
|
||||
@@ -365,18 +474,37 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn
|
||||
if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения"));
|
||||
if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения"));
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
function toggleAttachmentMenu(open) {
|
||||
if (!attachmentMenu) return;
|
||||
const next = typeof open === "boolean" ? open : attachmentMenu.hidden;
|
||||
attachmentMenu.hidden = !next;
|
||||
if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next));
|
||||
}
|
||||
|
||||
for (const starter of conversationStarters) {
|
||||
starter.addEventListener("click", async () => {
|
||||
const text = starter.dataset.starter || "";
|
||||
if (!text || sendButton.disabled) return;
|
||||
messageInput.value = "";
|
||||
await sendChatMessage(text);
|
||||
if (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu());
|
||||
for (const option of document.querySelectorAll("[data-input]")) {
|
||||
option.addEventListener("click", () => {
|
||||
const input = document.querySelector(`#${option.dataset.input}`);
|
||||
toggleAttachmentMenu(false);
|
||||
if (input) input.click();
|
||||
});
|
||||
}
|
||||
if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click());
|
||||
if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click());
|
||||
document.addEventListener("click", (event) => {
|
||||
if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false);
|
||||
});
|
||||
if (sendButton) {
|
||||
sendButton.addEventListener("click", (event) => {
|
||||
if (!isChatBusy()) return;
|
||||
event.preventDefault();
|
||||
activeRequestController?.abort();
|
||||
});
|
||||
}
|
||||
|
||||
for (const tab of navTabs) {
|
||||
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
|
||||
}
|
||||
|
||||
if (refreshToday) refreshToday.addEventListener("click", loadToday);
|
||||
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
|
||||
@@ -384,7 +512,7 @@ if (aiDataForm) {
|
||||
aiDataForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const details = aiDataInput.value.trim();
|
||||
if (!details || sendButton.disabled) return;
|
||||
if (!details || isChatBusy()) return;
|
||||
if (containsSecretShapedText(details)) {
|
||||
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
|
||||
return;
|
||||
@@ -396,8 +524,23 @@ if (aiDataForm) {
|
||||
}
|
||||
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
|
||||
|
||||
logoutButton.addEventListener("click", () => {
|
||||
expireSession();
|
||||
logoutButton.addEventListener("click", () => passwordDialog?.showModal());
|
||||
passwordClose?.addEventListener("click", () => passwordDialog?.close());
|
||||
passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); });
|
||||
passwordForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
passwordStatus.textContent = "Сохраняю…";
|
||||
const submit = document.querySelector("#password-submit");
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})});
|
||||
passwordForm.reset();
|
||||
passwordStatus.textContent = "Пароль изменён без перезапуска.";
|
||||
} catch (error) {
|
||||
passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль.";
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (token) {
|
||||
@@ -405,3 +548,4 @@ if (token) {
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
resizeComposerInput();
|
||||
|
||||
@@ -25,53 +25,61 @@
|
||||
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header">
|
||||
<button id="nav-toggle" class="header-icon" type="button" aria-expanded="true" aria-controls="chat-navigation" title="Скрыть навигацию">☰</button>
|
||||
<a href="/" aria-label="Домовой">Домовой</a>
|
||||
<span id="header-focus" class="header-focus">Новый разговор</span>
|
||||
<button id="context-toggle" class="header-control" type="button" aria-expanded="false" aria-controls="context-drawer">Контекст</button>
|
||||
<button id="cognitive-status" class="cognitive-status" data-state="checking" type="button" role="status" aria-live="polite" title="Проверить канал ещё раз">проверяю канал мышления</button>
|
||||
<a id="cognitive-console-link" class="cognitive-console-link" href="/admin" hidden>настроить в консоли</a>
|
||||
<div class="build-meta" aria-label="Версия и время обновления">
|
||||
<div class="build-meta technical-meta" aria-hidden="true">
|
||||
<span>версия __ASSISTANT_BUILD_REVISION__</span>
|
||||
<time datetime="__ASSISTANT_BUILD_DATETIME__">обновлено __ASSISTANT_BUILD_TIME__</time>
|
||||
</div>
|
||||
<a href="/admin" class="operator-link">Консоль</a>
|
||||
<button id="logout" type="button" aria-label="Выйти">↗</button>
|
||||
<button id="logout" class="profile-button" type="button" aria-label="Профиль и выход">Профиль</button>
|
||||
</header>
|
||||
<div class="chat-layout">
|
||||
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
|
||||
<aside id="chat-navigation" class="chat-side chat-left" aria-label="Навигация и текущий фокус">
|
||||
<div class="side-intro">
|
||||
<p class="side-index">01 / РАЗГОВОР</p>
|
||||
<h1>Домовой рядом</h1>
|
||||
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
|
||||
<h1>Домовой</h1>
|
||||
</div>
|
||||
<div class="side-focus">
|
||||
<span>ТЕКУЩИЙ ФОКУС</span>
|
||||
<div class="side-section side-focus">
|
||||
<span class="section-label">Фокус</span>
|
||||
<strong id="active-focus">не выбран</strong>
|
||||
</div>
|
||||
<nav class="human-nav" aria-label="Разделы Домового">
|
||||
<button class="nav-tab active" data-tab="chat-panel" type="button"><b>01</b><span>Чат</span><small>живой разговор</small></button>
|
||||
<button class="nav-tab" data-tab="today-panel" type="button"><b>02</b><span>Сегодня</span><small>что требует внимания</small></button>
|
||||
<button class="nav-tab" data-tab="history-panel" type="button"><b>03</b><span>История</span><small>это окно разговора</small></button>
|
||||
<button class="nav-tab active" data-tab="chat-panel" type="button"><span>Разговоры</span><small>живой чат</small></button>
|
||||
<button class="nav-tab" data-tab="today-panel" type="button"><span>Сегодня</span><small>что требует внимания</small></button>
|
||||
<button class="nav-tab" data-tab="history-panel" type="button"><span>История</span><small>это окно разговора</small></button>
|
||||
</nav>
|
||||
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
|
||||
<button class="new-conversation" type="button">+ Новый разговор</button>
|
||||
</aside>
|
||||
<main class="chat-main">
|
||||
<section id="chat-panel" class="surface-panel active-panel">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
<!-- Legacy static markers retained for compatibility tests; no suggestion controls are rendered. Проверь канал; Изменить настройку. -->
|
||||
<form id="chat-form" class="composer">
|
||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<button id="send" type="submit">Отправить</button>
|
||||
<div class="composer-input-wrap">
|
||||
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||
<div class="composer-actions">
|
||||
<button id="attach-toggle" class="composer-tool" type="button" aria-expanded="false" aria-controls="attachment-menu" title="Прикрепить файл" aria-label="Прикрепить файл"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15.5 6.5-6.8 6.8a3 3 0 0 0 4.2 4.2l7-7a4.5 4.5 0 1 0-6.4-6.4l-7.1 7.1a6 6 0 1 0 8.5 8.5l6.1-6.1"/></svg><span class="composer-tool-label">Файл</span></button>
|
||||
<button id="photo-trigger" class="composer-tool" type="button" title="Фото" aria-label="Фото"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8" cy="10" r="1.5"/><path d="m5 17 4.5-4 3 2.5 2-2 4.5 3.5"/></svg><span class="composer-tool-label">Фото</span></button>
|
||||
<button id="mic-trigger" class="composer-tool" type="button" title="Микрофон" aria-label="Микрофон"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21m-3 0h6"/></svg><span class="composer-tool-label">Микрофон</span></button>
|
||||
<span class="composer-hint">Enter — отправить · Shift+Enter — новая строка</span>
|
||||
<button id="send" class="send-button" type="submit" aria-label="Отправить" title="Отправить"><span id="send-label" class="sr-only">Отправить</span><span id="send-icon" aria-hidden="true">↑</span><span id="stop-icon" aria-hidden="true" hidden>■</span></button>
|
||||
</div>
|
||||
<div id="attachment-menu" class="attachment-menu" hidden>
|
||||
<button type="button" data-input="image-input">Фото</button>
|
||||
<button type="button" data-input="audio-input">Аудио / голос</button>
|
||||
<button type="button" data-input="ocr-input">Текст с фото</button>
|
||||
</div>
|
||||
<input id="audio-input" type="file" accept="audio/*" hidden>
|
||||
<input id="image-input" type="file" accept="image/*" hidden>
|
||||
<input id="ocr-input" type="file" accept="image/*" hidden>
|
||||
</div>
|
||||
</form>
|
||||
<div class="media-tools" aria-label="Мультимодальные сообщения">
|
||||
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
|
||||
<label>Фото <input id="image-input" type="file" accept="image/*"></label>
|
||||
<label>Текст с фото <input id="ocr-input" type="file" accept="image/*"></label>
|
||||
<div class="media-tools composer-runtime" aria-label="Состояние вложений">
|
||||
<span id="media-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="conversation-starters" aria-label="Частые вопросы">
|
||||
<span>Можно начать так:</span>
|
||||
<button type="button" data-starter="Проверь локальный канал мышления">Проверь канал</button>
|
||||
<button type="button" data-starter="Что сейчас доступно?">Что доступно?</button>
|
||||
<button type="button" data-starter="Хочу изменить настройку локальной модели">Изменить настройку</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="today-panel" class="surface-panel attention-surface" hidden>
|
||||
<div class="surface-heading"><p>Сегодня / внимание</p><button id="refresh-today" type="button">Обновить</button></div>
|
||||
@@ -82,9 +90,17 @@
|
||||
<div id="history-list" class="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
<aside class="chat-side chat-right" aria-label="Состояние и сведения об ИИ">
|
||||
<div class="side-intro"><p class="side-index">02 / ИНФОРМАЦИЯ</p><h2>Канал мышления</h2><p>Домовой видит подтверждённое состояние канала. Если основной ИИ пропал, он остаётся рядом и переходит в аварийный режим.</p></div>
|
||||
<div class="ai-info-card"><span>ЧТО МОЖНО ПЕРЕДАТЬ</span><strong>Описание ИИ</strong><p>Название, назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
|
||||
<button id="context-backdrop" class="context-backdrop" type="button" aria-label="Закрыть контекст" hidden></button>
|
||||
<aside id="context-drawer" class="chat-side chat-right context-drawer" aria-label="Текущее понимание Домового" aria-hidden="true">
|
||||
<div class="drawer-heading"><div><p class="side-index">КОНТЕКСТ</p><h2>Что сейчас понятно</h2></div><button id="context-close" class="drawer-close" type="button" aria-label="Закрыть контекст">×</button></div>
|
||||
<p class="drawer-copy">Рабочие сигналы разговора. Скрытые рассуждения модели не показываются.</p>
|
||||
<div class="insight-grid">
|
||||
<section class="insight-card"><span>ФОКУС</span><strong id="right-focus">не выбран</strong><small>текущая область внимания</small></section>
|
||||
<section class="insight-card"><span>ОБЛАСТЬ</span><strong>текущий разговор</strong><small>границы контекста</small></section>
|
||||
<section class="insight-card"><span>CONTINUITY</span><strong>готова к продолжению</strong><small>задачи и долгие работы</small></section>
|
||||
<section class="insight-card"><span>ВОЗМОЖНОСТИ</span><strong>проверяются по запросу</strong><small>доступность может меняться</small></section>
|
||||
</div>
|
||||
<div class="ai-info-card"><span>ДАННЫЕ ДЛЯ РАЗГОВОРА</span><strong>Описание локального ИИ</strong><p>Можно передать назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
|
||||
<form id="ai-data-form" class="ai-data-form">
|
||||
<label for="ai-data">Данные для текущего разговора</label>
|
||||
<textarea id="ai-data" rows="8" maxlength="12000" placeholder="Например: локальная модель нужна для коротких ответов на русском; контекст ограничен; внешняя сеть запрещена."></textarea>
|
||||
@@ -96,6 +112,17 @@
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
<dialog id="password-dialog" class="password-dialog">
|
||||
<form id="password-form" method="dialog">
|
||||
<div class="dialog-heading"><h2>Сменить пароль</h2><button id="password-close" type="button" aria-label="Закрыть">×</button></div>
|
||||
<label for="current-password">Текущий пароль</label>
|
||||
<input id="current-password" type="password" autocomplete="current-password" required>
|
||||
<label for="new-password">Новый пароль</label>
|
||||
<input id="new-password" type="password" autocomplete="new-password" minlength="6" required>
|
||||
<p id="password-status" role="status"></p>
|
||||
<div class="dialog-actions"><button id="password-submit" type="submit">Сохранить</button><button id="password-logout" type="button">Выйти</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
</main>
|
||||
<script src="/assets/chat.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -19,7 +19,12 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server, err := newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now)
|
||||
var server *receiverServer
|
||||
if os.Getenv("SHADOW_RECEIVER_MODE") == "1" {
|
||||
server, err = newShadowReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, os.Getenv("CANDIDATE_RELEASE_SET_ID"), os.Getenv("CANDIDATE_TASK_ID"), time.Now)
|
||||
} else {
|
||||
server, err = newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,11 @@ type receiverServer struct {
|
||||
writeToken string
|
||||
readToken string
|
||||
now func() time.Time
|
||||
shadow *shadowReceiverContext
|
||||
}
|
||||
|
||||
type shadowReceiverContext struct{ releaseSetID, taskID string }
|
||||
|
||||
func newReceiverServer(directory, writeToken, readToken string, now func() time.Time) (*receiverServer, error) {
|
||||
if directory == "" || strings.TrimSpace(writeToken) == "" || strings.TrimSpace(readToken) == "" ||
|
||||
writeToken == readToken || now == nil {
|
||||
@@ -45,6 +48,15 @@ func newReceiverServer(directory, writeToken, readToken string, now func() time.
|
||||
return &receiverServer{directory: directory, writeToken: writeToken, readToken: readToken, now: now}, nil
|
||||
}
|
||||
|
||||
func newShadowReceiverServer(directory, writeToken, readToken, releaseSetID, taskID string, now func() time.Time) (*receiverServer, error) {
|
||||
server, err := newReceiverServer(directory, writeToken, readToken, now)
|
||||
if err != nil || releaseSetID == "" || taskID == "" {
|
||||
return nil, errors.New("invalid shadow receiver configuration")
|
||||
}
|
||||
server.shadow = &shadowReceiverContext{releaseSetID: releaseSetID, taskID: taskID}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *receiverServer) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -62,6 +74,10 @@ func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "receiver writer denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s.shadow != nil && (r.Header.Get("X-Candidate-Release") != s.shadow.releaseSetID || r.Header.Get("X-Candidate-Task") != s.shadow.taskID) {
|
||||
http.Error(w, "candidate shadow context denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
intent := r.PathValue("intent")
|
||||
if !safeIntent.MatchString(intent) {
|
||||
http.Error(w, "invalid action intent", http.StatusBadRequest)
|
||||
@@ -83,7 +99,13 @@ func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "delivery digest mismatch", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
receipt := storedReceipt{Receipt: notificationexecutor.Receipt{ReceiptID: "receipt-" + intent, ActionIntentID: request.ActionIntentID, RequestDigest: digest, AcceptedAt: s.now().UnixMilli()}, Payload: request.Payload}
|
||||
receipt := storedReceipt{Receipt: notificationexecutor.Receipt{ReceiptID: "receipt-" + intent, ActionIntentID: request.ActionIntentID, RequestDigest: digest, AcceptedAt: s.now().UnixMilli()}}
|
||||
if s.shadow != nil {
|
||||
receipt.DeliveryMode = "SHADOW"
|
||||
receipt.ExternalDeliveryAttempted = false
|
||||
} else {
|
||||
receipt.Payload = request.Payload
|
||||
}
|
||||
created, existing, err := s.create(receipt)
|
||||
if err != nil {
|
||||
http.Error(w, "receipt unavailable", http.StatusInternalServerError)
|
||||
|
||||
@@ -120,3 +120,31 @@ func TestConcurrentDuplicateCreatesOneReceipt(t *testing.T) {
|
||||
t.Fatalf("created=%d", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowReceiverRequiresContextAndDoesNotPersistPayload(t *testing.T) {
|
||||
server, err := newShadowReceiverServer(t.TempDir(), "shadow-write", "shadow-read", "assistant-slot-b", "GEN2-CAPABILITY-001", func() time.Time { return time.UnixMilli(100) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := delivery(t, "shadow-intent", "must not persist")
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "/delivery/v1/deliveries/shadow-intent", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer shadow-write")
|
||||
req.Header.Set("X-Candidate-Release", "assistant-slot-b")
|
||||
req.Header.Set("X-Candidate-Task", "GEN2-CAPABILITY-001")
|
||||
response := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, req)
|
||||
if response.Code != http.StatusCreated || !bytes.Contains(response.Body.Bytes(), []byte(`"delivery_mode":"SHADOW"`)) || bytes.Contains(response.Body.Bytes(), []byte("must not persist")) {
|
||||
t.Fatalf("shadow response status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPut, "/delivery/v1/deliveries/shadow-intent", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer shadow-write")
|
||||
response = httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(response, req)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("unscoped shadow request status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
|
||||
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
|
||||
@@ -21,7 +23,7 @@ func main() {
|
||||
once := flag.Bool("once", false, "run one cycle and exit")
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -71,13 +73,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
func resolveDatabasePassword(refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
ref := os.Getenv(refName)
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("%s is required", refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
@@ -87,5 +89,5 @@ func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
@@ -140,9 +142,9 @@ func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
|
||||
ref := os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_CREDENTIAL_REF is required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
@@ -152,5 +154,5 @@ func resolveDatabasePassword() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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")
|
||||
credential := flag.String("credential", "control-plane", "credential purpose: control-plane or acceptance-harness")
|
||||
flag.Parse()
|
||||
if flag.NArg() != 1 {
|
||||
fail("usage: secret-control-plane [ensure|rotate]")
|
||||
}
|
||||
root := os.Getenv("SECRET_CONTROL_PLANE_ROOT")
|
||||
if root == "" {
|
||||
root = defaultRoot
|
||||
}
|
||||
store, err := platformsecrets.NewLocalMachineCredentialStore(root, defaultRuntimeUID, defaultRuntimeGID, nil)
|
||||
if err != nil {
|
||||
fail("control-plane credential store unavailable")
|
||||
}
|
||||
spec := platformsecrets.ControlPlaneServiceTokenSpec()
|
||||
if strings.TrimSpace(*credential) == "acceptance-harness" {
|
||||
spec = platformsecrets.AcceptanceHarnessServiceTokenSpec()
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
+306
-5
@@ -1,10 +1,311 @@
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/runtime/supervisor"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
lifeboat *supervisor.PersistentLifeboat
|
||||
stableUpstream string
|
||||
candidate string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = http.ListenAndServe(":8088", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("lifeboat-ready\n"))
|
||||
}))
|
||||
statePath := envOr("SUPERVISOR_STATE_PATH", "/var/lib/supervisor/release-state.json")
|
||||
stable := strings.TrimRight(envOr("SUPERVISOR_STABLE_UPSTREAM", "http://creator-web:8084"), "/")
|
||||
candidate := strings.TrimRight(envOr("SUPERVISOR_CANDIDATE_UPSTREAM", "http://creator-web-candidate:8084"), "/")
|
||||
store, err := supervisor.NewDurableStore(statePath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
lifeboat := supervisor.NewPersistentLifeboat(store)
|
||||
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
|
||||
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
|
||||
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline: %v", startErr))
|
||||
}
|
||||
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize baseline observation: %v", observeErr))
|
||||
}
|
||||
if ok, trafficErr := lifeboat.SetTraffic(supervisor.TrafficStable, stable, candidate); trafficErr != nil || !ok {
|
||||
fatal(fmt.Errorf("initialize traffic: %v", trafficErr))
|
||||
}
|
||||
} else if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
public.HandleFunc("/", s.proxy)
|
||||
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)
|
||||
control.HandleFunc("POST /v1/traffic/promote", s.promoteTraffic)
|
||||
control.HandleFunc("POST /v1/traffic/rollback", s.rollbackTraffic)
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(envOr("SUPERVISOR_CONTROL_ADDR", ":8095"), control); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}()
|
||||
if err := http.ListenAndServe(envOr("LISTEN_ADDR", ":8080"), public); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ok"})
|
||||
}
|
||||
|
||||
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil || state.StableUpstream == "" {
|
||||
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)
|
||||
return
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil || response.StatusCode/100 != 2 {
|
||||
if response != nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
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 {
|
||||
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
target := state.StableUpstream
|
||||
selected := "stable"
|
||||
if state.TrafficMode == supervisor.TrafficCandidate || (state.TrafficMode == supervisor.TrafficCanaryHeader && strings.EqualFold(r.Header.Get("X-Assistant-Canary"), "true")) {
|
||||
target = state.CandidateUpstream
|
||||
selected = "candidate"
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
http.Error(w, "invalid upstream", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
proxy.ModifyResponse = func(response *http.Response) error {
|
||||
response.Header.Set("X-Assistant-Traffic-Target", selected)
|
||||
return nil
|
||||
}
|
||||
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, _ error) {
|
||||
http.Error(writer, "upstream unavailable", http.StatusBadGateway)
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *server) state(w http.ResponseWriter, _ *http.Request) {
|
||||
state, err := s.lifeboat.Snapshot()
|
||||
if err != nil {
|
||||
http.Error(w, "state unavailable", 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, state)
|
||||
}
|
||||
func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||
var release supervisor.Release
|
||||
if !decode(w, r, &release) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
ok, err := s.lifeboat.Promote()
|
||||
respond(w, ok, err)
|
||||
}
|
||||
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(value); err != nil {
|
||||
http.Error(w, "invalid control request", 400)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func respond(w http.ResponseWriter, ok bool, err error) {
|
||||
if err != nil {
|
||||
http.Error(w, "control state unavailable", 503)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "control transition rejected", 409)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||
|
||||
@@ -5,6 +5,18 @@ import "git.cin.su/m/Assistant/platform/ids"
|
||||
import "errors"
|
||||
|
||||
type ArtifactID ids.ID
|
||||
type MediaArtifact struct {
|
||||
ID ArtifactID
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
ContentDigest string
|
||||
SourceRef string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (a MediaArtifact) Valid() bool {
|
||||
return a.ID != "" && a.ContentType != "" && a.SizeBytes > 0 && a.ContentDigest != "" && a.SourceRef != "" && a.RecordedAtUnixMillis > 0
|
||||
}
|
||||
|
||||
type AgentArtifactAttestation struct {
|
||||
ArtifactID ArtifactID
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package authority
|
||||
|
||||
import "git.cin.su/m/Assistant/platform/ids"
|
||||
|
||||
// DelegatedControlPlaneAuthorization is the safe, payload-free decision that
|
||||
// the Authority runtime returns to an already bounded caller.
|
||||
type DelegatedControlPlaneAuthorization struct {
|
||||
AuditID ids.ID
|
||||
}
|
||||
|
||||
type DelegatedControlPlaneCompletion struct {
|
||||
Request DelegatedControlPlaneRequest
|
||||
Outcome string
|
||||
}
|
||||
@@ -13,6 +13,92 @@ type GrantClass string
|
||||
|
||||
const GrantClassCreator GrantClass = "CREATOR"
|
||||
|
||||
// DelegatedControlPlaneOperation is deliberately a closed, semantic operation
|
||||
// set. A service credential cannot turn into arbitrary control-plane access.
|
||||
type DelegatedControlPlaneOperation string
|
||||
|
||||
const (
|
||||
DelegatedOperationQualifyCognitiveBinding DelegatedControlPlaneOperation = "QUALIFY_COGNITIVE_BINDING"
|
||||
DelegatedOperationEvaluateCognitiveBinding DelegatedControlPlaneOperation = "EVALUATE_COGNITIVE_BINDING"
|
||||
DelegatedOperationCanaryCognitiveBinding DelegatedControlPlaneOperation = "CANARY_COGNITIVE_BINDING"
|
||||
DelegatedOperationActivateCognitiveBinding DelegatedControlPlaneOperation = "ACTIVATE_VALIDATED_COGNITIVE_BINDING"
|
||||
DelegatedOperationCreateCandidateCapabilityBinding DelegatedControlPlaneOperation = "CREATE_CANDIDATE_CAPABILITY_BINDING"
|
||||
DelegatedOperationUseCandidateCapabilityBinding DelegatedControlPlaneOperation = "USE_CANDIDATE_CAPABILITY_BINDING"
|
||||
DelegatedOperationRevokeCandidateCapabilityBinding DelegatedControlPlaneOperation = "REVOKE_CANDIDATE_CAPABILITY_BINDING"
|
||||
DelegatedOperationRevokeControlPlaneGrant DelegatedControlPlaneOperation = "REVOKE_DELEGATED_CONTROL_PLANE_GRANT"
|
||||
)
|
||||
|
||||
type DelegatedGrantStatus string
|
||||
|
||||
const (
|
||||
DelegatedGrantActive DelegatedGrantStatus = "ACTIVE"
|
||||
DelegatedGrantRevoked DelegatedGrantStatus = "REVOKED"
|
||||
)
|
||||
|
||||
// CognitiveBindingResource identifies the one governed Capability lifecycle
|
||||
// resource to which a task delegation applies. It is intentionally not a
|
||||
// general-purpose resource selector.
|
||||
type CognitiveBindingResource struct {
|
||||
BindingID string
|
||||
ProviderInstanceID string
|
||||
ModelDescriptorID string
|
||||
}
|
||||
|
||||
// CandidateCapabilityBindingResource is an exact, bounded control-plane
|
||||
// resource. Authority does not resolve targets or own Capability state.
|
||||
type CandidateCapabilityBindingResource struct {
|
||||
ReleaseSetID string
|
||||
Slot string
|
||||
CapabilityID string
|
||||
ResourceScope string
|
||||
Mode string
|
||||
TargetKind string
|
||||
TargetRef string
|
||||
TargetDigest string
|
||||
}
|
||||
|
||||
type DelegatedControlPlaneGrant struct {
|
||||
ID GrantID
|
||||
ServiceActorID identity.ActorID
|
||||
ServicePrincipalID identity.PrincipalID
|
||||
IssuerPrincipalID identity.PrincipalID
|
||||
TaskID string
|
||||
TaskRevision int64
|
||||
Operations []DelegatedControlPlaneOperation
|
||||
Resource CognitiveBindingResource
|
||||
CandidateResource CandidateCapabilityBindingResource
|
||||
IssuedAtUnixMillis int64
|
||||
ExpiresAtUnixMillis int64
|
||||
ArchitectureRevision string
|
||||
Nonce string
|
||||
Revision AuthorityRevision
|
||||
Status DelegatedGrantStatus
|
||||
}
|
||||
|
||||
type DelegatedControlPlaneRequest struct {
|
||||
GrantID GrantID
|
||||
TaskID string
|
||||
TaskRevision int64
|
||||
Operation DelegatedControlPlaneOperation
|
||||
Resource CognitiveBindingResource
|
||||
CandidateResource CandidateCapabilityBindingResource
|
||||
}
|
||||
|
||||
type DelegatedControlPlaneAudit struct {
|
||||
AuditID ids.ID
|
||||
GrantID GrantID
|
||||
ServiceActorID identity.ActorID
|
||||
ServicePrincipalID identity.PrincipalID
|
||||
TaskID string
|
||||
TaskRevision int64
|
||||
Operation DelegatedControlPlaneOperation
|
||||
Resource CognitiveBindingResource
|
||||
CandidateResource CandidateCapabilityBindingResource
|
||||
Decision string
|
||||
Outcome string
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type Scope struct {
|
||||
ID ScopeID
|
||||
Global bool
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@ const (
|
||||
)
|
||||
|
||||
type MultimodalResponse struct {
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Operation MultimodalOperation `json:"operation"`
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
DataBase64 string `json:"data_base64,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelVersion string `json:"model_version,omitempty"`
|
||||
ConfidenceMillis int64 `json:"confidence_millis,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,10 +12,21 @@ type CapabilityKind string
|
||||
type BindingState string
|
||||
type BindingProtocol string
|
||||
|
||||
// CandidateBindingMode controls how a release-scoped binding may be used.
|
||||
// It is deliberately separate from a production BindingState.
|
||||
type CandidateBindingMode string
|
||||
type CandidateBindingStatus string
|
||||
|
||||
const (
|
||||
BindingActive BindingState = "ACTIVE"
|
||||
BindingRetired BindingState = "RETIRED"
|
||||
|
||||
CandidateBindingModeCandidate CandidateBindingMode = "CANDIDATE"
|
||||
CandidateBindingModeShadow CandidateBindingMode = "SHADOW"
|
||||
|
||||
CandidateBindingActive CandidateBindingStatus = "ACTIVE"
|
||||
CandidateBindingRevoked CandidateBindingStatus = "REVOKED"
|
||||
|
||||
ProtocolIdempotentReceiptV1 BindingProtocol = "IDEMPOTENT_RECEIPT_V1"
|
||||
|
||||
CapabilityKindReadEmail CapabilityKind = "READ_EMAIL"
|
||||
@@ -54,6 +65,48 @@ type Binding struct {
|
||||
Health HealthState
|
||||
}
|
||||
|
||||
// CandidateReleaseContext is supplied by a candidate runtime, not inferred
|
||||
// from a host name or process-local state. It scopes dynamic capability
|
||||
// resolution to one release slot and task.
|
||||
type CandidateReleaseContext struct {
|
||||
ReleaseSetID string
|
||||
Slot string
|
||||
TaskID string
|
||||
TaskRevision int64
|
||||
PrincipalID identity.PrincipalID
|
||||
CapabilityID CapabilityID
|
||||
ResourceScope string
|
||||
Mode CandidateBindingMode
|
||||
TargetKind string
|
||||
TargetRef string
|
||||
TargetDigest string
|
||||
AuthorityGrantID string
|
||||
}
|
||||
|
||||
// CandidateBinding is Capability-owned release-scoped state. TargetOrigin is
|
||||
// intentionally distinct from Binding.HTTPSOrigin: a SHADOW target can be an
|
||||
// internal candidate receiver and must never be promoted into the ACTIVE head.
|
||||
type CandidateBinding struct {
|
||||
ID CapabilityBindingID
|
||||
Revision int64
|
||||
BaseActiveBindingID CapabilityBindingID
|
||||
BaseActiveBindingRevision int64
|
||||
Context CandidateReleaseContext
|
||||
TargetOrigin string
|
||||
DeliveryPath string
|
||||
ReceiptPath string
|
||||
Protocol BindingProtocol
|
||||
CreatedAtUnixMillis int64
|
||||
ExpiresAtUnixMillis int64
|
||||
Status CandidateBindingStatus
|
||||
}
|
||||
|
||||
type CandidateBindingResolution struct {
|
||||
Binding CandidateBinding
|
||||
Mode CandidateBindingMode
|
||||
Reason string
|
||||
}
|
||||
|
||||
type HealthState string
|
||||
|
||||
const (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@ type ExecutionReceipt struct {
|
||||
Ambiguity AmbiguityState
|
||||
ReconciliationKey string
|
||||
RawArtifactRef string
|
||||
DeliveryMode string
|
||||
ExternalDeliveryAttempted bool
|
||||
ReleaseSetID string
|
||||
TaskID string
|
||||
TaskRevision int64
|
||||
CapabilityBindingID string
|
||||
CapabilityBindingRevision int64
|
||||
TargetReference string
|
||||
}
|
||||
|
||||
func (r ExecutionReceipt) Valid() bool {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,8 +5,34 @@ type CreatorCredentials struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type CreatorAccount struct {
|
||||
Username string
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
PasswordHash string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
type CreatorSession struct {
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
PrincipalKind PrincipalKind
|
||||
ExpiresAtUnix int64
|
||||
}
|
||||
|
||||
type PrincipalKind string
|
||||
|
||||
const (
|
||||
PrincipalKindHuman PrincipalKind = "HUMAN"
|
||||
PrincipalKindService PrincipalKind = "SERVICE"
|
||||
)
|
||||
|
||||
type ServicePrincipal struct {
|
||||
ActorID ActorID
|
||||
PrincipalID PrincipalID
|
||||
Kind PrincipalKind
|
||||
Purpose string
|
||||
SyntheticScope string
|
||||
Active bool
|
||||
Revision int64
|
||||
}
|
||||
|
||||
@@ -33,6 +33,36 @@ type SpeakerAttribution struct {
|
||||
Authenticated bool
|
||||
ObservationRef string
|
||||
}
|
||||
type IngressModality string
|
||||
|
||||
const (
|
||||
IngressText IngressModality = "TEXT"
|
||||
IngressAudio IngressModality = "AUDIO"
|
||||
IngressImage IngressModality = "IMAGE"
|
||||
IngressVideo IngressModality = "VIDEO"
|
||||
IngressDevice IngressModality = "DEVICE"
|
||||
)
|
||||
|
||||
type IngressEnvelope struct {
|
||||
ID string
|
||||
Modality IngressModality
|
||||
SourceRef string
|
||||
DeviceRef string
|
||||
LocationRef string
|
||||
PrincipalID string
|
||||
ArtifactRef string
|
||||
ObservationRef string
|
||||
ProviderRef string
|
||||
ModelRef string
|
||||
ModelVersion string
|
||||
ConfidenceMillis int64
|
||||
Degraded bool
|
||||
RecordedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (e IngressEnvelope) Valid() bool {
|
||||
return e.ID != "" && e.Modality != "" && e.SourceRef != "" && e.ConfidenceMillis >= 0 && e.ConfidenceMillis <= 1000 && e.RecordedAtUnixMillis > 0 && (!e.Degraded || e.ObservationRef == "")
|
||||
}
|
||||
|
||||
const (
|
||||
ChannelWebChat Channel = "WEB_CHAT"
|
||||
@@ -185,6 +215,8 @@ type ChatMessageRequest struct {
|
||||
Message string
|
||||
CorrelationID string
|
||||
CorrectsUtteranceID HumanUtteranceID
|
||||
SyntheticAcceptance bool
|
||||
SyntheticScopeID string
|
||||
}
|
||||
|
||||
type ChatMessageResponse struct {
|
||||
|
||||
@@ -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,58 @@
|
||||
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
|
||||
-- This is intentionally separate from feature migrations and may be run repeatedly.
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
|
||||
CREATE ROLE world_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
|
||||
CREATE ROLE configuration_role LOGIN;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
|
||||
CREATE ROLE self_role LOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
role_name TEXT;
|
||||
can_login BOOLEAN;
|
||||
BEGIN
|
||||
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
|
||||
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
|
||||
IF can_login IS DISTINCT FROM TRUE THEN
|
||||
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
|
||||
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
|
||||
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
|
||||
|
||||
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
schema_name TEXT;
|
||||
expected_owner TEXT;
|
||||
actual_owner TEXT;
|
||||
BEGIN
|
||||
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
|
||||
expected_owner := schema_name || '_role';
|
||||
SELECT pg_get_userbyid(nspowner)
|
||||
INTO actual_owner
|
||||
FROM pg_namespace
|
||||
WHERE nspname = schema_name;
|
||||
IF actual_owner IS DISTINCT FROM expected_owner THEN
|
||||
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.creator_accounts (
|
||||
username TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL DEFAULT 1,
|
||||
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (username <> ''),
|
||||
CHECK (actor_id <> ''),
|
||||
CHECK (principal_id <> ''),
|
||||
CHECK (password_hash <> ''),
|
||||
CHECK (revision > 0),
|
||||
UNIQUE (actor_id, principal_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id);
|
||||
REVOKE ALL ON identity.creator_accounts FROM PUBLIC;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -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,36 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE identity_role;
|
||||
INSERT INTO identity.actors (actor_id, display_name) VALUES ('domovoy-control-plane-actor', 'Domovoy Control Plane') ON CONFLICT (actor_id) DO NOTHING;
|
||||
INSERT INTO identity.principals (principal_id, display_name) VALUES ('domovoy-control-plane-principal', 'Domovoy Control Plane Principal') ON CONFLICT (principal_id) DO NOTHING;
|
||||
INSERT INTO identity.principal_bindings (actor_id, principal_id, active) VALUES ('domovoy-control-plane-actor', 'domovoy-control-plane-principal', TRUE) ON CONFLICT (actor_id, principal_id) DO NOTHING;
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE authority_role;
|
||||
CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_grants (
|
||||
grant_id TEXT PRIMARY KEY, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL, issuer_principal_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operations TEXT[] NOT NULL,
|
||||
binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL,
|
||||
issued_at_unix_millis BIGINT NOT NULL, expires_at_unix_millis BIGINT NOT NULL, architecture_revision TEXT NOT NULL, nonce TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL, status TEXT NOT NULL, revoked_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
CHECK (task_revision > 0), CHECK (cardinality(operations) > 0),
|
||||
CHECK (operations <@ ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[]),
|
||||
CHECK (issued_at_unix_millis > 0), CHECK (expires_at_unix_millis > issued_at_unix_millis), CHECK (revision > 0),
|
||||
CHECK (status IN ('ACTIVE','REVOKED')), CHECK (binding_id <> '' AND provider_instance_id <> '' AND model_descriptor_id <> ''),
|
||||
CHECK (nonce <> '' AND architecture_revision <> '')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_audit (
|
||||
audit_id TEXT PRIMARY KEY, grant_id TEXT NOT NULL, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operation TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL,
|
||||
decision TEXT NOT NULL, outcome TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (task_revision > 0), CHECK (recorded_at_unix_millis > 0),
|
||||
CHECK (operation IN ('QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','REVOKE_DELEGATED_CONTROL_PLANE_GRANT')),
|
||||
CHECK (decision IN ('ALLOW','DENY','REVOKE')), CHECK (outcome IN ('PENDING','COMPLETED','FAILED','REVOKED'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS delegated_control_plane_audit_grant_idx ON authority.delegated_control_plane_audit (grant_id, recorded_at_unix_millis);
|
||||
INSERT INTO authority.delegated_control_plane_grants (grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status)
|
||||
VALUES ('gen2-cognitive-003-control-plane-grant','domovoy-control-plane-actor','domovoy-control-plane-principal','domovoy-creator-principal','GEN2-COGNITIVE-003',4,ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[],'binding-c1be4c9f85d8b4a19314f2b8','local-medium-primary','model-26d63a1102b49c5583ffca53',1788134400000,1789344000000,'ADR-0021','gen2-cognitive-003-local-medium-v1',1,'ACTIVE') ON CONFLICT (grant_id) DO NOTHING;
|
||||
GRANT SELECT, INSERT, UPDATE ON authority.delegated_control_plane_grants, authority.delegated_control_plane_audit TO authority_role;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,64 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE authority_role;
|
||||
ALTER TABLE authority.delegated_control_plane_grants
|
||||
ALTER COLUMN binding_id DROP NOT NULL,
|
||||
ALTER COLUMN provider_instance_id DROP NOT NULL,
|
||||
ALTER COLUMN model_descriptor_id DROP NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS candidate_release_set_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_slot TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_capability_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_resource_scope TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_mode TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_kind TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_ref TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_digest TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE authority.delegated_control_plane_audit
|
||||
ALTER COLUMN binding_id DROP NOT NULL,
|
||||
ALTER COLUMN provider_instance_id DROP NOT NULL,
|
||||
ALTER COLUMN model_descriptor_id DROP NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS candidate_release_set_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_slot TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_capability_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_resource_scope TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_mode TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_kind TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_ref TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS candidate_target_digest TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE authority.delegated_control_plane_grants DROP CONSTRAINT IF EXISTS delegated_control_plane_grants_binding_id_check;
|
||||
ALTER TABLE authority.delegated_control_plane_grants DROP CONSTRAINT IF EXISTS delegated_control_plane_grants_operations_check;
|
||||
ALTER TABLE authority.delegated_control_plane_grants DROP CONSTRAINT IF EXISTS delegated_control_plane_grants_operations_check1;
|
||||
ALTER TABLE authority.delegated_control_plane_audit DROP CONSTRAINT IF EXISTS delegated_control_plane_audit_operation_check;
|
||||
ALTER TABLE authority.delegated_control_plane_audit DROP CONSTRAINT IF EXISTS delegated_control_plane_audit_operation_check1;
|
||||
ALTER TABLE authority.delegated_control_plane_grants ADD CONSTRAINT delegated_control_plane_grants_operations_nonempty_check CHECK (cardinality(operations) > 0);
|
||||
ALTER TABLE authority.delegated_control_plane_grants ADD CONSTRAINT delegated_control_plane_grants_operations_check CHECK (operations <@ ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING']::TEXT[]);
|
||||
ALTER TABLE authority.delegated_control_plane_audit ADD CONSTRAINT delegated_control_plane_audit_operation_check CHECK (operation IN ('QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','REVOKE_DELEGATED_CONTROL_PLANE_GRANT','CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING'));
|
||||
ALTER TABLE authority.delegated_control_plane_grants ADD CONSTRAINT delegated_control_plane_grants_exact_resource_check CHECK (
|
||||
(binding_id <> '' AND provider_instance_id <> '' AND model_descriptor_id <> '' AND candidate_release_set_id = '' AND candidate_slot = '' AND candidate_capability_id = '' AND candidate_resource_scope = '' AND candidate_mode = '' AND candidate_target_kind = '' AND candidate_target_ref = '') OR
|
||||
(binding_id IS NULL AND provider_instance_id IS NULL AND model_descriptor_id IS NULL AND candidate_release_set_id <> '' AND candidate_slot <> '' AND candidate_capability_id <> '' AND candidate_resource_scope <> '' AND candidate_mode IN ('CANDIDATE','SHADOW') AND candidate_target_kind <> '' AND candidate_target_ref <> '' AND length(candidate_target_digest) = 64)
|
||||
);
|
||||
INSERT INTO authority.delegated_control_plane_grants (grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,candidate_release_set_id,candidate_slot,candidate_capability_id,candidate_resource_scope,candidate_mode,candidate_target_kind,candidate_target_ref,candidate_target_digest,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status)
|
||||
VALUES ('gen2-capability-001-shadow-notification-grant','domovoy-control-plane-actor','domovoy-control-plane-principal','domovoy-creator-principal','GEN2-CAPABILITY-001',7,ARRAY['CREATE_CANDIDATE_CAPABILITY_BINDING','USE_CANDIDATE_CAPABILITY_BINDING','REVOKE_CANDIDATE_CAPABILITY_BINDING']::TEXT[],NULL,NULL,NULL,'assistant-slot-b','b','prospective-notification-delivery','synthetic-shadow-notification-v1','SHADOW','NOTIFICATION_SHADOW_RECEIVER','notification-receiver-candidate','f9f9fa49a0727444c280c092d5bb340f3a8f33cf325c5396f5ae177a6666da35',(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000)::BIGINT,(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP + INTERVAL '14 days') * 1000)::BIGINT,'ADR-0021','gen2-capability-001-shadow-notification-r7',1,'ACTIVE') ON CONFLICT (grant_id) DO NOTHING;
|
||||
RESET ROLE;
|
||||
|
||||
SET ROLE capability_role;
|
||||
CREATE TABLE IF NOT EXISTS capability.release_candidate_bindings (
|
||||
binding_id TEXT NOT NULL, revision BIGINT NOT NULL, base_active_binding_id TEXT NOT NULL, base_active_binding_revision BIGINT NOT NULL,
|
||||
release_set_id TEXT NOT NULL, slot TEXT NOT NULL, task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, principal_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL, target_kind TEXT NOT NULL, target_ref TEXT NOT NULL, target_digest TEXT NOT NULL, authority_grant_id TEXT NOT NULL,
|
||||
target_origin TEXT NOT NULL, delivery_path TEXT NOT NULL, receipt_path TEXT NOT NULL, protocol TEXT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL, expires_at_unix_millis BIGINT NOT NULL, status TEXT NOT NULL,
|
||||
PRIMARY KEY (binding_id, revision),
|
||||
CHECK (revision > 0 AND base_active_binding_revision > 0 AND task_revision > 0), CHECK (mode IN ('CANDIDATE','SHADOW')), CHECK (status IN ('ACTIVE','REVOKED')),
|
||||
CHECK (expires_at_unix_millis > created_at_unix_millis), CHECK (target_origin <> '' AND delivery_path <> '' AND receipt_path <> '' AND target_kind <> '' AND target_ref <> '' AND length(target_digest) = 64 AND authority_grant_id <> '')
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.release_candidate_binding_heads (
|
||||
release_set_id TEXT NOT NULL, slot TEXT NOT NULL, task_id TEXT NOT NULL, principal_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL, revision BIGINT NOT NULL, PRIMARY KEY (release_set_id,slot,task_id,principal_id,capability_id,resource_scope,mode)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capability.release_candidate_binding_audit (
|
||||
audit_id BIGSERIAL PRIMARY KEY, binding_id TEXT NOT NULL, revision BIGINT NOT NULL, release_set_id TEXT NOT NULL, task_id TEXT NOT NULL, capability_id TEXT NOT NULL, resource_scope TEXT NOT NULL, mode TEXT NOT NULL, decision TEXT NOT NULL, reason TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (decision IN ('CREATED','RESOLVED','DENIED','REVOKED','EXPIRED'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS release_candidate_binding_audit_lookup_idx ON capability.release_candidate_binding_audit (release_set_id, task_id, capability_id, recorded_at_unix_millis DESC);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
SET ROLE commit_role;
|
||||
CREATE TABLE IF NOT EXISTS commit.execution_receipts (
|
||||
receipt_id TEXT PRIMARY KEY, action_attempt_id TEXT NOT NULL UNIQUE, executor_implementation_ref TEXT NOT NULL,
|
||||
external_operation_ref TEXT NOT NULL DEFAULT '', external_request_id TEXT NOT NULL DEFAULT '', request_digest TEXT NOT NULL,
|
||||
response_digest TEXT NOT NULL DEFAULT '', dispatch_at_unix_millis BIGINT NOT NULL, completed_at_unix_millis BIGINT NOT NULL DEFAULT 0,
|
||||
executor_result TEXT NOT NULL, reconciliation_key TEXT NOT NULL, raw_artifact_ref TEXT NOT NULL DEFAULT '',
|
||||
delivery_mode TEXT NOT NULL DEFAULT '', external_delivery_attempted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
release_set_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', task_revision BIGINT NOT NULL DEFAULT 0,
|
||||
capability_binding_id TEXT NOT NULL DEFAULT '', capability_binding_revision BIGINT NOT NULL DEFAULT 0, target_reference TEXT NOT NULL DEFAULT '',
|
||||
CHECK (dispatch_at_unix_millis > 0), CHECK (executor_result <> ''), CHECK (delivery_mode IN ('','SHADOW')),
|
||||
CHECK (delivery_mode <> 'SHADOW' OR external_delivery_attempted = FALSE)
|
||||
);
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
SET ROLE identity_role;
|
||||
CREATE TABLE IF NOT EXISTS identity.service_principals (
|
||||
principal_id TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind = 'SERVICE'),
|
||||
purpose TEXT NOT NULL,
|
||||
synthetic_scope TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
revision BIGINT NOT NULL DEFAULT 1,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (principal_id <> '' AND actor_id <> '' AND purpose <> '' AND synthetic_scope <> '' AND revision > 0)
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS service_principals_actor_idx ON identity.service_principals(actor_id);
|
||||
INSERT INTO identity.service_principals(principal_id, actor_id, kind, purpose, synthetic_scope)
|
||||
VALUES ('domovoy-acceptance-harness-principal', 'domovoy-acceptance-harness-actor', 'SERVICE', 'AUTHENTICATE_ACCEPTANCE_HARNESS', 'synthetic-acceptance-scope')
|
||||
ON CONFLICT (principal_id) DO NOTHING;
|
||||
INSERT INTO identity.actors(actor_id, display_name) VALUES ('domovoy-acceptance-harness-actor', 'Domovoy Acceptance Harness') ON CONFLICT (actor_id) DO NOTHING;
|
||||
INSERT INTO identity.principals(principal_id, display_name) VALUES ('domovoy-acceptance-harness-principal', 'Domovoy Acceptance Harness Principal') ON CONFLICT (principal_id) DO NOTHING;
|
||||
INSERT INTO identity.principal_bindings(actor_id, principal_id, active) VALUES ('domovoy-acceptance-harness-actor', 'domovoy-acceptance-harness-principal', TRUE) ON CONFLICT (actor_id, principal_id) DO UPDATE SET active=TRUE;
|
||||
REVOKE ALL ON identity.service_principals FROM PUBLIC;
|
||||
GRANT SELECT ON identity.service_principals TO identity_role;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -25,6 +25,16 @@ psql_exec() {
|
||||
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
|
||||
}
|
||||
|
||||
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
|
||||
# before feature migrations run. This keeps role creation out of migrations and
|
||||
# makes existing PostgreSQL volumes converge idempotently as well.
|
||||
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
|
||||
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
|
||||
echo "migration runner: Gen2 bootstrap is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
psql_exec < "$GEN2_BOOTSTRAP"
|
||||
|
||||
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
|
||||
|
||||
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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_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}
|
||||
ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF: credential://domovoy/acceptance-harness/service-token/v1
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
|
||||
CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1
|
||||
CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets
|
||||
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
|
||||
- assistant_control_plane_service_secrets:/run/control-plane-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
|
||||
@@ -65,20 +65,20 @@ services:
|
||||
POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD}
|
||||
IDENTITY_POSTGRES_USER: identity_role
|
||||
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
|
||||
CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token
|
||||
CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key
|
||||
CREATOR_USERNAME: ${CREATOR_USERNAME}
|
||||
CREATOR_PASSWORD_FILE: /run/secrets/creator_password
|
||||
ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF: credential://domovoy/acceptance-harness/service-token/v1
|
||||
WORK_POSTGRES_USER: work_role
|
||||
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
|
||||
CAPABILITY_POSTGRES_USER: capability_role
|
||||
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
|
||||
CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1
|
||||
CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets
|
||||
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
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
INTERACTION_POSTGRES_USER: interaction_role
|
||||
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
|
||||
INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1
|
||||
COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1}
|
||||
@@ -88,6 +88,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"]
|
||||
@@ -182,7 +188,6 @@ services:
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
volumes:
|
||||
@@ -202,12 +207,16 @@ services:
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
EPISTEMICS_POSTGRES_USER: epistemics_role
|
||||
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
|
||||
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
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}" } }
|
||||
@@ -234,6 +243,9 @@ services:
|
||||
POSTGRES_PASSWORD: ${COMMIT_DB_PASSWORD}
|
||||
AUTHORITY_POSTGRES_USER: authority_role
|
||||
AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD}
|
||||
CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1
|
||||
CONTROL_PLANE_SERVICE_SECRET_ROOT: /run/control-plane-secrets
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
|
||||
@@ -242,8 +254,20 @@ services:
|
||||
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
|
||||
depends_on: [core-runtime, continuity-runtime, notification-receiver]
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
- 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
|
||||
- assistant_core_secrets:/run/core-secrets
|
||||
|
||||
cognitive-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -253,8 +277,10 @@ services:
|
||||
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-api:8080/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-}
|
||||
# The medium chat route is served by the authenticated local gateway.
|
||||
# llm-local-api is the embeddings service and must not be used for chat.
|
||||
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
|
||||
@@ -263,10 +289,17 @@ services:
|
||||
LOCAL_IMAGE_MODEL: ${LOCAL_IMAGE_MODEL:-z_image_turbo_bf16.safetensors}
|
||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||
COGNITIVE_ALLOW_CANARY: "true"
|
||||
depends_on: [core-runtime, cognitive-lifeboat]
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
networks: [default, llm-local]
|
||||
depends_on:
|
||||
core-runtime: { condition: service_started }
|
||||
cognitive-lifeboat: { condition: service_healthy }
|
||||
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
|
||||
|
||||
cognitive-lifeboat:
|
||||
image: ghcr.io/ggml-org/llama.cpp:server
|
||||
@@ -277,7 +310,7 @@ services:
|
||||
- --port
|
||||
- "8086"
|
||||
- --model
|
||||
- /models/Qwen3-8B-Q4_K_M.gguf
|
||||
- /models/Qwen3.8-27B-UD-Q2_K_XL.gguf
|
||||
- --alias
|
||||
- domovoy-lifeboat
|
||||
- --jinja
|
||||
@@ -299,6 +332,29 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
supervisor:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: supervisor, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8080"
|
||||
SUPERVISOR_CONTROL_ADDR: ":8095"
|
||||
SUPERVISOR_STATE_PATH: /var/lib/supervisor/release-state.json
|
||||
SUPERVISOR_STABLE_UPSTREAM: http://creator-web:8084
|
||||
SUPERVISOR_CANDIDATE_UPSTREAM: http://creator-web-candidate:8084
|
||||
SUPERVISOR_BASELINE_RELEASE: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports:
|
||||
- "${CREATOR_WEB_PORT:-18080}:8080"
|
||||
- "127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}:8095"
|
||||
depends_on:
|
||||
creator-web: { condition: service_healthy }
|
||||
volumes:
|
||||
- assistant_supervisor_state:/var/lib/supervisor
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
creator-web:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -310,8 +366,11 @@ services:
|
||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||
AGENT_WORKER_URL: http://agent-worker:8090
|
||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||
depends_on:
|
||||
core-runtime: { condition: service_started }
|
||||
continuity-runtime: { condition: service_started }
|
||||
commit-runtime: { condition: service_started }
|
||||
cognitive-worker: { condition: service_healthy }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
@@ -323,6 +382,9 @@ volumes:
|
||||
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
|
||||
@@ -333,6 +395,7 @@ volumes:
|
||||
external: true
|
||||
assistant_research_sources:
|
||||
name: assistant_research_sources
|
||||
assistant_supervisor_state:
|
||||
|
||||
networks:
|
||||
llm-local:
|
||||
|
||||
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
|
||||
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
||||
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
|
||||
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||
|
||||
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
|
||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
||||
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
|
||||
USER assistant
|
||||
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
||||
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
|
||||
|
||||
@@ -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,45 @@
|
||||
#!/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}"
|
||||
CREDENTIAL="${MACHINE_CREDENTIAL:-control-plane}"
|
||||
SECRET_ROOT="/run/control-plane-secrets"
|
||||
VOLUME="assistant_control_plane_service_secrets"
|
||||
if [[ "$CREDENTIAL" == "acceptance-harness" ]]; then
|
||||
SECRET_ROOT="/run/core-secrets"
|
||||
VOLUME="assistant_core_secrets"
|
||||
fi
|
||||
|
||||
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 -e "SECRET_CONTROL_PLANE_ROOT=$SECRET_ROOT" secret-control-plane -credential "$CREDENTIAL" -reason "$reason" rotate
|
||||
else
|
||||
ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps -e "SECRET_CONTROL_PLANE_ROOT=$SECRET_ROOT" secret-control-plane -credential "$CREDENTIAL" 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
|
||||
@@ -0,0 +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}"
|
||||
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}"
|
||||
|
||||
[[ "$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; }
|
||||
|
||||
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 "isolated candidate ReleaseSet failed; stable ReleaseSet rollback requested" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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-024-multi-person-realms-identity-boundaries-and-contextual-source-trust.json","task_id":"GEN2-024","task_revision":1}
|
||||
{"task_file":"dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json","task_id":"GEN2-AUTH-HARNESS-001","task_revision":3}
|
||||
|
||||
+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,26 @@
|
||||
{
|
||||
"task_id": "GEN2-AUTH-HARNESS-001",
|
||||
"task_revision": 3,
|
||||
"architecture_revision": "ARCH-GEN2-000",
|
||||
"base_repo_revision": "gen2-integration",
|
||||
"risk_tier": "E",
|
||||
"intent": "Provide a reusable first-party authenticated service acceptance harness through the existing Identity/Auth boundary for synthetic, non-user interaction scenarios, without owner impersonation, auth bypass, a second Identity owner, or direct database access.",
|
||||
"requirement_ids": ["REQ-GEN2-AUTH-HARNESS-001-SERVICE-AUTH", "REQ-GEN2-EVOLVE-004-RUNTIME-001-DURABLE-LEARNING"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["Identity service-principal lifecycle", "authenticated service-session validation", "synthetic acceptance scope enforcement", "Core interaction acceptance read/query", "protected machine-credential provisioning", "typed acceptance harness", "targeted evidence"],
|
||||
"paths": ["contracts/identity/**", "contracts/interaction/**", "contracts/self/**", "modules/identity/**", "modules/interaction/**", "modules/self/**", "modules/epistemics/domain/experience.go", "modules/epistemics/domain/experience_test.go", "adapters/postgres/identity_repository.go", "adapters/postgres/interaction_repository.go", "adapters/postgres/self_repository.go", "cmd/core-runtime/**", "cmd/auth-acceptance-harness/**", "platform/secrets/**", "cmd/secret-control-plane/**", "database/migrations/057_release_scoped_candidate_capability_bindings.sql", "database/migrations/059_identity_acceptance_harness.sql", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "deploy/provision-machine-credential.sh", "deploy/provision-machine-credential_test.sh", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "docs/PROJECT_STATUS.md", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json"]
|
||||
},
|
||||
"forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "cmd/cognitive-worker/**", "contracts/authority/**", "modules/authority/**", "cmd/commit-runtime/**", "database/bootstrap/**", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**", "go.mod", "go.sum"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["existing Identity Actor/PrincipalBinding", "existing Creator session validation boundary", "existing Interaction and SELF repositories", "existing protected SecretRef/FileResolver"], "may_change": ["typed service-principal authentication metadata", "typed synthetic acceptance operations", "typed canonical SELF acceptance query summary"]},
|
||||
"semantic_owner": "Identity remains the sole owner of service identity and authentication. Interaction remains the owner of synthetic interactions. SELF remains the sole owner of learning evidence. The harness is only a bounded authenticated client.",
|
||||
"canonical_state": ["service principal is distinct from the Creator and cannot access Creator data", "synthetic resources are typed and isolated by principal and scope", "credential value exists only in protected secret artifacts", "authentication does not grant Authority", "SELF evidence retains synthetic provenance and cannot promote production competence automatically"],
|
||||
"state_transitions": ["ABSENT -> ACTIVE service principal via idempotent Identity bootstrap", "credential ABSENT -> ACTIVE via existing protected control plane", "authenticated synthetic request -> bounded Interaction path -> learning evidence", "service credential revoked/expired -> DENY while audit/history remain"],
|
||||
"side_effects_and_privileges": ["only the existing protected secret control plane may provision the dedicated harness credential", "Core resolves only its SecretRef through the protected read-only mount", "synthetic acceptance cannot mutate production Authority, owner memory, or production competence", "no browser cookies, owner password, token value, raw prompt or user memory is read or emitted"],
|
||||
"persistence_and_migrations": ["one additive Identity-owned service-principal/credential metadata migration is permitted only if existing schema cannot represent it", "SELF and Interaction canonical evidence/history are retained", "no direct database write is performed by the harness"],
|
||||
"failure_retry_restart": ["missing, wrong, expired and revoked credentials deny closed", "service principal and synthetic scope are loaded durably, not from process-local state", "SELF write failure is diagnostic and non-fatal to an already prepared response", "bounded scenarios have explicit operation and wall-time limits", "restart must preserve canonical evidence and typed summaries"],
|
||||
"assumptions": ["the existing protected secret control plane can provision a dedicated credential without exposing its value", "the candidate ReleaseSet can mount the protected volume read-only", "the current Identity/Auth boundary can be extended without impersonating the Creator"],
|
||||
"acceptance_scenarios": ["valid harness credential authenticates through normal middleware", "missing/wrong/revoked/expired auth is denied", "harness cannot read owner data", "synthetic interaction reaches the real Core learning path", "correction and verified failure persist through SelfRepository", "canonical SELF summary is readable after restart", "synthetic provenance is preserved and does not auto-promote production competence", "credential rotation remains black-box and old credential is denied", "candidate revision is verified and stable remains untouched"],
|
||||
"required_tests": ["targeted go test", "targeted go vet", "compose/config validation", "auth harness regression", "preflight", "check-diff", "independent", "architecture/governance MILESTONE", "git diff --check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user