diff --git a/adapters/models/advisory.go b/adapters/models/advisory.go new file mode 100644 index 0000000..5c6b3ca --- /dev/null +++ b/adapters/models/advisory.go @@ -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 + } +} diff --git a/cmd/cognitive-worker/advisory_test.go b/cmd/cognitive-worker/advisory_test.go new file mode 100644 index 0000000..01081d8 --- /dev/null +++ b/cmd/cognitive-worker/advisory_test.go @@ -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) + } +} diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 8614bff..908871c 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -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 { diff --git a/contracts/cognitive/doc.go b/contracts/cognitive/doc.go new file mode 100644 index 0000000..cb6f669 --- /dev/null +++ b/contracts/cognitive/doc.go @@ -0,0 +1,2 @@ +// Package cognitive defines provider-neutral, non-canonical cognitive gateway contracts. +package cognitive diff --git a/contracts/cognitive/types.go b/contracts/cognitive/types.go new file mode 100644 index 0000000..497fa97 --- /dev/null +++ b/contracts/cognitive/types.go @@ -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 +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index b5c2917..ff42c7f 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/ARCH-000008-accept-cognitive-advisory-boundary.json", - "task_id": "ARCH-000008", + "task_file": "dev/tasks/DEV-000046-cognitive-advisory-contract.json", + "task_id": "DEV-000046", "task_revision": 1 } diff --git a/dev/tasks/DEV-000046-cognitive-advisory-contract.json b/dev/tasks/DEV-000046-cognitive-advisory-contract.json new file mode 100644 index 0000000..bfe8cf2 --- /dev/null +++ b/dev/tasks/DEV-000046-cognitive-advisory-contract.json @@ -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"] +} diff --git a/tests/cognitive_advisory/contract_test.go b/tests/cognitive_advisory/contract_test.go new file mode 100644 index 0000000..618cd08 --- /dev/null +++ b/tests/cognitive_advisory/contract_test.go @@ -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") + } +}