DEV-000053: add Creator advisory Mode A routing #28

Merged
m merged 1 commits from codex/creator-advisory-mode-a-routing-v1 into main 2026-08-16 18:36:16 +00:00
9 changed files with 316 additions and 22 deletions
+2 -1
View File
@@ -9,11 +9,12 @@ import (
func main() {
addr := envOr("LISTEN_ADDR", ":8084")
handler, err := newHandler(
handler, err := newHandlerWithAdvisoryMode(
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
&http.Client{Timeout: 10 * time.Second},
envOr("ADVISORY_BINDING_MODE", advisoryModeDeterministic),
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
)
if err != nil {
+107 -15
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
@@ -18,21 +19,26 @@ import (
)
const (
maxRequestBytes = 8 << 10
maxAdvisoryRequestBytes = 20 << 10
maxResponseBytes = 1 << 20
maxRequestBytes = 8 << 10
maxAdvisoryRequestBytes = 20 << 10
maxResponseBytes = 1 << 20
advisoryModeDeterministic = "deterministic"
advisoryModeOpenAIModeA = "openai-mode-a"
openAIAdvisoryBindingID = "openai-responses-gpt-5.4-mini-2026-03-17"
openAIAdvisoryModel = "gpt-5.4-mini-2026-03-17"
)
//go:embed static
var staticFiles embed.FS
type gateway struct {
core *url.URL
continuity *url.URL
commit *url.URL
cognitive *url.URL
client *http.Client
static http.Handler
core *url.URL
continuity *url.URL
commit *url.URL
cognitive *url.URL
advisoryMode string
client *http.Client
static http.Handler
}
type routeTarget uint8
@@ -44,6 +50,13 @@ const (
)
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) {
return newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL, client, advisoryModeDeterministic, cognitiveURLs...)
}
func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, advisoryMode string, cognitiveURLs ...string) (http.Handler, error) {
if advisoryMode != advisoryModeDeterministic && advisoryMode != advisoryModeOpenAIModeA {
return nil, fmt.Errorf("invalid advisory binding mode %q", advisoryMode)
}
core, err := parseRuntimeURL(coreURL, "Core")
if err != nil {
return nil, err
@@ -71,7 +84,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
if client == nil {
client = http.DefaultClient
}
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, advisoryMode: advisoryMode, client: client, static: http.FileServer(http.FS(assets))}), nil
}
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
@@ -89,6 +102,10 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
if r.Method == http.MethodGet && r.URL.Path == "/api/advisory-policy" {
g.handleAdvisoryPolicy(w, r)
return
}
if r.Method == http.MethodPost && r.URL.Path == "/api/advisory" {
g.handleAdvisory(w, r)
return
@@ -107,9 +124,52 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
type browserAdvisoryRequest struct {
Message string `json:"message"`
SessionID string `json:"session_id"`
WorkspaceID string `json:"workspace_id"`
Message string `json:"message"`
SessionID string `json:"session_id"`
WorkspaceID string `json:"workspace_id"`
EgressAcknowledged bool `json:"egress_acknowledged"`
}
type advisoryPolicy struct {
Mode string
ExternalEgress bool
AcknowledgementRequired bool
ProviderBindingID string
ProviderBindingRevision int64
ModelSnapshot string
RetentionPolicy string
}
func (g *gateway) policy() advisoryPolicy {
if g.advisoryMode == advisoryModeOpenAIModeA {
return advisoryPolicy{Mode: advisoryModeOpenAIModeA, ExternalEgress: true, AcknowledgementRequired: true, ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, ModelSnapshot: openAIAdvisoryModel, RetentionPolicy: "up-to-30-days-abuse-monitoring"}
}
return advisoryPolicy{Mode: advisoryModeDeterministic, ProviderBindingID: "deterministic-fake", ProviderBindingRevision: 1}
}
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK {
http.Error(w, "creator session denied", status)
return
}
if session.ActorID == "" || session.PrincipalID == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(g.policy())
}
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
@@ -123,6 +183,14 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
if g.advisoryMode == advisoryModeOpenAIModeA && !browserRequest.EgressAcknowledged {
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
return
}
if g.advisoryMode == advisoryModeDeterministic && browserRequest.EgressAcknowledged {
http.Error(w, "unexpected external egress acknowledgement", http.StatusBadRequest)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
@@ -153,7 +221,8 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
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}
policy := g.policy()
trusted := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, ProviderBindingID: capability.CapabilityBindingID(ids.ID(policy.ProviderBindingID)), ProviderBindingRevision: policy.ProviderBindingRevision}
payload, err := json.Marshal(trusted)
if err != nil {
http.Error(w, "advisory unavailable", http.StatusServiceUnavailable)
@@ -161,6 +230,9 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
}
target := *g.cognitive
target.Path = "/v1/advisories/deterministic"
if g.advisoryMode == advisoryModeOpenAIModeA {
target.Path = "/v1/advisories/openai"
}
upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload)))
if err != nil {
http.Error(w, "advisory unavailable", http.StatusBadGateway)
@@ -178,7 +250,7 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
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 {
if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || !validAdvisoryResponse(advisory, trusted, g.advisoryMode) {
http.Error(w, "invalid advisory response", http.StatusBadGateway)
return
}
@@ -187,6 +259,26 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(advisory)
}
func validAdvisoryResponse(response cognitive.AdvisoryResponse, request cognitive.AdvisoryRequest, mode string) bool {
if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision || len(response.ReferencedExcerptIDs) != 0 {
return false
}
if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) {
return false
}
if mode == advisoryModeDeterministic {
return response.FinishReason == cognitive.FinishProviderUnavailable && response.AdvisoryText == ""
}
switch response.FinishReason {
case cognitive.FinishCompleted:
return response.AdvisoryText != "" && len(response.AdvisoryText) <= request.MaxOutputBytes && utf8.ValidString(response.AdvisoryText)
case cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse:
return response.AdvisoryText == ""
default:
return false
}
}
func (g *gateway) getTrustedJSON(r *http.Request, upstream *url.URL, path, rawQuery, authorization string, destination any) (int, error) {
target := *upstream
target.Path = path
+131
View File
@@ -227,6 +227,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
!strings.Contains(response.Body.String(), "advisory-egress-ack") || !strings.Contains(response.Body.String(), "может храниться до 30 дней") ||
!strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") ||
strings.Contains(response.Body.String(), "bootstrap-token") {
t.Fatalf("console response = %d", response.Code)
@@ -236,6 +237,136 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
}
}
func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) {
_, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected")
if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") {
t.Fatalf("error=%v", err)
}
}
func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatalf("unexpected policy authentication request %s %q", r.URL.Path, r.Header.Get("Authorization"))
}
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
}))
defer core.Close()
handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA)
if err != nil {
t.Fatal(err)
}
denied := httptest.NewRecorder()
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil))
if denied.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", denied.Code)
}
request := httptest.NewRequest(http.MethodGet, "/api/advisory-policy", nil)
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
}
var policy advisoryPolicy
if err := json.Unmarshal(response.Body.Bytes(), &policy); err != nil {
t.Fatal(err)
}
if policy.Mode != advisoryModeOpenAIModeA || !policy.ExternalEgress || !policy.AcknowledgementRequired || policy.ProviderBindingID != openAIAdvisoryBindingID || policy.ProviderBindingRevision != 1 || policy.ModelSnapshot != openAIAdvisoryModel || policy.RetentionPolicy != "up-to-30-days-abuse-monitoring" {
t.Fatalf("unexpected policy %+v", policy)
}
}
func TestOpenAIAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
defer upstream.Close()
handler, err := newHandlerWithAdvisoryMode(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), advisoryModeOpenAIModeA, upstream.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":false}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || called {
t.Fatalf("status=%d upstream_called=%t", response.Code, called)
}
}
func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/creator/sessions/current":
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
case "/v1/creator/focus":
_, _ = io.WriteString(w, `{"ID":"focus-1","ActorID":"creator-actor","SessionID":"focus-session","Workspace":"home","ScopeID":"trusted-scope","Active":true}`)
default:
t.Fatalf("unexpected core path %s", r.URL.Path)
}
}))
defer core.Close()
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/advisories/openai" {
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
}
var trusted cognitive.AdvisoryRequest
if err := json.NewDecoder(r.Body).Decode(&trusted); err != nil {
t.Fatal(err)
}
if trusted.ProviderBindingID != openAIAdvisoryBindingID || trusted.ProviderBindingRevision != 1 || len(trusted.Context) != 0 || trusted.Message != "help" {
t.Fatalf("unexpected trusted request %+v", trusted)
}
_ = json.NewEncoder(w).Encode(cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: trusted.RequestID, ProviderBindingID: trusted.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "Проверьте границы.", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}})
}))
defer cognitiveRuntime.Close()
handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA, cognitiveRuntime.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":true}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Проверьте границы") {
t.Fatalf("response=%d %s", response.Code, response.Body.String())
}
}
func TestOpenAIAdvisoryRejectsBrowserSelectedBinding(t *testing.T) {
handler, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, advisoryModeOpenAIModeA)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/advisory", strings.NewReader(`{"message":"help","session_id":"focus-session","workspace_id":"home","egress_acknowledged":true,"provider_binding_id":"attacker"}`))
request.Header.Set("Authorization", "Bearer bounded-session")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func TestOpenAIAdvisoryResponseValidationFailsClosed(t *testing.T) {
request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, MaxOutputBytes: 16}
valid := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "совет", FinishReason: cognitive.FinishCompleted}
if !validAdvisoryResponse(valid, request, advisoryModeOpenAIModeA) {
t.Fatal("valid completed response was rejected")
}
invalid := []cognitive.AdvisoryResponse{
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "unexpected", FinishReason: cognitive.FinishTimeout},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "0123456789abcdefg", FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", ReferencedExcerptIDs: []cognitive.ContextExcerptID{"untrusted"}, FinishReason: cognitive.FinishCompleted},
{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: -1}},
}
for index, response := range invalid {
if validAdvisoryResponse(response, request, advisoryModeOpenAIModeA) {
t.Fatalf("invalid response %d was accepted: %+v", index, response)
}
}
}
func TestAdvisoryRequiresSessionBeforeUpstreamCalls(t *testing.T) {
called := false
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
@@ -103,9 +103,17 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px
.advisor-form > label { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.advisor-form textarea { width: 100%; min-height: 92px; padding: 13px 14px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: rgba(7,12,10,.82); font: 12px/1.55 var(--sans); transition: border-color .18s, box-shadow .18s; }
.advisor-form textarea:focus { outline: 0; border-color: var(--signal); box-shadow: 0 0 0 2px rgba(201,168,95,.12); }
.advisor-egress { padding: 13px 14px; border: 1px solid rgba(222,164,73,.58); border-left: 3px solid var(--warn); border-radius: var(--radius); background: rgba(111,72,18,.17); color: var(--muted); font-size: 11px; line-height: 1.5; }
.advisor-egress[hidden] { display: none; }
.advisor-egress p { margin: 0; }
.advisor-egress .advisor-egress-kicker { margin-bottom: 7px; color: var(--warn); font: 700 9px/1 var(--mono); letter-spacing: .12em; }
.advisor-egress label { margin-top: 11px; display: flex; align-items: flex-start; gap: 9px; color: var(--ink); font: 600 10px/1.4 var(--mono); cursor: pointer; }
.advisor-egress input { width: 16px; height: 16px; flex: 0 0 auto; margin: 0; accent-color: var(--warn); }
.advisor-egress input:focus-visible { outline: 2px solid var(--warn); outline-offset: 3px; }
.advisor-controls { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.advisor-controls > span { color: var(--muted); font: 9px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.advisor-controls i { width: 6px; height: 6px; margin-right: 7px; display: inline-block; border-radius: 50%; background: var(--good); box-shadow: 0 0 10px rgba(120,198,163,.7); }
.advisor-controls > span.external i { background: var(--warn); box-shadow: 0 0 10px rgba(222,164,73,.65); }
.advisor-controls .primary-button { width: auto; min-width: 180px; margin: 0; }
.advisor-result { grid-column: 1 / -1; padding-top: 18px; display: grid; grid-template-columns: 150px minmax(0,1fr); gap: 18px; border-top: 1px solid var(--line); }
.advisor-result[hidden] { display: none; }
+31 -2
View File
@@ -14,6 +14,7 @@ let currentProspective = null;
let pendingCommand = null;
let pendingNotification = null;
let lastAuthorizedActionID = "";
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
function setText(id, value, fallback = "—") {
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
@@ -80,6 +81,7 @@ async function showConsole() {
loginView.hidden = true;
consoleView.hidden = false;
restoreContext();
await loadAdvisoryPolicy();
const context = saveContext();
if (context["session-id"] && context["workspace-id"]) loadDashboard();
else loadActionHistory();
@@ -98,6 +100,8 @@ function endSession(message = "Сессия завершена.") {
pendingNotification = null;
lastAuthorizedActionID = "";
advisoryForm.reset();
advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
renderAdvisoryPolicy();
byId("advisory-result").hidden = true;
notify(message);
}
@@ -155,6 +159,11 @@ advisoryForm.addEventListener("submit", async (event) => {
return;
}
if (!message) return;
if (advisoryPolicy.AcknowledgementRequired && !byId("advisory-egress-ack").checked) {
notify("Подтвердите передачу этого вопроса внешнему провайдеру.");
byId("advisory-egress-ack").focus();
return;
}
button.disabled = true;
button.firstElementChild.textContent = "Проверяю контекст…";
byId("advisory-result").hidden = true;
@@ -162,23 +171,43 @@ advisoryForm.addEventListener("submit", async (event) => {
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"]})
body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"], egress_acknowledged: advisoryPolicy.AcknowledgementRequired && byId("advisory-egress-ack").checked})
});
const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE";
setText("advisory-state", state);
byId("advisory-state").dataset.state = state;
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? "Когнитивный провайдер пока не подключён. Контекст проверен, наружу ничего не отправлено." : result.AdvisoryText, "Ответ не получен.");
const unavailable = advisoryPolicy.ExternalEgress ? "Внешний провайдер недоступен. Ответ не получен." : "Когнитивный провайдер пока не подключён. Наружу ничего не отправлено.";
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? unavailable : result.AdvisoryText, "Ответ не получен.");
byId("advisory-result").hidden = false;
} catch (error) {
setText("advisory-state", "ОШИБКА");
setText("advisory-text", error.message);
byId("advisory-result").hidden = false;
} finally {
byId("advisory-egress-ack").checked = false;
button.disabled = false;
button.firstElementChild.textContent = "Получить совет";
}
});
async function loadAdvisoryPolicy() {
advisoryPolicy = await request("/api/advisory-policy");
renderAdvisoryPolicy();
}
function renderAdvisoryPolicy() {
const external = advisoryPolicy.Mode === "openai-mode-a" && advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true;
byId("advisory-egress").hidden = !external;
byId("advisory-egress-ack").checked = false;
byId("advisory-egress-ack").required = external;
setText("advisory-privacy", external ? "в OpenAI передаётся только текст вопроса" : "локальный детерминированный режим");
const privacy = byId("advisory-privacy");
privacy.classList.toggle("external", external);
const marker = document.createElement("i");
marker.setAttribute("aria-hidden", "true");
privacy.prepend(marker);
}
async function loadDashboard() {
const context = saveContext();
const sessionID = context["session-id"];
+9 -1
View File
@@ -101,8 +101,16 @@
<form id="advisory-form" class="advisor-form">
<label for="advisory-message">Ваш вопрос</label>
<textarea id="advisory-message" rows="3" maxlength="16384" placeholder="Что стоит проверить в выбранном контексте?" required></textarea>
<div id="advisory-egress" class="advisor-egress" hidden>
<p class="advisor-egress-kicker">ВНЕШНИЙ ПРОВАЙДЕР · MODE A</p>
<p>Текст этого вопроса будет отправлен в OpenAI и может храниться до 30 дней для мониторинга злоупотреблений. Контекст, идентификаторы и секреты Домового не передаются.</p>
<label for="advisory-egress-ack">
<input id="advisory-egress-ack" type="checkbox">
<span>Подтверждаю передачу этого вопроса в OpenAI</span>
</label>
</div>
<div class="advisor-controls">
<span id="advisory-privacy"><i></i> только выбранный контекст</span>
<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>
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000052-promote-disabled-openai-gate.json",
"task_id": "DEV-000052",
"task_file": "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json",
"task_id": "DEV-000053",
"task_revision": 1
}
@@ -0,0 +1,23 @@
{
"task_id": "DEV-000053",
"task_revision": 1,
"architecture_revision": "ADR-0007",
"base_repo_revision": "8318194cd076acb7243384fa9fe633712d9f358d",
"risk_tier": "E",
"intent": "Add server-selected Creator Web advisory routing for deterministic or OpenAI Mode A, expose an authenticated policy view, require per-request egress acknowledgement for OpenAI, and render a clear responsive retention disclosure while keeping the production default deterministic and provider selection unavailable to the browser.",
"requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000051", "INV-EPI-001"],
"allowed_change_scope": {"modules": [], "paths": ["cmd/creator-web/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000053-creator-openai-mode-a-routing.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
"contracts": {"consume": ["creator-advisory-v1", "ADR-0007", "CreatorSession", "Situation FocusBinding"], "may_change": []},
"semantic_owner": "none-gateway-and-display",
"canonical_state": [],
"state_transitions": [],
"side_effects_and_privileges": ["server configuration may select the already accepted OpenAI Mode A internal endpoint", "browser may acknowledge egress for one request but cannot select provider/model/binding", "production default remains deterministic without configuration change"],
"persistence_and_migrations": [],
"failure_retry_restart": ["missing/invalid acknowledgement denies OpenAI request before cognitive call", "invalid configured mode fails startup", "advisory and acknowledgement are not persisted", "no automatic retry", "restart restores configured mode only"],
"assumptions": ["production does not set ADVISORY_BINDING_MODE and therefore remains deterministic", "OpenAI credential remains absent", "docker-test remains prohibited"],
"acceptance_scenarios": ["authenticated policy endpoint reports server-selected mode and immutable binding metadata", "deterministic mode retains current payload and behavior without acknowledgement", "OpenAI mode requires explicit per-request acknowledgement and routes only to fixed OpenAI endpoint/binding", "browser cannot supply mode/model/binding/retention policy", "completed and typed failure responses are validated fail closed", "UI displays retention/egress disclosure and checkbox only in OpenAI Mode A", "acknowledgement and message are not stored", "desktop/mobile layout and console remain clean"],
"required_tests": ["unit", "gateway adversarial", "static UI", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "browser desktop/mobile", "CI"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
+3 -1
View File
@@ -1,6 +1,6 @@
# Assistant project status
Audit date: 2026-08-16. Active audit: DEV-000049 revision 1.
Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1.
## Delivered
@@ -34,4 +34,6 @@ ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026
DEV-000051 adds an optional cognitive-worker runtime gate for that adapter. It is disabled when `OPENAI_API_KEY_FILE` is absent, rejects direct environment credentials, bounds and validates the secret file, and returns 503 before parsing an OpenAI request while disabled. Production configuration and Creator Web routing are unchanged, so the gate is not active and no provider egress occurs.
DEV-000052 promotes the disabled gate without adding a credential, mount or provider environment variable. DEV-000053 adds the server-selected Creator Web routing policy: deterministic behavior remains the default, while the accepted OpenAI Mode A route can only be selected by server configuration and requires a fresh per-request egress acknowledgement. The browser receives authenticated binding metadata for disclosure but cannot select the provider, model or binding. The message and acknowledgement are not persisted.
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.