DEV-000046: add cognitive advisory contract #18
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user