Compare commits

...
Author SHA1 Message Date
m f65d0f8f3c DEV-000053 add Creator advisory Mode A routing
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-08-16 21:33:15 +03:00
m 8318194cd0 Merge pull request 'DEV-000052: promote disabled OpenAI gate' (#27) from codex/promote-disabled-openai-gate-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:49:34 +00:00
m bd38e1e2d3 chore: authorize disabled OpenAI gate promotion
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-08-16 20:48:50 +03:00
m 61f349ec40 Merge pull request 'DEV-000051: gate OpenAI advisory runtime' (#26) from codex/openai-advisory-runtime-gate-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:47:40 +00:00
m 417641dbf4 feat: gate OpenAI advisory runtime
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:47:01 +03:00
m 0ee9e4a8a1 Merge pull request 'DEV-000050: add OpenAI advisory adapter' (#25) from codex/openai-advisory-adapter-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:43:34 +00:00
m 3b009033d6 feat: add OpenAI advisory adapter
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-08-16 20:42:55 +03:00
m 00965f20e0 Merge pull request 'ARCH-000010: accept OpenAI advisory binding' (#24) from codex/accept-openai-advisory-binding-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:38:16 +00:00
m b61612f543 arch: accept OpenAI advisory binding
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-08-16 20:37:38 +03:00
m 835c62cc1d Merge pull request 'ARCH-000009: propose OpenAI advisory binding' (#23) from codex/openai-advisory-binding-proposal-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:35:11 +00:00
m 9818f06e05 docs: propose OpenAI advisory binding
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:34:32 +03:00
m 67390c8297 Merge pull request 'DEV-000049: audit production advisory boundary' (#22) from codex/post-advisory-production-audit-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:31:43 +00:00
m 1dc9f40595 test: audit production advisory boundary
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:30:12 +03:00
m 4de34ad8f8 Merge pull request 'DEV-000048 r2: honor advisory message size contract' (#21) from codex/creator-advisory-console-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:23:50 +00:00
m f6dba0d39b Merge remote-tracking branch 'origin/main' into codex/creator-advisory-console-v1
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:23:11 +03:00
m 59f5b4ce57 fix: honor advisory message size contract
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:22:27 +03:00
m 6440cbff81 Merge pull request 'DEV-000048: add Creator advisory console' (#20) from codex/creator-advisory-console-v1 into main
CI / test (push) Successful in 12s
2026-08-16 17:16:32 +00:00
m d0255558e2 feat: add creator advisory console
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-08-16 20:15:47 +03:00
m 861438a4ca Merge pull request 'DEV-000047: promote cognitive advisory fake' (#19) from codex/deploy-cognitive-advisory-fake-v1 into main
CI / test (push) Successful in 12s
2026-08-16 17:07:08 +00:00
m fbc6538d18 chore: authorize cognitive advisory promotion
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s
2026-08-16 20:06:36 +03:00
m 4b8832766a Merge pull request 'DEV-000046: add cognitive advisory contract' (#18) from codex/cognitive-advisory-contract-v1 into main
CI / test (push) Successful in 11s
2026-08-16 17:05:51 +00:00
m f9cdd67229 feat: add cognitive advisory contract
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 11s
2026-08-16 20:05:12 +03:00
m b896d218f8 Merge pull request 'ARCH-000008: accept Creator advisory boundary' (#17) from codex/cognitive-provider-proposal-v1 into main
CI / test (push) Successful in 12s
2026-08-16 17:01:53 +00:00
m d1081bb70e architecture: accept creator advisory boundary
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 20:01:20 +03:00
m 78862ffe05 Merge pull request 'ARCH-000007: propose Creator cognitive advisory' (#16) from codex/cognitive-provider-proposal-v1 into main
CI / test (push) Successful in 10s
2026-08-16 16:59:11 +00:00
m e4bc702be7 docs: propose creator cognitive advisory
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 19:58:28 +03:00
m 43d1ef5821 Merge pull request 'DEV-000045: attest production artifact revision' (#15) from codex/production-artifact-provenance-v1 into main
CI / test (push) Successful in 10s
2026-08-16 16:53:44 +00:00
m d5cd964e51 deploy: attest production artifact revision
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 19:53:00 +03:00
m 959c89d47b Merge pull request 'DEV-000044: audit post-notification production state' (#14) from codex/post-notification-production-audit-v1 into main
CI / test (push) Successful in 11s
2026-08-16 16:49:26 +00:00
m 760f671077 docs: audit post-notification production state
CI / test (push) Successful in 11s
CI / test (pull_request) Successful in 11s
2026-08-16 19:48:37 +03:00
m 759328972a test: normalize mounted notification token
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 11s
2026-08-16 19:33:48 +03:00
m 01e1bdea1e fix: mount notification secret in commit runtime
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 11s
2026-08-16 19:29:17 +03:00
m 7a3280b007 deploy: add production notification delivery
CI / test (pull_request) Successful in 11s
CI / test (push) Successful in 10s
2026-08-16 19:25:19 +03:00
m c6139699a9 feat: enable creator notification command
CI / test (pull_request) Successful in 11s
CI / test (push) Successful in 11s
2026-08-16 19:19:00 +03:00
m 6e0040fe89 feat: add notification delivery foundation
CI / test (pull_request) Successful in 12s
CI / test (push) Successful in 10s
2026-08-16 19:08:07 +03:00
m 45fb5f8709 architecture: accept prospective notification delivery
CI / test (pull_request) Successful in 10s
CI / test (push) Successful in 10s
2026-08-16 18:58:09 +03:00
78 changed files with 4611 additions and 74 deletions
+105
View File
@@ -0,0 +1,105 @@
package models
import (
"errors"
"fmt"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/cognitive"
)
const (
MaxAdvisoryMessageBytes = 16 * 1024
MaxAdvisoryExcerpts = 8
MaxAdvisoryExcerptBytes = 8 * 1024
MaxAdvisoryContextBytes = 48 * 1024
MaxAdvisoryResponseBytes = 16 * 1024
)
var ErrInvalidAdvisory = errors.New("invalid creator advisory")
func ValidateAdvisoryRequest(request cognitive.AdvisoryRequest) error {
if request.Version != cognitive.CreatorAdvisoryV1 || request.RequestID == "" || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.ProviderBindingID == "" || request.ProviderBindingRevision <= 0 {
return fmt.Errorf("%w: missing or unsupported identity/binding fields", ErrInvalidAdvisory)
}
if request.Message == "" || !utf8.ValidString(request.Message) || len(request.Message) > MaxAdvisoryMessageBytes {
return fmt.Errorf("%w: message", ErrInvalidAdvisory)
}
if request.Locale == "" || len(request.Locale) > 32 || !utf8.ValidString(request.Locale) || request.MaxOutputBytes <= 0 || request.MaxOutputBytes > MaxAdvisoryResponseBytes {
return fmt.Errorf("%w: locale or response limit", ErrInvalidAdvisory)
}
if len(request.Context) > MaxAdvisoryExcerpts {
return fmt.Errorf("%w: too many context excerpts", ErrInvalidAdvisory)
}
seen := make(map[cognitive.ContextExcerptID]struct{}, len(request.Context))
total := 0
for _, excerpt := range request.Context {
if excerpt.ID == "" || excerpt.SourceKind == "" || excerpt.SourceID == "" || excerpt.Revision <= 0 || excerpt.Text == "" || !utf8.ValidString(excerpt.Text) || len(excerpt.Text) > MaxAdvisoryExcerptBytes {
return fmt.Errorf("%w: context excerpt", ErrInvalidAdvisory)
}
if excerpt.Sensitivity != cognitive.SensitivityPublic && excerpt.Sensitivity != cognitive.SensitivityPrivateAllowed {
return fmt.Errorf("%w: context sensitivity", ErrInvalidAdvisory)
}
if _, exists := seen[excerpt.ID]; exists {
return fmt.Errorf("%w: duplicate context excerpt", ErrInvalidAdvisory)
}
seen[excerpt.ID] = struct{}{}
total += len(excerpt.Text)
}
if total > MaxAdvisoryContextBytes {
return fmt.Errorf("%w: total context", ErrInvalidAdvisory)
}
return nil
}
func ValidateAdvisoryResponse(request cognitive.AdvisoryRequest, response cognitive.AdvisoryResponse) error {
if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision {
return fmt.Errorf("%w: response identity", ErrInvalidAdvisory)
}
if !validFinishReason(response.FinishReason) || !utf8.ValidString(response.AdvisoryText) || len(response.AdvisoryText) > request.MaxOutputBytes {
return fmt.Errorf("%w: response content", ErrInvalidAdvisory)
}
if response.FinishReason == cognitive.FinishCompleted && response.AdvisoryText == "" {
return fmt.Errorf("%w: completed response is empty", ErrInvalidAdvisory)
}
if response.FinishReason != cognitive.FinishCompleted && response.AdvisoryText != "" {
return fmt.Errorf("%w: failure response contains advisory text", ErrInvalidAdvisory)
}
if response.FinishReason != cognitive.FinishCompleted && len(response.ReferencedExcerptIDs) != 0 {
return fmt.Errorf("%w: failure response contains citations", ErrInvalidAdvisory)
}
allowed := make(map[cognitive.ContextExcerptID]struct{}, len(request.Context))
for _, excerpt := range request.Context {
allowed[excerpt.ID] = struct{}{}
}
seen := make(map[cognitive.ContextExcerptID]struct{}, len(response.ReferencedExcerptIDs))
for _, id := range response.ReferencedExcerptIDs {
if _, ok := allowed[id]; !ok {
return fmt.Errorf("%w: unknown citation", ErrInvalidAdvisory)
}
if _, ok := seen[id]; ok {
return fmt.Errorf("%w: duplicate citation", ErrInvalidAdvisory)
}
seen[id] = struct{}{}
}
if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) {
return fmt.Errorf("%w: negative usage", ErrInvalidAdvisory)
}
return nil
}
func DeterministicAdvisoryUnavailable(request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
if err := ValidateAdvisoryRequest(request); err != nil {
return cognitive.AdvisoryResponse{}, err
}
return cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: request.ProviderBindingRevision, FinishReason: cognitive.FinishProviderUnavailable}, nil
}
func validFinishReason(reason cognitive.FinishReason) bool {
switch reason {
case cognitive.FinishCompleted, cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse:
return true
default:
return false
}
}
+269
View File
@@ -0,0 +1,269 @@
package models
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
const (
OpenAIAdvisoryBindingID capability.CapabilityBindingID = "openai-responses-gpt-5.4-mini-2026-03-17"
OpenAIAdvisoryBindingRevision int64 = 1
openAIResponsesURL = "https://api.openai.com/v1/responses"
openAIModel = "gpt-5.4-mini-2026-03-17"
openAIMaxOutputTokens = 4096
openAIResponseLimitBytes = 1 << 20
openAITimeout = 30 * time.Second
openAIInstructions = "Provide non-canonical advisory text only. State uncertainty when appropriate. Never claim that an action occurred, never request or expose secrets, and never emit tool or executable instructions."
)
type OpenAIAdvisoryAdapter struct {
apiKey []byte
client *http.Client
}
func NewOpenAIAdvisoryAdapter(apiKey []byte, transport http.RoundTripper) (*OpenAIAdvisoryAdapter, error) {
if len(apiKey) == 0 || bytes.IndexAny(apiKey, "\r\n") >= 0 {
return nil, errors.New("invalid OpenAI credential")
}
if transport == nil {
base, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("default HTTP transport is unavailable")
}
cloned := base.Clone()
cloned.Proxy = nil
transport = cloned
}
return &OpenAIAdvisoryAdapter{
apiKey: append([]byte(nil), apiKey...),
client: &http.Client{
Transport: transport,
Timeout: openAITimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
type openAIRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input string `json:"input"`
Store bool `json:"store"`
Reasoning openAIReasoning `json:"reasoning"`
MaxOutputTokens int `json:"max_output_tokens"`
}
type openAIReasoning struct {
Effort string `json:"effort"`
}
type openAIResponse struct {
Status string `json:"status"`
Model string `json:"model"`
Store *bool `json:"store"`
Output []json.RawMessage `json:"output"`
Usage *openAIUsage `json:"usage"`
IncompleteDetails *openAIIncompleteDetails `json:"incomplete_details"`
}
type openAIIncompleteDetails struct {
Reason string `json:"reason"`
}
type openAIUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
type openAIOutputHeader struct {
Type string `json:"type"`
}
type openAIMessage struct {
Type string `json:"type"`
Role string `json:"role"`
Status string `json:"status"`
Content []json.RawMessage `json:"content"`
}
type openAIContentHeader struct {
Type string `json:"type"`
}
type openAIOutputText struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []json.RawMessage `json:"annotations"`
}
type openAIRefusal struct {
Type string `json:"type"`
Refusal string `json:"refusal"`
}
type openAIReasoningItem struct {
Type string `json:"type"`
Summary []json.RawMessage `json:"summary"`
}
func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
if err := ValidateAdvisoryRequest(request); err != nil {
return cognitive.AdvisoryResponse{}, err
}
if request.ProviderBindingID != OpenAIAdvisoryBindingID || request.ProviderBindingRevision != OpenAIAdvisoryBindingRevision || len(request.Context) != 0 {
return cognitive.AdvisoryResponse{}, fmt.Errorf("%w: OpenAI Mode A binding", ErrInvalidAdvisory)
}
payload, err := json.Marshal(openAIRequest{
Model: openAIModel,
Instructions: openAIInstructions,
Input: request.Message,
Store: false,
Reasoning: openAIReasoning{Effort: "low"},
MaxOutputTokens: openAIMaxOutputTokens,
})
if err != nil {
return cognitive.AdvisoryResponse{}, err
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, openAIResponsesURL, bytes.NewReader(payload))
if err != nil {
return cognitive.AdvisoryResponse{}, err
}
httpRequest.Header.Set("Authorization", "Bearer "+string(a.apiKey))
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
httpResponse, err := a.client.Do(httpRequest)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return advisoryFailure(request, cognitive.FinishTimeout), nil
}
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return advisoryFailure(request, cognitive.FinishTimeout), nil
}
return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil
}
defer httpResponse.Body.Close()
if httpResponse.StatusCode != http.StatusOK {
return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil
}
if contentType := httpResponse.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "application/json") {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
body, err := io.ReadAll(io.LimitReader(httpResponse.Body, openAIResponseLimitBytes+1))
if err != nil || len(body) > openAIResponseLimitBytes {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
var providerResponse openAIResponse
if err := json.Unmarshal(body, &providerResponse); err != nil || providerResponse.Model != openAIModel || (providerResponse.Store != nil && *providerResponse.Store) || providerResponse.Usage == nil || providerResponse.Usage.InputTokens < 0 || providerResponse.Usage.OutputTokens < 0 {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
if providerResponse.Status == "incomplete" && providerResponse.IncompleteDetails != nil && providerResponse.IncompleteDetails.Reason == "max_output_tokens" {
return advisoryFailure(request, cognitive.FinishLengthLimit), nil
}
if providerResponse.Status != "completed" || providerResponse.IncompleteDetails != nil {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
text, refused, ok := extractOpenAIText(providerResponse.Output)
if !ok {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
if refused {
return advisoryFailure(request, cognitive.FinishContentRejected), nil
}
response := cognitive.AdvisoryResponse{
Version: cognitive.CreatorAdvisoryV1,
RequestID: request.RequestID,
ProviderBindingID: request.ProviderBindingID,
ProviderBindingRevision: request.ProviderBindingRevision,
AdvisoryText: text,
FinishReason: cognitive.FinishCompleted,
Usage: &cognitive.ProviderUsage{
InputUnits: providerResponse.Usage.InputTokens,
OutputUnits: providerResponse.Usage.OutputTokens,
},
}
if err := ValidateAdvisoryResponse(request, response); err != nil {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
return response, nil
}
func extractOpenAIText(output []json.RawMessage) (string, bool, bool) {
var text string
refused := false
messages := 0
for _, raw := range output {
var header openAIOutputHeader
if json.Unmarshal(raw, &header) != nil {
return "", false, false
}
switch header.Type {
case "reasoning":
var item openAIReasoningItem
if json.Unmarshal(raw, &item) != nil || len(item.Summary) != 0 {
return "", false, false
}
case "message":
messages++
if messages != 1 {
return "", false, false
}
var message openAIMessage
if json.Unmarshal(raw, &message) != nil || message.Role != "assistant" || message.Status != "completed" || len(message.Content) != 1 {
return "", false, false
}
var contentHeader openAIContentHeader
if json.Unmarshal(message.Content[0], &contentHeader) != nil {
return "", false, false
}
switch contentHeader.Type {
case "output_text":
var content openAIOutputText
if json.Unmarshal(message.Content[0], &content) != nil || content.Text == "" || !utf8.ValidString(content.Text) || len(content.Annotations) != 0 {
return "", false, false
}
text = content.Text
case "refusal":
var content openAIRefusal
if json.Unmarshal(message.Content[0], &content) != nil || content.Refusal == "" {
return "", false, false
}
refused = true
default:
return "", false, false
}
default:
return "", false, false
}
}
if messages != 1 || (refused && text != "") || (!refused && text == "") {
return "", false, false
}
return text, refused, true
}
func advisoryFailure(request cognitive.AdvisoryRequest, reason cognitive.FinishReason) cognitive.AdvisoryResponse {
return cognitive.AdvisoryResponse{
Version: cognitive.CreatorAdvisoryV1,
RequestID: request.RequestID,
ProviderBindingID: request.ProviderBindingID,
ProviderBindingRevision: request.ProviderBindingRevision,
FinishReason: reason,
}
}
+200
View File
@@ -0,0 +1,200 @@
package models
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func validOpenAIAdvisoryRequest() cognitive.AdvisoryRequest {
return cognitive.AdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1,
RequestID: "request-1",
ActorID: identity.ActorID("actor-private"),
PrincipalID: identity.PrincipalID("principal-private"),
ScopeID: authority.ScopeID("scope-private"),
Message: "What should I verify?",
Locale: "en",
MaxOutputBytes: MaxAdvisoryResponseBytes,
ProviderBindingID: OpenAIAdvisoryBindingID,
ProviderBindingRevision: OpenAIAdvisoryBindingRevision,
}
}
func providerHTTPResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
func validOpenAIResponse(text string) string {
body, _ := json.Marshal(map[string]any{
"status": "completed",
"model": openAIModel,
"store": false,
"output": []any{
map[string]any{"type": "reasoning", "summary": []any{}},
map[string]any{"type": "message", "role": "assistant", "status": "completed", "content": []any{
map[string]any{"type": "output_text", "text": text, "annotations": []any{}},
}},
},
"usage": map[string]any{"input_tokens": 12, "output_tokens": 7},
})
return string(body)
}
func TestOpenAIAdvisorySendsOnlyModeAPayload(t *testing.T) {
calls := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
calls++
if request.Method != http.MethodPost || request.URL.String() != openAIResponsesURL || request.Host != "api.openai.com" {
t.Fatalf("unexpected target: %s %s host=%s", request.Method, request.URL, request.Host)
}
if request.Header.Get("Authorization") != "Bearer test-key" || request.Header.Get("Content-Type") != "application/json" {
t.Fatal("provider authentication or content type is missing")
}
body, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if len(payload) != 6 || payload["model"] != openAIModel || payload["input"] != "What should I verify?" || payload["store"] != false || payload["instructions"] != openAIInstructions || payload["max_output_tokens"] != float64(openAIMaxOutputTokens) {
t.Fatalf("unexpected provider payload keys/values: %v", payload)
}
reasoning, ok := payload["reasoning"].(map[string]any)
if !ok || len(reasoning) != 1 || reasoning["effort"] != "low" {
t.Fatalf("unexpected reasoning payload: %v", payload["reasoning"])
}
serialized := string(body)
for _, forbidden := range []string{"actor-private", "principal-private", "scope-private", "request-1", "tools", "previous_response_id", "conversation", "background"} {
if strings.Contains(serialized, forbidden) {
t.Fatalf("provider payload leaked forbidden field %q", forbidden)
}
}
return providerHTTPResponse(http.StatusOK, validOpenAIResponse("Check the current evidence.")), nil
})
adapter, err := NewOpenAIAdvisoryAdapter([]byte("test-key"), transport)
if err != nil {
t.Fatal(err)
}
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil {
t.Fatal(err)
}
if calls != 1 || response.FinishReason != cognitive.FinishCompleted || response.AdvisoryText != "Check the current evidence." || response.Usage == nil || response.Usage.InputUnits != 12 || response.Usage.OutputUnits != 7 {
t.Fatalf("unexpected response: calls=%d response=%+v", calls, response)
}
}
func TestOpenAIAdvisoryRejectsContextBeforeTransport(t *testing.T) {
called := false
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("must not call")
}))
request := validOpenAIAdvisoryRequest()
request.Context = []cognitive.ContextExcerpt{{ID: "excerpt", SourceKind: "task", SourceID: "task", Revision: 1, Sensitivity: cognitive.SensitivityPublic, Text: "public"}}
if _, err := adapter.Advise(context.Background(), request); err == nil || called {
t.Fatalf("error=%v called=%t", err, called)
}
}
func TestOpenAIAdvisoryDoesNotFollowRedirectOrRetry(t *testing.T) {
calls := 0
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
response := providerHTTPResponse(http.StatusFound, "{}")
response.Header.Set("Location", "https://attacker.invalid/steal")
return response, nil
}))
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishProviderUnavailable {
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
}
}
type timeoutError struct{}
func (timeoutError) Error() string { return "timeout" }
func (timeoutError) Timeout() bool { return true }
func (timeoutError) Temporary() bool { return true }
func TestOpenAIAdvisoryMapsTimeoutAndDoesNotRetry(t *testing.T) {
calls := 0
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return nil, timeoutError{}
}))
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishTimeout {
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
}
}
func TestOpenAIAdvisoryMapsRefusal(t *testing.T) {
body := `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":"cannot comply"}]}],"usage":{"input_tokens":3,"output_tokens":0}}`
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
return providerHTTPResponse(http.StatusOK, body), nil
}))
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil || response.FinishReason != cognitive.FinishContentRejected || response.AdvisoryText != "" {
t.Fatalf("error=%v response=%+v", err, response)
}
}
func TestOpenAIAdvisoryMapsOutputLimit(t *testing.T) {
body := `{"status":"incomplete","model":"gpt-5.4-mini-2026-03-17","store":false,"incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":3,"output_tokens":4096}}`
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
return providerHTTPResponse(http.StatusOK, body), nil
}))
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil || response.FinishReason != cognitive.FinishLengthLimit || response.AdvisoryText != "" {
t.Fatalf("error=%v response=%+v", err, response)
}
}
func TestOpenAIAdvisoryRejectsExecutableOrUntrustedOutput(t *testing.T) {
tests := map[string]string{
"tool call": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"function_call","name":"mutate"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
"citation": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer","annotations":[{"type":"url_citation"}]}]}],"usage":{"input_tokens":1,"output_tokens":1}}`,
"wrong model": strings.Replace(validOpenAIResponse("answer"), openAIModel, "moving-alias", 1),
"oversize": validOpenAIResponse(strings.Repeat("a", MaxAdvisoryResponseBytes+1)),
"malformed": `{`,
}
for name, body := range tests {
t.Run(name, func(t *testing.T) {
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
return providerHTTPResponse(http.StatusOK, body), nil
}))
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
if err != nil || response.FinishReason != cognitive.FinishInvalidProviderResponse || response.AdvisoryText != "" {
t.Fatalf("error=%v response=%+v", err, response)
}
})
}
}
func TestOpenAIAdvisoryRejectsInvalidCredential(t *testing.T) {
for _, key := range [][]byte{nil, []byte("line\nbreak")} {
if _, err := NewOpenAIAdvisoryAdapter(key, nil); err == nil {
t.Fatalf("credential %q was accepted", key)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
package notificationexecutor
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
"git.cin.su/m/Assistant/modules/commit/ports"
)
const maxResponseBytes = 16 << 10
var ErrInvalidNotification = errors.New("invalid prospective notification")
type Payload struct {
ActionIntentID commitcontract.ActionIntentID `json:"action_intent_id"`
TemplateCode string `json:"template_code"`
ProspectiveItemID string `json:"prospective_item_id"`
ExpectedRevision int64 `json:"expected_revision"`
Title string `json:"title"`
Message string `json:"message"`
}
type DeliveryRequest struct {
Payload
RequestDigest string `json:"request_digest"`
}
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"`
}
func Digest(payload Payload) (string, error) {
if payload.ActionIntentID == "" || payload.TemplateCode != commitcontract.ProspectiveDeliveredTemplateV1 ||
payload.ProspectiveItemID == "" || payload.ExpectedRevision < 1 || strings.TrimSpace(payload.Title) == "" ||
strings.TrimSpace(payload.Message) == "" || len(payload.Title) > 160 || len(payload.Message) > 2048 {
return "", ErrInvalidNotification
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error) {
subject := action.Proposal.NotificationSubject
if action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification ||
action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification ||
subject.BindingID == "" || subject.BindingRevision < 1 {
return Payload{}, ErrInvalidNotification
}
payload := Payload{ActionIntentID: action.Proposal.IntentID, TemplateCode: subject.TemplateCode,
ProspectiveItemID: string(subject.ProspectiveItemID), ExpectedRevision: int64(subject.ExpectedRevision),
Title: subject.RenderedTitle, Message: subject.RenderedMessage}
digest, err := Digest(payload)
if err != nil || digest != subject.RequestDigest {
return Payload{}, ErrInvalidNotification
}
return payload, nil
}
type Executor struct {
binding capability.Binding
secret string
client *http.Client
executeAllowed bool
}
func New(binding capability.Binding, secret string, client *http.Client) (*Executor, error) {
if err := capabilitydomain.UsableBinding(binding); err != nil {
return nil, ErrInvalidNotification
}
return newExecutor(binding, secret, client, true)
}
func NewReconciler(binding capability.Binding, secret string, client *http.Client) (*Executor, error) {
if err := capabilitydomain.ValidateBinding(binding); err != nil {
return nil, ErrInvalidNotification
}
return newExecutor(binding, secret, client, false)
}
func newExecutor(binding capability.Binding, secret string, client *http.Client, executeAllowed bool) (*Executor, error) {
if strings.TrimSpace(secret) == "" || client == nil {
return nil, ErrInvalidNotification
}
copyClient := *client
copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
return &Executor{binding: binding, secret: secret, client: &copyClient, executeAllowed: executeAllowed}, nil
}
func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if !e.executeAllowed {
return ports.ExecutionResult{Known: true}
}
payload, err := e.requestFor(action)
if err != nil {
return ports.ExecutionResult{Known: true}
}
body, err := json.Marshal(DeliveryRequest{Payload: payload, RequestDigest: action.Proposal.NotificationSubject.RequestDigest})
if err != nil {
return ports.ExecutionResult{Known: true}
}
endpoint, err := e.endpoint(e.binding.DeliveryPath, string(action.Proposal.IntentID))
if err != nil {
return ports.ExecutionResult{Known: true}
}
request, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return ports.ExecutionResult{Known: true}
}
request.Header.Set("Authorization", "Bearer "+e.secret)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := e.client.Do(request)
if err != nil {
return ports.ExecutionResult{}
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated {
if response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
return ports.ExecutionResult{Known: true}
}
return ports.ExecutionResult{}
}
receipt, err := decodeReceipt(response.Body)
if err != nil || !matchingReceipt(receipt, action) {
return ports.ExecutionResult{}
}
return ports.ExecutionResult{Known: true, Succeeded: true}
}
func (e *Executor) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if _, err := e.requestFor(action); err != nil {
return ports.ExecutionResult{Known: true}
}
endpoint, err := e.endpoint(e.binding.ReceiptPath, string(action.Proposal.IntentID))
if err != nil {
return ports.ExecutionResult{Known: true}
}
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return ports.ExecutionResult{Known: true}
}
request.Header.Set("Authorization", "Bearer "+e.secret)
request.Header.Set("Accept", "application/json")
response, err := e.client.Do(request)
if err != nil {
return ports.ExecutionResult{}
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return ports.ExecutionResult{}
}
receipt, err := decodeReceipt(response.Body)
if err != nil {
return ports.ExecutionResult{}
}
if !matchingReceipt(receipt, action) {
return ports.ExecutionResult{Known: true}
}
return ports.ExecutionResult{Known: true, Succeeded: true}
}
func (e *Executor) requestFor(action commitcontract.AuthorizedAction) (Payload, error) {
if action.Proposal.NotificationSubject.BindingID != e.binding.ID || action.Proposal.NotificationSubject.BindingRevision != e.binding.Revision ||
action.PrincipalID != e.binding.OwnerPrincipalID {
return Payload{}, ErrInvalidNotification
}
return PayloadFromAction(action)
}
func (e *Executor) endpoint(path, intent string) (string, error) {
base, err := url.Parse(e.binding.HTTPSOrigin)
if err != nil {
return "", err
}
base.Path = path + "/" + url.PathEscape(intent)
base.RawPath, base.RawQuery, base.Fragment = "", "", ""
return base.String(), nil
}
func decodeReceipt(reader io.Reader) (Receipt, error) {
var receipt Receipt
decoder := json.NewDecoder(io.LimitReader(reader, maxResponseBytes+1))
if err := decoder.Decode(&receipt); err != nil {
return Receipt{}, err
}
if receipt.ReceiptID == "" || receipt.ActionIntentID == "" || len(receipt.RequestDigest) != 64 || receipt.AcceptedAt <= 0 {
return Receipt{}, fmt.Errorf("invalid receipt")
}
return receipt, nil
}
func matchingReceipt(receipt Receipt, action commitcontract.AuthorizedAction) bool {
return receipt.ActionIntentID == action.Proposal.IntentID && receipt.RequestDigest == action.Proposal.NotificationSubject.RequestDigest
}
@@ -0,0 +1,109 @@
package notificationexecutor
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/identity"
)
func testAction(t *testing.T) commitcontract.AuthorizedAction {
t.Helper()
payload := Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 2, Title: "Домовой", Message: "Пора вернуться к задаче"}
digest, err := Digest(payload)
if err != nil {
t.Fatal(err)
}
return commitcontract.AuthorizedAction{PrincipalID: identity.PrincipalID("owner-1"), Proposal: commitcontract.ActionProposal{
IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification,
NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 2, TemplateCode: payload.TemplateCode, BindingID: "binding-1", BindingRevision: 1, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest},
}}
}
func testBinding(origin string) capability.Binding {
return capability.Binding{ID: "binding-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: "owner-1", Revision: 1, State: capability.BindingActive, HTTPSOrigin: origin, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}
}
func TestExecutorPutAndReconcileMatchReceipt(t *testing.T) {
action := testAction(t)
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
t.Error("missing credential")
}
if r.Method == http.MethodPut && r.URL.Path != "/delivery/v1/deliveries/intent-1" {
t.Errorf("bad put path %s", r.URL.Path)
}
if r.Method == http.MethodGet && r.URL.Path != "/delivery/v1/receipts/intent-1" {
t.Errorf("bad get path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "receipt-intent-1", ActionIntentID: "intent-1", RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
}))
defer server.Close()
executor, err := New(testBinding(server.URL), "secret", server.Client())
if err != nil {
t.Fatal(err)
}
if result := executor.Execute(action); !result.Known || !result.Succeeded {
t.Fatalf("execute: %+v", result)
}
if result := executor.Reconcile(action); !result.Known || !result.Succeeded {
t.Fatalf("reconcile: %+v", result)
}
}
func TestExecutorAmbiguousAndIntegrityOutcomes(t *testing.T) {
action := testAction(t)
for name, handler := range map[string]http.HandlerFunc{
"server error": func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) },
"bad receipt": func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(Receipt{ReceiptID: "r", ActionIntentID: "other", RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
},
} {
t.Run(name, func(t *testing.T) {
server := httptest.NewTLSServer(handler)
defer server.Close()
executor, err := New(testBinding(server.URL), "secret", server.Client())
if err != nil {
t.Fatal(err)
}
if result := executor.Execute(action); result.Known {
t.Fatalf("must be unknown: %+v", result)
}
})
}
action.Proposal.NotificationSubject.RequestDigest = "bad"
server := httptest.NewTLSServer(http.NotFoundHandler())
defer server.Close()
executor, err := New(testBinding(server.URL), "secret", server.Client())
if err != nil {
t.Fatal(err)
}
if result := executor.Execute(action); !result.Known || result.Succeeded {
t.Fatalf("invalid evidence must fail closed: %+v", result)
}
}
func TestExecutorDoesNotFollowRedirect(t *testing.T) {
action := testAction(t)
targetCalls := 0
target := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { targetCalls++ }))
defer target.Close()
redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirect.Close()
executor, err := New(testBinding(redirect.URL), "secret", redirect.Client())
if err != nil {
t.Fatal(err)
}
if result := executor.Execute(action); result.Known {
t.Fatalf("redirect must be ambiguous: %+v", result)
}
if targetCalls != 0 {
t.Fatal("redirect target was called")
}
}
@@ -6,10 +6,13 @@ import (
"fmt"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrCapabilityHealthNotFound = errors.New("capability health not found")
var ErrCapabilityBindingNotFound = errors.New("capability binding not found")
var ErrCapabilityBindingConflict = errors.New("capability binding conflict")
type CapabilityRepository struct{ pool *pgxpool.Pool }
@@ -56,3 +59,72 @@ func (r *CapabilityRepository) RebuildHealthProjection(ctx context.Context) (int
}
return result.RowsAffected(), nil
}
func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capability.Binding) 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.notification_bindings (
binding_id,capability_id,owner_principal_id,revision,state,https_origin,delivery_path,receipt_path,
protocol,secret_ref,verified_at_unix_millis,health)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`,
binding.ID, binding.CapabilityID, binding.OwnerPrincipalID, binding.Revision, binding.State,
binding.HTTPSOrigin, binding.DeliveryPath, binding.ReceiptPath, binding.Protocol, binding.SecretRef,
binding.VerifiedAtUnixMillis, binding.Health)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
existing, loadErr := r.LoadBinding(ctx, binding.ID, binding.Revision)
if loadErr != nil || existing != binding {
return ErrCapabilityBindingConflict
}
}
if binding.State == capability.BindingActive {
result, err = tx.Exec(ctx, `INSERT INTO capability.notification_binding_heads (capability_id,owner_principal_id,binding_id,revision)
VALUES ($1,$2,$3,$4) ON CONFLICT (capability_id,owner_principal_id) DO UPDATE SET binding_id=EXCLUDED.binding_id,revision=EXCLUDED.revision
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision`, binding.CapabilityID, binding.OwnerPrincipalID, binding.ID, binding.Revision)
if err != nil || result.RowsAffected() != 1 {
if err != nil {
return err
}
return ErrCapabilityBindingConflict
}
} else {
_, err = tx.Exec(ctx, `DELETE FROM capability.notification_binding_heads WHERE capability_id=$1 AND owner_principal_id=$2 AND binding_id=$3 AND revision < $4`, binding.CapabilityID, binding.OwnerPrincipalID, binding.ID, binding.Revision)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
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,
protocol,secret_ref,verified_at_unix_millis,health FROM capability.notification_bindings WHERE binding_id=$1 AND revision=$2`, id, revision).
Scan(&binding.ID, &binding.CapabilityID, &binding.OwnerPrincipalID, &binding.Revision, &binding.State,
&binding.HTTPSOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.SecretRef,
&binding.VerifiedAtUnixMillis, &binding.Health)
if err != nil {
return capability.Binding{}, fmt.Errorf("load capability binding: %w", err)
}
return binding, nil
}
func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) (capability.Binding, error) {
var binding capability.Binding
err := r.pool.QueryRow(ctx, `SELECT b.binding_id,b.capability_id,b.owner_principal_id,b.revision,b.state,b.https_origin,b.delivery_path,b.receipt_path,
b.protocol,b.secret_ref,b.verified_at_unix_millis,b.health FROM capability.notification_binding_heads h
JOIN capability.notification_bindings b ON b.binding_id=h.binding_id AND b.revision=h.revision
WHERE h.capability_id=$1 AND h.owner_principal_id=$2 AND b.state=$3`, capabilityID, owner, capability.BindingActive).
Scan(&binding.ID, &binding.CapabilityID, &binding.OwnerPrincipalID, &binding.Revision, &binding.State,
&binding.HTTPSOrigin, &binding.DeliveryPath, &binding.ReceiptPath, &binding.Protocol, &binding.SecretRef,
&binding.VerifiedAtUnixMillis, &binding.Health)
if err != nil {
return capability.Binding{}, fmt.Errorf("load active capability binding: %w", err)
}
return binding, nil
}
+31 -9
View File
@@ -7,6 +7,7 @@ import (
"reflect"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -30,6 +31,12 @@ func OpenCommit(ctx context.Context, c ContinuityConfig) (*CommitRepository, err
}
func (r *CommitRepository) Close() { r.pool.Close() }
func (r *CommitRepository) SaveAuthorized(ctx context.Context, a commitcontract.AuthorizedAction) error {
prospectiveItemID := a.Proposal.ContinuitySubject.ProspectiveItemID
expectedRevision := a.Proposal.ContinuitySubject.ExpectedRevision
if a.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
prospectiveItemID = a.Proposal.NotificationSubject.ProspectiveItemID
expectedRevision = a.Proposal.NotificationSubject.ExpectedRevision
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
@@ -42,15 +49,19 @@ func (r *CommitRepository) SaveAuthorized(ctx context.Context, a commitcontract.
authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
proposal_state,action_state,authorized_at_unix_millis,envelope_revision)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,1)
proposal_state,action_state,authorized_at_unix_millis,envelope_revision,notification_template_code,
capability_binding_id,capability_binding_revision,notification_title,notification_message,request_digest)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,1,$22,$23,$24,$25,$26,$27)
ON CONFLICT DO NOTHING`,
a.ID, a.Proposal.IntentID, a.Proposal.ActorID, a.PrincipalID, a.GrantID, a.GrantRevision, a.ScopeID,
a.Proposal.Target.TaskID, a.Proposal.Target.GoalID, a.Proposal.Target.ScopeID, a.Proposal.Target.Revision,
a.Proposal.CapabilityID, a.Proposal.Operation, a.Proposal.ContinuitySubject.ProspectiveItemID,
a.Proposal.ContinuitySubject.ExpectedRevision, a.Proposal.ContinuitySubject.FromState,
a.Proposal.CapabilityID, a.Proposal.Operation, prospectiveItemID,
expectedRevision, a.Proposal.ContinuitySubject.FromState,
a.Proposal.ContinuitySubject.ToState, a.Proposal.ContinuitySubject.EvidenceRef,
a.Proposal.State, a.State, a.AuthorizedAtUnixMillis)
a.Proposal.State, a.State, a.AuthorizedAtUnixMillis, a.Proposal.NotificationSubject.TemplateCode,
a.Proposal.NotificationSubject.BindingID, a.Proposal.NotificationSubject.BindingRevision,
a.Proposal.NotificationSubject.RenderedTitle, a.Proposal.NotificationSubject.RenderedMessage,
a.Proposal.NotificationSubject.RequestDigest)
if err != nil {
return err
}
@@ -128,7 +139,8 @@ func (r *CommitRepository) ListAuthorizedByOwner(ctx context.Context, actorID id
rows, err := r.pool.Query(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
proposal_state,action_state,authorized_at_unix_millis
proposal_state,action_state,authorized_at_unix_millis,notification_template_code,capability_binding_id,
capability_binding_revision,notification_title,notification_message,request_digest
FROM commit.authorized_action_envelopes
WHERE actor_id=$1 AND principal_id=$2
ORDER BY authorized_at_unix_millis DESC, authorized_action_id DESC LIMIT $3`, actorID, principalID, limit)
@@ -163,7 +175,8 @@ func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcont
err := scanAuthorized(querier.QueryRow(ctx, `SELECT authorized_action_id,action_intent_id,actor_id,principal_id,grant_id,grant_revision,scope_id,
task_id,goal_id,task_scope_id,task_revision,capability_id,operation,prospective_item_id,
expected_prospective_revision,prospective_from_state,prospective_to_state,evidence_ref,
proposal_state,action_state,authorized_at_unix_millis
proposal_state,action_state,authorized_at_unix_millis,notification_template_code,capability_binding_id,
capability_binding_revision,notification_title,notification_message,request_digest
FROM commit.authorized_action_envelopes WHERE authorized_action_id=$1`, id), &action)
if errors.Is(err, pgx.ErrNoRows) {
return commitcontract.AuthorizedAction{}, ErrAuthorizedActionNotFound
@@ -175,12 +188,21 @@ func loadAuthorized(ctx context.Context, querier commitRowQuerier, id commitcont
}
func scanAuthorized(row commitRowScanner, action *commitcontract.AuthorizedAction) error {
return row.Scan(
err := row.Scan(
&action.ID, &action.Proposal.IntentID, &action.Proposal.ActorID, &action.PrincipalID,
&action.GrantID, &action.GrantRevision, &action.ScopeID, &action.Proposal.Target.TaskID,
&action.Proposal.Target.GoalID, &action.Proposal.Target.ScopeID, &action.Proposal.Target.Revision,
&action.Proposal.CapabilityID, &action.Proposal.Operation, &action.Proposal.ContinuitySubject.ProspectiveItemID,
&action.Proposal.ContinuitySubject.ExpectedRevision, &action.Proposal.ContinuitySubject.FromState,
&action.Proposal.ContinuitySubject.ToState, &action.Proposal.ContinuitySubject.EvidenceRef,
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis)
&action.Proposal.State, &action.State, &action.AuthorizedAtUnixMillis,
&action.Proposal.NotificationSubject.TemplateCode, &action.Proposal.NotificationSubject.BindingID,
&action.Proposal.NotificationSubject.BindingRevision, &action.Proposal.NotificationSubject.RenderedTitle,
&action.Proposal.NotificationSubject.RenderedMessage, &action.Proposal.NotificationSubject.RequestDigest)
if err == nil && action.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
action.Proposal.NotificationSubject.ProspectiveItemID = action.Proposal.ContinuitySubject.ProspectiveItemID
action.Proposal.NotificationSubject.ExpectedRevision = action.Proposal.ContinuitySubject.ExpectedRevision
action.Proposal.ContinuitySubject = continuity.SatisfyDeliveredSubject{}
}
return err
}
@@ -0,0 +1,58 @@
# ADR-0005: Prospective Notification Delivery
Status: Accepted
Date: 2026-08-16
Decision authority: Creator continuation after the explicit `ARCH-000005` acceptance request
## Context
Domovoy has a durable Commit/Executor safety path, but no accepted operation that crosses the Assistant canonical-state boundary. `ARCH-000005` compared alternatives and proposed a typed notification about one Creator-owned `ProspectiveItem`. The Creator accepted that recommendation and selected local Windows notification display as the first channel.
## Decision
1. The first external operation is `deliver-prospective-notification-v1` under capability `prospective-notification-delivery`.
2. The subject is one Creator-owned `ProspectiveItem` at an expected revision in `DELIVERED`; delivery does not change Continuity state and does not prove reading, satisfaction, or commitment fulfilment.
3. The Creator client supplies only the item, expected revision, live Work target, and explicit confirmation. The server derives authority, operation, binding, destination, template, body, and action identities.
4. Capability owns a versioned, owner-local `CapabilityBinding`. Commit records its ID and revision; secrets remain external references and bytes are available only to the relevant adapters.
5. Commit/Executor is the only Assistant component permitted to issue the mutating delivery request. It uses the fixed HTTPS destination and receipt paths from the active binding, disables redirects, and accepts only bounded typed responses.
6. Delivery uses `PUT <delivery-path>/<ActionIntentID>`. Same intent and digest returns the original receipt; a different digest is an integrity conflict. Read-only reconciliation uses `GET <receipt-path>/<ActionIntentID>`.
7. Ambiguous send outcomes become `UNKNOWN_OUTCOME`; they are never blindly retried. Reconciliation compares the durable request digest with the external receipt.
8. The initial destination is a dedicated receipt receiver deployed on `docker-prod`, exposed through the existing `https://assistent.cin.su` origin. It has no access to Assistant canonical databases and persists immutable external receipts in its own durable volume.
9. The destination uses separate credentials for Commit write/reconciliation and Windows read access. Credentials are generated and stored outside Git, database rows, images, logs, browser storage, and cognitive context.
10. A current-user Windows agent reads accepted receipts, shows local operating-system notifications, and keeps only local projection state needed to avoid redisplay. Receiver acceptance is the Commit outcome; operating-system display and human reading are explicitly outside that outcome.
11. Binding retirement blocks new sends while preserving bounded reconciliation for historical actions. Secret rotation creates a new secret version and binding revision without rewriting history.
12. `docker-test` is prohibited for implementation, verification, and deployment of this capability.
## Rationale
The operation adds useful external behavior without introducing arbitrary webhooks, provider payloads, SSRF-controlled destinations, or provider-specific semantics. A compatible receiver makes duplicate prevention and read-only reconciliation testable. Separating the receipt outcome from Windows display keeps Commit evidence honest while permitting eventual local delivery when the workstation is available.
## Consequences
- Capability binding lifecycle and persistence must be implemented before authorization can succeed.
- Commit requires a typed notification envelope and durable digest evidence in addition to the existing Continuity transition envelope.
- The receiver and Windows reader are adapters, not owners of Continuity or Commit canonical state.
- A receiver outage or ambiguous response cannot trigger an automatic duplicate send.
- The receiver requires durable backup/retention and operational health checks; local Windows projection loss may cause redisplay but cannot cause a second external receipt.
- No new cross-module dependency or canonical owner is introduced; existing registry permissions are sufficient.
## Validity conditions
- Owner, Work justification, Authority grant, expected Prospective revision, and exact binding revision are validated before Commit execution.
- Receiver receipt identity and digest are stable and queryable by `ActionIntentID`.
- Only the isolated Executor can read the write credential.
- Windows read access cannot create or alter receipts.
- Tests cover hostile input, duplicate intents, digest conflicts, lost acknowledgement, restart recovery, retirement, and rotation.
## Revisit triggers
- A provider cannot implement authoritative idempotency and read-only receipt lookup.
- Notification content requires Artifact payload ownership rather than the bounded V1 template.
- A requirement appears to prove operating-system display or human reading.
- Multi-recipient routing, arbitrary templates, or another provider/channel is requested.
## Migration and rollback
Implementation is additive. Rollback retires the binding, revokes both external credentials, stops the Windows agent and receiver, and preserves Commit attempts plus receiver receipts for audit and reconciliation. Existing internal Continuity actions remain unchanged.
@@ -0,0 +1,31 @@
# ADR-0006 — Creator advisory boundary
Status: Accepted
Date: 2026-08-16
Accepted under: ARCH-000008
## Decision
1. The first cognitive product slice is `creator-advisory-v1`: ephemeral advisory text for an authenticated Creator.
2. Advisory output is non-canonical, has no tools or Executor access, and cannot create or mutate any semantic object or external state.
3. The provider-neutral request/response boundary, data-denial rules, isolation, telemetry and promotion lifecycle are those specified by `docs/COGNITIVE_PROVIDER_PROPOSAL.md`.
4. Initial hard limits are: user message 16 KiB; at most 8 context excerpts; each excerpt 8 KiB; combined context 48 KiB; response 16 KiB; request timeout 30 seconds; at most 2 concurrent requests per Principal; at most 20 accepted requests per Principal per rolling hour.
5. Context sensitivity is fail closed. Only explicitly selected `PUBLIC` or `PRIVATE_ALLOWED` excerpts may enter the gateway; `SECRET`, `UNKNOWN` and omitted classifications are rejected.
6. V1 has no server-side advisory conversation persistence, provider memory, automatic context crawling, automatic retries, tool calls or conversion of an answer into an ActionProposal.
7. The deterministic fake remains the initial and test binding. External provider egress is disabled until a later accepted decision names the provider/API/model family and its retention/training/data-egress policy.
8. A real provider binding cannot promote unless all deterministic boundary/safety tests pass, its evaluation corpus is recorded, and the Creator explicitly enables a fixed binding revision. A new model revision never inherits promotion.
## Consequences
- Provider-neutral gateway contracts and a fake adapter may now be implemented without canonical storage or new privileges.
- Cognitive worker continues with no database role and no Commit/Executor credentials.
- Provider-specific HTTP, credentials, outbound network access, production activation and content egress remain prohibited until separately accepted.
- Loss of cognition affects only advisory availability; Continuity, Commit and existing Creator commands remain correct.
## Revisit triggers
- A concrete provider/API/model is selected.
- Conversation persistence or provider memory is requested.
- Advisory text should become a typed command candidate.
- Context must be compiled automatically from canonical owners.
- Limits or cost controls need production adjustment.
@@ -0,0 +1,43 @@
# ADR-0007 — OpenAI Creator advisory binding
Status: Accepted
Date: 2026-08-16
Accepted under: ARCH-000010
## Decision
1. The first real provider class for `creator-advisory-v1` is OpenAI at the fixed HTTPS origin `https://api.openai.com`, using `POST /v1/responses`.
2. Binding revision 1 targets the immutable model snapshot `gpt-5.4-mini-2026-03-17` with reasoning effort `low`. An alias or another snapshot is a different binding revision and receives no inherited promotion.
3. Requests set `store: false`, are synchronous and single-turn, and contain no tools, functions, conversations, background mode, streaming, files, images, web/file search, MCP, hosted execution, `previous_response_id` or provider memory.
4. Mode A permits only the Creator message explicitly entered for that request plus a fixed data-free system instruction. Automatic excerpts and `PRIVATE_ALLOWED` context are prohibited until a separately evidenced ZDR/MAM binding revision is accepted and promoted.
5. Before each Mode A request the Creator UI must disclose that the message leaves the private network and provider abuse-monitoring retention may be up to 30 days. Submission acknowledges that request only and is not durable blanket consent.
6. `SECRET`, `UNKNOWN`, credentials, authorization/routing identifiers and ambient context never enter the provider payload. Trusted Actor, Principal, Scope, session, workspace, task and binding identifiers remain local authorization/routing data and are not model context.
7. The API key belongs to a dedicated OpenAI project and may be mounted read-only only into `cognitive-worker` as a secret file. It is absent from Git, databases, images, environment variables, logs, browser state and other containers.
8. Adapter implementation must use the standard library, fixed origin, normal TLS verification, disabled redirects and ambient proxies, strict request/response types, a 30-second deadline, no automatic retry and no request/response body logging.
9. Provider output is advisory text only. Tool/executable/unknown output items, invalid JSON, origin changes, oversize output or malformed text fail closed and are never partially displayed.
10. Initial canary limits are one in-flight request, five accepted requests per minute and fifty per day for the single Creator. A provider project spend limit and alert are required before any production key is mounted.
11. Production activation requires adversarial fake-server tests, a synthetic/public evaluation record, shadow evidence and explicit promotion of the exact binding revision. This ADR alone does not authorize a real key, network egress or production call.
## Rationale
The existing provider-neutral boundary makes cognition replaceable and non-canonical. A dated snapshot preserves reproducibility and prevents silent model drift. Mode A enables a narrow explicit advisory use while making the provider's default retention boundary visible and forbidding automatic private context. Isolation and fail-closed mapping preserve the Assistant when cognition is absent or malformed.
## Consequences
- A provider adapter may be implemented under `adapters/models` and exercised only against adversarial local fake servers before promotion.
- The cognitive worker still has no canonical owner, database role, Commit/Executor credential or external mutation path.
- Existing `creator-advisory-v1` contracts do not change.
- The deterministic fake remains the only production binding until a separate evaluation/credential/promotion task succeeds.
- A ChatGPT or Codex subscription does not substitute for a dedicated API project, API billing controls or API key.
## Rollback
Disable or retire the OpenAI binding and resolve all requests to `deterministic-fake`, returning `PROVIDER_UNAVAILABLE`. No canonical repair or migration is needed because advisory request/response state is not persisted.
## Revisit triggers
- OpenAI publishes or withdraws a model snapshot.
- ZDR/MAM is approved for the exact API project.
- Context excerpts, conversation persistence, tools, streaming or multimodal input are requested.
- Evaluation shows the snapshot, effort, limits, latency or cost are unsuitable.
- Provider policy, retention, region, endpoint or origin changes.
+2
View File
@@ -3,3 +3,5 @@
Accepted architecture decisions live here. Normal implementation tasks may read but not create/change accepted ADRs unless explicitly authorized.
Use `ADR-XXXX-title.md` with: problem, evidence, alternatives, decision, rationale, consequences, validity conditions, revisit triggers, migration/rollback, status.
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary, and `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class.
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.cin.su/m/Assistant/adapters/models"
"git.cin.su/m/Assistant/contracts/cognitive"
)
type stubAdvisoryProvider struct {
called bool
request cognitive.AdvisoryRequest
response cognitive.AdvisoryResponse
err error
}
func (s *stubAdvisoryProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
s.called = true
s.request = request
return s.response, s.err
}
func TestDeterministicAdvisoryRejectsUnknownJSONField(t *testing.T) {
body := `{"Version":"creator-advisory-v1","Unexpected":"provider override"}`
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
response := httptest.NewRecorder()
deterministicAdvisory(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", response.Code)
}
}
func TestDeterministicAdvisoryReturnsUnavailableContract(t *testing.T) {
body := `{"Version":"creator-advisory-v1","RequestID":"request-1","ActorID":"actor","PrincipalID":"principal","ScopeID":"scope","Message":"help","Context":[],"Locale":"en","MaxOutputBytes":4096,"ProviderBindingID":"fake-binding","ProviderBindingRevision":1}`
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
response := httptest.NewRecorder()
deterministicAdvisory(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
var advisory cognitive.AdvisoryResponse
if err := json.NewDecoder(response.Body).Decode(&advisory); err != nil {
t.Fatal(err)
}
if advisory.FinishReason != cognitive.FinishProviderUnavailable || advisory.AdvisoryText != "" {
t.Fatalf("unexpected response: %+v", advisory)
}
}
func TestOpenAIAdvisoryIsUnavailableBeforeBodyParsingWhenDisabled(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`not-json`))
response := httptest.NewRecorder()
openAIAdvisory(nil)(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, want 503", response.Code)
}
}
func TestOpenAIAdvisoryStrictlyDecodesBeforeProvider(t *testing.T) {
provider := &stubAdvisoryProvider{}
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`{"Version":"creator-advisory-v1","Unexpected":"provider override"}`))
response := httptest.NewRecorder()
openAIAdvisory(provider)(response, request)
if response.Code != http.StatusBadRequest || provider.called {
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
}
}
func TestOpenAIAdvisoryReturnsProviderNeutralResponse(t *testing.T) {
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{
Version: cognitive.CreatorAdvisoryV1,
RequestID: "request-1",
ProviderBindingID: models.OpenAIAdvisoryBindingID,
ProviderBindingRevision: models.OpenAIAdvisoryBindingRevision,
AdvisoryText: "advice",
FinishReason: cognitive.FinishCompleted,
}}
body := `{"Version":"creator-advisory-v1","RequestID":"request-1","ActorID":"actor","PrincipalID":"principal","ScopeID":"scope","Message":"help","Context":[],"Locale":"en","MaxOutputBytes":4096,"ProviderBindingID":"openai-responses-gpt-5.4-mini-2026-03-17","ProviderBindingRevision":1}`
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(body))
response := httptest.NewRecorder()
openAIAdvisory(provider)(response, request)
if response.Code != http.StatusOK || !provider.called || provider.request.Message != "help" {
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
}
var advisory cognitive.AdvisoryResponse
if err := json.NewDecoder(response.Body).Decode(&advisory); err != nil {
t.Fatal(err)
}
if advisory.AdvisoryText != "advice" || advisory.FinishReason != cognitive.FinishCompleted {
t.Fatalf("unexpected response: %+v", advisory)
}
}
func TestLoadOpenAIAdvisoryProviderDefaultsDisabled(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
provider, err := loadOpenAIAdvisoryProvider("")
if err != nil || provider != nil {
t.Fatalf("provider=%v error=%v", provider, err)
}
}
func TestLoadOpenAIAdvisoryProviderRejectsDirectEnvironmentCredential(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "must-not-be-read")
if _, err := loadOpenAIAdvisoryProvider(""); err == nil || strings.Contains(err.Error(), "must-not-be-read") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadOpenAIAdvisoryProviderUsesBoundedSecretFile(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
directory := t.TempDir()
validPath := filepath.Join(directory, "valid")
if err := os.WriteFile(validPath, []byte("test-project-key\n"), 0o600); err != nil {
t.Fatal(err)
}
provider, err := loadOpenAIAdvisoryProvider(validPath)
if err != nil || provider == nil {
t.Fatalf("provider=%v error=%v", provider, err)
}
for name, content := range map[string][]byte{
"empty": nil,
"embedded whitespace": []byte("invalid key"),
"oversized": []byte(strings.Repeat("x", maxOpenAISecretBytes+1)),
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(directory, name)
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadOpenAIAdvisoryProvider(path); err == nil {
t.Fatal("invalid secret file was accepted")
}
})
}
}
+104
View File
@@ -1,28 +1,132 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"git.cin.su/m/Assistant/adapters/models"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/runtime/health"
"io"
"log"
"net/http"
"os"
)
const maxOpenAISecretBytes = 8 << 10
type advisoryProvider interface {
Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error)
}
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8083"
}
openAIProvider, err := loadOpenAIAdvisoryProvider(os.Getenv("OPENAI_API_KEY_FILE"))
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
mux.HandleFunc("POST /v1/advisories/deterministic", deterministicAdvisory)
mux.HandleFunc("POST /v1/advisories/openai", openAIAdvisory(openAIProvider))
log.Printf("cognitive-worker listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
request, ok := decodeAdvisoryRequest(w, r)
if !ok {
return
}
response, err := models.DeterministicAdvisoryUnavailable(request)
if err != nil {
http.Error(w, "invalid advisory request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
func openAIAdvisory(provider advisoryProvider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if provider == nil {
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
return
}
request, ok := decodeAdvisoryRequest(w, r)
if !ok {
return
}
response, err := provider.Advise(r.Context(), request)
if err != nil {
http.Error(w, "invalid advisory request", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
}
func decodeAdvisoryRequest(w http.ResponseWriter, r *http.Request) (cognitive.AdvisoryRequest, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 80*1024)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var request cognitive.AdvisoryRequest
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid advisory request", http.StatusBadRequest)
return cognitive.AdvisoryRequest{}, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
http.Error(w, "invalid advisory request", http.StatusBadRequest)
return cognitive.AdvisoryRequest{}, false
}
return request, true
}
func loadOpenAIAdvisoryProvider(secretPath string) (advisoryProvider, error) {
if os.Getenv("OPENAI_API_KEY") != "" {
return nil, errors.New("direct OpenAI credential environment is forbidden; use OPENAI_API_KEY_FILE")
}
if secretPath == "" {
return nil, nil
}
file, err := os.Open(secretPath)
if err != nil {
return nil, fmt.Errorf("open OpenAI credential file: %w", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, maxOpenAISecretBytes+1))
if err != nil {
return nil, fmt.Errorf("read OpenAI credential file: %w", err)
}
defer clear(raw)
if len(raw) > maxOpenAISecretBytes {
return nil, errors.New("OpenAI credential file is oversized")
}
key := bytes.TrimSpace(raw)
if len(key) == 0 {
return nil, errors.New("OpenAI credential file is empty")
}
for _, character := range key {
if character < 0x21 || character > 0x7e {
return nil, errors.New("OpenAI credential file contains invalid characters")
}
}
provider, err := models.NewOpenAIAdvisoryAdapter(key, nil)
if err != nil {
return nil, errors.New("OpenAI credential file is invalid")
}
return provider, nil
}
func deterministicProposal(w http.ResponseWriter, r *http.Request) {
var request models.DeterministicRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+30
View File
@@ -8,8 +8,10 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/work"
@@ -95,10 +97,38 @@ func (c creatorCoreClient) Binding(ctx context.Context, actor identity.ActorID,
return binding, nil
}
func (c creatorCoreClient) ActiveCapabilityBinding(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) (capability.Binding, error) {
var binding capability.Binding
target := *c.baseURL
target.Path = "/v1/capability-bindings/active"
query := target.Query()
query.Set("capability_id", string(capabilityID))
query.Set("owner_principal_id", string(owner))
target.RawQuery = query.Encode()
status, err := c.getURL(ctx, target, "", &binding)
if err != nil || status != http.StatusOK {
return capability.Binding{}, errCreatorQueryUnavailable
}
return binding, nil
}
func (c creatorCoreClient) CapabilityBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) {
var binding capability.Binding
status, err := c.get(ctx, "/v1/capability-bindings/"+url.PathEscape(string(id))+"/revisions/"+strconv.FormatInt(revision, 10), "", &binding)
if err != nil || status != http.StatusOK {
return capability.Binding{}, errCreatorQueryUnavailable
}
return binding, nil
}
func (c creatorCoreClient) get(ctx context.Context, path, authorization string, destination any) (int, error) {
target := *c.baseURL
target.Path = path
target.RawQuery = ""
return c.getURL(ctx, target, authorization, destination)
}
func (c creatorCoreClient) getURL(ctx context.Context, target url.URL, authorization string, destination any) (int, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return 0, errCreatorQueryUnavailable
+116 -1
View File
@@ -4,12 +4,16 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/notificationexecutor"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
@@ -25,6 +29,7 @@ type creatorSessionSource interface {
Session(context.Context, string) (identity.CreatorSession, error)
Task(context.Context, work.TaskID) (work.TaskTarget, error)
Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error)
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
}
type creatorContinuitySource interface {
@@ -60,6 +65,13 @@ type creatorSatisfyRequest struct {
EvidenceRef string
}
type creatorNotifyRequest struct {
Target work.TaskTarget
ProspectiveItemID continuity.ProspectiveItemID
ExpectedRevision continuity.ProspectiveRevision
Confirm bool
}
type creatorActionResponse struct {
AuthorizedActionID commitcontract.AuthorizedActionID
ActionIntentID commitcontract.ActionIntentID
@@ -83,6 +95,7 @@ type creatorActionHistoryResponse struct {
func registerCreatorCommandRoutes(mux *http.ServeMux, handler creatorCommandHandler) {
mux.HandleFunc("POST /v1/creator/continuity/satisfy-delivered", handler.satisfyDelivered)
mux.HandleFunc("POST /v1/creator/continuity/notify-delivered", handler.notifyDelivered)
mux.HandleFunc("POST /v1/creator/authorized-actions/{id}/reconcile", handler.reconcile)
mux.HandleFunc("GET /v1/creator/authorized-actions", handler.listAuthorizedActions)
}
@@ -113,9 +126,13 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
return
}
prospectiveItemID := action.Proposal.ContinuitySubject.ProspectiveItemID
if action.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
prospectiveItemID = action.Proposal.NotificationSubject.ProspectiveItemID
}
item := creatorActionHistoryItem{
AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID,
Operation: action.Proposal.Operation, ProspectiveItemID: action.Proposal.ContinuitySubject.ProspectiveItemID,
Operation: action.Proposal.Operation, ProspectiveItemID: prospectiveItemID,
State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis,
}
if found {
@@ -129,6 +146,104 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h
_ = json.NewEncoder(w).Encode(creatorActionHistoryResponse{Items: items})
}
func (h creatorCommandHandler) notifyDelivered(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
if !ok {
return
}
var request creatorNotifyRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || !request.Confirm || request.Target.TaskID == "" || request.Target.GoalID == "" || request.Target.ScopeID == "" || request.Target.Revision == 0 || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
http.Error(w, "invalid Creator notification request", http.StatusBadRequest)
return
}
item, err := h.continuity.Item(r.Context(), authorization, request.ProspectiveItemID)
if errors.Is(err, errCreatorOwnerDenied) {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if errors.Is(err, errCreatorSubjectMissing) {
http.Error(w, "prospective item not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Continuity owner validation unavailable", http.StatusServiceUnavailable)
return
}
if item.OwnerPrincipalID != session.PrincipalID {
http.Error(w, "prospective item denied", http.StatusForbidden)
return
}
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.ScopeID != request.Target.ScopeID || item.GoalID != request.Target.GoalID {
http.Error(w, "prospective item is stale or outside justification", http.StatusConflict)
return
}
target, err := h.core.Task(r.Context(), request.Target.TaskID)
if err != nil || target != request.Target {
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
return
}
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
if err != nil {
http.Error(w, "Creator authority unavailable", http.StatusConflict)
return
}
identityBinding, err := h.core.Binding(r.Context(), session.ActorID, session.PrincipalID)
if err != nil {
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
return
}
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, session.PrincipalID)
if err != nil {
http.Error(w, "notification capability unavailable", http.StatusConflict)
return
}
intentID, authorizedID, attemptID, err := h.actionIDs()
if err != nil {
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
return
}
title := "Домовой: напоминание"
message := fmt.Sprintf("Проспективная запись %s ожидает вашего внимания.", item.ID)
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
return
}
proposal := commitcontract.ActionProposal{IntentID: intentID, ActorID: session.ActorID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: request.Target, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest}}
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
if err != nil {
http.Error(w, "Creator authorization denied", http.StatusConflict)
return
}
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
http.Error(w, "commit authorized action", http.StatusInternalServerError)
return
}
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
http.Error(w, "execute committed notification", http.StatusInternalServerError)
return
}
status := http.StatusCreated
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
status = http.StatusAccepted
}
writeCreatorActionResponse(w, status, action, attempt)
}
func ensureCreatorEOF(decoder *json.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
return errors.New("trailing JSON")
}
func (h creatorCommandHandler) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
session, ok := h.creatorSession(w, r, authorization)
+46 -5
View File
@@ -11,6 +11,7 @@ import (
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
@@ -19,10 +20,12 @@ import (
)
type fakeCreatorCore struct {
session identity.CreatorSession
target work.TaskTarget
binding identity.PrincipalBinding
sessionErr error
session identity.CreatorSession
target work.TaskTarget
binding identity.PrincipalBinding
capabilityBinding capability.Binding
capabilityErr error
sessionErr error
}
func (f fakeCreatorCore) Session(context.Context, string) (identity.CreatorSession, error) {
@@ -34,6 +37,9 @@ func (f fakeCreatorCore) Task(context.Context, work.TaskID) (work.TaskTarget, er
func (f fakeCreatorCore) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) {
return f.binding, nil
}
func (f fakeCreatorCore) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) {
return f.capabilityBinding, f.capabilityErr
}
type fakeCreatorContinuity struct {
item continuity.ProspectiveItem
@@ -120,6 +126,41 @@ func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) {
}
}
func TestCreatorNotificationDerivesTypedEnvelopeAndLeavesContinuityState(t *testing.T) {
handler, repository, executor := validCreatorCommandHandler()
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(`{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true}`))
request.Header.Set("Authorization", "Bearer creator-session")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusCreated || executor.calls != 1 {
t.Fatalf("status=%d calls=%d body=%s", response.Code, executor.calls, response.Body.String())
}
action := repository.action
subject := action.Proposal.NotificationSubject
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification || action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification || subject.BindingID != "notification-binding" || subject.BindingRevision != 1 || len(subject.RequestDigest) != 64 || subject.RenderedTitle == "" || subject.RenderedMessage == "" || action.Proposal.ContinuitySubject.ProspectiveItemID != "" {
t.Fatalf("invalid notification action: %#v", action)
}
}
func TestCreatorNotificationRejectsMissingConfirmationAndHostileFields(t *testing.T) {
for name, body := range map[string]string{"no confirmation": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4}`, "hostile destination": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true,"URL":"https://evil.invalid","Body":"arbitrary"}`} {
t.Run(name, func(t *testing.T) {
handler, repository, executor := validCreatorCommandHandler()
mux := http.NewServeMux()
registerCreatorCommandRoutes(mux, handler)
request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(body))
request.Header.Set("Authorization", "Bearer creator-session")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || executor.calls != 0 || repository.action.ID != "" {
t.Fatalf("status=%d calls=%d", response.Code, executor.calls)
}
})
}
}
func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *testing.T) {
tests := []struct {
name string
@@ -230,7 +271,7 @@ func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing
func validCreatorCommandHandler() (creatorCommandHandler, *fakeExecutionRepository, *knownExecutor) {
session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000}
target := work.TaskTarget{TaskID: "task-1", GoalID: "goal-1", ScopeID: "scope-1", Revision: 3}
core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}}
core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}, capabilityBinding: capability.Binding{ID: "notification-binding", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: session.PrincipalID, Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}}
item := continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: session.PrincipalID, ScopeID: target.ScopeID, GoalID: target.GoalID, State: continuity.ProspectiveDelivered, Revision: 4}
grant := authority.AuthorityGrant{ID: creatorGrantID, ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: target.ScopeID, Class: authority.GrantClassCreator, Revision: 2, Active: true}
repository := &fakeExecutionRepository{}
+31 -5
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/continuityexecutor"
@@ -59,13 +60,10 @@ func main() {
if executorToken == "" {
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
}
executor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
continuityExecutor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
registerExecutionRoutes(mux, repository, executor, executor, executorToken)
creatorHTTPClient := &http.Client{Timeout: 5 * time.Second}
creatorCore, err := newCreatorCoreClient(coreURL, creatorHTTPClient)
if err != nil {
@@ -75,9 +73,21 @@ func main() {
if err != nil {
log.Fatal(err)
}
notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE")
if err != nil {
log.Fatal(err)
}
notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF")
if notificationSecretRef == "" {
log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required")
}
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken)
registerCreatorCommandRoutes(mux, creatorCommandHandler{
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
executor: executor, reconciler: executor, newID: ids.New, now: time.Now,
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
})
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
var request authorizationRequest
@@ -138,6 +148,22 @@ func main() {
}
}
func readCommitSecretFile(name string) (string, error) {
path := os.Getenv(name)
if path == "" {
return "", fmt.Errorf("%s is required", name)
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
secret := strings.TrimSpace(string(value))
if secret == "" {
return "", fmt.Errorf("%s is empty", name)
}
return secret, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
@@ -0,0 +1,62 @@
package main
import (
"context"
"net/http"
"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"
"git.cin.su/m/Assistant/modules/commit/ports"
)
type notificationBindingSource interface {
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error)
}
type operationDispatcher struct {
continuityExecutor ports.Executor
continuityReconciler ports.Reconciler
bindings notificationBindingSource
secretRef string
secret string
client *http.Client
}
func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
return d.continuityExecutor.Execute(action)
}
subject := action.Proposal.NotificationSubject
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
if err != nil || exact.SecretRef != d.secretRef {
return ports.ExecutionResult{Known: true}
}
active, err := d.bindings.ActiveCapabilityBinding(context.Background(), action.Proposal.CapabilityID, action.PrincipalID)
if err != nil || active != exact {
return ports.ExecutionResult{Known: true}
}
executor, err := notificationexecutor.New(exact, d.secret, d.client)
if err != nil {
return ports.ExecutionResult{Known: true}
}
return executor.Execute(action)
}
func (d operationDispatcher) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult {
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
return d.continuityReconciler.Reconcile(action)
}
subject := action.Proposal.NotificationSubject
exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision)
if err != nil || exact.SecretRef != d.secretRef {
return ports.ExecutionResult{}
}
reconciler, err := notificationexecutor.NewReconciler(exact, d.secret, d.client)
if err != nil {
return ports.ExecutionResult{}
}
return reconciler.Reconcile(action)
}
@@ -0,0 +1,83 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
)
type fakeNotificationBindings struct {
exact capability.Binding
active capability.Binding
exactErr error
activeErr error
}
func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) {
return f.exact, f.exactErr
}
func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) {
return f.active, f.activeErr
}
func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction {
t.Helper()
payload := notificationexecutor.Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: "Пора вернуться"}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
t.Fatal(err)
}
return commitcontract.AuthorizedAction{PrincipalID: binding.OwnerPrincipalID, Proposal: commitcontract.ActionProposal{IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 1, TemplateCode: payload.TemplateCode, BindingID: binding.ID, BindingRevision: binding.Revision, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest}}}
}
func dispatcherBinding(origin string) capability.Binding {
return capability.Binding{ID: "binding-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: "owner-1", Revision: 1, State: capability.BindingActive, HTTPSOrigin: origin, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}
}
func TestOperationDispatcherRequiresExactCurrentBinding(t *testing.T) {
var action commitcontract.AuthorizedAction
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
}))
defer server.Close()
binding := dispatcherBinding(server.URL)
action = dispatcherAction(t, binding)
fallback := &knownExecutor{}
dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding, active: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()}
if result := dispatcher.Execute(action); !result.Known || !result.Succeeded {
t.Fatalf("execute=%+v", result)
}
stale := binding
stale.Revision = 2
dispatcher.bindings = fakeNotificationBindings{exact: binding, active: stale}
if result := dispatcher.Execute(action); !result.Known || result.Succeeded {
t.Fatalf("stale binding=%+v", result)
}
}
func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) {
var action commitcontract.AuthorizedAction
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatal("reconciliation mutated receiver")
}
_ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1})
}))
defer server.Close()
binding := dispatcherBinding(server.URL)
action = dispatcherAction(t, binding)
binding.State = capability.BindingRetired
binding.Revision = 1
fallback := &knownExecutor{}
dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()}
if result := dispatcher.Reconcile(action); !result.Known || !result.Succeeded {
t.Fatalf("reconcile=%+v", result)
}
}
+38
View File
@@ -17,6 +17,7 @@ import (
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
@@ -183,6 +184,43 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
})
mux.HandleFunc("GET /v1/capability-bindings/{id}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) {
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
if err != nil || revision < 1 {
http.Error(w, "invalid binding revision", http.StatusBadRequest)
return
}
binding, err := capabilityRepository.LoadBinding(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
if err != nil {
http.Error(w, "capability binding not found", http.StatusNotFound)
return
}
if err = capabilitydomain.ValidateBinding(binding); err != nil {
http.Error(w, "capability binding invalid", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("GET /v1/capability-bindings/active", func(w http.ResponseWriter, r *http.Request) {
capabilityID := capability.CapabilityID(r.URL.Query().Get("capability_id"))
ownerID := identity.PrincipalID(r.URL.Query().Get("owner_principal_id"))
if capabilityID == "" || ownerID == "" {
http.Error(w, "invalid binding query", http.StatusBadRequest)
return
}
binding, err := capabilityRepository.LoadActiveBinding(r.Context(), capabilityID, ownerID)
if err != nil {
http.Error(w, "active capability binding not found", http.StatusNotFound)
return
}
if err = capabilitydomain.UsableBinding(binding); err != nil {
http.Error(w, "active capability binding unavailable", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("POST /v1/capabilities/health-projection/rebuild", func(w http.ResponseWriter, r *http.Request) {
rebuilt, err := capabilityRepository.RebuildHealthProjection(r.Context())
if err != nil {
+3 -1
View File
@@ -9,11 +9,13 @@ import (
func main() {
addr := envOr("LISTEN_ADDR", ":8084")
handler, err := newHandler(
handler, err := newHandlerWithAdvisoryMode(
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
&http.Client{Timeout: 10 * time.Second},
envOr("ADVISORY_BINDING_MODE", advisoryModeDeterministic),
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
)
if err != nil {
log.Fatal(err)
+240 -9
View File
@@ -9,22 +9,36 @@ import (
"net/http"
"net/url"
"strings"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/situation"
"git.cin.su/m/Assistant/platform/ids"
)
const (
maxRequestBytes = 8 << 10
maxResponseBytes = 1 << 20
maxRequestBytes = 8 << 10
maxAdvisoryRequestBytes = 20 << 10
maxResponseBytes = 1 << 20
advisoryModeDeterministic = "deterministic"
advisoryModeOpenAIModeA = "openai-mode-a"
openAIAdvisoryBindingID = "openai-responses-gpt-5.4-mini-2026-03-17"
openAIAdvisoryModel = "gpt-5.4-mini-2026-03-17"
)
//go:embed static
var staticFiles embed.FS
type gateway struct {
core *url.URL
continuity *url.URL
commit *url.URL
client *http.Client
static http.Handler
core *url.URL
continuity *url.URL
commit *url.URL
cognitive *url.URL
advisoryMode string
client *http.Client
static http.Handler
}
type routeTarget uint8
@@ -35,7 +49,14 @@ const (
targetCommit
)
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (http.Handler, error) {
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) {
return newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL, client, advisoryModeDeterministic, cognitiveURLs...)
}
func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, advisoryMode string, cognitiveURLs ...string) (http.Handler, error) {
if advisoryMode != advisoryModeDeterministic && advisoryMode != advisoryModeOpenAIModeA {
return nil, fmt.Errorf("invalid advisory binding mode %q", advisoryMode)
}
core, err := parseRuntimeURL(coreURL, "Core")
if err != nil {
return nil, err
@@ -48,6 +69,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (
if err != nil {
return nil, err
}
cognitiveURL := "http://cognitive-worker:8083"
if len(cognitiveURLs) > 0 {
cognitiveURL = cognitiveURLs[0]
}
cognitiveRuntime, err := parseRuntimeURL(cognitiveURL, "Cognitive")
if err != nil {
return nil, err
}
assets, err := fs.Sub(staticFiles, "static")
if err != nil {
return nil, fmt.Errorf("load embedded console: %w", err)
@@ -55,7 +84,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (
if client == nil {
client = http.DefaultClient
}
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, client: client, static: http.FileServer(http.FS(assets))}), nil
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, advisoryMode: advisoryMode, client: client, static: http.FileServer(http.FS(assets))}), nil
}
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
@@ -73,6 +102,14 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
if r.Method == http.MethodGet && r.URL.Path == "/api/advisory-policy" {
g.handleAdvisoryPolicy(w, r)
return
}
if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" {
g.handleAdvisory(w, r)
return
}
g.forwardAPI(w, r)
return
}
@@ -86,6 +123,198 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.static.ServeHTTP(w, r)
}
type browserAdvisoryRequest struct {
Message string `json:"message"`
SessionID string `json:"session_id"`
WorkspaceID string `json:"workspace_id"`
EgressAcknowledged bool `json:"egress_acknowledged"`
}
type advisoryPolicy struct {
Mode string
ExternalEgress bool
AcknowledgementRequired bool
ProviderBindingID string
ProviderBindingRevision int64
ModelSnapshot string
RetentionPolicy string
}
func (g *gateway) policy() advisoryPolicy {
if g.advisoryMode == advisoryModeOpenAIModeA {
return advisoryPolicy{Mode: advisoryModeOpenAIModeA, ExternalEgress: true, AcknowledgementRequired: true, ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, ModelSnapshot: openAIAdvisoryModel, RetentionPolicy: "up-to-30-days-abuse-monitoring"}
}
return advisoryPolicy{Mode: advisoryModeDeterministic, ProviderBindingID: "deterministic-fake", ProviderBindingRevision: 1}
}
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK {
http.Error(w, "creator session denied", status)
return
}
if session.ActorID == "" || session.PrincipalID == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(g.policy())
}
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
var browserRequest browserAdvisoryRequest
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
http.Error(w, "invalid advisory request", http.StatusBadRequest)
return
}
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
if g.advisoryMode == advisoryModeOpenAIModeA && !browserRequest.EgressAcknowledged {
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
return
}
if g.advisoryMode == advisoryModeDeterministic && browserRequest.EgressAcknowledged {
http.Error(w, "unexpected external egress acknowledgement", http.StatusBadRequest)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK {
http.Error(w, "creator session denied", status)
return
}
query := url.Values{"session_id": {browserRequest.SessionID}, "workspace_id": {browserRequest.WorkspaceID}}.Encode()
var focus situation.FocusBinding
status, err = g.getTrustedJSON(r, g.core, "/v1/creator/focus", query, authorization, &focus)
if err != nil {
http.Error(w, "creator focus unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK {
http.Error(w, "creator focus unavailable", status)
return
}
if session.ActorID == "" || session.PrincipalID == "" || focus.ActorID != session.ActorID || focus.ScopeID == "" {
http.Error(w, "creator focus denied", http.StatusForbidden)
return
}
requestID, err := ids.New()
if err != nil {
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
return
}
policy := g.policy()
trusted := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, ProviderBindingID: capability.CapabilityBindingID(ids.ID(policy.ProviderBindingID)), ProviderBindingRevision: policy.ProviderBindingRevision}
payload, err := json.Marshal(trusted)
if err != nil {
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
return
}
target := *g.cognitive
target.Path = "/v1/advisories/deterministic"
if g.advisoryMode == advisoryModeOpenAIModeA {
target.Path = "/v1/advisories/openai"
}
upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload)))
if err != nil {
http.Error(w, "advisory unavailable", http.StatusBadGateway)
return
}
upstreamRequest.Header.Set("Content-Type", "application/json")
response, err := g.client.Do(upstreamRequest)
if err != nil {
http.Error(w, "advisory unavailable", http.StatusBadGateway)
return
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
http.Error(w, "advisory unavailable", http.StatusBadGateway)
return
}
var advisory cognitive.AdvisoryResponse
if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || !validAdvisoryResponse(advisory, trusted, g.advisoryMode) {
http.Error(w, "invalid advisory response", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(advisory)
}
func validAdvisoryResponse(response cognitive.AdvisoryResponse, request cognitive.AdvisoryRequest, mode string) bool {
if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision || len(response.ReferencedExcerptIDs) != 0 {
return false
}
if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) {
return false
}
if mode == advisoryModeDeterministic {
return response.FinishReason == cognitive.FinishProviderUnavailable && response.AdvisoryText == ""
}
switch response.FinishReason {
case cognitive.FinishCompleted:
return response.AdvisoryText != "" && len(response.AdvisoryText) <= request.MaxOutputBytes && utf8.ValidString(response.AdvisoryText)
case cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse:
return response.AdvisoryText == ""
default:
return false
}
}
func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQuery, authorization string, destination any) (int, error) {
target := *upstream
target.Path = path
target.RawQuery = rawQuery
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
return 0, err
}
request.Header.Set("Authorization", authorization)
response, err := g.client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes))
return response.StatusCode, nil
}
if err := decodeStrictJSON(response.Body, maxResponseBytes, destination); err != nil {
return response.StatusCode, err
}
return response.StatusCode, nil
}
func decodeStrictJSON(reader io.Reader, limit int64, destination any) error {
decoder := json.NewDecoder(io.LimitReader(reader, limit+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return fmt.Errorf("trailing JSON")
}
return nil
}
func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
routeTarget, upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path)
if !allowed {
@@ -159,6 +388,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
return targetCommit, "/v1/creator/authorized-actions", true
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
return targetCommit, "/v1/creator/continuity/satisfy-delivered", true
case method == http.MethodPost && path == "/api/actions/notify-delivered":
return targetCommit, "/v1/creator/continuity/notify-delivered", true
case method == http.MethodPost && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"):
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/actions/"), "/reconcile")
if validID(id) {
+247
View File
@@ -1,11 +1,15 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/cognitive"
)
func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) {
@@ -74,6 +78,7 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
{http.MethodGet, "/api/tasks/../../healthz"},
{http.MethodPost, "/api/actions/arbitrary"},
{http.MethodGet, "/api/actions/satisfy-delivered"},
{http.MethodGet, "/api/actions/notify-delivered"},
} {
request := httptest.NewRequest(test.method, test.path, nil)
response := httptest.NewRecorder()
@@ -158,6 +163,34 @@ func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) {
}
}
func TestGatewayRoutesTypedNotificationCommandToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/continuity/notify-delivered" || r.Method != http.MethodPost {
t.Fatalf("unexpected Commit target %s %s", r.Method, r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
text := string(body)
if !strings.Contains(text, "prospective-1") || !strings.Contains(text, "Confirm") || strings.Contains(text, "URL") || strings.Contains(text, "CapabilityID") {
t.Fatalf("unexpected notification body %s", text)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"State":"SUCCEEDED"}`)
}))
defer commitRuntime.Close()
handler, err := newHandler("http://core-runtime.invalid", "http://continuity-runtime.invalid", commitRuntime.URL, commitRuntime.Client())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/actions/notify-delivered", strings.NewReader(`{"ProspectiveItemID":"prospective-1","Confirm":true}`))
request.Header.Set("Authorization", "Bearer bounded-session")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestGatewayRoutesCreatorActionHistoryToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/authorized-actions" || r.URL.RawQuery != "limit=25" || r.Method != http.MethodGet {
@@ -193,6 +226,8 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
!strings.Contains(response.Body.String(), "advisory-egress-ack") || !strings.Contains(response.Body.String(), "может храниться до 30 дней") ||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
strings.Contains(response.Body.String(), "bootstrap-token") {
t.Fatalf("console response = %d", response.Code)
@@ -201,3 +236,215 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
t.Fatal("content security policy is missing")
}
}
func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) {
_, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected")
if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") {
t.Fatalf("error=%v", err)
}
}
func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatalf("unexpected policy authentication request %s %q", r.URL.Path, r.Header.Get("Authorization"))
}
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
}))
defer core.Close()
handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA)
if err != nil {
t.Fatal(err)
}
denied := httptest.NewRecorder()
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil))
if denied.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", denied.Code)
}
request := httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil)
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
}
var policy advisoryPolicy
if err := json.Unmarshal(response.Body.Bytes(), &policy); err != nil {
t.Fatal(err)
}
if policy.Mode != advisoryModeOpenAIModeA || !policy.ExternalEgress || !policy.AcknowledgementRequired || policy.ProviderBindingID != openAIAdvisoryBindingID || policy.ProviderBindingRevision != 1 || policy.ModelSnapshot != openAIAdvisoryModel || policy.RetentionPolicy != "up-to-30-days-abuse-monitoring" {
t.Fatalf("unexpected policy %+v", policy)
}
}
func TestOpenAIAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
defer upstream.Close()
handler, err := newHandlerWithAdvisoryMode(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), advisoryModeOpenAIModeA, upstream.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":false}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || called {
t.Fatalf("status=%d upstream_called=%t", response.Code, called)
}
}
func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/creator/sessions/current":
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
case "/v1/creator/focus":
_, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ScopeID":"trusted-scope","Active":true}`)
default:
t.Fatalf("unexpected core path %s", r.URL.Path)
}
}))
defer core.Close()
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/advisories/openai" {
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
}
var trusted cognitive.AdvisoryRequest
if err := json.NewDecoder(r.Body).Decode(&trusted); err != nil {
t.Fatal(err)
}
if trusted.ProviderBindingID != openAIAdvisoryBindingID || trusted.ProviderBindingRevision != 1 || len(trusted.Context) != 0 || trusted.Message != "help" {
t.Fatalf("unexpected trusted request %+v", trusted)
}
_ = json.NewEncoder(w).Encode(cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: trusted.RequestID, ProviderBindingID: trusted.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "Проверьте границы.", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}})
}))
defer cognitiveRuntime.Close()
handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA, cognitiveRuntime.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":true}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Проверьте границы") {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestOpenAIAdvisoryRejectsBrowserSelectedBinding(t *testing.T) {
handler, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, advisoryModeOpenAIModeA)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":true,"provider_binding_id":"attacker"}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func TestOpenAIAdvisoryResponseValidationFailsClosed(t *testing.T) {
request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, MaxOutputBytes: 16}
valid := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "совет", FinishReason: cognitive.FinishCompleted}
if !validAdvisoryResponse(valid, request, advisoryModeOpenAIModeA) {
t.Fatal("valid completed response was rejected")
}
invalid := []cognitive.AdvisoryResponse{
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "unexpected", FinishReason: cognitive.FinishTimeout},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "0123456789abcdefg", FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", ReferencedExcerptIDs: []cognitive.ContextExcerptID{"untrusted"}, FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: -1}},
}
for index, response := range invalid {
if validAdvisoryResponse(response, request, advisoryModeOpenAIModeA) {
t.Fatalf("invalid response %d was accepted: %+v", index, response)
}
}
}
func TestAdvisoryRequiresSessionBeforeUpstreamCalls(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
defer upstream.Close()
handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), upstream.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home"}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || called {
t.Fatalf("status=%d upstream_called=%t", response.Code, called)
}
}
func TestAdvisoryAssemblesTrustedIdentityAndScope(t *testing.T) {
longMessage := strings.Repeat("a", 16*1024)
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatal("missing bounded session")
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/creator/sessions/current":
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
case "/v1/creator/focus":
if r.URL.Query().Get("session_id") != "focus-session" || r.URL.Query().Get("workspace_id") != "home" {
t.Fatalf("unexpected focus query: %s", r.URL.RawQuery)
}
_, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ProjectID":"domovoy","GoalID":"goal-1","ScopeID":"trusted-scope","Active":true}`)
default:
t.Fatalf("unexpected core path %s", r.URL.Path)
}
}))
defer core.Close()
cognitive := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
text := string(body)
var trusted cognitive.AdvisoryRequest
if err := json.Unmarshal(body, &trusted); err != nil {
t.Fatal(err)
}
if trusted.ActorID != "creator-actor" || trusted.PrincipalID != "creator-principal" || trusted.ScopeID != "trusted-scope" || trusted.Message != longMessage || strings.Contains(text, "attacker") {
t.Fatal("cognitive request was not assembled from trusted context")
}
var request map[string]any
_ = json.Unmarshal(body, &request)
_, _ = fmt.Fprintf(w, `{"Version":"creator-advisory-v1","RequestID":%q,"ProviderBindingID":"deterministic-fake","ProviderBindingRevision":1,"AdvisoryText":"","ReferencedExcerptIDs":null,"FinishReason":"PROVIDER_UNAVAILABLE","Usage":null}`, request["RequestID"])
}))
defer cognitive.Close()
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitive.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(fmt.Sprintf(`{"message":%q,"session_id":"focus-session","workspace_id":"home"}`, longMessage)))
request.Header.Set("Authorization", "Bearer bounded-session")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "PROVIDER_UNAVAILABLE") {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestAdvisoryRejectsBrowserAuthorityFieldsBeforeCognitiveCall(t *testing.T) {
called := false
cognitive := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
defer cognitive.Close()
handler, err := newHandler("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, cognitive.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","ActorID":"attacker"}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || called {
t.Fatalf("status=%d cognitive_called=%t", response.Code, called)
}
}
+40
View File
@@ -94,6 +94,34 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.signal-strip strong[data-state="error"] { color: var(--bad); }
.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); }
.advisor-intro { align-self: center; }
.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; }
.advisor-intro h2 { margin: 0; font: 400 clamp(25px, 3vw, 36px)/1.05 var(--serif); }
.advisor-intro span { display: block; max-width: 44ch; margin-top: 12px; color: var(--muted); font-size: 11px; line-height: 1.55; }
.advisor-form { display: grid; gap: 9px; }
.advisor-form > label { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.advisor-form textarea { width: 100%; min-height: 92px; padding: 13px 14px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: rgba(7,12,10,.82); font: 12px/1.55 var(--sans); transition: border-color .18s, box-shadow .18s; }
.advisor-form textarea:focus { outline: 0; border-color: var(--signal); box-shadow: 0 0 0 2px rgba(201,168,95,.12); }
.advisor-egress { padding: 13px 14px; border: 1px solid rgba(222,164,73,.58); border-left: 3px solid var(--warn); border-radius: var(--radius); background: rgba(111,72,18,.17); color: var(--muted); font-size: 11px; line-height: 1.5; }
.advisor-egress[hidden] { display: none; }
.advisor-egress p { margin: 0; }
.advisor-egress .advisor-egress-kicker { margin-bottom: 7px; color: var(--warn); font: 700 9px/1 var(--mono); letter-spacing: .12em; }
.advisor-egress label { margin-top: 11px; display: flex; align-items: flex-start; gap: 9px; color: var(--ink); font: 600 10px/1.4 var(--mono); cursor: pointer; }
.advisor-egress input { width: 16px; height: 16px; flex: 0 0 auto; margin: 0; accent-color: var(--warn); }
.advisor-egress input:focus-visible { outline: 2px solid var(--warn); outline-offset: 3px; }
.advisor-controls { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.advisor-controls > span { color: var(--muted); font: 9px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.advisor-controls i { width: 6px; height: 6px; margin-right: 7px; display: inline-block; border-radius: 50%; background: var(--good); box-shadow: 0 0 10px rgba(120,198,163,.7); }
.advisor-controls > span.external i { background: var(--warn); box-shadow: 0 0 10px rgba(222,164,73,.65); }
.advisor-controls .primary-button { width: auto; min-width: 180px; margin: 0; }
.advisor-result { grid-column: 1 / -1; padding-top: 18px; display: grid; grid-template-columns: 150px minmax(0,1fr); gap: 18px; border-top: 1px solid var(--line); }
.advisor-result[hidden] { display: none; }
.advisor-result span { display: block; margin-bottom: 7px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .12em; }
.advisor-result strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
.advisor-result strong[data-state="COMPLETED"] { color: var(--good); }
.advisor-result p { margin: 0; color: var(--ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
.register-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16, 23, 20, .82); }
.register-panel > header { padding: 17px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.register-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
@@ -198,6 +226,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.action-result strong[data-state="FAILED"] { color: var(--bad); }
.action-result strong[data-state="UNKNOWN_OUTCOME"] { color: var(--warn); }
.action-result .secondary-button { min-width: 154px; margin: 0; padding: 0 14px; }
.notification-command { grid-column: 1 / -1; padding: 14px 0 0; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-top: 1px solid var(--line-soft); }
.notification-command div { min-width: 0; }
.notification-command strong { display: block; margin-bottom: 5px; color: var(--ink); font: 600 10px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.notification-command span { display: block; color: var(--muted); font-size: 11px; line-height: 1.45; }
.notification-command .secondary-button { flex: 0 0 auto; min-width: 190px; margin: 0; }
.confirmation-dialog { width: min(560px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #111915; box-shadow: var(--shadow); }
.confirmation-dialog::backdrop { background: rgba(3, 6, 5, .78); backdrop-filter: blur(5px); }
.confirmation-dialog form { padding: 30px; }
@@ -223,6 +256,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.context-rail { padding-inline: 20px; }
.workspace { padding: 32px 24px; }
.data-grid { grid-template-columns: 1fr; }
.advisor-panel { grid-template-columns: 1fr; }
.capability-card { grid-column: auto; }
.prospective-card { grid-column: auto; }
}
@@ -241,6 +275,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.last-update { text-align: left; }
.signal-strip { grid-template-columns: 1fr; }
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
.advisor-panel { padding: 20px 16px; gap: 20px; }
.advisor-controls { align-items: stretch; flex-direction: column; }
.advisor-controls .primary-button { width: 100%; }
.advisor-result { grid-template-columns: 1fr; gap: 10px; }
.register-panel > header { align-items: start; }
.activity-panel > header { align-items: flex-start; }
.activity-tools { align-items: flex-end; flex-direction: column-reverse; }
@@ -269,6 +307,8 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.commit-zone-copy { grid-column: auto; grid-row: auto; margin-bottom: 6px; }
.action-result { grid-template-columns: 1fr; align-items: stretch; }
.action-result .secondary-button { width: 100%; }
.notification-command { align-items: stretch; flex-direction: column; }
.notification-command .secondary-button { width: 100%; }
.confirmation-dialog form { padding: 24px 20px; }
.confirmation-grid { grid-template-columns: 1fr; }
.confirmation-grid .wide { grid-column: auto; }
+129
View File
@@ -3,6 +3,7 @@ const loginView = byId("login-view");
const consoleView = byId("console-view");
const loginForm = byId("login-form");
const contextForm = byId("context-form");
const advisoryForm = byId("advisory-form");
const loginError = byId("login-error");
const toast = byId("toast");
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
@@ -11,7 +12,9 @@ let toastTimer;
let currentTask = null;
let currentProspective = null;
let pendingCommand = null;
let pendingNotification = null;
let lastAuthorizedActionID = "";
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
function setText(id, value, fallback = "—") {
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
@@ -78,6 +81,7 @@ async function showConsole() {
loginView.hidden = true;
consoleView.hidden = false;
restoreContext();
await loadAdvisoryPolicy();
const context = saveContext();
if (context["session-id"] && context["workspace-id"]) loadDashboard();
else loadActionHistory();
@@ -93,7 +97,12 @@ function endSession(message = "Сессия завершена.") {
currentTask = null;
currentProspective = null;
pendingCommand = null;
pendingNotification = null;
lastAuthorizedActionID = "";
advisoryForm.reset();
advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
renderAdvisoryPolicy();
byId("advisory-result").hidden = true;
notify(message);
}
@@ -140,6 +149,65 @@ byId("refresh-all").addEventListener("click", loadDashboard);
byId("refresh-actions").addEventListener("click", loadActionHistory);
byId("logout").addEventListener("click", () => endSession());
advisoryForm.addEventListener("submit", async (event) => {
event.preventDefault();
const context = saveContext();
const message = byId("advisory-message").value.trim();
const button = byId("advisory-submit");
if (!context["session-id"] || !context["workspace-id"]) {
notify("Сначала укажите сессию и рабочую область контекста.");
return;
}
if (!message) return;
if (advisoryPolicy.AcknowledgementRequired && !byId("advisory-egress-ack").checked) {
notify("Подтвердите передачу этого вопроса внешнему провайдеру.");
byId("advisory-egress-ack").focus();
return;
}
button.disabled = true;
button.firstElementChild.textContent = "Проверяю контекст…";
byId("advisory-result").hidden = true;
try {
const result = await request("/api/advisory", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"], egress_acknowledged: advisoryPolicy.AcknowledgementRequired && byId("advisory-egress-ack").checked})
});
const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE";
setText("advisory-state", state);
byId("advisory-state").dataset.state = state;
const unavailable = advisoryPolicy.ExternalEgress ? "Внешний провайдер недоступен. Ответ не получен." : "Когнитивный провайдер пока не подключён. Наружу ничего не отправлено.";
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? unavailable : result.AdvisoryText, "Ответ не получен.");
byId("advisory-result").hidden = false;
} catch (error) {
setText("advisory-state", "ОШИБКА");
setText("advisory-text", error.message);
byId("advisory-result").hidden = false;
} finally {
byId("advisory-egress-ack").checked = false;
button.disabled = false;
button.firstElementChild.textContent = "Получить совет";
}
});
async function loadAdvisoryPolicy() {
advisoryPolicy = await request("/api/advisory-policy");
renderAdvisoryPolicy();
}
function renderAdvisoryPolicy() {
const external = advisoryPolicy.Mode === "openai-mode-a" && advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true;
byId("advisory-egress").hidden = !external;
byId("advisory-egress-ack").checked = false;
byId("advisory-egress-ack").required = external;
setText("advisory-privacy", external ? "в OpenAI передаётся только текст вопроса" : "локальный детерминированный режим");
const privacy = byId("advisory-privacy");
privacy.classList.toggle("external", external);
const marker = document.createElement("i");
marker.setAttribute("aria-hidden", "true");
privacy.prepend(marker);
}
async function loadDashboard() {
const context = saveContext();
const sessionID = context["session-id"];
@@ -426,6 +494,7 @@ async function loadCapability(capabilityID) {
function updateActionReadiness() {
const button = byId("prepare-satisfaction");
const notificationButton = byId("prepare-notification");
if (!button) return;
const evidence = byId("satisfaction-evidence").value.trim();
let message = "Доступно только для доставленного ожидания с проверенной Work-задачей.";
@@ -441,6 +510,19 @@ function updateActionReadiness() {
}
button.disabled = !ready;
setText("action-readiness", message);
let notificationReady = false;
let notificationMessage = "Доступно для доставленного ожидания с проверенной Work-задачей.";
if (!currentProspective) notificationMessage = "Сначала выберите и загрузите ожидание.";
else if (currentProspective.State !== "DELIVERED") notificationMessage = `Состояние ${currentProspective.State || "UNKNOWN"}: уведомление доступно только для DELIVERED.`;
else if (!currentTask) notificationMessage = "Загрузите проверенную Work-задачу как обоснование.";
else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) notificationMessage = "Цель или область Work-задачи не совпадает с ожиданием.";
else {
notificationReady = true;
notificationMessage = `Готово: receiver получит фиксированный шаблон для ${currentProspective.ID} · rev ${currentProspective.Revision}.`;
}
notificationButton.disabled = !notificationReady;
setText("notification-readiness", notificationMessage);
}
byId("satisfaction-evidence").addEventListener("input", updateActionReadiness);
@@ -473,6 +555,53 @@ byId("cancel-satisfaction").addEventListener("click", () => {
byId("satisfaction-dialog").close();
});
byId("prepare-notification").addEventListener("click", () => {
updateActionReadiness();
if (byId("prepare-notification").disabled || !currentTask || !currentProspective) return;
pendingNotification = {
Target: {TaskID: currentTask.TaskID, GoalID: currentTask.GoalID, ScopeID: currentTask.ScopeID, Revision: currentTask.Revision},
ProspectiveItemID: currentProspective.ID,
ExpectedRevision: currentProspective.Revision,
Confirm: true,
};
setText("notify-confirm-prospective", pendingNotification.ProspectiveItemID);
setText("notify-confirm-prospective-revision", pendingNotification.ExpectedRevision);
setText("notify-confirm-task", pendingNotification.Target.TaskID);
setText("notify-confirm-task-revision", pendingNotification.Target.Revision);
setText("notification-confirmation-error", "", "");
byId("notification-dialog").showModal();
});
byId("cancel-notification").addEventListener("click", () => {
pendingNotification = null;
byId("notification-dialog").close();
});
byId("confirm-notification").addEventListener("click", async () => {
if (!pendingNotification) return;
const command = pendingNotification;
const button = byId("confirm-notification");
const prepare = byId("prepare-notification");
button.disabled = true;
prepare.disabled = true;
button.textContent = "Отправляю…";
setText("notification-confirmation-error", "", "");
try {
const result = await request("/api/actions/notify-delivered", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(command)});
pendingNotification = null;
byId("notification-dialog").close();
renderActionResult(result);
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
} catch (error) {
setText("notification-confirmation-error", error.message);
} finally {
button.disabled = false;
button.textContent = "Подтверждаю отправку";
updateActionReadiness();
}
});
byId("confirm-satisfaction").addEventListener("click", async () => {
if (!pendingCommand) return;
const command = pendingCommand;
+52
View File
@@ -92,6 +92,34 @@
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
</section>
<section class="advisor-panel" aria-labelledby="advisor-title">
<div class="advisor-intro">
<p>COGNITIVE / ADVISORY V1</p>
<h2 id="advisor-title">Спросить Домового</h2>
<span>Ответ — только совет. Он не сохраняется, не создаёт задач и не запускает действий.</span>
</div>
<form id="advisory-form" class="advisor-form">
<label for="advisory-message">Ваш вопрос</label>
<textarea id="advisory-message" rows="3" maxlength="16384" placeholder="Что стоит проверить в выбранном контексте?" required></textarea>
<div id="advisory-egress" class="advisor-egress" hidden>
<p class="advisor-egress-kicker">ВНЕШНИЙ ПРОВАЙДЕР · MODE A</p>
<p>Текст этого вопроса будет отправлен в OpenAI и может храниться до 30 дней для мониторинга злоупотреблений. Контекст, идентификаторы и секреты Домового не передаются.</p>
<label for="advisory-egress-ack">
<input id="advisory-egress-ack" type="checkbox">
<span>Подтверждаю передачу этого вопроса в OpenAI</span>
</label>
</div>
<div class="advisor-controls">
<span id="advisory-privacy"><i></i> локальный детерминированный режим</span>
<button id="advisory-submit" class="primary-button" type="submit"><span>Получить совет</span><span aria-hidden="true">→</span></button>
</div>
</form>
<div id="advisory-result" class="advisor-result" aria-live="polite" hidden>
<div><span>СТАТУС</span><strong id="advisory-state">—</strong></div>
<p id="advisory-text"></p>
</div>
</section>
<section class="register-panel" aria-labelledby="register-title">
<header>
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
@@ -164,6 +192,10 @@
<button id="prepare-satisfaction" class="commit-button" type="button" disabled>
<span>Отметить исполненным</span><span aria-hidden="true">→</span>
</button>
<div class="notification-command">
<div><strong>Windows-напоминание</strong><span id="notification-readiness">Доступно для доставленного ожидания с проверенной Work-задачей.</span></div>
<button id="prepare-notification" class="secondary-button" type="button" disabled>Отправить уведомление</button>
</div>
<div id="action-result" class="action-result" hidden>
<div><span>ИСХОД</span><strong id="action-state">—</strong></div>
<div><span>ACTION</span><code id="action-id">—</code></div>
@@ -209,6 +241,26 @@
</form>
</dialog>
<dialog id="notification-dialog" class="confirmation-dialog" aria-labelledby="notification-confirmation-title">
<form method="dialog">
<p class="eyebrow">commit / внешнее действие</p>
<h2 id="notification-confirmation-title">Отправить напоминание?</h2>
<p class="confirmation-copy">Домовой создаст неизменяемое авторизованное действие и передаст типизированное уведомление в закреплённый receiver. Состояние Continuity не изменится; доставка не означает прочтение.</p>
<dl class="confirmation-grid">
<div><dt>Ожидание</dt><dd id="notify-confirm-prospective">—</dd></div>
<div><dt>Ревизия</dt><dd id="notify-confirm-prospective-revision">—</dd></div>
<div><dt>Work-задача</dt><dd id="notify-confirm-task">—</dd></div>
<div><dt>Ревизия Work</dt><dd id="notify-confirm-task-revision">—</dd></div>
<div class="wide"><dt>Канал</dt><dd>Локальное уведомление Windows · закреплённый receiver</dd></div>
</dl>
<p id="notification-confirmation-error" class="form-error" role="alert"></p>
<div class="confirmation-actions">
<button id="cancel-notification" class="text-button" type="button">Отмена</button>
<button id="confirm-notification" class="commit-button" type="button">Подтверждаю отправку</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/assets/console.js" defer></script>
</body>
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
func main() {
addr := envOr("LISTEN_ADDR", ":8085")
writeToken, err := readSecretFile("RECEIVER_WRITE_TOKEN_FILE")
if err != nil {
log.Fatal(err)
}
readToken, err := readSecretFile("RECEIVER_READ_TOKEN_FILE")
if err != nil {
log.Fatal(err)
}
server, err := newReceiverServer(envOr("RECEIPT_DIRECTORY", "/var/lib/notification-receiver"), writeToken, readToken, time.Now)
if err != nil {
log.Fatal(err)
}
log.Printf("notification-receiver listening on %s", addr)
if err = http.ListenAndServe(addr, server.routes()); err != nil {
log.Fatal(err)
}
}
func readSecretFile(name string) (string, error) {
path := os.Getenv(name)
if path == "" {
return "", fmt.Errorf("%s is required", name)
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", name, err)
}
secret := strings.TrimSpace(string(value))
if secret == "" {
return "", fmt.Errorf("%s is empty", name)
}
return secret, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/notificationexecutor"
)
const maxDeliveryBytes = 8 << 10
var safeIntent = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
type storedReceipt struct {
notificationexecutor.Receipt
Payload notificationexecutor.Payload `json:"payload"`
}
type receiverServer struct {
directory string
writeToken string
readToken string
now func() time.Time
}
func newReceiverServer(directory, writeToken, readToken string, now func() time.Time) (*receiverServer, error) {
if directory == "" || strings.TrimSpace(writeToken) == "" || strings.TrimSpace(readToken) == "" ||
writeToken == readToken || now == nil {
return nil, errors.New("invalid receiver configuration")
}
if err := os.MkdirAll(directory, 0700); err != nil {
return nil, fmt.Errorf("create receipt directory: %w", err)
}
return &receiverServer{directory: directory, writeToken: writeToken, readToken: readToken, now: now}, nil
}
func (s *receiverServer) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"status":"ok"}`)
})
mux.HandleFunc("PUT /delivery/v1/deliveries/{intent}", s.put)
mux.HandleFunc("GET /delivery/v1/receipts/{intent}", s.get)
mux.HandleFunc("GET /delivery/v1/receipts", s.list)
return mux
}
func (s *receiverServer) put(w http.ResponseWriter, r *http.Request) {
if !tokenMatches(s.writeToken, r) {
http.Error(w, "receiver writer denied", http.StatusUnauthorized)
return
}
intent := r.PathValue("intent")
if !safeIntent.MatchString(intent) {
http.Error(w, "invalid action intent", http.StatusBadRequest)
return
}
var request notificationexecutor.DeliveryRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDeliveryBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || string(request.ActionIntentID) != intent {
http.Error(w, "invalid delivery", http.StatusBadRequest)
return
}
if err := ensureEOF(decoder); err != nil {
http.Error(w, "invalid delivery", http.StatusBadRequest)
return
}
digest, err := notificationexecutor.Digest(request.Payload)
if err != nil || digest != request.RequestDigest {
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}
created, existing, err := s.create(receipt)
if err != nil {
http.Error(w, "receipt unavailable", http.StatusInternalServerError)
return
}
if !created {
if existing.RequestDigest != digest {
http.Error(w, "action intent digest conflict", http.StatusConflict)
return
}
receipt = existing
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if created {
w.WriteHeader(http.StatusCreated)
}
_ = json.NewEncoder(w).Encode(receipt)
}
func (s *receiverServer) get(w http.ResponseWriter, r *http.Request) {
if !tokenMatches(s.writeToken, r) && !tokenMatches(s.readToken, r) {
http.Error(w, "receiver reader denied", http.StatusUnauthorized)
return
}
intent := r.PathValue("intent")
if !safeIntent.MatchString(intent) {
http.Error(w, "invalid action intent", http.StatusBadRequest)
return
}
receipt, err := s.load(intent)
if errors.Is(err, os.ErrNotExist) {
http.Error(w, "receipt not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "receipt unavailable", http.StatusInternalServerError)
return
}
writeStoredReceipt(w, receipt)
}
func (s *receiverServer) list(w http.ResponseWriter, r *http.Request) {
if !tokenMatches(s.readToken, r) {
http.Error(w, "receiver reader denied", http.StatusUnauthorized)
return
}
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
http.Error(w, "invalid limit", http.StatusBadRequest)
return
}
limit = parsed
}
entries, err := os.ReadDir(s.directory)
if err != nil {
http.Error(w, "receipts unavailable", http.StatusInternalServerError)
return
}
receipts := make([]storedReceipt, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
receipt, loadErr := s.load(strings.TrimSuffix(entry.Name(), ".json"))
if loadErr != nil {
continue
}
receipts = append(receipts, receipt)
}
sort.Slice(receipts, func(i, j int) bool {
if receipts[i].AcceptedAt == receipts[j].AcceptedAt {
return receipts[i].ReceiptID > receipts[j].ReceiptID
}
return receipts[i].AcceptedAt > receipts[j].AcceptedAt
})
if len(receipts) > limit {
receipts = receipts[:limit]
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(struct {
Items []storedReceipt `json:"items"`
}{Items: receipts})
}
func (s *receiverServer) create(receipt storedReceipt) (bool, storedReceipt, error) {
encoded, err := json.Marshal(receipt)
if err != nil {
return false, storedReceipt{}, err
}
temporary, err := os.CreateTemp(s.directory, ".receipt-*.tmp")
if err != nil {
return false, storedReceipt{}, err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err = temporary.Chmod(0600); err == nil {
_, err = temporary.Write(encoded)
}
if err == nil {
err = temporary.Sync()
}
closeErr := temporary.Close()
if err == nil {
err = closeErr
}
if err != nil {
return false, storedReceipt{}, err
}
target := s.path(string(receipt.ActionIntentID))
if err = os.Link(temporaryName, target); err == nil {
return true, storedReceipt{}, nil
}
if !errors.Is(err, os.ErrExist) {
return false, storedReceipt{}, err
}
existing, err := s.load(string(receipt.ActionIntentID))
return false, existing, err
}
func (s *receiverServer) load(intent string) (storedReceipt, error) {
var receipt storedReceipt
file, err := os.Open(s.path(intent))
if err != nil {
return receipt, err
}
defer file.Close()
decoder := json.NewDecoder(io.LimitReader(file, maxDeliveryBytes+1))
if err = decoder.Decode(&receipt); err != nil {
return storedReceipt{}, err
}
if string(receipt.ActionIntentID) != intent {
return storedReceipt{}, errors.New("receipt identity mismatch")
}
return receipt, nil
}
func (s *receiverServer) path(intent string) string {
return filepath.Join(s.directory, intent+".json")
}
func tokenMatches(expected string, r *http.Request) bool {
provided := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func ensureEOF(decoder *json.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
return errors.New("trailing JSON")
}
func writeStoredReceipt(w http.ResponseWriter, receipt storedReceipt) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(receipt)
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.cin.su/m/Assistant/adapters/notificationexecutor"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
)
func delivery(t *testing.T, intent, message string) notificationexecutor.DeliveryRequest {
t.Helper()
payload := notificationexecutor.Payload{ActionIntentID: commitcontract.ActionIntentID(intent), TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: message}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
t.Fatal(err)
}
return notificationexecutor.DeliveryRequest{Payload: payload, RequestDigest: digest}
}
func request(t *testing.T, handler http.Handler, method, path, token string, value any) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
if value != nil {
if err := json.NewEncoder(&body).Encode(value); err != nil {
t.Fatal(err)
}
}
r := httptest.NewRequest(method, path, &body)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
func TestReceiverIdempotencyConflictAndRestart(t *testing.T) {
directory := t.TempDir()
clock := func() time.Time { return time.UnixMilli(100) }
server, err := newReceiverServer(directory, "write-secret", "read-secret", clock)
if err != nil {
t.Fatal(err)
}
first := delivery(t, "intent-1", "message one")
w := request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", first)
if w.Code != http.StatusCreated {
t.Fatalf("create status %d: %s", w.Code, w.Body.String())
}
w = request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", first)
if w.Code != http.StatusOK {
t.Fatalf("repeat status %d", w.Code)
}
conflict := delivery(t, "intent-1", "different")
w = request(t, server.routes(), http.MethodPut, "/delivery/v1/deliveries/intent-1", "write-secret", conflict)
if w.Code != http.StatusConflict {
t.Fatalf("conflict status %d", w.Code)
}
restarted, err := newReceiverServer(directory, "write-secret", "read-secret", clock)
if err != nil {
t.Fatal(err)
}
w = request(t, restarted.routes(), http.MethodGet, "/delivery/v1/receipts/intent-1", "write-secret", nil)
if w.Code != http.StatusOK {
t.Fatalf("restart get status %d", w.Code)
}
w = request(t, restarted.routes(), http.MethodGet, "/delivery/v1/receipts?limit=10", "read-secret", nil)
if w.Code != http.StatusOK {
t.Fatalf("list status %d", w.Code)
}
}
func TestReceiverSeparatesReadAndWriteAuthority(t *testing.T) {
server, err := newReceiverServer(t.TempDir(), "write-secret", "read-secret", time.Now)
if err != nil {
t.Fatal(err)
}
handler := server.routes()
value := delivery(t, "intent-1", "message")
if w := request(t, handler, http.MethodPut, "/delivery/v1/deliveries/intent-1", "read-secret", value); w.Code != http.StatusUnauthorized {
t.Fatalf("reader wrote: %d", w.Code)
}
if w := request(t, handler, http.MethodGet, "/delivery/v1/receipts", "write-secret", nil); w.Code != http.StatusUnauthorized {
t.Fatalf("writer listed: %d", w.Code)
}
}
func TestConcurrentDuplicateCreatesOneReceipt(t *testing.T) {
server, err := newReceiverServer(t.TempDir(), "write-secret", "read-secret", time.Now)
if err != nil {
t.Fatal(err)
}
value := delivery(t, "intent-concurrent", "message")
handler := server.routes()
codes := make(chan int, 8)
var group sync.WaitGroup
for range 8 {
group.Add(1)
go func() {
defer group.Done()
codes <- request(t, handler, http.MethodPut, "/delivery/v1/deliveries/intent-concurrent", "write-secret", value).Code
}()
}
group.Wait()
close(codes)
created := 0
for code := range codes {
if code == http.StatusCreated {
created++
} else if code != http.StatusOK {
t.Fatalf("unexpected status %d", code)
}
}
if created != 1 {
t.Fatalf("created=%d", created)
}
}
+31 -1
View File
@@ -1,10 +1,40 @@
package capability
import "git.cin.su/m/Assistant/platform/ids"
import (
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/platform/ids"
)
type CapabilityID ids.ID
type CapabilityBindingID ids.ID
type BindingState string
type BindingProtocol string
const (
BindingActive BindingState = "ACTIVE"
BindingRetired BindingState = "RETIRED"
ProtocolIdempotentReceiptV1 BindingProtocol = "IDEMPOTENT_RECEIPT_V1"
)
// Binding is Capability-owned correctness state. SecretRef is an opaque
// lookup name; it must never contain credential bytes.
type Binding struct {
ID CapabilityBindingID
CapabilityID CapabilityID
OwnerPrincipalID identity.PrincipalID
Revision int64
State BindingState
HTTPSOrigin string
DeliveryPath string
ReceiptPath string
Protocol BindingProtocol
SecretRef string
VerifiedAtUnixMillis int64
Health HealthState
}
type HealthState string
const (
+2
View File
@@ -0,0 +1,2 @@
// Package cognitive defines provider-neutral, non-canonical cognitive gateway contracts.
package cognitive
+71
View File
@@ -0,0 +1,71 @@
package cognitive
import (
"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 RequestID ids.ID
type ContextExcerptID ids.ID
type ContractVersion string
type Sensitivity string
type FinishReason string
const (
CreatorAdvisoryV1 ContractVersion = "creator-advisory-v1"
SensitivityPublic Sensitivity = "PUBLIC"
SensitivityPrivateAllowed Sensitivity = "PRIVATE_ALLOWED"
SensitivitySecret Sensitivity = "SECRET"
SensitivityUnknown Sensitivity = "UNKNOWN"
FinishCompleted FinishReason = "COMPLETED"
FinishLengthLimit FinishReason = "LENGTH_LIMIT"
FinishContentRejected FinishReason = "CONTENT_REJECTED"
FinishProviderUnavailable FinishReason = "PROVIDER_UNAVAILABLE"
FinishTimeout FinishReason = "TIMEOUT"
FinishInvalidProviderResponse FinishReason = "INVALID_PROVIDER_RESPONSE"
)
type ContextExcerpt struct {
ID ContextExcerptID
SourceKind string
SourceID string
Revision int64
Sensitivity Sensitivity
Text string
}
// AdvisoryRequest is assembled by a trusted gateway. Actor, Principal, Scope
// and Binding fields must never be accepted as browser-selected authority.
type AdvisoryRequest struct {
Version ContractVersion
RequestID RequestID
ActorID identity.ActorID
PrincipalID identity.PrincipalID
ScopeID authority.ScopeID
Message string
Context []ContextExcerpt
Locale string
MaxOutputBytes int
ProviderBindingID capability.CapabilityBindingID
ProviderBindingRevision int64
}
type ProviderUsage struct {
InputUnits int64
OutputUnits int64
}
type AdvisoryResponse struct {
Version ContractVersion
RequestID RequestID
ProviderBindingID capability.CapabilityBindingID
ProviderBindingRevision int64
AdvisoryText string
ReferencedExcerptIDs []ContextExcerptID
FinishReason FinishReason
Usage *ProviderUsage
}
+29 -11
View File
@@ -28,20 +28,38 @@ const (
type ExternalOperation string
const (
CapabilityTestExecutor capability.CapabilityID = "test-executor"
CapabilityContinuityTransition capability.CapabilityID = "continuity-transition"
OperationTestIdempotentEffect ExternalOperation = "test-idempotent-effect"
OperationSatisfyDelivered ExternalOperation = "satisfy-delivered"
CapabilityTestExecutor capability.CapabilityID = "test-executor"
CapabilityContinuityTransition capability.CapabilityID = "continuity-transition"
CapabilityProspectiveNotification capability.CapabilityID = "prospective-notification-delivery"
OperationTestIdempotentEffect ExternalOperation = "test-idempotent-effect"
OperationSatisfyDelivered ExternalOperation = "satisfy-delivered"
OperationDeliverProspectiveNotification ExternalOperation = "deliver-prospective-notification-v1"
)
const ProspectiveDeliveredTemplateV1 = "prospective-delivered-v1"
// ProspectiveNotificationSubject is immutable Commit evidence. Destination
// data is resolved from the exact Capability binding revision, never from UI.
type ProspectiveNotificationSubject struct {
ProspectiveItemID continuity.ProspectiveItemID
ExpectedRevision continuity.ProspectiveRevision
TemplateCode string
BindingID capability.CapabilityBindingID
BindingRevision int64
RenderedTitle string
RenderedMessage string
RequestDigest string
}
type ActionProposal struct {
IntentID ActionIntentID
ActorID identity.ActorID
CapabilityID capability.CapabilityID
Target work.TaskTarget
Operation ExternalOperation
ContinuitySubject continuity.SatisfyDeliveredSubject
State ActionState
IntentID ActionIntentID
ActorID identity.ActorID
CapabilityID capability.CapabilityID
Target work.TaskTarget
Operation ExternalOperation
ContinuitySubject continuity.SatisfyDeliveredSubject
NotificationSubject ProspectiveNotificationSubject
State ActionState
}
type AuthorizedAction struct {
+29 -1
View File
@@ -110,9 +110,20 @@ CREATE TABLE IF NOT EXISTS commit.authorized_action_envelopes (
action_state TEXT NOT NULL,
authorized_at_unix_millis BIGINT NOT NULL,
envelope_revision BIGINT NOT NULL,
notification_template_code TEXT NOT NULL DEFAULT '',
capability_binding_id TEXT NOT NULL DEFAULT '',
capability_binding_revision BIGINT NOT NULL DEFAULT 0,
notification_title TEXT NOT NULL DEFAULT '',
notification_message TEXT NOT NULL DEFAULT '',
request_digest TEXT NOT NULL DEFAULT '',
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (grant_revision > 0), CHECK (task_revision > 0),
CHECK (authorized_at_unix_millis > 0), CHECK (envelope_revision = 1)
CHECK (authorized_at_unix_millis > 0), CHECK (envelope_revision = 1),
CHECK (operation <> 'deliver-prospective-notification-v1' OR (
notification_template_code = 'prospective-delivered-v1' AND notification_title <> '' AND notification_message <> '' AND capability_binding_id <> '' AND
capability_binding_revision > 0 AND request_digest ~ '^[0-9a-f]{64}$' AND
expected_prospective_revision > 0 AND prospective_item_id <> ''
))
);
RESET ROLE;
@@ -157,6 +168,23 @@ CREATE TABLE IF NOT EXISTS capability.health_projection (
semantic_version INTEGER NOT NULL,
observed_at_unix_millis BIGINT
);
CREATE TABLE IF NOT EXISTS capability.notification_bindings (
binding_id TEXT NOT NULL, capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL,
revision BIGINT NOT NULL, state TEXT NOT NULL, https_origin TEXT NOT NULL,
delivery_path TEXT NOT NULL, receipt_path TEXT NOT NULL, protocol TEXT NOT NULL,
secret_ref TEXT NOT NULL, verified_at_unix_millis BIGINT NOT NULL, health TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (binding_id, revision),
CHECK (revision > 0), CHECK (verified_at_unix_millis > 0),
CHECK (state IN ('ACTIVE','RETIRED')), CHECK (health IN ('AVAILABLE','DEGRADED','UNAVAILABLE')),
CHECK (protocol = 'IDEMPOTENT_RECEIPT_V1'), CHECK (https_origin LIKE 'https://%'),
CHECK (delivery_path LIKE '/%'), CHECK (receipt_path LIKE '/%')
);
CREATE TABLE IF NOT EXISTS capability.notification_binding_heads (
capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL,
binding_id TEXT NOT NULL, revision BIGINT NOT NULL,
PRIMARY KEY (capability_id, owner_principal_id), CHECK (revision > 0)
);
RESET ROLE;
SET ROLE continuity_role;
@@ -0,0 +1,36 @@
BEGIN;
SET ROLE capability_role;
CREATE TABLE IF NOT EXISTS capability.notification_bindings (
binding_id TEXT NOT NULL,
capability_id TEXT NOT NULL,
owner_principal_id TEXT NOT NULL,
revision BIGINT NOT NULL,
state TEXT NOT NULL,
https_origin TEXT NOT NULL,
delivery_path TEXT NOT NULL,
receipt_path TEXT NOT NULL,
protocol TEXT NOT NULL,
secret_ref TEXT NOT NULL,
verified_at_unix_millis BIGINT NOT NULL,
health TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (binding_id, revision),
CHECK (revision > 0),
CHECK (verified_at_unix_millis > 0),
CHECK (state IN ('ACTIVE','RETIRED')),
CHECK (health IN ('AVAILABLE','DEGRADED','UNAVAILABLE')),
CHECK (protocol = 'IDEMPOTENT_RECEIPT_V1'),
CHECK (https_origin LIKE 'https://%'),
CHECK (delivery_path LIKE '/%'),
CHECK (receipt_path LIKE '/%')
);
CREATE TABLE IF NOT EXISTS capability.notification_binding_heads (
capability_id TEXT NOT NULL,
owner_principal_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
revision BIGINT NOT NULL,
PRIMARY KEY (capability_id, owner_principal_id),
CHECK (revision > 0)
);
RESET ROLE;
COMMIT;
@@ -0,0 +1,22 @@
BEGIN;
SET ROLE commit_role;
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_template_code TEXT NOT NULL DEFAULT '';
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS capability_binding_id TEXT NOT NULL DEFAULT '';
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS capability_binding_revision BIGINT NOT NULL DEFAULT 0;
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_title TEXT NOT NULL DEFAULT '';
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS notification_message TEXT NOT NULL DEFAULT '';
ALTER TABLE commit.authorized_action_envelopes ADD COLUMN IF NOT EXISTS request_digest TEXT NOT NULL DEFAULT '';
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='authorized_action_notification_evidence_v1' AND conrelid='commit.authorized_action_envelopes'::regclass) THEN
ALTER TABLE commit.authorized_action_envelopes ADD CONSTRAINT authorized_action_notification_evidence_v1 CHECK (
operation <> 'deliver-prospective-notification-v1' OR (
notification_template_code = 'prospective-delivered-v1' AND notification_title <> '' AND notification_message <> '' AND
capability_binding_id <> '' AND capability_binding_revision > 0 AND
request_digest ~ '^[0-9a-f]{64}$' AND expected_prospective_revision > 0 AND prospective_item_id <> ''
)
) NOT VALID;
END IF;
END $$;
ALTER TABLE commit.authorized_action_envelopes VALIDATE CONSTRAINT authorized_action_notification_evidence_v1;
RESET ROLE;
COMMIT;
+11
View File
@@ -0,0 +1,11 @@
assistent.cin.su {
encode zstd gzip
handle /delivery/* {
reverse_proxy host.docker.internal:18081
}
handle {
reverse_proxy host.docker.internal:18080
}
}
+37 -7
View File
@@ -4,6 +4,7 @@ services:
build:
context: ..
dockerfile: deploy/images/postgres.Dockerfile
args: { SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" }
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-assistant}
@@ -30,8 +31,27 @@ services:
command: ["-js", "-m", "8222"]
volumes: ["assistant_nats:/data"]
notification-receiver:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: notification-receiver, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8085"
RECEIPT_DIRECTORY: /var/lib/notification-receiver
RECEIVER_WRITE_TOKEN_FILE: /run/notification-writer/token
RECEIVER_READ_TOKEN_FILE: /run/notification-reader/token
ports: ["${NOTIFICATION_RECEIVER_PORT:-18081}:8085"]
volumes:
- assistant_notification_receipts:/var/lib/notification-receiver
- assistant_notification_writer_secret:/run/notification-writer:ro
- assistant_notification_reader_secret:/run/notification-reader:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/healthz >/dev/null"]
interval: 10s
timeout: 3s
retries: 10
core-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime } }
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8080"
@@ -55,7 +75,7 @@ services:
- assistant_core_secrets:/run/secrets:ro
continuity-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime } }
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: continuity-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8081"
@@ -68,7 +88,7 @@ services:
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
commit-runtime:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime } }
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8082"
@@ -82,24 +102,29 @@ services:
CORE_RUNTIME_URL: http://core-runtime:8080
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
NOTIFICATION_WRITE_TOKEN_FILE: /run/notification-writer/token
NOTIFICATION_WRITE_SECRET_REF: domovoy-notification-write-v1
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
depends_on: [core-runtime, continuity-runtime]
depends_on: [core-runtime, continuity-runtime, notification-receiver]
volumes:
- assistant_notification_writer_secret:/run/notification-writer:ro
cognitive-worker:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker } }
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment: { LISTEN_ADDR: ":8083" }
creator-web:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web } }
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8084"
CORE_RUNTIME_URL: http://core-runtime:8080
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
COMMIT_RUNTIME_URL: http://commit-runtime:8082
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
depends_on: [core-runtime, continuity-runtime, commit-runtime]
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
interval: 10s
@@ -111,3 +136,8 @@ volumes:
assistant_nats:
assistant_core_secrets:
external: true
assistant_notification_receipts:
assistant_notification_writer_secret:
external: true
assistant_notification_reader_secret:
external: true
+3
View File
@@ -1,4 +1,7 @@
FROM postgres:18.6-alpine
ARG SOURCE_REVISION=unknown
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 deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
+3
View File
@@ -7,6 +7,9 @@ ARG TARGET
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/runtime ./cmd/${TARGET}
FROM alpine:3.23
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
USER assistant
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
@@ -0,0 +1,56 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[0-9a-f]{40}$')]
[string]$Revision,
[string]$DockerContext = 'docker-prod',
[string]$LocalManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json",
[string]$NasManifestPath = '\\nas\MST\codex\_monitoring\domovoy\deployment-manifest.json',
[string[]]$ExpectedContainers = @(
'assistant-postgres-1',
'assistant-notification-receiver-1',
'assistant-core-runtime-1',
'assistant-continuity-runtime-1',
'assistant-commit-runtime-1',
'assistant-cognitive-worker-1',
'assistant-creator-web-1'
)
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Write-AtomicJson([string]$Path, [string]$Json) {
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$partial = Join-Path $directory ('.' + [IO.Path]::GetFileName($Path) + ".${PID}.partial")
try {
[IO.File]::WriteAllText($partial, $Json + "`n", [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $partial -Destination $Path -Force
}
finally {
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force }
}
}
$artifacts = foreach ($container in $ExpectedContainers) {
$raw = ((& docker.exe --context $DockerContext inspect $container --format '{{json .}}') -join '').Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "Cannot inspect $container" }
$inspect = $raw | ConvertFrom-Json
$actual = [string]$inspect.Config.Labels.'org.opencontainers.image.revision'
if ($actual -ne $Revision) { throw "$container revision mismatch: expected=$Revision actual=$actual" }
if ($inspect.State.Status -ne 'running') { throw "$container is not running" }
[ordered]@{ container = $container; image_id = [string]$inspect.Image; revision = $actual }
}
$manifest = [ordered]@{
version = 1
project = 'domovoy'
revision = $Revision
promoted_at_utc = [DateTime]::UtcNow.ToString('o')
artifacts = @($artifacts)
}
$json = $manifest | ConvertTo-Json -Depth 5
Write-AtomicJson $LocalManifestPath $json
Write-AtomicJson $NasManifestPath $json
Write-Output "Production artifact confirmed: revision=$Revision containers=$($artifacts.Count)"
@@ -0,0 +1,83 @@
[CmdletBinding()]
param(
[string]$Endpoint = 'https://assistent.cin.su/delivery/v1/receipts?limit=100',
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
[string]$StatePath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-state.json",
[ValidateRange(1, 5)]
[int]$MaximumDisplays = 3,
[switch]$NoDisplay
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Write-AtomicJson {
param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path)
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$temporary = Join-Path $directory ('.{0}.{1}.tmp' -f (Split-Path -Leaf $Path), [guid]::NewGuid().ToString('N'))
try {
[IO.File]::WriteAllText($temporary, (($Value | ConvertTo-Json -Depth 8) + "`n"), [Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $temporary -Destination $Path -Force
}
finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } }
}
$uri = [Uri]$Endpoint
if ($uri.Scheme -ne 'https' -or $uri.Host -ne 'assistent.cin.su' -or $uri.AbsolutePath -ne '/delivery/v1/receipts') {
throw 'Notification receiver endpoint is outside the fixed HTTPS binding'
}
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw "Notification receiver key not found: $KeyRecordPath" }
$keyRecord = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
if ($keyRecord.version -ne 1 -or $keyRecord.project -ne 'domovoy' -or $keyRecord.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
$secure = ConvertTo-SecureString ([string]$keyRecord.protected_value)
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try { $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) }
if ([string]::IsNullOrWhiteSpace($token)) { throw 'Notification receiver key is empty' }
try {
$response = Invoke-RestMethod -Uri $uri.AbsoluteUri -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 15
}
finally { $token = $null; $secure.Dispose() }
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
if (Test-Path -LiteralPath $StatePath -PathType Leaf) {
$previous = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
if ($previous.version -eq 1) { foreach ($id in @($previous.displayed_receipt_ids)) { [void]$seen.Add([string]$id) } }
}
$candidates = @($response.items | Where-Object {
$_.receipt_id -match '^receipt-[A-Za-z0-9_-]{1,128}$' -and
$_.action_intent_id -match '^[A-Za-z0-9_-]{1,128}$' -and
$_.request_digest -match '^[0-9a-f]{64}$' -and
$_.accepted_at_unix_millis -gt 0 -and
$_.payload.template_code -eq 'prospective-delivered-v1' -and
-not [string]::IsNullOrWhiteSpace([string]$_.payload.title) -and
-not [string]::IsNullOrWhiteSpace([string]$_.payload.message) -and
-not $seen.Contains([string]$_.receipt_id)
} | Sort-Object accepted_at_unix_millis | Select-Object -First $MaximumDisplays)
if (-not $NoDisplay -and $candidates.Count -gt 0) {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$notification = [System.Windows.Forms.NotifyIcon]::new()
try {
$notification.Icon = [System.Drawing.SystemIcons]::Information
$notification.Visible = $true
$notification.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info
foreach ($receipt in $candidates) {
$notification.BalloonTipTitle = ([string]$receipt.payload.title).Substring(0, [Math]::Min(63, ([string]$receipt.payload.title).Length))
$notification.BalloonTipText = ([string]$receipt.payload.message).Substring(0, [Math]::Min(255, ([string]$receipt.payload.message).Length))
$notification.ShowBalloonTip(6000)
Start-Sleep -Seconds 7
[void]$seen.Add([string]$receipt.receipt_id)
}
}
finally { $notification.Dispose() }
}
if (-not $NoDisplay) {
$bounded = @($seen | Select-Object -Last 1000)
Write-AtomicJson -Path $StatePath -Value ([ordered]@{ version = 1; displayed_receipt_ids = $bounded; last_poll_at_utc = [DateTime]::UtcNow.ToString('o') })
}
Write-Output "notification_receiver=poll_ok new=$($candidates.Count) displayed=$(if ($NoDisplay) { 0 } else { $candidates.Count })"
@@ -0,0 +1,40 @@
[CmdletBinding()]
param(
[string]$ScriptPath = '\\nas\MST\codex\assistent\deploy\windows\Invoke-DomovoyNotificationReceiver.ps1',
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
[string]$NasKeyBackupPath = '\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json',
[string]$TaskName = 'Domovoy Notification Receiver'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf) -and (Test-Path -LiteralPath $NasKeyBackupPath -PathType Leaf)) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeyRecordPath) | Out-Null
Copy-Item -LiteralPath $NasKeyBackupPath -Destination $KeyRecordPath
}
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw 'DPAPI-protected notification read key is missing' }
$record = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
if ($record.version -ne 1 -or $record.project -ne 'domovoy' -or $record.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
$probe = ConvertTo-SecureString ([string]$record.protected_value)
$probe.Dispose()
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $KeyRecordPath /inheritance:r /grant:r "${identity}:F" | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'DPAPI key ACL hardening failed' }
$nasDirectory = Split-Path -Parent $NasKeyBackupPath
New-Item -ItemType Directory -Force -Path $nasDirectory | Out-Null
$partial = "$NasKeyBackupPath.$PID.partial"
Copy-Item -LiteralPath $KeyRecordPath -Destination $partial
Move-Item -LiteralPath $partial -Destination $NasKeyBackupPath -Force
if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { throw 'Notification receiver script not found' }
$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source
$arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`""
$action = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments
$repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 2) -RepetitionDuration (New-TimeSpan -Days 3650)
$logon = New-ScheduledTaskTrigger -AtLogOn -User $identity
$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger @($repeat, $logon) -Principal $principal -Settings $settings -Description 'Read typed Domovoy receipts and show local Windows notifications.' -Force | Out-Null
Write-Output "Scheduled task registered: $TaskName"
@@ -7,6 +7,7 @@ param(
[string]$NasBackupRoot = '\\nas\MST\codex\_backups\domovoy\postgres',
[string]$NasStatusPath = '\\nas\MST\codex\_monitoring\domovoy\status.json',
[string]$LocalStatusPath = "$env:LOCALAPPDATA\Domovoy\monitoring\status.json",
[string]$DeploymentManifestPath = "$env:LOCALAPPDATA\Domovoy\monitoring\deployment-manifest.json",
[double]$MaxProductionBackupAgeHours = 30,
[double]$MaxNasBackupAgeHours = 36,
[int]$MinimumCertificateDays = 21,
@@ -14,6 +15,7 @@ param(
'assistant-core-runtime-1',
'assistant-creator-web-1',
'assistant-commit-runtime-1',
'assistant-notification-receiver-1',
'assistant-continuity-runtime-1',
'assistant-cognitive-worker-1',
'assistant-postgres-1',
@@ -59,6 +61,21 @@ foreach ($container in $ExpectedContainers) {
catch { Add-Check "container:$container" $false $_.Exception.Message }
}
try {
$manifest = Get-Content -LiteralPath $DeploymentManifestPath -Raw | ConvertFrom-Json
if ($manifest.version -ne 1 -or $manifest.revision -notmatch '^[0-9a-f]{40}$') { throw 'invalid deployment manifest' }
if (@($manifest.artifacts).Count -ne 7) { throw 'deployment manifest artifact count mismatch' }
foreach ($artifact in $manifest.artifacts) {
$raw = ((& docker.exe --context $DockerContext inspect $artifact.container --format '{{json .}}') -join '').Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) { throw "cannot inspect $($artifact.container)" }
$inspect = $raw | ConvertFrom-Json
$label = [string]$inspect.Config.Labels.'org.opencontainers.image.revision'
if ($label -ne $manifest.revision -or [string]$inspect.Image -ne [string]$artifact.image_id) { throw "artifact drift: $($artifact.container)" }
}
Add-Check 'deployment-artifact' $true "revision=$($manifest.revision) containers=$(@($manifest.artifacts).Count)"
}
catch { Add-Check 'deployment-artifact' $false $_.Exception.Message }
try {
$response = Invoke-RestMethod -Uri $Endpoint -TimeoutSec 15
$ok = $response.status -eq 'ok'
@@ -130,6 +147,14 @@ try {
}
catch { Add-Check 'offhost-backup-task' $false $_.Exception.Message }
try {
$notificationTask = Get-ScheduledTask -TaskName 'Domovoy Notification Receiver'
$notificationInfo = Get-ScheduledTaskInfo -TaskName $notificationTask.TaskName
$ok = $notificationTask.State -ne 'Disabled' -and $notificationInfo.LastTaskResult -eq 0
Add-Check 'notification-receiver-task' $ok "state=$($notificationTask.State) last_result=$($notificationInfo.LastTaskResult) next=$($notificationInfo.NextRunTime.ToString('o'))"
}
catch { Add-Check 'notification-receiver-task' $false $_.Exception.Message }
$overall = if (@($checks | Where-Object { -not $_.ok }).Count -eq 0) { 'healthy' } else { 'degraded' }
$record = [ordered]@{
version = 1
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/ARCH-000005-first-external-operation-proposal.json",
"task_id": "ARCH-000005",
"task_file": "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json",
"task_id": "DEV-000053",
"task_revision": 1
}
@@ -0,0 +1,76 @@
{
"task_id": "ARCH-000006",
"task_revision": 1,
"architecture_revision": "ADR-0004",
"base_repo_revision": "b4f86b8c46870fe6a1c38f9cfdf35f0a818f9065",
"risk_tier": "E",
"intent": "Accept the typed prospective notification operation, compatible receipt protocol, docker-prod receiver destination, and local Windows display channel proposed by ARCH-000005.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "CONSTITUTION-1.3.5", "CONSTITUTION-1.3.14", "CONSTITUTION-1.3.31"],
"allowed_change_scope": {
"modules": [],
"paths": [
"architecture/adrs/ADR-0005-prospective-notification-delivery.md",
"docs/FIRST_EXTERNAL_OPERATION_PROPOSAL.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/ARCH-000006-accept-prospective-notification-delivery.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/ARCHITECTURE_CONSTITUTION.md",
"architecture/CORE_SEMANTIC_MODEL_V1.md",
"architecture/architecture-registry.yaml",
"architecture/architecture-registry.json",
"ci/**",
".gitea/**",
"security/**",
"database/**",
"modules/**",
"contracts/**",
"adapters/**",
"cmd/**",
"deploy/**",
"tests/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 2,
"new_third_party_dependencies": 0
},
"contracts": {
"consume": ["contracts.authority@v1", "contracts.capability@v1", "contracts.work@v1", "contracts.continuity@v1", "contracts.commit@v1"],
"may_change": ["typed Commit notification envelope", "CapabilityBinding lifecycle", "prospective notification executor port"]
},
"semantic_owner": "Capability owns binding; Commit owns action evidence; Continuity owns ProspectiveItem; the receiver is an external adapter system",
"canonical_state": ["no canonical state added by this acceptance task"],
"state_transitions": ["no runtime transition in this acceptance task", "future delivery never changes ProspectiveItem state"],
"side_effects_and_privileges": [
"accept one future Commit write/reconciliation credential scoped to the fixed receiver protocol",
"accept one future read-only Windows receiver credential",
"no credential generation, endpoint call, deployment, or runtime mutation in this task"
],
"persistence_and_migrations": ["none in this acceptance task"],
"failure_retry_restart": [
"ambiguous send outcome is UNKNOWN_OUTCOME and is never blindly retried",
"reconciliation is read-only and compares immutable digest evidence",
"Windows projection failure cannot alter receiver or canonical Commit outcomes"
],
"assumptions": [
"The Creator's continuation after the explicit acceptance request accepts the ARCH-000005 recommendation and local Windows channel",
"The compatible receiver is an external adapter boundary even when deployed on docker-prod beside Assistant containers",
"The existing architecture registry ownership and dependency graph is sufficient",
"docker-test remains prohibited"
],
"acceptance_scenarios": [
"ADR accepts only the typed notification operation and no arbitrary webhook surface",
"CapabilityBinding remains Capability-owned and secrets remain outside canonical state",
"Commit success means receiver receipt acceptance, not Windows display or human reading",
"receiver write and Windows read credentials are separate and least-privileged",
"timeout remains UNKNOWN_OUTCOME and reconciliation remains read-only",
"no registry change, credential creation, implementation, or deployment occurs in this task"
],
"required_tests": ["architecture preflight", "exact diff scope", "constitutional and ADR semantic review", "go test", "go vet", "CI", "independent Tier E review before promotion"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "ARCH-000007",
"task_revision": 1,
"architecture_revision": "generation-1",
"base_repo_revision": "43d1ef58213938c7ad8761375705849eec8fd901",
"risk_tier": "E",
"intent": "Produce a proposal-only decision for Domovoy's first real cognitive provider slice: authenticated Creator advisory responses with explicit data boundaries, replaceable provider binding, evaluation/promotion rules, and mechanically absent canonical or external mutation authority.",
"requirement_ids": ["FD-001", "FD-009", "FD-023", "FD-025", "INV-EPI-001", "USER-CONTINUE-PROJECT"],
"allowed_change_scope": {"modules": [], "paths": ["docs/COGNITIVE_PROVIDER_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000007-cognitive-provider-proposal.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["FD-001", "FD-009", "FD-023", "FD-025", "existing authenticated Creator session"], "may_change": []},
"semantic_owner": "proposal-only; no architecture decision is accepted by this task",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["select this proposal in the active task pointer", "no provider call, credential creation, deployment, canonical mutation or external action"],
"persistence_and_migrations": [],
"failure_retry_restart": ["proposal defines timeout and provider-unavailable as explicit advisory failures", "advisory requests are not silently retried", "loss of cognition does not affect continuity or integrity"],
"assumptions": ["the first cognitive slice is advisory text, not autonomous action", "the user has not yet accepted a provider, data policy or retention policy", "docker-test remains prohibited"],
"acceptance_scenarios": ["proposal defines a provider-neutral typed advisory contract", "proposal defines allowed and forbidden context data", "model output has no canonical write or Executor path", "proposal defines secret isolation and log redaction", "proposal defines deterministic validation, timeout, rate and size limits", "proposal defines fake/eval/shadow/canary/promotion and rollback", "proposal identifies the minimum decisions requiring explicit user acceptance", "no runtime, protected architecture, credential or deployment state changes"],
"required_tests": ["task schema", "architecture preflight", "exact diff scope", "documentation review", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "ARCH-000008",
"task_revision": 1,
"architecture_revision": "generation-1",
"base_repo_revision": "78862ffe05f162fd397b688c3baefc0ad3a56a21",
"risk_tier": "E",
"intent": "Accept the provider-neutral Creator advisory boundary from ARCH-000007 with fail-closed no-egress defaults, fixed initial limits, no persistence/tools/actions, and no selected external provider.",
"requirement_ids": ["FD-001", "FD-009", "FD-023", "FD-025", "INV-EPI-001", "USER-CONTINUATION-AFTER-ARCH-000007"],
"allowed_change_scope": {"modules": [], "paths": ["architecture/adrs/ADR-0006-creator-advisory-boundary.md", "architecture/adrs/README.md", "docs/COGNITIVE_PROVIDER_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000008-accept-cognitive-advisory-boundary.json"]},
"forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "architecture/CORE_SEMANTIC_MODEL_V1.md", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["ARCH-000007 proposal", "existing authenticated Creator session"], "may_change": []},
"semantic_owner": "architecture decision only; cognitive worker owns no canonical semantics",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["accept ADR-0006", "no provider call, credential, deployment or runtime mutation"],
"persistence_and_migrations": [],
"failure_retry_restart": ["external egress remains disabled until a provider decision", "advisory requests are ephemeral and never retried automatically", "provider absence returns explicit unavailable state"],
"assumptions": ["the user's continuation after receiving ARCH-000007 accepts the recommended advisory-only boundary", "provider choice and external data egress remain unaccepted, so fail closed", "docker-test remains prohibited"],
"acceptance_scenarios": ["ADR accepts provider-neutral contract and deterministic limits", "no external provider or data egress is accepted", "no canonical persistence, tools or automatic actions are permitted", "fake gateway implementation becomes authorized under a separate task", "real adapter remains blocked until provider/data policy acceptance"],
"required_tests": ["task schema", "architecture preflight", "exact diff scope", "independent protected-path review", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "ARCH-000009",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "67390c82976aa9ff69c9de5a88e974836c89bc87",
"risk_tier": "E",
"intent": "Produce a proposal-only decision for an initial OpenAI Responses API binding for Creator advisory, with an immutable model snapshot, explicit default-retention disclosure, no tools or provider memory, bounded data egress, isolated credential, evaluation/canary gates and fail-closed rollback.",
"requirement_ids": ["ADR-0006", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["docs/OPENAI_ADVISORY_BINDING_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000009-openai-advisory-binding-proposal.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0006"], "may_change": []},
"semantic_owner": "none-proposal-only",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["select this proposal in the active task pointer", "no API key creation, provider call, egress, production mutation or ADR acceptance"],
"persistence_and_migrations": [],
"failure_retry_restart": ["proposal preserves deterministic PROVIDER_UNAVAILABLE fallback", "no automatic retry", "provider timeout or invalid response cannot fabricate advisory text"],
"assumptions": ["OpenAI is the recommended initial provider but is not accepted by this proposal alone", "default API abuse-monitoring retention may be up to 30 days unless the account is approved for ZDR/MAM", "docker-test remains prohibited"],
"acceptance_scenarios": ["proposal names fixed API origin, endpoint and immutable model snapshot", "proposal defines exact request features and forbidden tools/state", "default-retention and ZDR behavior are explicit", "data classes permitted before and after ZDR are separated", "credential isolation and log redaction are explicit", "evaluation, canary, spend/rate, health and rollback gates are defined", "implementation and production egress remain unapproved"],
"required_tests": ["official-source verification", "architecture preflight", "exact diff scope", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "ARCH-000010",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "835c62cc1d63348b11e0035ceda68965be0d760a",
"risk_tier": "E",
"intent": "Accept the ARCH-000009 recommendation as the first real Creator advisory provider binding class: OpenAI Responses API, immutable gpt-5.4-mini snapshot and Mode A per-request disclosure, while keeping production egress and credential activation subject to separate implementation, evaluation and promotion tasks.",
"requirement_ids": ["ADR-0006", "ARCH-000009", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["architecture/adrs/ADR-0007-openai-advisory-binding.md", "architecture/adrs/README.md", "docs/OPENAI_ADVISORY_BINDING_PROPOSAL.md", "dev/ACTIVE_TASK.json", "dev/tasks/ARCH-000010-accept-openai-advisory-binding.json"]},
"forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/CORE_SEMANTIC_MODEL_V1.md", "architecture/architecture-registry.*", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0006", "ARCH-000009 proposal"], "may_change": []},
"semantic_owner": "none-architecture-decision",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["accept ADR-0007", "authorize a separately scoped fake-server adapter implementation", "no API key, provider call, egress or production activation"],
"persistence_and_migrations": [],
"failure_retry_restart": ["deterministic fake remains production fallback", "no automatic provider retry", "invalid or unavailable provider response fails closed"],
"assumptions": ["the Creator's continuation after the explicit recommendation accepts OpenAI, the fixed snapshot and Mode A retention disclosure", "a later production promotion still requires a dedicated OpenAI project/key and spend controls", "docker-test remains prohibited"],
"acceptance_scenarios": ["ADR fixes provider origin, endpoint, model snapshot and Mode A data policy", "tools, provider memory and automatic context remain forbidden", "implementation may use only adversarial local fake transport until a later promotion task", "real key and production egress remain disabled", "rollback preserves deterministic PROVIDER_UNAVAILABLE"],
"required_tests": ["architecture preflight", "exact diff scope", "CI", "cold-readable ADR review"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,68 @@
{
"task_id": "DEV-000041",
"task_revision": 1,
"architecture_revision": "ADR-0005",
"base_repo_revision": "45fb5f8709ef9f327c5e41e7f9adb0d7a7d06866",
"risk_tier": "E",
"intent": "Implement the non-user-facing foundation for ADR-0005: Capability-owned versioned receiver binding, typed Commit notification evidence, a durable idempotent receipt receiver, and a strict HTTPS executor/reconciler adapter without enabling a Creator mutation route or installing credentials.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "ADR-0005"],
"allowed_change_scope": {
"modules": ["capability", "commit"],
"paths": [
"contracts/capability/types.go",
"contracts/commit/types.go",
"modules/capability/domain/binding.go",
"modules/capability/domain/binding_test.go",
"modules/capability/ports/repository.go",
"adapters/postgres/capability_repository.go",
"adapters/postgres/capability_repository_test.go",
"adapters/postgres/commit_repository.go",
"adapters/notificationexecutor/executor.go",
"adapters/notificationexecutor/executor_test.go",
"cmd/core-runtime/main.go",
"cmd/core-runtime/main_test.go",
"cmd/notification-receiver/main.go",
"cmd/notification-receiver/server.go",
"cmd/notification-receiver/server_test.go",
"database/migrations/010_capability_notification_binding.sql",
"database/migrations/011_commit_notification_envelope.sql",
"database/bootstrap/001_roles_and_schemas.sql",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000041-notification-delivery-foundation.json"
]
},
"forbidden_change_scope": {
"paths": [
"architecture/**",
"ci/**",
".gitea/**",
"modules/identity/**",
"modules/authority/**",
"modules/work/**",
"modules/continuity/**",
"cmd/creator-web/**",
"deploy/**",
"security/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0
},
"contracts": {
"consume": ["contracts.identity@v1", "contracts.capability@v1", "contracts.commit@v1", "ADR-0005 fixed receipt protocol"],
"may_change": ["CapabilityBinding V1", "Commit prospective notification evidence V1"]
},
"semantic_owner": "Capability owns binding state; Commit owns immutable action evidence; receiver storage is external adapter receipt state",
"canonical_state": ["capability.notification_bindings", "future Commit envelope columns only; no action rows created by this task"],
"state_transitions": ["CapabilityBinding ACTIVE to RETIRED with monotonic revision", "receiver receipt absent to immutable present"],
"side_effects_and_privileges": ["receiver PUT writes only its isolated receipt directory", "receiver GET/list are read-only", "HTTPS adapter accepts only a validated fixed binding", "no credentials installed and no route exposed to Creator"],
"persistence_and_migrations": ["add Capability-owned binding table", "add backward-compatible notification evidence columns to Commit envelope", "receiver uses atomic create and immutable receipt files"],
"failure_retry_restart": ["same intent and digest returns the original receipt", "same intent with different digest is conflict", "timeout or malformed success is unknown", "GET reconciliation never writes", "restart rebuilds receiver truth from durable receipt files"],
"assumptions": ["The public TLS reverse proxy is configured only in a later deployment task", "secret references are identifiers and never secret bytes", "docker-test remains prohibited"],
"acceptance_scenarios": ["binding validation rejects HTTP, non-fixed paths, invalid revisions, unhealthy or retired state", "owner-local binding load cannot return a foreign principal binding", "receiver authorization separates writer and reader", "receiver survives restart and keeps one receipt per action intent", "executor disables redirects, bounds response size, verifies receipt identity and digest, and classifies ambiguous failures as unknown", "historical Commit rows remain readable after additive migration", "no Creator command can invoke the new executor in this task"],
"required_tests": ["unit", "receiver restart and conflict", "TLS executor and reconciliation", "PostgreSQL repository contract where available", "architecture preflight", "exact diff scope", "go test", "go vet", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,46 @@
{
"task_id": "DEV-000042",
"task_revision": 1,
"architecture_revision": "ADR-0005",
"base_repo_revision": "6e0040fe89c938549295798c77006877902a338d",
"risk_tier": "E",
"intent": "Enable the ADR-0005 typed prospective notification command through the authenticated Creator flow, exact CapabilityBinding validation, dynamic Commit Executor dispatch, and an explicit confirmation control in the Creator console without installing production credentials or deploying.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-CONT-001", "ADR-0005"],
"allowed_change_scope": {
"modules": ["commit"],
"paths": [
"modules/commit/domain/commit.go",
"modules/commit/domain/notification_test.go",
"adapters/notificationexecutor/executor.go",
"adapters/notificationexecutor/executor_test.go",
"cmd/commit-runtime/creator_clients.go",
"cmd/commit-runtime/creator_command.go",
"cmd/commit-runtime/creator_command_test.go",
"cmd/commit-runtime/main.go",
"cmd/commit-runtime/notification_executor.go",
"cmd/commit-runtime/notification_executor_test.go",
"cmd/creator-web/server.go",
"cmd/creator-web/server_test.go",
"cmd/creator-web/static/index.html",
"cmd/creator-web/static/assets/console.js",
"cmd/creator-web/static/assets/console.css",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000042-creator-notification-command.json"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "ci/**", ".gitea/**", "database/**", "deploy/**", "security/**", "modules/identity/**", "modules/authority/**", "modules/work/**", "modules/continuity/**"]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["contracts.identity@v1", "contracts.authority@v1", "contracts.work@v1", "contracts.continuity@v1", "contracts.capability@v1", "contracts.commit@v1", "ADR-0005"], "may_change": []},
"semantic_owner": "Commit owns action evidence and attempts; Capability owns binding; Continuity owns the unchanged ProspectiveItem; Creator UI is a replaceable projection",
"canonical_state": ["existing Commit authorized action envelopes and attempts only"],
"state_transitions": ["notification action AUTHORIZED to EXECUTING to SUCCEEDED, FAILED, or UNKNOWN_OUTCOME", "ProspectiveItem remains DELIVERED"],
"side_effects_and_privileges": ["new Creator route may invoke only deliver-prospective-notification-v1 after explicit confirmation", "Executor resolves only the exact committed binding and configured external secret reference", "no credential installation or production call in this task"],
"persistence_and_migrations": ["uses DEV-000041 additive schema; no migration in this task"],
"failure_retry_restart": ["stale item, Work target, grant, binding, owner or digest fail closed", "ambiguous PUT records UNKNOWN_OUTCOME", "reconciliation uses GET only", "retired historical binding may reconcile but cannot execute a new send"],
"assumptions": ["one configured write secret reference is sufficient for the initial receiver", "browser confirmation is explicit per notification request", "docker-test remains prohibited"],
"acceptance_scenarios": ["browser cannot submit actor, principal, capability, operation, destination, template, body, IDs, headers or credentials", "confirmation=false is rejected", "foreign owner, non-DELIVERED or stale item, stale Work, invalid grant, and missing/unhealthy/revised binding are rejected before external execution", "server renders the fixed V1 message and digest", "dynamic executor dispatch leaves existing Continuity satisfaction unchanged", "action history identifies notification actions", "UI exposes clear pending/success/unknown/error states and remains usable on desktop/mobile"],
"required_tests": ["domain authorization", "hostile request fields", "dynamic executor dispatch", "Creator HTTP", "gateway allowlist", "responsive browser", "browser console", "architecture preflight", "exact diff scope", "go test", "go vet", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,37 @@
{
"task_id": "DEV-000043",
"task_revision": 1,
"architecture_revision": "ADR-0005",
"base_repo_revision": "c6139699a9efed624b49b15c801f5c04e884a0aa",
"risk_tier": "E",
"intent": "Deploy the accepted notification operation on docker-prod using available port 18081, fixed HTTPS routing, isolated writer/reader secret volumes, a durable receiver volume, and a current-user Windows display agent with DPAPI-protected read credential; verify an exact-artifact end-to-end receipt without docker-test.",
"requirement_ids": ["ADR-0005", "INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "USER-DOCKER-PROD-ONLY"],
"allowed_change_scope": {
"modules": [],
"paths": [
"deploy/docker-compose.prod.yml",
"deploy/caddy/assistent.caddy",
"deploy/windows/Invoke-DomovoyNotificationReceiver.ps1",
"deploy/windows/Register-DomovoyNotificationTask.ps1",
"deploy/windows/Test-DomovoyProductionHealth.ps1",
"tests/creator_commit/verify-remote.ps1",
"docs/CREDENTIALS_AND_SECRETS.md",
"docs/DEPLOYMENT.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000043-production-notification-delivery.json"
]
},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 2, "new_third_party_dependencies": 0},
"contracts": {"consume": ["ADR-0005", "DEV-000041 receiver protocol", "DEV-000042 Creator command", "existing production compose and monitoring"], "may_change": []},
"semantic_owner": "no new canonical owner; receiver is external receipt state and Windows state is a rebuildable projection",
"canonical_state": ["CapabilityBinding inserted through Capability-owned schema", "Commit action/attempt evidence", "receiver immutable receipt volume"],
"state_transitions": ["install ACTIVE binding revision 1", "external receipt absent to present", "Windows local projection unseen to displayed"],
"side_effects_and_privileges": ["generate isolated write and read tokens outside Git", "deploy notification-receiver on docker-prod port 18081", "route only /delivery/* through assistent.cin.su", "mount write secret into Commit and receiver only", "mount read secret into receiver only", "register current-user Windows polling task"],
"persistence_and_migrations": ["apply migrations 010 and 011 on production", "create external secret volumes and durable receiver volume", "store Windows read token only as CurrentUser DPAPI ciphertext and back up ciphertext to the private NAS key directory"],
"failure_retry_restart": ["compose restart preserves receipts", "Windows polls read-only and deduplicates by receipt ID", "poll failure leaves receipts unseen for later retry", "receiver outage yields UNKNOWN_OUTCOME and read-only reconciliation", "no blind mutation retry"],
"assumptions": ["docker.cin.su SSH alias docker is the production host", "port 18081 is available", "Caddy host routing can reach host.docker.internal:18081", "assistent.cin.su TLS remains valid", "docker-test remains prohibited"],
"acceptance_scenarios": ["all expected containers including notification-receiver are healthy", "Caddy exposes receiver only under HTTPS /delivery/*", "unauthorized/write-with-read-token/arbitrary path requests are denied", "same intent and digest returns one receipt and mismatched digest conflicts", "production migration and binding match exact ADR fields", "Creator command yields SUCCEEDED or recoverable UNKNOWN_OUTCOME with durable evidence", "Windows agent reads without write privilege and displays/deduplicates the receipt", "restart preserves receipt and scheduled task succeeds", "no plaintext token appears in Git, task arguments, logs, database, browser storage or NAS"],
"required_tests": ["PowerShell parser", "unit", "architecture preflight", "exact diff scope", "go test", "go vet", "CI", "production compose health", "HTTPS receiver adversarial smoke", "Creator authenticated smoke", "receiver restart persistence", "Windows scheduled-task result", "browser console", "docker-test absence"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,32 @@
{
"task_id": "DEV-000044",
"task_revision": 3,
"architecture_revision": "ADR-0005",
"base_repo_revision": "759328972a7ae583f27eec5b97a7a8dea9df2ce7",
"risk_tier": "E",
"intent": "Re-audit the exact merged Assistant artifact after the first production notification operation, update repository status evidence, and identify remaining work without inventing unapproved product or architecture scope; docker-test remains prohibited.",
"requirement_ids": ["ADR-0005", "INV-AUTH-001", "INV-OWN-001", "INV-CONT-001", "INV-TOPO-001", "USER-DOCKER-PROD-ONLY"],
"allowed_change_scope": {
"modules": [],
"paths": [
"docs/FOUNDATION_EXIT_AUDIT.md",
"docs/PROJECT_STATUS.md",
"tests/creator_commit/verify-remote.ps1",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000044-post-notification-production-audit.json"
]
},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["ADR-0005", "DEV-000041", "DEV-000042", "DEV-000043", "existing production verification scripts"], "may_change": []},
"semantic_owner": "none-verification",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["read-only production verification", "authenticated smoke may create only its documented disposable test evidence through the existing Commit path"],
"persistence_and_migrations": [],
"failure_retry_restart": ["audit records failures as gaps and does not weaken checks", "no production mutation outside existing verification harness semantics"],
"assumptions": ["origin/main is the deployed exact artifact", "docker.cin.su SSH alias docker remains the production host", "docker-test remains prohibited"],
"acceptance_scenarios": ["local deterministic tests and architecture checks pass", "production stack and Windows tasks are healthy", "notification HTTPS authorization and durable receipt path pass", "Creator remote smoke passes through a Windows/Docker CLI-safe command invocation", "production artifact provenance is either proven or recorded as an explicit gap", "repository documents completed post-foundation work and remaining product decisions"],
"required_tests": ["task schema", "architecture preflight", "exact diff scope", "go test", "go vet", "production health", "authenticated Creator notification smoke", "docker-test absence"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000045",
"task_revision": 1,
"architecture_revision": "ADR-0005",
"base_repo_revision": "959c89d47bf231dbcfb7a05e7b63b0e8579e3f0c",
"risk_tier": "E",
"intent": "Stamp project-built production images with the immutable Git revision, record promoted image digests in an external deployment manifest, and make production health reject provenance drift without using docker-test.",
"requirement_ids": ["INV-TOPO-001", "DEV-000044-FINDING-ARTIFACT-PROVENANCE", "USER-DOCKER-PROD-ONLY"],
"allowed_change_scope": {"modules": [], "paths": ["deploy/docker-compose.prod.yml", "deploy/images/runtime.Dockerfile", "deploy/images/postgres.Dockerfile", "deploy/windows/Confirm-DomovoyProductionArtifact.ps1", "deploy/windows/Test-DomovoyProductionHealth.ps1", "docs/DEPLOYMENT.md", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000045-production-artifact-provenance.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["existing production Compose topology", "Docker OCI image metadata"], "may_change": []},
"semantic_owner": "none-operational-verification",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["rebuild and replace project-owned production containers with exact merged source", "write non-secret deployment manifest to local profile and private NAS monitoring directory"],
"persistence_and_migrations": ["non-secret external deployment manifest only"],
"failure_retry_restart": ["manifest is published atomically only after all expected labels and digests verify", "failed verification leaves previous manifest intact", "health becomes degraded on revision or digest drift"],
"assumptions": ["docker context docker-prod is the production endpoint", "Git HEAD is merged origin/main before promotion", "docker-test remains prohibited"],
"acceptance_scenarios": ["every project-built image has the merged Git revision OCI label", "manifest records revision and exact image digests", "health verifies running labels and digests against the manifest", "production remains healthy after exact-artifact promotion"],
"required_tests": ["PowerShell parser", "task schema", "architecture preflight", "exact diff scope", "go test", "go vet", "Compose config", "production artifact confirmation", "production health", "CI", "docker-test absence"],
"terminal_gap_states": ["BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000046",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "b896d218f80af277ccf4e15d28fbffb1c7bad937",
"risk_tier": "E",
"intent": "Implement the provider-neutral creator-advisory-v1 contract, fail-closed validation, deterministic unavailable fake, and cognitive-worker HTTP contract without provider egress, persistence, tools or canonical authority.",
"requirement_ids": ["ADR-0006", "FD-001", "FD-009", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["contracts/cognitive/**", "adapters/models/**", "cmd/cognitive-worker/**", "tests/cognitive_advisory/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000046-cognitive-advisory-contract.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/authority/**", "contracts/commit/**", "modules/**", "adapters/postgres/**", "database/**", "deploy/**", "security/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["contracts.identity@v1", "contracts.authority@v1", "contracts.capability@v1", "ADR-0006"], "may_change": ["new contracts.cognitive creator-advisory-v1"]},
"semantic_owner": "none-cognitive-gateway",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["none; deterministic endpoint performs no network call or mutation"],
"persistence_and_migrations": [],
"failure_retry_restart": ["invalid or secret context rejected before adapter", "fake reports PROVIDER_UNAVAILABLE without fabricated text", "request loss creates no obligation and no retry"],
"assumptions": ["real provider remains unselected and outbound egress disabled", "docker-test remains prohibited"],
"acceptance_scenarios": ["valid typed request serializes without provider types", "secret/unknown/oversized/duplicate context is rejected", "response citations outside supplied excerpts are rejected", "unknown JSON fields are rejected", "fake returns explicit PROVIDER_UNAVAILABLE and no action/tool fields", "existing deterministic proposal fake remains compatible"],
"required_tests": ["unit", "HTTP contract", "adversarial validation", "go test", "go vet", "architecture preflight", "exact diff scope", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000047",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "4b8832766a979ae4ff69e921fc28f5f0d15b91b6",
"risk_tier": "E",
"intent": "Promote the exact merged provider-neutral cognitive advisory fake to docker-prod, refresh all project image provenance labels/manifests, and prove the internal endpoint has no provider egress or mutation behavior.",
"requirement_ids": ["ADR-0006", "DEV-000045", "DEV-000046", "USER-DOCKER-PROD-ONLY"],
"allowed_change_scope": {"modules": [], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000047-production-cognitive-advisory-fake.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**", "docs/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "existing production compose", "production artifact manifest v1"], "may_change": []},
"semantic_owner": "none-production-promotion",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["rebuild and replace project-owned containers on docker-prod from exact merged main", "refresh non-secret artifact manifest", "no provider credential or outbound provider call"],
"persistence_and_migrations": ["refresh external non-secret deployment manifest only"],
"failure_retry_restart": ["existing canonical volumes remain mounted", "failed promotion does not publish a new manifest", "fake returns PROVIDER_UNAVAILABLE after restart"],
"assumptions": ["current production settings are reused in process memory without printing", "docker-test remains prohibited"],
"acceptance_scenarios": ["all seven project images carry exact merged main revision", "production health remains healthy", "valid internal advisory request returns PROVIDER_UNAVAILABLE with empty text", "unknown provider override field is rejected", "cognitive worker has no database/executor/provider secret environment or mounts"],
"required_tests": ["architecture preflight", "exact diff scope", "CI", "production build/up", "artifact confirmation", "production health", "adversarial advisory smoke", "secret/mount inspection", "docker-test absence"],
"terminal_gap_states": ["BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000048",
"task_revision": 2,
"architecture_revision": "ADR-0006",
"base_repo_revision": "861438a4cae844edfe8a17615fb700d17c950d8e",
"risk_tier": "E",
"intent": "Expose creator-advisory-v1 through authenticated Creator Web, assembling trusted identity/scope fields server-side and adding a responsive advisory console that displays explicit provider-unavailable state without persistence, tools or egress.",
"requirement_ids": ["ADR-0006", "DEV-000046", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["cmd/creator-web/**", "deploy/docker-compose.prod.yml", "tests/creator_web/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000048-creator-advisory-console.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "cmd/cognitive-worker/**", "database/**", "security/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "CreatorSession", "Situation FocusBinding"], "may_change": []},
"semantic_owner": "none-gateway-and-display",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["Creator Web may call fixed internal cognitive-worker endpoint after Core session/focus validation", "no provider network egress or credentials"],
"persistence_and_migrations": [],
"failure_retry_restart": ["advisory text is not persisted", "session/focus failure prevents cognitive call", "provider unavailable is displayed explicitly", "no automatic retry"],
"assumptions": ["browser supplies only message and existing focus locators", "deterministic fake remains the only binding", "docker-test remains prohibited"],
"acceptance_scenarios": ["unauthenticated advisory denied before cognitive call", "browser cannot select Actor/Principal/Scope/binding/provider/context", "trusted request uses current session and validated focus", "the gateway accepts the full 16 KiB advisory message contract plus its JSON envelope", "UI labels advisory as non-canonical and unavailable", "message is not stored in local/session storage", "desktop/mobile layout and console are clean"],
"required_tests": ["unit", "gateway adversarial", "static UI", "go test", "go vet", "architecture preflight", "exact diff scope", "browser desktop/mobile", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000049",
"task_revision": 1,
"architecture_revision": "ADR-0006",
"base_repo_revision": "4de34ad8f8389d28b1120a3c6203ea32b8c7ff22",
"risk_tier": "E",
"intent": "Add and execute a repeatable read-only production audit for the authenticated Creator advisory boundary after DEV-000048 promotion, proving contract limits, authority-field rejection, explicit degraded behavior and cognitive-worker isolation without selecting or calling an external provider.",
"requirement_ids": ["ADR-0006", "DEV-000046", "DEV-000048", "INV-AUTH-001", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["tests/cognitive_advisory/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000049-post-advisory-production-audit.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "security/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "CreatorSession", "Situation FocusBinding"], "may_change": []},
"semantic_owner": "none-audit-only",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["read production runtime/container metadata", "issue an ephemeral Creator session and advisory request", "no database mutation, external provider egress or credential persistence"],
"persistence_and_migrations": [],
"failure_retry_restart": ["audit fails closed on missing focus or credential", "audit never prints credentials or session tokens", "advisory content remains ephemeral and is not retried"],
"assumptions": ["production contains at least one active Creator-owned FocusBinding", "Creator credential remains mounted as a Docker secret", "docker-test remains prohibited"],
"acceptance_scenarios": ["authenticated 16 KiB advisory returns only PROVIDER_UNAVAILABLE with empty text and citations", "unauthenticated advisory is denied", "browser authority-field injection is rejected", "the audit sentinel is absent from cognitive-worker logs", "cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount", "Creator console exposes the advisory UI", "production revision and health remain valid"],
"required_tests": ["PowerShell parse", "architecture preflight", "exact diff scope", "production advisory audit", "production artifact confirmation", "production health", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000050",
"task_revision": 1,
"architecture_revision": "ADR-0007",
"base_repo_revision": "00965f20e0c9a0e15a9444c72c4208f328834e08",
"risk_tier": "E",
"intent": "Implement the fixed OpenAI Responses API Creator advisory adapter with standard-library HTTP and adversarial fake-transport tests, without wiring runtime configuration, adding a real credential, enabling external egress or changing the provider-neutral contract.",
"requirement_ids": ["ADR-0006", "ADR-0007", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["adapters/models/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000050-openai-advisory-adapter.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0007"], "may_change": []},
"semantic_owner": "none-provider-adapter",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["adapter can construct one fixed-origin HTTPS request when invoked by a later authorized runtime task", "this task invokes only local fake transports and grants no runtime/provider credential or production egress"],
"persistence_and_migrations": [],
"failure_retry_restart": ["one call and no retry", "deadline maps to TIMEOUT", "transport/status failure maps to PROVIDER_UNAVAILABLE", "refusal maps to CONTENT_REJECTED", "invalid or executable provider response maps to INVALID_PROVIDER_RESPONSE", "no request or response is persisted"],
"assumptions": ["Mode A permits message-only payload and forbids all context excerpts", "fixed binding is openai-responses-gpt-5.4-mini-2026-03-17 revision 1", "docker-test remains prohibited"],
"acceptance_scenarios": ["request uses only fixed origin/path/model/instructions/message/store=false/reasoning low and output limit", "authority/routing fields and context are absent from provider JSON", "redirects and ambient proxies are disabled", "unknown/tool output, citation annotations, wrong model and oversize/malformed responses fail closed", "refusal, timeout and unavailable mappings are explicit", "tests prove no automatic retry and no body logging", "no SDK or third-party dependency is added", "runtime remains deterministic fake only"],
"required_tests": ["unit", "adversarial fake transport", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000051",
"task_revision": 1,
"architecture_revision": "ADR-0007",
"base_repo_revision": "0ee9e4a8a11e4bf216007d288b07d74b4049cac5",
"risk_tier": "E",
"intent": "Add a fail-closed optional OpenAI advisory endpoint to cognitive-worker that can only initialize from a bounded secret file and remains unavailable by default, with no production compose, credential, egress or active Creator Web binding change.",
"requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000050", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["cmd/cognitive-worker/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000051-openai-advisory-runtime-gate.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/creator-web/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "OpenAIAdvisoryAdapter", "ADR-0007"], "may_change": []},
"semantic_owner": "none-cognitive-runtime-gate",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["cognitive-worker code may read one explicitly configured OpenAI secret file and invoke the fixed adapter when a later deployment mounts it", "current production compose and Creator Web binding remain unchanged, so no provider call is activated"],
"persistence_and_migrations": [],
"failure_retry_restart": ["missing configuration keeps endpoint unavailable", "configured unreadable/empty/oversized secret fails startup", "invalid request fails before provider", "provider failures remain typed and are not retried", "restart loses no advisory state"],
"assumptions": ["the OpenAI secret file is absent from current production", "Creator Web continues to call deterministic endpoint", "docker-test remains prohibited"],
"acceptance_scenarios": ["default startup has no provider and deterministic fake remains usable", "OpenAI endpoint returns 503 before parsing/requesting when provider is disabled", "only a bounded secret file can initialize the adapter", "direct environment credential is unsupported", "strict request decoding and provider-neutral response encoding are preserved", "health/log responses expose no secret", "no deploy or Creator Web path changes activate egress"],
"required_tests": ["unit", "handler adversarial", "secret-file boundary", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000052",
"task_revision": 1,
"architecture_revision": "ADR-0007",
"base_repo_revision": "61f349ec409ea399149184ba3bdef79e52409ba0",
"risk_tier": "E",
"intent": "Promote the exact merged disabled-by-default OpenAI runtime gate to docker-prod, refreshing all project image provenance while proving no OpenAI credential/mount/egress activation, the OpenAI endpoint remains unavailable and deterministic advisory plus production health remain intact.",
"requirement_ids": ["ADR-0007", "DEV-000045", "DEV-000051", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["dev/ACTIVE_TASK.json", "dev/tasks/DEV-000052-promote-disabled-openai-gate.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "deploy/**", "docs/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0007", "production artifact provenance"], "may_change": []},
"semantic_owner": "none-promotion-only",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["rebuild and replace the seven project-owned containers on docker-prod from exact merged main", "refresh external deployment manifests", "no OpenAI secret, provider call, egress route or Creator Web binding"],
"persistence_and_migrations": [],
"failure_retry_restart": ["existing canonical volumes and secrets remain mounted unchanged", "failed promotion does not publish a new manifest", "OpenAI endpoint remains 503 and deterministic advisory remains available after restart"],
"assumptions": ["current production contains no OPENAI_API_KEY or OPENAI_API_KEY_FILE configuration", "docker-test remains prohibited"],
"acceptance_scenarios": ["all seven project images carry exact merged main revision", "production health remains healthy", "OpenAI endpoint returns 503", "deterministic advisory returns PROVIDER_UNAVAILABLE", "cognitive worker has no OpenAI/provider credential environment or mount", "Creator Web continues routing to deterministic endpoint"],
"required_tests": ["architecture preflight", "exact diff scope", "CI", "exact artifact confirmation", "production health", "disabled OpenAI endpoint smoke", "deterministic advisory smoke", "cognitive isolation inspection"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000053",
"task_revision": 1,
"architecture_revision": "ADR-0007",
"base_repo_revision": "8318194cd076acb7243384fa9fe633712d9f358d",
"risk_tier": "E",
"intent": "Add server-selected Creator Web advisory routing for deterministic or OpenAI Mode A, expose an authenticated policy view, require per-request egress acknowledgement for OpenAI, and render a clear responsive retention disclosure while keeping the production default deterministic and provider selection unavailable to the browser.",
"requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000051", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["cmd/creator-web/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0007", "CreatorSession", "Situation FocusBinding"], "may_change": []},
"semantic_owner": "none-gateway-and-display",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["server configuration may select the already accepted OpenAI Mode A internal endpoint", "browser may acknowledge egress for one request but cannot select provider/model/binding", "production default remains deterministic without configuration change"],
"persistence_and_migrations": [],
"failure_retry_restart": ["missing/invalid acknowledgement denies OpenAI request before cognitive call", "invalid configured mode fails startup", "advisory and acknowledgement are not persisted", "no automatic retry", "restart restores configured mode only"],
"assumptions": ["production does not set ADVISORY_BINDING_MODE and therefore remains deterministic", "OpenAI credential remains absent", "docker-test remains prohibited"],
"acceptance_scenarios": ["authenticated policy endpoint reports server-selected mode and immutable binding metadata", "deterministic mode retains current payload and behavior without acknowledgement", "OpenAI mode requires explicit per-request acknowledgement and routes only to fixed OpenAI endpoint/binding", "browser cannot supply mode/model/binding/retention policy", "completed and typed failure responses are validated fail closed", "UI displays retention/egress disclosure and checkbox only in OpenAI Mode A", "acknowledgement and message are not stored", "desktop/mobile layout and console remain clean"],
"required_tests": ["unit", "gateway adversarial", "static UI", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "browser desktop/mobile", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
+121
View File
@@ -0,0 +1,121 @@
# Cognitive provider proposal — Creator advisory V1
Status: advisory boundary accepted by `ADR-0006`. Provider traffic, credentials, external data egress and deployment remain unaccepted.
## 1. Product slice
The first real cognitive feature should be `creator-advisory-v1`: an authenticated Creator asks a question and receives an advisory text response. The response is explicitly non-canonical and cannot itself create or change a Goal, Task, Commitment, Prospective item, Authority grant, Capability binding, ActionProposal or external effect.
This slice is useful before autonomous behavior: Domovoy can explain current information and help formulate a possible next step while every durable or external change remains an explicit existing Creator command through its semantic owner and, where required, Commit/Executor.
## 2. Typed boundary
The provider-neutral Cognitive Gateway request should contain only:
- contract version `creator-advisory-v1`;
- request ID generated by the gateway;
- authenticated Actor ID, Principal ID and Scope ID supplied by the trusted server, never chosen by the browser;
- user message as bounded UTF-8 text;
- a bounded list of typed context excerpts with source kind, source ID, revision, sensitivity class and exact text;
- locale and response-size limit;
- provider binding ID and revision resolved by Capability, not supplied by the browser.
The response should contain:
- request ID and binding revision;
- advisory text;
- references only to supplied context excerpt IDs;
- finish reason from a closed enum: `COMPLETED | LENGTH_LIMIT | CONTENT_REJECTED | PROVIDER_UNAVAILABLE | TIMEOUT | INVALID_PROVIDER_RESPONSE`;
- provider usage counters when available, treated as operational metadata;
- no tool call, executable command, authority claim or canonical object mutation.
Free-form provider JSON must not cross into Core contracts. A provider adapter translates its wire response into this closed gateway response and rejects unknown executable/tool fields.
## 3. Data policy
V1 should be data-minimal:
- allowed by default: the current user message plus context the authenticated Creator explicitly selected for this request;
- denied by default: passwords, tokens, secret files, credential paths, environment variables, private keys, database connection strings, raw logs, browser storage, notification credentials and backup recovery material;
- no automatic repository, filesystem, email, calendar, network or database crawling;
- no implicit conversation history or provider-side memory;
- no full canonical table dump; each context excerpt is bounded, typed and scope-checked by its owner-facing query before gateway assembly;
- request and response bodies are not written to ordinary application logs;
- persistence is absent in the first slice. If conversation history is later requested, it requires an Artifact/Epistemics ownership and retention decision rather than provider memory.
Provider retention/training behavior is a deployment acceptance condition. A binding may become active only when its configured data-handling mode matches the accepted deployment policy.
## 4. Authority and isolation
The cognitive worker remains outside canonical ownership and receives no database role, Commit Executor credential, notification writer token, Creator password/session signing key or filesystem secret beyond its single provider credential.
Creator Web calls a server-side gateway endpoint after session validation. The browser never receives the provider key, provider base URL, model identifier override, system instruction or binding secret reference. The cognitive worker may call only the fixed origin in the active Capability binding; redirects, browser-supplied URLs and ambient proxy credentials are denied.
The advisory response is displayed as untrusted assistance. It is never automatically converted to an ActionProposal. A later “use this suggestion” flow must parse into a typed owner command and obtain explicit Creator confirmation under a separate task.
## 5. Deterministic controls
Before a provider call:
- require a valid Creator session and exact Scope binding;
- enforce request and per-excerpt byte limits, excerpt count, total context limit and UTF-8 validity;
- reject secret-classified or unknown-classified context;
- resolve an active Capability binding and exact revision;
- apply a fixed timeout, concurrency ceiling and per-Principal rate budget;
- build fixed system instructions in server code, not from browser fields.
After a provider call:
- cap response bytes and validate UTF-8;
- reject tool calls, executable fields and unknown response shapes;
- allow citations only to supplied excerpt IDs;
- report timeout/provider failure distinctly; do not fabricate an answer;
- do not silently retry, because a second paid request is a distinct attempt even though no external world mutation occurs.
## 6. Provider binding and secrets
Capability owns the binding lifecycle and health, including provider adapter kind, fixed HTTPS origin, model identifier, data-handling mode, limits and secret reference. Provider-specific wire types stay under `adapters/models`; domain packages do not import a provider SDK.
The provider credential is generated or supplied outside Git and PostgreSQL, mounted read-only only into the cognitive worker, and referenced by an opaque secret reference. It is absent from Creator responses, logs, task packets, image layers, health payloads, browser storage and cognitive context.
V1 should use the standard library HTTP client unless an accepted provider requires functionality that cannot be implemented safely without a dependency. TLS verification is mandatory; redirects are disabled; the origin is fixed by the binding.
## 7. Evaluation and promotion
Promotion follows `fake → offline evaluation → shadow (optional) → explicit canary → active`:
1. Keep the deterministic fake as the contract test oracle and degraded fallback indicator, not as a fabricated real answer.
2. Run a versioned, secret-free evaluation corpus covering scope leakage, prompt injection, secret requests, unsupported citations, oversized input, timeout and malformed responses.
3. A shadow provider result is never shown or committed and must use only data approved for shadow processing.
4. Canary is explicit for the Creator Principal, with a fixed binding revision and immediate disable switch.
5. Promotion records the evaluated adapter/model/binding revisions and thresholds. A stronger/newer model never inherits promotion automatically.
6. Rollback retires the binding and returns explicit `PROVIDER_UNAVAILABLE`; continuity, Commit and existing deterministic product commands remain operational.
## 8. Failure and observability
Operational telemetry may record request ID, binding ID/revision, timings, byte counts, finish reason, HTTP status class and usage totals. It must not record prompt, context, response or credentials. Provider 429/5xx, timeout, invalid TLS, redirect and invalid response are separate health evidence.
There is no durable semantic obligation to finish an advisory request after process loss. The caller may explicitly submit a new request. Background retry queues and unmanaged goroutines are not introduced.
## 9. Implementation slices after acceptance
1. Accept a decision record specifying the contract, deployment data policy and initial provider binding class.
2. Add provider-neutral gateway types and fake contract tests without changing canonical modules.
3. Add one provider adapter using fixed-origin HTTPS and an external secret file; add adversarial fake-server tests.
4. Add authenticated Creator advisory endpoint and UI with no persistence or action conversion.
5. Add Capability health/binding deployment, evaluation evidence and canary controls.
6. Deploy on `docker.cin.su`, mount the provider secret only into cognitive worker, then verify no-LLM degraded operation and rollback.
## 10. Decisions requiring explicit acceptance
Implementation remains blocked until the Creator explicitly accepts:
1. advisory-only first slice, with no tools and no automatic actions;
2. initial provider/API and model family;
3. whether any user/context content may leave the private infrastructure;
4. provider retention/training policy required for the binding;
5. initial hard limits for message/context/output and request rate;
6. no server-side conversation persistence in V1;
7. evaluation/canary thresholds and acceptable operating cost.
No choice above is inferred from possession of credentials or broad machine access.
+2
View File
@@ -24,3 +24,5 @@ Each privileged runtime gets only its required secret/capability bindings. `comm
Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together.
The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile.
Prospective notification delivery uses two unrelated random tokens. The write token is stored only in external Docker volume `assistant_notification_writer_secret`, mounted read-only into `commit-runtime` and `notification-receiver`. The read token is stored in external Docker volume `assistant_notification_reader_secret`, mounted read-only only into `notification-receiver`; its workstation copy is CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\notification-receiver-read-key.dpapi.json`. Only that ciphertext is backed up to `\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json`. Plaintext tokens must not enter compose environment, Git, PostgreSQL, task arguments, logs, browser storage, or NAS.
+15
View File
@@ -14,6 +14,21 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/
The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed.
Project-built production images receive `org.opencontainers.image.revision` and `org.opencontainers.image.source` OCI labels. Promotion must set `ASSISTANT_SOURCE_REVISION` to the full merged `origin/main` SHA before `docker compose build/up`; the default `unknown` is intentionally rejected by artifact confirmation. After all project containers are running, execute:
```powershell
$revision = git rev-parse origin/main
powershell -ExecutionPolicy Bypass -File .\deploy\windows\Confirm-DomovoyProductionArtifact.ps1 -Revision $revision -DockerContext docker-prod
```
The confirmation script accepts the promotion only when every project-built container has that revision label, then atomically records its exact Docker image ID in `%LOCALAPPDATA%\Domovoy\monitoring\deployment-manifest.json` and the private NAS monitoring directory. Production health compares every running label and image ID to this manifest and becomes degraded on drift. The manifest is non-secret operational evidence; it is not canonical Assistant state.
The first external operation adds `assistant-notification-receiver-1` on production host port `18081`. Caddy applies `deploy/caddy/assistent.caddy`, routing only `https://assistent.cin.su/delivery/*` to that port and all other paths to Creator Web on `18080`. The receiver has an isolated durable volume and no Assistant database access. Commit success means an immutable receiver receipt exists; Windows display and human reading are not Commit outcomes.
Migrations `010_capability_notification_binding.sql` and `011_commit_notification_envelope.sql` must be applied before the new runtimes start. The initial Capability binding is owner-local for `domovoy-creator-principal`, revision 1, capability `prospective-notification-delivery`, origin `https://assistent.cin.su`, delivery path `/delivery/v1/deliveries`, receipt path `/delivery/v1/receipts`, protocol `IDEMPOTENT_RECEIPT_V1`, and secret reference `domovoy-notification-write-v1`.
`deploy/windows/Invoke-DomovoyNotificationReceiver.ps1` polls the receipt list with the read-only credential, displays up to three unseen local notifications, and records only rebuildable receipt IDs under `%LOCALAPPDATA%\Domovoy`. `Register-DomovoyNotificationTask.ps1` registers the current-user task every two minutes and at logon. It requires an interactive session for display and never uses `docker-test`.
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs.
+4 -9
View File
@@ -1,6 +1,6 @@
# First external operation proposal
Status: **PROPOSED — NOT ACCEPTED**
Status: **ACCEPTED by ADR-0005**
Task: `ARCH-000005`, revision 1
@@ -12,7 +12,7 @@ Choose the smallest useful real-world operation that exercises Domovoy's existin
`Creator session → owner validation → live Work justification → Capability binding revision → Authority validation → durable Commit → isolated Executor → external receipt → durable attempt/outcome → read-only reconciliation`
This proposal changes no runtime, contract, schema, architecture authority, credential, endpoint, or deployment state. Its Tier E task packet selects itself in `dev/ACTIVE_TASK.json` only so fail-closed promotion CI validates the proposal's exact diff.
The proposal task changed no runtime, contract, schema, credential, endpoint, or deployment state. Its recommendation and the local Windows display channel were subsequently accepted by `ADR-0005` under task `ARCH-000006`.
## Current gap
@@ -141,11 +141,6 @@ The endpoint origin and paths come only from the validated binding. Redirects ar
6. Add the explicit Creator confirmation flow and action-history rendering.
7. Configure one concrete compatible destination, generate its credential outside Git, deploy on `docker-prod`, and run exact-artifact production smoke.
## Decision still required
## Resolution
Implementation is `BLOCKED_BY_SPEC_GAP` until both are explicitly accepted:
1. the typed operation and safety protocol in this proposal;
2. one concrete destination base URL that implements the receipt protocol, including where the resulting notification is ultimately shown to the Creator.
No credential should be generated before that destination exists.
`ADR-0005` accepts this typed operation and protocol. The first compatible receiver will run on `docker-prod` behind the existing `https://assistent.cin.su` origin, and a separately authorized local Windows agent will show accepted receipts to the Creator. Receiver acceptance is the external Commit outcome; operating-system display and human reading are not.
+6 -4
View File
@@ -21,8 +21,10 @@ Status is based on executed repository tests and Docker-stack verification on 20
Foundation V1 is eligible for freeze. Creator session authentication was also completed as DEV-000019 and does not carry Authority.
Post-foundation product work is intentionally separate:
The three original post-foundation gates are complete:
1. Choose and specify the first real external operation before adding a production Executor credential.
2. Build Creator API/UI only through validated Identity, Authority and Commit boundaries.
3. Add exact diff-to-task scope comparison to promotion CI when the first Git baseline exists.
1. `ADR-0005` specifies the first real external operation, prospective notification delivery.
2. DEV-000024 through DEV-000032 and DEV-000040 through DEV-000043 provide the authenticated Creator API/UI and external operation only through the validated Identity, Authority, Continuity, Capability and Commit boundaries.
3. `ADR-0004` and ARCH-000004 implement exact diff-to-task scope comparison.
DEV-000044 re-ran local architecture/unit checks and the production Creator-to-receiver smoke on 2026-08-16. The deployed stack and Windows operational tasks were healthy. Current product status and explicitly uncommitted future scope are recorded in `docs/PROJECT_STATUS.md`.
+126
View File
@@ -0,0 +1,126 @@
# OpenAI advisory binding proposal V1
Status: **ACCEPTED by ADR-0007 — not production-enabled**
Task: `ARCH-000009` revision 1
Parent boundary: `ADR-0006`
## Recommendation
Use OpenAI only as the first replaceable provider behind the existing `creator-advisory-v1` Cognitive Gateway boundary:
- fixed origin: `https://api.openai.com`;
- endpoint: `POST /v1/responses`;
- model snapshot: `gpt-5.4-mini-2026-03-17`;
- reasoning effort: `low` for the initial evaluation and canary;
- `store: false`, synchronous response only, no background mode;
- no tools, function calls, web/file search, MCP, code interpreter, hosted shell, images, audio, files, conversations or `previous_response_id`;
- one request produces one ephemeral advisory response and no provider-side memory.
OpenAI's current guidance recommends the Responses API for modern reasoning workflows and describes `gpt-5.6-terra` as the balance of capability and cost. This proposal nevertheless selects the published immutable `gpt-5.4-mini-2026-03-17` snapshot: ADR-0006 requires a model revision not to inherit promotion silently, while the current Terra page does not publish a dated snapshot. A later Terra snapshot requires a new binding revision and evaluation rather than alias drift.
Official sources checked on 2026-08-16:
- [OpenAI model guidance](https://developers.openai.com/api/docs/guides/latest-model)
- [GPT-5.4 mini model and snapshot](https://developers.openai.com/api/docs/models/gpt-5.4-mini)
- [OpenAI API data controls](https://developers.openai.com/api/docs/guides/your-data#default-usage-policies-by-endpoint)
## Data decision
OpenAI states that API data is not used for training unless the customer opts in. Under default controls, however, abuse-monitoring logs can include prompts and responses and may be retained for up to 30 days. Zero Data Retention and Modified Abuse Monitoring require OpenAI approval. `store: false` avoids Responses application-state storage but does not by itself remove default abuse-monitoring retention.
Therefore the initial binding has two distinct, non-interchangeable modes:
### Mode A — default retention, first canary
Permitted:
- the Creator's text entered explicitly for this single advisory request;
- a fixed system instruction containing no user or project data;
- locale and output-size controls.
Forbidden:
- automatic context excerpts, even when marked `PRIVATE_ALLOWED`;
- credentials, tokens, passwords, private keys, connection strings or authentication headers;
- Actor, Principal, Scope, session, workspace, Goal, Task, provider-binding or internal request identifiers;
- files, images, database rows, logs, browser history, conversation history and ambient machine context.
The UI must disclose before each request that the explicitly entered text leaves the private network and may be retained by the provider for abuse monitoring for up to 30 days. Submission is the per-request acknowledgement; it is not durable blanket consent.
### Mode B — approved ZDR/MAM project
Enabling `PRIVATE_ALLOWED` context requires separate evidence that the exact OpenAI project has the approved control enabled, a new binding revision, a new evaluation, and explicit Creator promotion. Even then, `SECRET` and `UNKNOWN` remain forbidden, and tools/background/conversations remain disabled.
## Provider request mapping
The adapter constructs provider JSON itself. Provider-specific types stay under `adapters/models`; no provider type crosses into contracts or domain packages.
The request contains only:
- fixed snapshot model;
- fixed non-executable system instruction: answer as non-canonical advice, state uncertainty, never claim an action occurred, never request or expose secrets;
- the single Creator message;
- `store: false`;
- reasoning effort `low`;
- an output limit consistent with the existing 16 KiB response contract.
The adapter must not serialize trusted routing/authority fields into the provider prompt. Those fields authorize local scope selection only; they are not model context.
## Response mapping
The adapter accepts text output only. It rejects:
- tool calls or executable items;
- unknown output item kinds;
- response identifiers offered as future conversation state;
- output larger than the contract limit;
- invalid UTF-8, malformed JSON, redirects or an origin change.
Provider refusal maps to `CONTENT_REJECTED`; deadline expiry to `TIMEOUT`; transport or availability failure to `PROVIDER_UNAVAILABLE`; malformed/forbidden provider output to `INVALID_PROVIDER_RESPONSE`. No failure is retried automatically and no partial output is shown after a failed validation.
## Isolation and transport
- The API key is created in a dedicated OpenAI project and mounted read-only only into `cognitive-worker` as a Docker secret file.
- The secret is absent from Git, PostgreSQL, image layers, environment variables, logs, health responses, browser storage and other containers.
- Standard-library HTTPS client, normal certificate validation, fixed SNI/Host, disabled redirects and disabled ambient proxy variables.
- Egress is limited operationally to the fixed OpenAI API origin; DNS/IP changes are treated as infrastructure, not browser-controlled routing.
- Request bodies, response bodies, authorization headers and message hashes are not logged. Metrics contain binding revision, latency bucket, finish reason and token counters only.
## Limits and cost controls
- 30-second hard deadline inherited from ADR-0006;
- no automatic retry;
- 5 requests per minute and 50 requests per day for the single Creator during canary;
- maximum five simultaneous in-flight requests is reduced to one during canary;
- provider output is capped by both provider token limit and the 16 KiB validated gateway limit;
- OpenAI project spend limit and alert must be configured before the key is mounted;
- any rate, model, prompt or spend-limit change creates a new binding revision.
Official pricing is mutable and is operational metadata rather than architecture. At review time the documented GPT-5.4 mini price is USD 0.75 per million input tokens and USD 4.50 per million output tokens; deployment must re-check the provider dashboard and project limit.
## Evaluation and promotion
1. Implement the adapter against an adversarial local fake server; do not add the production key.
2. Test fixed origin, redirect denial, proxy denial, strict JSON, timeout, body limits, forbidden output kinds and log redaction.
3. Run an offline corpus containing only synthetic/public advisory cases. Record exact snapshot, prompt revision, expected safety properties, latency and estimated cost.
4. Mount a dedicated project key only after Mode A disclosure and spend controls exist.
5. Run shadow calls whose results are never shown, using synthetic/public inputs only.
6. Creator explicitly promotes the exact binding revision to a small canary.
7. Canary permits only explicit message text, one in-flight request, no automatic context and immediate rollback.
8. Broader use requires recorded evaluation evidence and a separate promotion decision.
## Rollback and degraded operation
Rollback disables/retires the OpenAI binding and restores `deterministic-fake` as the only active binding. The UI returns explicit `PROVIDER_UNAVAILABLE`. Creator authentication, Situation, Continuity, Commit, notification delivery and all canonical state remain available. Removing provider cognition never requires a database migration or state repair because advisory requests and responses are not persisted.
## Decisions still requiring explicit acceptance
Before implementation:
1. accept OpenAI Responses API as the first provider;
2. accept `gpt-5.4-mini-2026-03-17` as the first immutable model snapshot;
3. accept Mode A's possible provider abuse-monitoring retention up to 30 days, or require ZDR approval before any real call;
4. accept the per-request disclosure wording and initial rate/spend policy.
Before production egress, a dedicated OpenAI API project and API key must exist. A ChatGPT/Codex subscription is not evidence that an API key or API billing project is available.
+39
View File
@@ -0,0 +1,39 @@
# Assistant project status
Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1.
## Delivered
- Foundation V1 is frozen and all exit criteria pass.
- The single Creator account authenticates at `https://assistent.cin.su`; authentication does not imply Authority.
- Creator can inspect the bounded operational view, register and satisfy Continuity prospective items, inspect action history, and explicitly request the accepted notification operation.
- `deliver-prospective-notification-v1` executes only through Capability binding, current Authority and Commit/Executor validation. Its success evidence is an immutable receiver receipt.
- Production runs on `docker.cin.su`; the project does not use the shared test Docker host.
- PostgreSQL backup/restore, encrypted off-host backup, production health monitoring and the current-user Windows notification projection are installed and healthy.
## Evidence from DEV-000044
- `go test ./...`, `go vet ./...`, architecture preflight/checks and PowerShell parsing pass.
- `Test-DomovoyProductionHealth.ps1` reports `healthy` for the production stack, HTTPS/DNS/TLS, backups and registered Windows tasks.
- `tests/creator_commit/verify-remote.ps1` passes the authenticated Creator → Commit → notification receiver receipt flow on `docker-prod`.
- The smoke harness was corrected to preserve HTTP Authorization headers under Windows PowerShell 5.1 native-command argument handling.
## Remaining work requiring a new task decision
1. **Cognitive provider:** the cognitive worker intentionally remains the deterministic fake. Selecting a real model/provider, data policy and allowed proposal contract requires a separate capability/architecture decision; it must not gain canonical mutation authority.
2. **Product breadth:** notification delivery is the only accepted real external operation. Any additional operation needs its own proposal, authority model, idempotency/reconciliation semantics and Capability binding.
3. **Artifact and Epistemics product behavior:** their ownership boundaries exist, but no product task has yet specified durable user-facing workflows for them.
DEV-000045 closes the DEV-000044 artifact-provenance finding for new promotions by stamping the merged Git revision into OCI labels, recording exact promoted image IDs outside the repository, and making production monitoring reject label or digest drift. Images that predate DEV-000045 remain historically unverifiable from the image alone.
ADR-0006 and DEV-000046 establish the provider-neutral, non-canonical Creator advisory boundary. DEV-000048 connects it to authenticated Creator Web and adds the advisory console; until a provider/data-egress decision is accepted, it deliberately reports `PROVIDER_UNAVAILABLE` and sends nothing outside the private runtime topology.
DEV-000049 adds and executes a repeatable production audit for that boundary. It proves that an authenticated 16 KiB message reaches only the deterministic unavailable binding, unauthenticated and browser-supplied authority fields fail closed, the request sentinel is absent from cognitive-worker logs, and the cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount.
ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026-03-17` and Mode A per-request retention disclosure as the first real provider binding class. DEV-000050 implements its standard-library adapter and adversarial fake-transport tests: fixed origin/payload, message-only egress, no tools or provider memory, redirect/proxy denial, no retry, strict output mapping and fail-closed timeout/refusal/unavailable/invalid states. The adapter is not wired into the runtime; production remains on `deterministic-fake` with no provider credential or egress.
DEV-000051 adds an optional cognitive-worker runtime gate for that adapter. It is disabled when `OPENAI_API_KEY_FILE` is absent, rejects direct environment credentials, bounds and validates the secret file, and returns 503 before parsing an OpenAI request while disabled. Production configuration and Creator Web routing are unchanged, so the gate is not active and no provider egress occurs.
DEV-000052 promotes the disabled gate without adding a credential, mount or provider environment variable. DEV-000053 adds the server-selected Creator Web routing policy: deterministic behavior remains the default, while the accepted OpenAI Mode A route can only be selected by server configuration and requires a fresh per-request egress acknowledgement. The browser receives authenticated binding metadata for disclosure but cannot select the provider, model or binding. The message and acknowledgement are not persisted.
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
+54
View File
@@ -0,0 +1,54 @@
package domain
import (
"errors"
"net/url"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
var ErrInvalidBinding = errors.New("invalid capability binding")
func ValidateBinding(binding capability.Binding) error {
if binding.ID == "" || binding.CapabilityID == "" || binding.OwnerPrincipalID == "" ||
binding.Revision < 1 || binding.Protocol != capability.ProtocolIdempotentReceiptV1 ||
binding.SecretRef == "" || binding.VerifiedAtUnixMillis <= 0 {
return ErrInvalidBinding
}
if binding.State != capability.BindingActive && binding.State != capability.BindingRetired {
return ErrInvalidBinding
}
if binding.Health != capability.HealthAvailable && binding.Health != capability.HealthDegraded && binding.Health != capability.HealthUnavailable {
return ErrInvalidBinding
}
origin, err := url.Parse(binding.HTTPSOrigin)
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") {
return ErrInvalidBinding
}
if !fixedPath(binding.DeliveryPath) || !fixedPath(binding.ReceiptPath) || binding.DeliveryPath == binding.ReceiptPath {
return ErrInvalidBinding
}
return nil
}
func UsableBinding(binding capability.Binding) error {
if err := ValidateBinding(binding); err != nil || binding.State != capability.BindingActive || binding.Health != capability.HealthAvailable {
return ErrInvalidBinding
}
return nil
}
func RetireBinding(binding capability.Binding) (capability.Binding, error) {
if err := ValidateBinding(binding); err != nil || binding.State != capability.BindingActive {
return capability.Binding{}, ErrInvalidBinding
}
binding.State = capability.BindingRetired
binding.Revision++
return binding, nil
}
func fixedPath(value string) bool {
return strings.HasPrefix(value, "/") && value != "/" && !strings.HasSuffix(value, "/") &&
!strings.Contains(value, "//") && !strings.ContainsAny(value, "?#\\") && !strings.Contains(value, "..")
}
+45
View File
@@ -0,0 +1,45 @@
package domain
import (
"testing"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
)
func validBinding() capability.Binding {
return capability.Binding{ID: "binding-1", CapabilityID: "prospective-notification-delivery", OwnerPrincipalID: identity.PrincipalID("owner-1"), Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/receipts", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}
}
func TestValidateBinding(t *testing.T) {
b := validBinding()
b.ReceiptPath = "/delivery/v1/lookup"
if err := ValidateBinding(b); err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*capability.Binding){
func(v *capability.Binding) { v.HTTPSOrigin = "http://assistent.cin.su" },
func(v *capability.Binding) { v.DeliveryPath = "//evil" },
func(v *capability.Binding) { v.SecretRef = "" },
func(v *capability.Binding) { v.Revision = 0 },
func(v *capability.Binding) { v.Health = capability.HealthDegraded },
} {
candidate := b
mutate(&candidate)
if err := UsableBinding(candidate); err == nil {
t.Fatalf("expected rejection: %+v", candidate)
}
}
}
func TestRetireBindingIncrementsRevision(t *testing.T) {
b := validBinding()
b.ReceiptPath = "/delivery/v1/lookup"
retired, err := RetireBinding(b)
if err != nil {
t.Fatal(err)
}
if retired.State != capability.BindingRetired || retired.Revision != 2 {
t.Fatalf("unexpected retired binding: %+v", retired)
}
}
+4
View File
@@ -4,10 +4,14 @@ import (
"context"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
)
type Repository interface {
SaveHealth(context.Context, capability.Health) error
LoadHealth(context.Context, capability.CapabilityID) (capability.Health, error)
RebuildHealthProjection(context.Context) (int64, error)
SaveBinding(context.Context, capability.Binding) error
LoadBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error)
LoadActiveBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
}
+12
View File
@@ -1,6 +1,7 @@
package domain
import (
"encoding/hex"
"errors"
"strings"
@@ -71,11 +72,22 @@ func allowedOperation(proposal commitcontract.ActionProposal, context Authorizat
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
subject.FromState == continuity.ProspectiveDelivered && subject.ToState == continuity.ProspectiveSatisfied &&
strings.TrimSpace(subject.EvidenceRef) != ""
case context.Grant.Class == authority.GrantClassCreator && proposal.CapabilityID == commitcontract.CapabilityProspectiveNotification && proposal.Operation == commitcontract.OperationDeliverProspectiveNotification:
subject := proposal.NotificationSubject
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
subject.TemplateCode == commitcontract.ProspectiveDeliveredTemplateV1 && subject.BindingID != "" && subject.BindingRevision > 0 &&
strings.TrimSpace(subject.RenderedTitle) != "" && len(subject.RenderedTitle) <= 160 &&
strings.TrimSpace(subject.RenderedMessage) != "" && len(subject.RenderedMessage) <= 2048 && validDigest(subject.RequestDigest)
default:
return false
}
}
func validDigest(value string) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == 32 && value == strings.ToLower(value)
}
// Execute records UNKNOWN_OUTCOME rather than silently retrying an ambiguous
// external mutation.
func Execute(action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID, executor ports.Executor) (commitcontract.ActionAttempt, error) {
@@ -0,0 +1,46 @@
package domain
import (
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/authority"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/work"
)
type allowValidator struct{}
func (allowValidator) Validate(authority.AuthorizationContext) error { return nil }
func notificationProposal() commitcontract.ActionProposal {
return commitcontract.ActionProposal{IntentID: "intent", ActorID: "actor", CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: work.TaskTarget{TaskID: "task", GoalID: "goal", ScopeID: "scope", Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item", ExpectedRevision: 1, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: "binding", BindingRevision: 1, RenderedTitle: "Домовой", RenderedMessage: "Пора вернуться", RequestDigest: strings.Repeat("a", 64)}}
}
func notificationContext() AuthorizationContext {
var context AuthorizationContext
context.Binding.ActorID, context.Binding.PrincipalID, context.Binding.Active = "actor", "owner", true
context.Scope = authority.Scope{ID: "scope"}
context.Grant = authority.AuthorityGrant{ID: "grant", ActorID: "actor", PrincipalID: "owner", ScopeID: "scope", Class: authority.GrantClassCreator, Revision: 1, Active: true}
return context
}
func TestAuthorizeProspectiveNotification(t *testing.T) {
action, err := Authorize(notificationProposal(), notificationContext(), allowValidator{}, "authorized", 1)
if err != nil {
t.Fatal(err)
}
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
t.Fatal("operation changed")
}
}
func TestAuthorizeProspectiveNotificationRejectsInvalidEvidence(t *testing.T) {
for name, mutate := range map[string]func(*commitcontract.ActionProposal){"binding": func(p *commitcontract.ActionProposal) { p.NotificationSubject.BindingID = "" }, "digest": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RequestDigest = "AA" }, "message": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RenderedMessage = "" }, "operation": func(p *commitcontract.ActionProposal) { p.Operation = "arbitrary-webhook" }} {
t.Run(name, func(t *testing.T) {
proposal := notificationProposal()
mutate(&proposal)
if _, err := Authorize(proposal, notificationContext(), allowValidator{}, "authorized", 1); err == nil {
t.Fatal("expected denial")
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
package cognitive_advisory_test
import (
"encoding/json"
"strings"
"testing"
"git.cin.su/m/Assistant/adapters/models"
"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/identity"
"git.cin.su/m/Assistant/platform/ids"
)
func validRequest() cognitive.AdvisoryRequest {
return cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(ids.ID("request-1")), ActorID: identity.ActorID(ids.ID("actor")), PrincipalID: identity.PrincipalID(ids.ID("principal")), ScopeID: authority.ScopeID(ids.ID("scope")), Message: "What should I review?", Context: []cognitive.ContextExcerpt{{ID: cognitive.ContextExcerptID(ids.ID("excerpt-1")), SourceKind: "prospective-item", SourceID: "item-1", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Review tomorrow"}}, Locale: "en", MaxOutputBytes: 4096, ProviderBindingID: capability.CapabilityBindingID(ids.ID("fake-binding")), ProviderBindingRevision: 1}
}
func TestDeterministicAdvisoryIsExplicitlyUnavailable(t *testing.T) {
request := validRequest()
response, err := models.DeterministicAdvisoryUnavailable(request)
if err != nil {
t.Fatal(err)
}
if response.FinishReason != cognitive.FinishProviderUnavailable || response.AdvisoryText != "" {
t.Fatalf("unexpected fake response: %+v", response)
}
if err := models.ValidateAdvisoryResponse(request, response); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"ActionProposal", "ToolCall", "AuthorityGrant"} {
if strings.Contains(string(raw), forbidden) {
t.Fatalf("response contains forbidden field %s", forbidden)
}
}
}
func TestAdvisoryRejectsUnsafeContextAndCitations(t *testing.T) {
tests := []struct {
name string
mutate func(*cognitive.AdvisoryRequest)
}{
{"secret", func(r *cognitive.AdvisoryRequest) { r.Context[0].Sensitivity = cognitive.SensitivitySecret }},
{"unknown", func(r *cognitive.AdvisoryRequest) { r.Context[0].Sensitivity = cognitive.SensitivityUnknown }},
{"duplicate", func(r *cognitive.AdvisoryRequest) { r.Context = append(r.Context, r.Context[0]) }},
{"oversized-message", func(r *cognitive.AdvisoryRequest) { r.Message = strings.Repeat("x", models.MaxAdvisoryMessageBytes+1) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := validRequest()
test.mutate(&request)
if err := models.ValidateAdvisoryRequest(request); err == nil {
t.Fatal("unsafe request accepted")
}
})
}
request := validRequest()
response := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "answer", ReferencedExcerptIDs: []cognitive.ContextExcerptID{cognitive.ContextExcerptID(ids.ID("not-supplied"))}, FinishReason: cognitive.FinishCompleted}
if err := models.ValidateAdvisoryResponse(request, response); err == nil {
t.Fatal("unknown citation accepted")
}
}
@@ -0,0 +1,86 @@
[CmdletBinding()]
param(
[string]$DockerContext = 'docker-prod',
[string]$BaseURL = 'https://assistent.cin.su',
[string]$CoreContainer = 'assistant-core-runtime-1',
[string]$PostgresContainer = 'assistant-postgres-1',
[string]$CognitiveContainer = 'assistant-cognitive-worker-1'
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
function Get-ContainerEnvironment([string]$Container) {
$result = @{}
$inspect = ((& docker.exe --context $DockerContext inspect $Container) -join "`n") | ConvertFrom-Json
foreach ($entry in $inspect[0].Config.Env) {
$parts = $entry -split '=', 2
$result[$parts[0]] = if ($parts.Count -eq 2) { $parts[1] } else { '' }
}
return $result
}
function Invoke-StatusRequest([string]$Uri, [hashtable]$Headers, [string]$Body) {
$parameters = @{
Uri = $Uri
Method = 'Post'
ContentType = 'application/json'
SkipHttpErrorCheck = $true
}
if ($Headers.Count -gt 0) { $parameters.Headers = $Headers }
if ($Body) { $parameters.Body = $Body }
return Invoke-WebRequest @parameters
}
$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get
if ($health.status -ne 'ok' -or $health.runtime -ne 'creator-web') { throw 'Creator Web health is invalid' }
$page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get
if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' }
$coreEnvironment = Get-ContainerEnvironment $CoreContainer
$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
foreach ($name in $required) {
if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) {
throw "Core runtime is missing $name"
}
}
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' }
$sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest
$creatorPassword = $null
if (-not $issued.Token) { throw 'Creator session was not issued' }
$authorization = @{Authorization = "Bearer $($issued.Token)"}
$focusSql = "select session_id,workspace_id from situation.focus_bindings where active=true and actor_id='domovoy-creator-actor' order by session_id desc limit 1"
$focusLine = ((& docker.exe --context $DockerContext exec --env "PGPASSWORD=$($coreEnvironment['POSTGRES_PASSWORD'])" $PostgresContainer psql -U $coreEnvironment['POSTGRES_USER'] -d $coreEnvironment['POSTGRES_DATABASE'] -AtF '|' -c $focusSql) -join '').Trim()
if ($LASTEXITCODE -ne 0 -or $focusLine -notmatch '^[-a-zA-Z0-9._:]+\|[-a-zA-Z0-9._:]+$') { throw 'Active Creator focus is unavailable' }
$focus = $focusLine -split '\|', 2
$sentinelPrefix = "audit-$([guid]::NewGuid().ToString('N'))-"
$message = $sentinelPrefix + ('a' * ((16 * 1024) - $sentinelPrefix.Length))
$advisoryBody = @{message = $message; session_id = $focus[0]; workspace_id = $focus[1]} | ConvertTo-Json -Compress
$advisory = Invoke-RestMethod -Uri "$BaseURL/api/advisory" -Method Post -ContentType 'application/json' -Headers $authorization -Body $advisoryBody
if ($advisory.Version -ne 'creator-advisory-v1' -or $advisory.FinishReason -ne 'PROVIDER_UNAVAILABLE') { throw 'Advisory did not fail closed' }
if (-not [string]::IsNullOrEmpty([string]$advisory.AdvisoryText) -or ($null -ne $advisory.ReferencedExcerptIDs -and @($advisory.ReferencedExcerptIDs).Count -ne 0)) { throw 'Unavailable advisory contained generated content or citations' }
$unauthenticated = Invoke-StatusRequest "$BaseURL/api/advisory" @{} $advisoryBody
if ($unauthenticated.StatusCode -ne 401) { throw "Unauthenticated advisory status=$($unauthenticated.StatusCode)" }
$injectedBody = @{message = 'authority-injection'; session_id = $focus[0]; workspace_id = $focus[1]; ActorID = 'attacker'} | ConvertTo-Json -Compress
$injected = Invoke-StatusRequest "$BaseURL/api/advisory" $authorization $injectedBody
if ($injected.StatusCode -ne 400) { throw "Authority-field injection status=$($injected.StatusCode)" }
$cognitiveInspect = ((& docker.exe --context $DockerContext inspect $CognitiveContainer) -join "`n") | ConvertFrom-Json
$forbiddenEnvironment = @($cognitiveInspect[0].Config.Env | Where-Object { ($_ -split '=', 2)[0] -match '(?i)(POSTGRES|DATABASE|COMMIT|EXECUTOR|CREATOR|PASSWORD|TOKEN|PROVIDER|API_KEY)' })
if ($forbiddenEnvironment.Count -ne 0) { throw 'Cognitive worker has forbidden privileged/provider environment' }
$forbiddenMounts = @($cognitiveInspect[0].Mounts | Where-Object { $_.Destination -match '(?i)(secret|credential|token|postgres|docker)' })
if ($forbiddenMounts.Count -ne 0) { throw 'Cognitive worker has a forbidden credential or infrastructure mount' }
$recentLogs = ((& docker.exe --context $DockerContext logs --since 5m $CognitiveContainer 2>&1) -join "`n")
if ($recentLogs.Contains($sentinelPrefix)) { throw 'Advisory content leaked into cognitive-worker logs' }
$issued.Token = $null
$authorization.Clear()
$message = $null
$advisoryBody = $null
Write-Output 'DEV-000049 production Creator advisory boundary audit: PASS'
+31 -9
View File
@@ -4,6 +4,7 @@ $ErrorActionPreference = "Stop"
$coreContainer = "assistant-core-runtime-1"
$commitContainer = "assistant-commit-runtime-1"
$continuityContainer = "assistant-continuity-runtime-1"
$receiverContainer = "assistant-notification-receiver-1"
function Post-Json([string]$container, [string]$url, [object]$body) {
$json = $body | ConvertTo-Json -Depth 10 -Compress
@@ -28,7 +29,7 @@ function Creator-Post([string]$url, [object]$body, [string]$token) {
try {
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
docker --context $DockerContext cp $local "${commitContainer}:$remote" | Out-Null
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $CREATOR_TEST_SESSION" --post-file "$1" "$2"' sh $remote $url
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -qO- --header=Content-Type:application/json --header=$auth --post-file=$1 $2' sh $remote $url
if ($LASTEXITCODE -ne 0) { throw "Creator POST $url failed" }
return ($result -join "`n")
} finally {
@@ -38,7 +39,7 @@ function Creator-Post([string]$url, [object]$body, [string]$token) {
}
function Creator-Get([string]$url, [string]$token) {
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$1"' sh $url
$result = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -qO- --header=$auth $1' sh $url
if ($LASTEXITCODE -ne 0) { throw "Creator GET $url failed" }
return ($result -join "`n")
}
@@ -52,7 +53,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
docker --context $DockerContext cp $local "${commitContainer}:$remote" | Out-Null
$previous = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$result = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $CREATOR_TEST_SESSION" --post-file "$1" "$2"' sh $remote $url 2>&1) -join "`n"
$result = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $commitContainer sh -c 'auth=Authorization:\ Bearer\ $CREATOR_TEST_SESSION; IFS=; wget -S -O /dev/null --header=Content-Type:application/json --header=$auth --post-file=$1 $2' sh $remote $url 2>&1) -join "`n"
$ErrorActionPreference = $previous
if ($result -notmatch "HTTP/1\.[01] $status") { throw "Creator POST $url did not return $status" }
} finally {
@@ -61,12 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
}
}
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c '
token="$CREATOR_BOOTSTRAP_TOKEN"
if [ -n "$CREATOR_BOOTSTRAP_TOKEN_FILE" ]; then token=$(cat "$CREATOR_BOOTSTRAP_TOKEN_FILE"); fi
if [ -z "$token" ]; then exit 7; fi
wget -qO- --header="Authorization: Bearer $token" --post-data="" "$1/v1/creator/sessions"
' sh http://localhost:8080
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token
@@ -101,4 +97,30 @@ if ($updated.State -ne "SATISFIED" -or $updated.Revision -ne 2) { throw "Creator
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/satisfy-delivered $command $creatorSession 409
Expect-CreatorStatus "http://localhost:8082/v1/creator/authorized-actions/$($action.AuthorizedActionID)/reconcile" @{} $creatorSession 409
$notificationProspective = "prospective-notification-$suffix"
Post-Json $continuityContainer http://localhost:8081/v1/prospective-items @{
ID=$notificationProspective;CommitmentID="commitment-notification-$suffix";OwnerPrincipalID="domovoy-creator-principal"
ScopeID=$scope;GoalID=$goal;TriggerAtUnixMillis=1800000000000;State="DELIVERED";Revision=1
} | Out-Null
$notificationCommand = @{
Target=@{TaskID=$task;GoalID=$goal;ScopeID=$scope;Revision=1}
ProspectiveItemID=$notificationProspective;ExpectedRevision=1;Confirm=$true
}
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand "invalid" 401
$hostileNotification = $notificationCommand.Clone()
$hostileNotification.URL = 'https://example.invalid'
$hostileNotification.Body = 'arbitrary'
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $hostileNotification $creatorSession 400
$notificationAction = Creator-Post http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand $creatorSession | ConvertFrom-Json
if ($notificationAction.State -ne 'SUCCEEDED' -or -not $notificationAction.ActionIntentID) { throw 'Creator notification did not produce a successful receipt' }
$notificationItem = docker --context $DockerContext exec $continuityContainer wget -qO- "http://localhost:8081/v1/prospective-items/$notificationProspective" | ConvertFrom-Json
if ($notificationItem.State -ne 'DELIVERED' -or $notificationItem.Revision -ne 1) { throw 'Notification changed Continuity canonical state' }
$receiptRaw = docker --context $DockerContext exec $receiverContainer sh -c 'token=$(cat /run/notification-reader/token); auth=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$auth $1' sh "http://localhost:8085/delivery/v1/receipts/$($notificationAction.ActionIntentID)"
if ($LASTEXITCODE -ne 0) { throw 'Notification receipt lookup failed' }
$receipt = ($receiptRaw -join "`n") | ConvertFrom-Json
if ($receipt.action_intent_id -ne $notificationAction.ActionIntentID -or $receipt.request_digest -notmatch '^[0-9a-f]{64}$') { throw 'Notification receipt evidence mismatch' }
$notificationHistory = Creator-Get 'http://localhost:8082/v1/creator/authorized-actions?limit=50' $creatorSession | ConvertFrom-Json
$notificationHistoryItem = @($notificationHistory.Items | Where-Object { $_.AuthorizedActionID -eq $notificationAction.AuthorizedActionID })
if ($notificationHistoryItem.Count -ne 1 -or $notificationHistoryItem[0].Operation -ne 'deliver-prospective-notification-v1' -or $notificationHistoryItem[0].State -ne 'SUCCEEDED') { throw 'Notification action history is incomplete' }
Write-Output "Creator Commit command verification: PASS"