Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6dba0d39b | ||
|
|
59f5b4ce57 | ||
|
|
6440cbff81 | ||
|
|
d0255558e2 | ||
|
|
861438a4ca | ||
|
|
fbc6538d18 | ||
|
|
4b8832766a | ||
|
|
f9cdd67229 | ||
|
|
b896d218f8 | ||
|
|
d1081bb70e | ||
|
|
78862ffe05 | ||
|
|
e4bc702be7 | ||
|
|
43d1ef5821 | ||
|
|
d5cd964e51 | ||
|
|
959c89d47b | ||
|
|
760f671077 | ||
|
|
759328972a | ||
|
|
01e1bdea1e | ||
|
|
7a3280b007 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
@@ -17,12 +19,35 @@ func main() {
|
||||
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)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
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 deterministicProposal(w http.ResponseWriter, r *http.Request) {
|
||||
var request models.DeterministicRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ func main() {
|
||||
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
+141
-4
@@ -9,11 +9,18 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -23,6 +30,7 @@ type gateway struct {
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
}
|
||||
@@ -35,7 +43,7 @@ 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) {
|
||||
core, err := parseRuntimeURL(coreURL, "Core")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,6 +56,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 +71,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, client: client, static: http.FileServer(http.FS(assets))}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -73,6 +89,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" {
|
||||
g.handleAdvisory(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -86,6 +106,123 @@ 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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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("deterministic-fake")), ProviderBindingRevision: 1}
|
||||
payload, err := json.Marshal(trusted)
|
||||
if err != nil {
|
||||
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
target := *g.cognitive
|
||||
target.Path = "/v1/advisories/deterministic"
|
||||
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 || advisory.RequestID != trusted.RequestID || advisory.ProviderBindingID != trusted.ProviderBindingID || advisory.ProviderBindingRevision != 1 || advisory.FinishReason != cognitive.FinishProviderUnavailable || advisory.AdvisoryText != "" || len(advisory.ReferencedExcerptIDs) != 0 {
|
||||
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 (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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -222,6 +226,7 @@ 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(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
|
||||
strings.Contains(response.Body.String(), "bootstrap-token") {
|
||||
t.Fatalf("console response = %d", response.Code)
|
||||
@@ -230,3 +235,85 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
t.Fatal("content security policy is missing")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,26 @@ 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-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 .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; }
|
||||
@@ -228,6 +248,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; }
|
||||
}
|
||||
@@ -246,6 +267,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; }
|
||||
|
||||
@@ -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"];
|
||||
@@ -96,6 +97,8 @@ function endSession(message = "Сессия завершена.") {
|
||||
pendingCommand = null;
|
||||
pendingNotification = null;
|
||||
lastAuthorizedActionID = "";
|
||||
advisoryForm.reset();
|
||||
byId("advisory-result").hidden = true;
|
||||
notify(message);
|
||||
}
|
||||
|
||||
@@ -142,6 +145,40 @@ 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;
|
||||
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"]})
|
||||
});
|
||||
const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE";
|
||||
setText("advisory-state", state);
|
||||
byId("advisory-state").dataset.state = state;
|
||||
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? "Когнитивный провайдер пока не подключён. Контекст проверен, наружу ничего не отправлено." : result.AdvisoryText, "Ответ не получен.");
|
||||
byId("advisory-result").hidden = false;
|
||||
} catch (error) {
|
||||
setText("advisory-state", "ОШИБКА");
|
||||
setText("advisory-text", error.message);
|
||||
byId("advisory-result").hidden = false;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.firstElementChild.textContent = "Получить совет";
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
const context = saveContext();
|
||||
const sessionID = context["session-id"];
|
||||
|
||||
@@ -92,6 +92,26 @@
|
||||
<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 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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package cognitive defines provider-neutral, non-canonical cognitive gateway contracts.
|
||||
package cognitive
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000042-creator-notification-command.json",
|
||||
"task_id": "DEV-000042",
|
||||
"task_revision": 1
|
||||
"task_file": "dev/tasks/DEV-000048-creator-advisory-console.json",
|
||||
"task_id": "DEV-000048",
|
||||
"task_revision": 2
|
||||
}
|
||||
|
||||
@@ -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,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,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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Assistant project status
|
||||
|
||||
Audit date: 2026-08-16. Active audit: DEV-000044 revision 3.
|
||||
|
||||
## 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.
|
||||
|
||||
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user