stacken.docs

Styrning för en växande portfölj av AI-tjänster

stacken.ai ↗

Ur repot

Atlas — tjänstekartan

Vad detta är: en levande karta över plattformens tjänster — yta, lagring, auth-modell, anropare, live-status. Koden är sanningen; atlasen är kartan. Regeln (D-30): en PR som ändrar en tjänsts verklighet (rutter, tabeller, kontrakt, anropare, live-status) uppdaterar tjänstens sektion i samma PR — samma disciplin som docs/decisions.md. Fasstartens kartläggning verifierar atlasen mot koden och rapporterar diffen; en diff är en signal om att något mergats förbi regeln.

Varje sektion bär en verifieringsstämpel (datum + fas). När sektionen verifierats eller ändrats skrivs en ny stämpel på en egen rad överst i sektionen. Befintliga stämpelrader skrivs inte om och kedjas inte med “Föregående” (#654): då kan två PR:er som stämplar samma sektion mergas efter varandra, och verkställaren löser krocken mekaniskt. Stämpeln bär NULÄGET; tidigare verifieringar ligger i ett hopfällt Stämpelhistorik-block direkt under (D-119 — stämplarna hade vuxit till 24 000 tecken med tretton nästlade “Föregående”, vilket gjorde att det senast verifierade inte gick att läsa ut). Historiken är oförändrad, bara flyttad.

Ytan står inte här i sin helhet. Atlasen beskriver ytan i prosa; den fullständiga endpoint-listan per tjänst är api-ytan.md, genererad ur tjänsternas incheckade openapi.json och grindad. Vill du veta varför ytorna ligger som de gör: arkitektur.md. Vem som får anropa vem, som diagram: anropsgraf.md, genererad ur chartsens NetworkPolicy-egress och grindad på samma sätt.

Tvärsnitt (verifierat/uppdaterat 2026-07-27, D-68 — e2e-raden: sviten kör nu även som post-merge-signal på main, inte bara som PR-gate; föregående 2026-07-19, D-47 — observability-raden till 11/11 (provisioning-api + policy-engine instrumenterade, #T5 stängt); föregående 2026-07-18, D-46 — observability-raden uppdaterad till EN konsekvent enumerering (feedback-api instrumenterad, 9/11, kvar provisioning-api + policy-engine) och pii-api tillagd i JSON-logg-uppräkningen; fas R3:s D-41 e2e-radens preview/eval-trigger/GDPR-tillägg (21 lås a–u, tasks 10/15/19) och övriga rader i övrigt oförändrade; 2026-07-22, D-52 — observability via libs/observability fleet-vitt):

  • Postgres-isolering: tjänsterna delar platform-pg men isolerar via egna scheman och/eller egna alembic-versionstabeller: alembic_version_auth_api, alembic_version_policy_engine, alembic_version_llm_gateway, alembic_version_provisioning_api; eval/feedback/lifecycle använder default-namnet alembic_version i sina egna scheman (eval, feedback, lifecycle); mcp-gateway default-namn i tenant-lokal DB. llm-gatewayens tabeller ligger i public (delas läsvägen med feedback-api:s usage_events-läsning) — alembic skapar dem okvalificerat och gateway_app har ingen search_path-override; det tidigare router-påståendet var doc-drift på cirkulärt belägg, rättat i D-45 (se llm-gateway-sektionen). bff har sedan fas N sin första Postgres-yta (schema bff_public, D-32), och sedan fas Q ett andra schema bff_chat (D-37) — egen alembic-historik i services/bff/alembic/versions/, nu 7 migrationer.
  • Auth-mönstret (D-24): plattforms-JWT (RS256, JWKS från auth-api) med typ-claim (platform-user | platform-service | legacy), scopes ur scope_matrix.py (en sanningskälla), tenant ur claim. Dual-mode legacy service-token lever kvar i policy-engine, feedback-api och provisioning-api (utfasning pågår). Sedan D-60: en ny tvär-tenant operatör-scope tenant:export:read finns — burits av en tenant-lös platform-service-JWT (operatören har ingen tenant-X-bunden JWT), grindad kind-/tenant-agnostiskt via require_scope, tenant ur path (aldrig ur claim) på plattforms-operatörens tenant-export-plan (provisioning-api + 5 nedströms read-only-endpoints, se respektive sektion). Sedan fas O (D-34) forwardar även BFF:ns arbetsläges-/chat användaridentiteten till llm-gatewayen — inte som JWT utan som verifierade X-User-Id-Hash/X-User-Role/X-User-Groups-headers ovanpå den tenant-scopade dgt--nyckeln (datapathens auth-väg är oförändrad, identiteten läggs till). Sedan fas Q (D-37) exponerar BFF:n dessutom subjekt-scopade GDPR-endpoints (/gdpr/users/{user_id_hash}/conversations/export|erase) verifierade mot en forwardad plattforms-JWT (Bearer, JWKS) — samma D-26-mönster som övriga SAR-nedströmsanrop.
  • Observability-läget (#T5 — 11/11, stängt D-47): JSON-logg finns i alla elva produkttjänster (auth-api, llm-gateway, eval-api, feedback-api, lifecycle-api, mcp-gateway, bff, pii-api, rag-api, samt provisioning-api + policy-engine sedan D-47; log_format=json/_JsonFormatter/motsvarande default). OTel (FastAPIInstrumentor) finns nu i 11/11 produkttjänster (memory-api är stub, utanför räkningen): lifecycle-api, mcp-gateway, eval-api, bff, llm-gateway, auth-api, pii-api (fas Q, D-37), rag-api (fas R1, D-39), feedback-api (D-46) och provisioning-api + policy-engine (D-47 — samma mall: TraceIdMiddleware + typat felkuvert {error,detail,trace_id} + FastAPIInstrumentor.instrument_app(app); se respektive sektion). Alla produkttjänster är nu FastAPI-instrumenterade — #T5 stängt (den tidigare nämnarglidningen mellan releaseplanens “8/8”/“8/9” och atlasens “9/11” landar på 11/11: elva icke-stub-produkttjänster). bff, auth-api, eval-api, rag-api och provisioning-api kompletterar med HTTPXClientInstrumentor (utgående httpx-anrop injicerar traceparent); policy-engine och feedback-api utelämnar det medvetet (inga utgående httpx-anrop / DB-only); llm-gateway instrumenterar medvetet endast FastAPI — httpx-instrumentering utelämnad vid providergränsen (D-34 punkt 5: gatewayens delade httpx-klient är provider-vänd, global instrumentering skulle injicera traceparent i anrop till Anthropic/OpenAI/Google). x-trace-id/W3C traceparent är korrelationsnyckeln. Sedan D-52 routar trace/logging/felkuvert flottvitt genom libs/observability (D-47:s kopiera-per-tjänst ersatt); 5xx-bypass-gapet (o-hanterat fel gav ingen x-trace-id/access-loggrad) stängt. Dokumenterade undantag: feedback-api/pii-api behåller egen structlog-logg (GDPR-testad _redact_pii); llm-gateway behåller sitt OpenAI-kompatibla felkuvert och importerar ej libben; bff behåller sin egen http_exception_handler för sitt publika dict-wire-kontrakt.
  • Rate limiting (skalgrind #T6, D-53): bff/feedback-api/mcp-gateway/auth-api har sedan Wave 2 pluggbar limiter-storage (RATE_LIMIT_STORAGE_URI, default memory://, delad Valkey i prod — BSD-3-Clause Redis-protokoll-server via Linux Foundation, medvetet INTE Redis Inc:s post-2024 source-available Redis) → rate-limit gäller nu globalt över pods (bevisat: tests/load/test_global_rate_limit_proof.py, delad store → exakt N, memory:// → 2N). auth-apis tidigare DÖDA limiter-config är nu verkställd (/v1/token/exchange m.fl. skyddade). Sedan D-175 (P17) räknar även llm-gatewayens per-nyckelgräns i samma Valkey, fail-closed med 503 när storen är nere.
  • Live-ytor i ops-ui (LIVE_SURFACES i mocks/handlers.ts): evals, me, audit, usage, users, protection (fas P2, D-36: GET/PUT protection-defaults), knowledge (fas R3, D-41: kunskaps-CRUD + preview, se ops-ui-sektionen), assistants (fas S1, D-42: assistants-CRUD live genom BFF→policy-engine), models (fas S2, D-43: registrets governance-projektion via BFF:s GET /api/models), decide (fas S2-ff, D-44: DecideTest-panelen “Testa beslutet” live genom BFF:s decide-proxy POST /api/policy/v1/decide), signals (fas S3, D-45: kvalitetssignals-panelen live genom BFF-passthrough → feedback-apis GET /api/feedback/v1/assistants/{id}/quality-signals, gap 16) — flippas per deploy via VITE_LIVE_SURFACES; produktionen kör mock-läge tills en live-BFF finns. chat-ui har sedan fas Q (D-37) sin egen VITE_LIVE_SURFACES, sedan D-82 ['public', 'work', 'me', 'assistants', 'models'] där work fail-closed kräver de tre sista — se frontends-sektionen.
  • E2e-sviten (fas O/P1/P2/Q/R1/R2/R3/S1/S2/S2-ff, D-34/D-35/D-36/D-37/D-39/D-40/D-41/D-42/D-43/D-44): tests/e2e/ (compose-orkestrerad: fröet + Keycloak + BFF + pii-api + feedback-api + rag-pg (pgvector) + rag-api + rag-api-migrate (fas R1) + mcp-gateway + mcp-gateway-migrate + mcpgw-databasen i rag-pg (port 18088) + rag-api-registreringsfröet (fas R2, Task 14) + eval-api + eval-api-migrate mot den DELADE platform-DB:n med eget eval-schema (port 18089) + BFF:ns UPSTREAM_MCP_URL + ett direkt-DB service_clients-frö för rag-apis eval-trigger (_seed_eval_service_client, hashar med auth-api-containerns egen hash_secret — fröet och verifieringen kan aldrig glida isär) (fas R3, Task 10/15), ärlig provider-stub inkl. embeddings-stub) kör som PR-gate och post-merge-signal på main (D-68) — .github/workflows/e2e.yml, pull_request på services/**/libs/**/tests/e2e/** + sedan D-68 push på main med samma path-filter (skälet: ingen av de elva per-tjänst-sviterna kan se ett tvärsnittsfel som bara uppstår när två tjänsters migrationskedjor möter varandra i en delad databas) + workflow_dispatch; concurrency-grupp per PR-ref (avbryter en föregående körning), per commit-sha på push (varje merge får sin egen grupp — en delad grupp per ref hade kunnat kassera en köad körning vid snabba merges i rad, fångat och lagat i D-68); grön-krav per fas (D-33 princip 3) verkställs mekaniskt, inte manuellt. En röd körning på main larmar ingen automatiskt (D-68) — signal, inte larm. 21 regressionslås (a–u) (22 testfunktioner totalt över test_chain.py/test_conversations.py/test_knowledge.py; därutöver test_assistants.py (v–z, S1), test_models.py (aa–ae, S2) och test_decide_probe.py (af–ai, S2-ff, D-44 — DecideTest live genom BFF decide-proxyn: (af) allow + no-store + WORM-rad input_snapshot->>'action'='assistant_probe', (ag) governance-only via KONTRAST — rollrestrikterad assistent ger probe-allow men klassiskt decide deny, (ah) substitut genom proxyn, (ai) smugglad tenant_id → 422); hela sviten 36/36 på ren volym): (a) publik chatt strömmar token, INTE degraded; (b) arbetsläges-chatt strömmar + usage bär user-kontext; (c) SAR-export 200 (körs medvetet FÖRE (b), se filens docstring); (d) arbetsläget fail-closed när policy-engine är nere; (e) trace_id-länken usage_events↔policy_decisions — räknar route-decide och output_check-decide separat (fas P2-skärpning, D-36); (f) fas P1: publik väg blockerar personnummer mot gron-assistent; (g) fas P1: arbetsläget maskerar personnummer mot gul-assistent; (h) fas P1 fail-closed när pii-api är nere; (i) fas P2: publik väg — output_check håller tillbaka ett PII-laddat providersvar (hold-and-release, degraded, ingen läcka); (j) fas Q, tests/e2e/test_conversations.py: konversations-roundtrip genom hela kedjan (create → POST message → D-14-SSE-events → persisterat svar), DB-belägg att payload_ciphertext inte innehåller klartext; (k) uploads/scan: personnummer mot gul-assistent ⇒ masked, mot grön ⇒ blocked, WORM-rad med action='upload_scan'; (l) TTL-verkställighet: förfluten temporary-konversation borta ur listan OCH fysiskt raderad efter sweep-trigger (R7); (m) SAR-sömmen: auth-apins export bär conversations i coverage.covered med innehåll, erase raderar fysiskt; (n) arbetsläges-PNR-kedjan genom konversationsrutten (maskering före provider + pii_verdict-event, arv av lås (g)); (o) fas R1, tests/e2e/test_knowledge.py::test_ingest_disposition_through_chain: ingest-dispositionen genom hela kedjan (scan→decide(knowledge_ingest)→chunk→embed→index) — en gul-klassad kollektion maskerar och indexerar (chunk_count>0, ingen rå personnummer-sifferrad i rag-pg:s chunks.text), en grön-klassad kollektion blockeras redan vid ingest (failed, error_code=pii_blocked, chunk_count=0 — röd-först vid ingest, inte vid frågan); WORM-belägg (policy_decisions med input_snapshot->>'action'='knowledge_ingest'); (p) samma fil, test_retrieval_roundtrip_and_authz: retrieval/authz + usage-belägg — en gul assistent frågar rag-api:s interna POST /v1/collections/{id}/query direkt (service-token) och får träffar med citation-formen (§6.2: collection_id/document_id/section_anchor/title), en grön assistent mot samma gul-klassade kollektion får tomt resultat (results=[], inte degraded — korrekt-tomt, röd-först vid frågan); usage_events har minst en rad med model='stckn-e5-large' (belägg att embeddings gick genom llm-gatewayen); (q) fas R2, test_knowledge.py::test_citations_through_chain: citations genom hela kedjan — kunskapsbunden assistent (seedad knowledge_scope → publicerad gul-collection) ⇒ konversations-SSE bär event: citation med §6.2-formen (verified_at date-only), persisterad CitationAt (at > 0) i GET conversation, WORM-rad input_snapshot->>'action'='tool_call' (trace-scopad, decision='allow'); (r) samma fil, test_kalltvang_denies_without_hits + test_knowledge_chat_fails_closed_when_mcp_gateway_down: källtvånget — kunskapsbunden assistent mot tom publicerad collection (internal_chat outflow=medium) ⇒ terminalt degraded, noll token-läcka, deny-rad med kalltvang-insignalerna (knowledge_bound=true, has_citations=false, outflow≥medium) i input_snapshot (failures persisteras inte som kolumn); mcp-gateway stoppad ⇒ kunskapsbunden chat degraderar terminalt (aldrig tyst kunskapslöst svar); (s) fas R3, test_preview_policy_gated_through_chain: preview-vägen policy-prövad genom hela kedjan — tenant_admin genom BFF:n (/api/mcp/v1/admin/knowledge/preview) mot en seedad kollektion, ett utkast syns ENDAST med include_drafts=true, svaret bär policy.decision='allow' + parsbart decision_id + expired_amounts (utgånget beloppsblock prefixat i answer), WORM-rad input_snapshot->>'action'='knowledge_preview' (trace-scopad, assistant_id IS NULL), källtvångssvaret vid tom kollektion, 503 policy_unavailable + Retry-After: 5 + no-store direkt mot gatewayen när policy-engine stoppas (fail-closed, aldrig förbi decide); (t) fas R3, test_knowledge_published_triggers_eval: publicera dokument i en kollektion bunden till en assistent ⇒ eval-api får inom 60 s en run med trigger='knowledge_published' för den assistenten (körningens STATUS låses inte — riggen saknar en riktig LLM, existensen av raden är belägget, V3); ett utkast i samma kollektion triggar INTE (räkningen oförändrad efter grace-fönstret); eval-api nere ⇒ publiceringens ingest-flöde förblir grönt (best-effort låst åt båda hållen, D-19); (u) fas R3, test_sar_covers_authoring_metadata (MÅSTE ligga sist i modulen — pseudonymiserar realm-användaren testuser): SAR-exporten bär rag.documents.authoring_metadata i coverage.covered med subjektets dokument (updated_by/classified_by = subjektets sub, metadata-only — content_markdown saknas), erase pseudonymiserar auth.users-raden medan rag-apis documents-rader förblir ORÖRDA (updated_by/classified_by står kvar som UUID — pseudonym-by-construction bevisad direkt i databasen, ingen erase-väg mot rag-api).

services/auth-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.15.0 → 1.15.1.)

Verifierad/uppdaterad 2026-10-08 (#655, krav D3/D4, D-664 — utdraget per person lämnar inget konversationsinnehåll till tenant-adminen. POST /v1/admin/tenants/{t}/users/{id}/export anropar inte längre bff:s /gdpr/users/{uid}/conversations/export; conversations är {} och står i _NOT_COVERED med skälet content_disclosure_break_glass_only. GdprDownstreamClient.conversations_export är borttagen. Ny konstant CONTENT_DISCLOSURE_SCOPE = "innehall:utlamna" i app/services/scope_matrix.py: ingen roll ger den, och tenant-adminens allowed_scopes-skribent skriver bara token:delegate/token:channel. Den bärs av en tenant-lös driftklient, provisionerad som tenant:export:read. Runbook: docs/runbooks/innehallsutlamning.md.)

Verifierad/uppdaterad 2026-10-08 (#621, P48, D-648 — ny scope tenant:usage:read, dokumenterad i app/services/scope_matrix.py bredvid tenant:export:read. Ingen roll ger den. Den bärs bara av en tenant-lös platform-service-klient via service_clients.allowed_scopes och utfärdas med POST /v1/token/client som förut. Ingen kodväg i auth-api ändras. Låst i tests/unit/test_oauth_scope_capping.py. Provisionering och återkallelse: docs/runbooks/operator-forbrukning.md. appVersion 1.14.2 → 1.14.3.)

Verifierad/uppdaterad 2026-10-08 (#408, #660 G1 — chartet sätter SAR-vägens fem nedströmsadresser. charts/auth-api/values.yaml har config.policyEngineUrl, llmGatewayUrl, feedbackApiUrl, bffUrl och ragApiUrl med klusterinterna defaults (http://policy-engine:8080, http://llm-gateway:8082, http://feedback-api:8083, http://bff:8084, http://rag-api:8080), och ConfigMapen bär dem som POLICY_ENGINE_URL … RAG_API_URL. Före ändringen kunde chartet inte sätta någon av dem, så get_gdpr_client gav None och export/radering svarade 503 gdpr_downstream_unconfigured i varje instans. Portarna är desamma som NetworkPolicyns egress-regler. Ingen kodändring; chart version 0.9.0 → 0.10.0.)

Verifierad/uppdaterad 2026-10-07 (P43, #607 — registerutdraget och raderingen frågar policy-engine i båda identitetsrymderna. policy_decisions.user_id_hash bär auth.users.id orört från chattvägen och aktörsvärdet (D-113) från mcp-gateway. app/api/admin/gdpr.py::_decisions och ::_decision_traces frågar /v1/admin/decisions respektive /decisions/traces med båda värdena och slår ihop svaren. Totalen summeras, så trunkering i en av rymderna ger fortfarande ett CoverageGap. Aktörsvärdet räknas av libs.audit_chain.actor_value, samma funktion som mcp-gateway använder. Feedback på verktygsvägens spår exporteras och raderas nu också. appVersion 1.14.1 → 1.14.2.)

Stämpelhistorik — 25 tidigare verifieringar
  • Föregående 2026-10-08 (#621, P48, D-648 — ny scope tenant:usage:read, dokumenterad i app/services/scope_matrix.py bredvid tenant:export:read. Ingen roll ger den. Den bärs bara av en tenant-lös platform-service-klient via service_clients.allowed_scopes och utfärdas med POST /v1/token/client som förut. Ingen kodväg i auth-api ändras. Låst i tests/unit/test_oauth_scope_capping.py. Provisionering och återkallelse: docs/runbooks/operator-forbrukning.md. appVersion 1.14.2 → 1.14.3.)
  • Föregående 2026-10-06 (P76, D-174 — lifecycle:activation_check bärs av tenant_admin och assistant_editor, samma roller som har policy:write och får aktivera via provisioning-api. Grinden är en läsning. provisioning-apis service-client bär scopet som förut. appVersion 1.14.0 → 1.14.1.)
  • Föregående 2026-10-04 (P17, #485, D-169 — anropstaken är ett överbelastningsskydd för hela plattformen. slowapi-limitern nycklar på konstanten PLATFORM_KEY (app/rate_limit.py::platform_key) i stället för peer-adressen, och OAuth-dörrens ASGI-lager (app/api/oauth.py::_refuse_if_over_limit) använder samma nyckel. Bakom ingressen var adressen ändå alltid ingressens; nu betyder talet samma sak i alla miljöer. Nya standardvärden, satta efter den legitima toppen vid 10k samtidiga användare (härledningen i D-169): RATE_LIMIT_TOKEN_EXCHANGE, _TOKEN_DELEGATE, _ME och RATE_LIMIT_OAUTH_TOKEN 3000/minute; _TOKEN_CLIENT, _TOKEN_CHANNEL, _ADMIN och RATE_LIMIT_OAUTH_AUTHORIZE 600/minute. Credential stuffing bromsas fortfarande per konto i app/account_backoff.py. Inga chart-values ändrade; appVersion 1.13.0 → 1.14.0.)
  • Föregående 2026-10-04 (P38, #144 — OAuth-callbackens JSON-felsvar bär trace_id. När biljetten inte går att verifiera svarar GET /v1/oauth/callback 400 {"error": "access_denied", "trace_id": "…"} i stället för bara error, samma trace_id som x-trace-id. Omdirigeringsgrenen är oförändrad; klientens redirect-URI får inga nya parametrar. appVersion 1.12.0 → 1.13.0.)
  • Föregående 2026-10-04 (D-166, P38 #144 — OAuth-raderna gallras, och GDPR-motiveringen är rättad. Ny entrypoint app/retention_sweep.py och ett dygns-CronJob (charts/auth-api/templates/cronjob.yaml, namn <release>-auth-api-retention-sweep, schema 37 3 * * *, concurrencyPolicy: Forbid, activeDeadlineSeconds: 3600) raderar auth.oauth_codes 24 h efter expires_at och auth.oauth_refresh_tokens när kedjans chain_expires_at passerat. Händelserna är oauth_retention_sweep_done/_failed; ett fel ger exit 1. Jobbet får bara PLATFORM_DB_URL (egen SweepSettings), inte signeringsnyckeln eller IdP-hemligheten. NetworkPolicyns podSelector är nu app: auth-api ensam, så att jobbpodden med eget app.kubernetes.io/name omfattas av samma egress (mönstret från bff, D-94). SAR-raden nedan: erasure motiveras inte längre med ON DELETE CASCADE, som mark_erased aldrig utlöser. appVersion 1.11.0 → 1.12.0, chart version 0.8.0 → 0.9.0.)
  • Föregående 2026-10-04 (P38, #144 — två OAuth-vägar som saknade auditrad skriver nu en. register_client (dynamisk registrering, av som default) skriver oauth.client.registered (aktör anonymous, metadata client_id/grant_types/scope) i samma transaktion som klientraden; OAuthClientRepo.create committar inte längre själv. En redan inlöst auktoriseringskod som presenteras igen ger oauth.code.replayed (metadata client_id, presented_by, consumed_at) från load_authorization_code, eftersom SDK:n nekar innan exchange_authorization_code nås. Okänd och utgången kod skriver fortfarande ingenting. Svaren utåt är oförändrade. appVersion 1.10.1 → 1.11.0)
  • Föregående 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Ingen beteendeändring här: flaggan fanns sedan #143. Låset flyttas från tests/unit/test_dockerfile_access_log.py, som tas bort, till flottvakten. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.10.0 → 1.10.1.)
  • Föregående 2026-10-04 (P17, #485 — misslyckade försök räknas per angripet konto, inte per avsändare. De tre rutter som verifierar en klienthemlighet (/v1/token/client, /delegate, /channel) går genom tokens.py::_authenticate_client, som räknar misslyckanden per client_id i samma delade store som slowapi (app/account_backoff.py, nyckeln är en sha256 av client_id, okända konton räknas likadant). Efter ACCOUNT_BACKOFF_FREE_ATTEMPTS (5) misslyckanden inom ACCOUNT_BACKOFF_WINDOW_SECONDS (900) får kontot en spärrtid som börjar på ACCOUNT_BACKOFF_BASE_SECONDS (1) och dubblas per misslyckande upp till ACCOUNT_BACKOFF_MAX_SECONDS (60). Under spärrtiden svarar rutten 429 + Retry-After (detail: account_backoff) utan att pröva hemligheten, så gissningen bromsas och ingen argon2-hash körs. Backoff, inte låsning: efter spärrtiden prövas hemligheten igen och ett lyckat försök nollställer räknaren; andra konton påverkas aldrig. Otillgänglig store ⇒ 503 + Retry-After, ingen prövning (fail-closed). De adressnycklade slowapi-taken står kvar oförändrade och är fortfarande i praktiken globala bakom ingressen (#219); omräkningen av dem är en senare del av P17. Inga chart-values ändrade (standardvärdena ligger i config.py); appVersion 1.9.0 → 1.10.0.)
  • Föregående 2026-09-15 (D-146, P3 — kanalkonto → person, som en tjänsteyta i auth-api. Ny tabell auth.channel_identities (migration 0010: (tenant_id, channel, external_team_id, external_user_id) unikt, FK mot auth.users och okvalificerad tenants med cascade). Ny rutt POST /v1/token/channel (Basic service-client med nytt scope token:channel): slår upp mappningen och myntar personens platform-user-token med adaptern som act, roller/grupper ur databasen via RoleResolver; saknas raden är svaret 403 channel_identity_unmapped, ingen tjänstekonto-fallback (#208, OWASP ASI03), låst av tools/checks/check_no_channel_service_fallback.py (pre-commit; syntaktisk, täckningsgräns i filen). Ny admin-router app/api/admin/channel_identities.py: GET/PUT/DELETE /v1/admin/tenants/{t}/channel-identities[/{channel}/{team_id}/{external_user_id}], tenant_admin, e-post in → users.id lagras, kräver active, auditrader channel_identity.mapped|unmapped i samma transaktion. Skribent för scopet: PUT/DELETE .../service-clients/{id}/channel, ServiceClientOut.channel_allowed. GDPR: auth.channel_identities i _COVERED, exporteras under channel_identities och raderas i erase-transaktionen (channel_identities_erased i svaret); tenant-exit-exporten listar tabellen. Ingen Slack-mottagare, app eller signeringsnyckel: det kommer med P4/P5. appVersion 1.8.0 → 1.9.0, chart version 0.7.0 → 0.8.0 (ny values-nyckel rateLimitTokenChannel → RATE_LIMIT_TOKEN_CHANNEL; kanaladaptern är en M2M-anropare för alla tenantens användare, så taket dimensioneras efter aktiva användare / TTL, inte per människa).)
  • Föregående 2026-08-30 (D-131 — subjekt-tokenets aud är inte längre en spärr utan en allowlist. /v1/token/delegates spärr 2 prövar tokenet först UTAN expected_audience (oförändrad kodväg för /v1/token/exchange-tokens), och därefter mot DELEGATABLE_AUDIENCES — kommaseparerad, tom default, ny nyckel i chartens configmap som bara renderas när den är satt. Listan prövas post för post och aud läses ALDRIG ur det overifierade tokenet: bara värden vi själva konfigurerat testas, och PyJWT avgör kryptografiskt om tokenet bär dem. Tom lista = exakt dagens beteende, och P2a:s test_subject_token_with_audience_cannot_be_delegated står orört kvar som beviset för det. ExpiredTokenError kortsluter loopen så felskälet inte degraderar till subject_token_invalid i svaret och i auditkedjan. Upphäver asymmetrin D-129 skrev ut: både det UTFÄRDADE och det SUBJEKT-buret tokenet får nu bära aud. Gatewayens sida är inte byggd — DelegationNotPossible reses fortfarande före anropet hit (D-131 Kvarstår 2). appVersion 1.7.0 → 1.8.0, chart version 0.6.1 → 0.7.0 (BÅDE templates och values ändrades, till skillnad från D-129).).
  • Föregående 2026-08-29 (D-129 — /v1/token/delegate kan binda resultatet till en mottagare. Nytt VALFRITT audience på TokenDelegateRequest, vidare till mint_user(audience=...) som redan kunde sätta aud. Rutten kunde delegera men inte binda, och P2:s första klart-när kräver en audience-bunden token — utan aud går tokenet att spela vidare mot vilken tjänst som helst som litar på utfärdaren. Additivt: utelämnat fält ger exakt det obundna token som förut, låst av test_omitted_audience_is_byte_for_byte_the_old_behaviour. Asymmetrin står kvar och är avsiktlig: det UTFÄRDADE tokenet får bära aud, men SUBJEKT-tokenet får det fortfarande inte (spärr 2, D-101 Kvarstår 7) — och det är precis därför mcp-gatewayens MCP-dörr inte kan delegera. appVersion 1.6.0 → 1.7.0.).
  • Föregående 2026-08-29 (D-125 — delegeringsrätten får en skribent. Ny admin-router app/api/admin/service_clients.py: GET /v1/admin/tenants/{t}/service-clients plus PUT/DELETE .../{client_id}/delegation, som slår på och av token:delegate i service_clients.allowed_scopes. Fram till nu skrev INGEN kodväg i repot den kolumnen — scopet kunde bara beviljas med handskriven SQL mot kundens databas, och den raden lämnade inget spår, till skillnad från delegeringarna den möjliggjorde. Ytan är require_tenant_admin-grindad, bara tenantens egna klienter (tenant_id IS NULL är operatörsplanet och osynligt här), och additiv: array_append/array_remove rör bara token:delegate, aldrig andra scopes som tenant:export:read (D-60). Beviljande och återkallande skrivs till auth_events.v1 i samma transaktion som kolumnändringen; en oförändrad rad ger ingen auditrad. Klientraden SKAPAS fortfarande bara av break-glass-CLI:t, så provisioneringen är kvar hos ops — det som flyttade är flippen, och scope_matrix.pys samt tokens.py:352:s påstående att ingen kodväg skriver kolumnen är rättat i samma PR. _NOT_COVERED i app/api/admin/gdpr.py fick också en rad: mcp.access_grants ligger i en TENANT-LOKAL databas som auth-apis raderingsväg inte når, och det står nu utskrivet i stället för underförstått. Låst av tests/integration/test_admin_service_clients.py — POST /v1/token/delegate går 403 → 200 → 403 genom ytan, och båda ändringarna ligger i kedjan).
  • Föregående 2026-08-26 (D-115 — SAR-täckningen deklarerar innehållsspåret som en LUCKA. model_call_content (llm-gateways nya maskerade innehållsspår, P12) nycklas på trace_id och bär medvetet ingen subjektsidentifierare, alltså går den inte att scopea en art. 15-export mot. D-31 kräver att ett sådant store DEKLARERAS i _NOT_COVERED i stället för att tyst utelämnas — exporten får aldrig vara falskt komplett — och raden ligger nu där, låst av test_admin_gdpr_export.py. Ingen ytändring, ingen ny läsväg: exportsvaret får en post till i sin luckförteckning).
  • Föregående 2026-08-26 (D-114 — ingen provisionering före auktoriseringsbeslutet. OAuth-callbacken LÄSER nu vem personen är (_befintlig_anvandare, ren läsning som även ser en oclaimad inbjudan), beslutar, och anropar jit.upsert FÖRST på den godkända grenen. Fram till nu provisionerades personen före beslutet, så en utomstående som nekades ändå fick e-post, idp_sub, sina grupper från den EGNA organisationen och status='active' inskrivna i kundens auth.users — synliga i tenant-adminens lista, eftersom list_by_tenant inte filtrerar på status. Mätt i #269, vägval (a) i #268. Nekandets auditrad bär nu actor_type="anonymous" och en HASH av idp_sub i metadata när personen inte finns hos oss — D-31:s id-only-regel är oförändrad, en hash är inte idp_sub, och e-post/namn/grupper skrivs fortfarande aldrig. RoleResolver.resolve tar user_id=None. Tidigare stämpel 2026-08-25: D-111 — två hårda-regelbrott i OAuth-ingången lagade, och stöldsdetektionens race mätt. OAuthCodeRepo.create committar inte längre: kodraden och oauth.authorization.granted ligger nu i SAMMA transaktion, så garantin är strukturell i stället för att vila på satsordningen i callbacken. Uvicorn startas med --no-access-log — dess egen access-logger skrev IdP:ns auktoriseringskod ur callbackens query-sträng till stdout vid varje inloggning, en kredential i logg. revoke_chains race mot samtidig utfärdning är lagat (D-112, vägval a i #257): rotate committar inte längre, och _issue_pair körs i anroparens transaktion på rotationsvägen, så bränningen och efterföljaren står eller faller tillsammans och stöldsdetektionen kan inte svepa förbi emellan. Priset är att radlåset hålls över subjektuppslag och mintning — lasteffekten är omätt. D-112 ersätter D-71:s resonemang om vem som committar. Tidigare stämpel 2026-08-18: D-101 — tjänsten utfärdar delegerade tokens: ny rutt POST /v1/token/delegate (RFC 8693), och act finns för första gången i plattformen. Agenten skickar användarens token som subject_token och får tillbaka ett token där sub fortfarande är människan och act = {"sub": "<client_id>"} namnger agenten (app/services/token_minter.py::mint_user(act_sub=...) — parametern är en sträng, inte en dict, så ett malformat act inte kan myntas härifrån). Grinden är token:delegate i den befintliga service_clients.allowed_scopes — ingen ny tabell, ingen ny kolumn, ingen migration. Scopet beviljas aldrig via rollmappning från SSO (samma kategori som tenant:export:read, D-60; skälet står i scope_matrix.pys docstring), och ingen kodväg i repot skriver allowed_scopes — varken en rutt, ett CLI-kommando eller en seed — så scopet kan bara beviljas med handskriven SQL (D-101 Kvarstår 1, samma lucka som P19 beskriver för access_grants). Vad en driftdatabas innehåller är inte mätt och påstås därför inte; grinden testar medlemskap i listan, aldrig IS NULL. Sex spärrar, i ordning: (1) klienten måste bära token:delegate; (2) subjekt-tokenet verifieras mot den lokala nyckelkällan (get_local_key_source(), samma som /v1/me — inte JWKS över nätet, D-92); (3) typ måste vara platform-user, annars hade mint_service:s client_id-i-sub gjort en robot till falsk människa; (4) act får inte redan finnas — inga delegeringskedjor, annars kan en agent tvätta bort sitt eget spår genom att delegera vidare; (5) en tenant-bunden klient kan inte delegera i en annan tenant, medan en tenantlös klient får göra det med flit (plattformsplanets agent, samma form som token_client; säkerheten bärs av att anroparen redan måste ha användarens giltiga token, inte av det if:et — låst av test_tenantless_client_may_delegate_in_any_tenant); (6) subjektets auth.users-rad måste finnas och vara active — en avstängd eller raderad person kan inte delegeras åt, med ordagrant samma skäl som /v1/token/exchange svarar (user_disabled/user_erased), och allt annat (saknad rad, invited) nekas som subject_user_unknown. Spärren stänger en asymmetri mellan plattformens två myntningsvägar: utan den kunde en agent som håller en raderad persons ännu giltiga token få en FÄRSK legitimation i hennes namn, plus en token.minted.delegated-rad i WORM-kedjan med hennes plattforms-id skriven EFTER raderingen. Aldrig bredare, aldrig längre: roller, scopes och grupper kopieras verbatim ur subjekt-tokenet — inget från agenten — och TTL:n kapas till subjektets återstående liv. Varje nekande EFTER klientidentifieringen skrivs till auth_events.v1 och COMMITTAS före sitt raise — tio av ruttens tolv nekandevägar, alla genom _deny_delegation, inklusive de fyra som avvisar själva subjekt-tokenet (stulet, utgånget, tjänste-, redan delegerat), eftersom det är just de vägarna en agent som sonderar en legitimationsutfärdande rutt går. De två som INTE auditeras är missing_basic_auth och malformed_basic_auth i det delade _parse_basic (samma funktion /v1/token/client använder; tokens.py:79 och :324 är dess enda anropare): de reser innan skrivaren är rörd, eftersom klienten då är oidentifierad och det inte finns någon actor_id att skriva — men följden är att ett skräpanrop mot rutten inte lämnar något spår alls (D-101 Kvarstår 8). Metadata bär bara {"reason": ...}, aldrig tokenet eller dess claims (GDPR first), och nekanderadens tenant_id är alltid klientens, aldrig subjekt-tokenets: tokenet är i flera fall just det som inte gick att lita på, och en tenant läst ur det hade låtit en angripare välja vilken kedja hans sondering hamnar i. Nytt tak rate_limit_token_delegate (default 60/minute, samma som syskonrutterna — och per ingress-podd, inte per klient, issue #219). Känd gräns, medvetet inte “fixad”: ett subjekt-token som bär aud går inte att delegera. Verifieraren konstrueras utan expected_audience och JWTVerifier är binär i den frågan (D-71), så tokens från OAuth-/RFC 8707-vägen svarar 401 subject_token_invalid medan tokens från /v1/token/exchange fungerar; att skicka en audience hade försvagat audience-isoleringen, som är en säkerhetsegenskap (D-101 Kvarstår 7, låst av test_subject_token_with_audience_cannot_be_delegated). Rutten UPPRÄTTHÅLLER ingenting — att avvisa anrop utan act är P2. Och den har ingen anropare i dag: ingen agent finns än (P4 obyggd), så den är bevisad i sviten, inte i drift (Kvarstår 2). _AUDIT_EVENTS_V1_FIELDS är mekaniskt låst till exakt fem fält (tests/integration/test_audit_chain_act.py): auth_events.v1 och mcp-gatewayens mcp_tool_calls.v1 delar whitelist-konstant i validatorns register (tools/audit-chain/audit_chain_cli/validator.py), utan per-rad-versionering, så ett sjätte fält hade räknat om varje historisk rad med fältet satt till None och rapporterat båda kedjorna som manipulerade från rad ett. act ligger därför i metadata, som redan hashas. Noll nya tabeller, noll nya kolumner, noll migrationer. appVersion 1.3.3 → 1.4.0 (minor: ny rutt och nytt beteende, inte en bugfix) — utan bump ser Argo ingen diff och rullar inte ut den nya koden (D-89). Chartens egen version är MEDVETET orörd på 0.6.0: inga templates och inga values ändrades, samma prejudikat som D-99, D-100 och D-77.)
  • Föregående 2026-08-17 (D-99 — INGEN KODÄNDRING i tjänsten. sub har fått en ny bindande konsument, och det är hela poängen med stämpeln. Mcp-gateway nycklar sedan D-99 access_grants.subject på platform:<auth.users.id> — alltså på exakt det värde den här tjänsten sätter i sub (app/services/token_minter.py:47, via JITProvisioner.upsert → jit_provisioner.py:39). Följden: sub är numera bärande för VERKTYGSBEHÖRIGHET i en annan tjänst, inte bara för identitet. Att ändra vad sub betyder — byta det till ett externt id, återanvända det, göra det per-session, eller låta en annan tokentyp bära ett icke-UUID där — bryter auktorisation i mcp-gateway, och bryter den i den form D-99 finns för att avskaffa: en grant som ser konfigurerad ut och matchar ingen. Databasen på mottagarsidan kräver ett gement, bindestreckat UUID efter taggen, så en icke-UUID-form gör granten omöjlig att ens spara. Tre egenskaper som gör detta säkert i dag, och som därför är kontrakt snarare än detaljer: (1) sub för en användartoken ÄR str(auth.users.id); (2) tjänstetokens bär client_id i sub (token_minter.py:75), och det som håller en sådan identitet borta från grant-tabellen är DB-predikatet, inte en typ-kontroll vid MCP-dörren — dörren verifierar via mcp-gatewayens PlatformTokenVerifier → libs/platform_auths JWTVerifier, vars _ALLOWED_TYPS släpper igenom BÅDE platform-user och platform-service (libs/platform_auth/libs/platform_auth/verifier.py:15); den här tjänstens StackenOAuthProvider.load_access_token, som mycket riktigt avvisar typ != "platform-user" (app/services/oauth_provider.py:694), ligger på SDK-vägen och anropas aldrig av gatewayen. client_id är fri Text och seedas för hand, så ett tjänstesubjekt blir platform:<client_id> och kan inte sparas som grant om inte värdet råkar vara ett kanoniskt gement UUID (D-99 Kvarstår 10); (3) JWT:n bär ingen email-claim (claims-listan sätts på token_minter.py:45-61), vilket är skälet till att D-99 förkastade e-post som grant-nyckel utan att behöva ändra något här. groups-claimen är dessutom skälet till att mcp-gatewayens HTTP-dörr inte behöver något kataloguppslag: claimen är auth-apis snitt mot tenantens group_mappings (app/repositories/role_repo.py:54-69), alltså är uppslaget redan gjort, hos den tjänst som äger det. Två egenskaper i raderingsvägen som D-99 vilar på och som ingen får ändra oreflekterat: mark_erased nollar email, idp_sub, display_name och idp_groups (app/repositories/user_repo.py:70-85) medan ingenting i raderingsvägen rör mcp-gatewayens access_grants (verifierat: noll träffar på mcp/gateway i app/clients/downstream.py och app/api/admin/gdpr.py) — det var precis varför en e-postnycklad grant hade överlevt personen; och upsert_login konfliktar på (tenant_id, idp_sub) (user_repo.py:130-150), så en raderad rad (idp_sub = NULL) ger en återvändande person en NY rad med ett NYTT id, som därför inte kan ärva gamla grants. Baksidan av samma egenskap: en raderad person får tyst en ny identitet vid nästa inloggning — radering nekar inte senare inloggningar (D-99 Kvarstår 6, eget ärende). Noll kodändringar, noll migrationer, noll chart-ändringar, appVersion orörd.)
  • Föregående 2026-08-16 (D-98 — OAuth-SDK:ns tre rutter bär tak; D-71 Kvarstår 1 stängd. GET /authorize, POST /token och POST /register var otaklade medan syskonrutterna /v1/token/client och /v1/token/exchange inte var det, och main.py sa uttryckligen att luckan var deklarerad snarare än stängd. Nu: rate_limit_oauth_authorize och rate_limit_oauth_token (båda default 60/minute, samma tak och samma skäl som syskonrutterna — det är samma delade auth-api som ska stå emot). Mekaniken är asymmetrisk med flit. /authorize ÄR en funktion och går den vanliga slowapi-dekoratorvägen — men som en modulnivå-_authorize_endpoint dekorerad EN gång vid import, som bygger sin egen AuthorizationHandler(get_oauth_provider()) per request; slowapis registry är processglobal och nycklad på module.func utan att någonsin rensas, så en dekorerad closure per create_app() ackumulerade dubbletter och multiplicerade träffräkningen (mätt: två create_app() gav två träffar per verkligt anrop). /token och /register är CORS-lindade ASGI-INSTANSER, inte funktioner, och kan inte bära dekoratorn alls — kontrollen ligger därför i _NamedASGIEndpoint, byggd på limits publika API (exakt de klasser slowapi använder internt) över SAMMA RATE_LIMIT_STORAGE_URI, så de två dörrarna inte kan glida isär. Samma lösning som mcp-gatewayens RateLimitedASGI (D-76) för identiskt problem. /register fick tak trots att den är avstängd som default, så att ett OAUTH_DYNAMIC_REGISTRATION_ENABLED=true inte tyst återöppnar hålet; metadata-/discovery-rutten är medvetet otaklad. Otillgänglig store ⇒ /token och /register svarar 503 + Retry-After (fail-closed, ASGI-lagret refuserar innan appen nås); /authorize refuserar också, men inte med samma kuvert — en icke-OSError-store-outage (produktionsfallet: redis-pys ConnectionError ärver RedisError, inte OSError) ger 500 utan Retry-After — fail-closed håller, kuvertet ljuger om orsaken (D-98 Kvarstår 2). Preflights undantas INTE: ett metodbaserat OPTIONS-undantag prövades och ströks efter mätning — Starlettes CORS-kortslutning kräver Origin OCH Access-Control-Request-Method, så ett bart OPTIONS /token (med eller utan formulärkropp) nådde TokenHandler/databasen otaklat; en preflight är dessutom last som vilken annan. Den föräldralösa _authorize_with_cookie är borttagen (cookie-vägen täcks av test_oauth_flow.py). Taket är per ingress-podd, inte per klient: nyckeln är peer-IP och ingen tjänst sätter --proxy-headers/FORWARDED_ALLOW_IPS, så bakom ingress-nginx är varje IP-nycklat tak i praktiken ETT globalt tak — samma egenskap som D-76 uttalade för MCP-dörren, här ärvd utan att gränsvärdena valdes för den (issue #219, det femte hålet). Chart version 0.5.3 → 0.6.0, appVersion 1.3.1 → 1.3.2.)
  • Föregående 2026-08-10 (D-92 — tjänsten verifierar sina EGNA tokens ur nyckelringen i stället för över nätet. get_require_auth byggde auth-dependencyn mot {AUTH_API_ISSUER}/.well-known/jwks.json, alltså ett utgående HTTPS-anrop till det egna publika värdnamnet för att läsa nycklar som redan ligger på disk. Chartens NetworkPolicy har default-deny egress utan regel dit, så hämtningen hängde tills den gav upp och hela /v1/admin/tenants/{tid}/* — users, role-bindings, group-mappings, GDPR-rutterna, tenant-exporten — svarade 5xx i drift (issue #182, mätt på customer-prod). /v1/token/exchange berördes aldrig: den verifierar IdP:ns token, inte plattformens. Mönstret fanns redan i tjänsten — /v1/me har verifierat lokalt hela tiden, med en nästlad _LocalJWKS byggd om per anrop; den är upphissad till dependencies.py::LocalKeySource och används nu av båda vägarna, så det är en kopia mindre och ingen ny väg. libs/platform_auth fick ett KeySource-protokoll och ett valfritt key_source på build_auth_dependency (additivt; de tio andra tjänsterna hämtar fortsatt över HTTP, vilket är rätt — för dem ÄR utfärdaren en fjärrtjänst). En injicerad källa går medvetet FÖRBI _VerifierProvider-singletonen i stället för att tyst ignoreras av den som hann först. Ingen NetworkPolicy-ändring — vägvalet var att ta bort nätverksberoendet, inte att öppna brandväggen för det. Chart version 0.5.2 → 0.5.3, appVersion 1.3.0 → 1.3.1.)
  • Föregående 2026-08-02 (D-85 — OIDC-discovery vid start fäller inte längre podden. lifespan reste RuntimeError vid discovery-fel, så en extern IdP med en dålig minut kraschloopade HELA bff:n — inklusive /healthz, /readyz och publika planet (/public/*), som aldrig rör IdP:n. Försöket är kvar men loggar en strukturerad varning (oidc_discovery_failed_at_boot med issuer/cause/impact) och fortsätter; ett misslyckat försök cachas inte, så första inloggningen gör om det utan omstart. Förutsättningen som följde med: OIDCClient.load() validerar nu FÖRE cachning (issuer-likhet per OIDC Discovery §4.3, tre obligatoriska endpoints, inget https→http-nedgraderande) — porten av D-78:s granskningsfynd i auth-api. Utan den hade lazy boot gjort ett 200-svar med skräp till ett TYST permanent fel i stället för en synlig kraschloop. Ny typad OIDCDiscoveryError; /auth/login och /auth/callback svarar 503 idp_unavailable + Retry-After (var ohanterad 500-väg), /auth/logout faller tillbaka på lokal utloggning även vid oanvändbart dokument (D-81:s semantik). Chart version 0.2.4 → 0.2.5, appVersion 1.1.4 → 1.1.5.)
  • Föregående 2026-08-02 (D-84 — NetworkPolicyns app: keycloak-egress borttagen (issue #157), samma kvarleva som i charts/bff. Symptomet skiljer sig dock och är värt att känna igen: auth-api gör discovery LAZY, så en blockerad IdP ger ingen kraschloop utan en podd som bootar och 503:ar idp_unavailable på varje token-exchange — tjänsten ser frisk ut, inloggningen är det inte. Ingen kodändring, chart version 0.5.0 → 0.5.1.)
  • Föregående 2026-07-31 (D-78 — tjänsten antar inte längre Keycloak, och dess tenant-FK:er pekar på tabellen som faktiskt skrivs. Två ändringar: (1) IdP:ns tre adresser (jwks_uri, authorization_endpoint, token_endpoint) hämtas ur {IDP_ISSUER}/.well-known/openid-configuration (app/services/idp_discovery.py, mönstret kopierat ur services/bff/app/auth/oidc.py) i stället för att konkateneras ur Keycloaks namnkonvention på issuern — mot Google gav konkateneringen tre 404-adresser, så varje token-utbyte 401:ade. KeycloakVerifier/KeycloakRelyingParty heter nu OidcIdpVerifier/OidcRelyingParty, och de tre settingsen KEYCLOAK_* heter IDP_* utan bakåtkompatibel läsning — ett gammalt namn ger field required och en pod som vägrar starta, avsiktligt fail-closed framför en tyst dubbel väg. Discovery är LAZY (första användningen, inte lifespan): auth-api mintar tokens för hela plattformen och en oanträffbar kund-IdP får inte bli ett plattformsavbrott. /v1/token/exchange svarar nu 503 idp_unavailable + Retry-After när discovery eller JWKS inte går att hämta — före passet blev båda 500. (2) De sju tenant-FK:erna (users, group_mappings, role_bindings, service_clients, oauth_clients, oauth_codes, oauth_refresh_tokens) pekar inte längre på platform.tenants utan på okvalificerad tenants — samma namn provisioning-api (ägaren) faktiskt skapar, och samma llm-gatewayens fyra FK:er redan använde. Migration 0009 pekar om befintliga databaser katalogdrivet (läser pg_constraint, bevarar varje constraints ON DELETE, hoppar över den som redan pekar rätt); 0001/0007/0008 är ändrade i samma PR så en FÄRSK databas är rätt direkt. Följden: e2e-riggens spegelrad och platform-schema-bootstrap-platshållaren är BORTA. Chartens idpIssuer är required utan default och det GAMLA namnet keycloakIssuer FÄLLER renderingen (_helpers.tpl::auth-api.idpIssuer) — Helm-values är additiva, så utan grinden hade en overlay som fortfarande skickar det gamla namnet renderat tyst och gett en pod som bootar Ready och 503:ar mot exempeldomänen. Chart version 0.4.0 → 0.5.0, appVersion 1.2.1 → 1.3.0 (imagen ändrad, D-69). STATUS: koden är byggd och bevisad i riggen, INTE utrullad — appVersion 1.3.0 är taggen CI bygger vid merge, och beskrivningarna ovan i presens gäller den imagen, inte den som kör i drift i skrivande stund. Ingen tenant är provisionerad (D-70 Kvarstår 3 kvarstår öppen); MCP-dörren är fortfarande inte i kraft i drift.)
  • Föregående 2026-07-30 (D-77 — OAuth-ingången är nu KÖRD, inte bara byggd. tests/e2e/test_mcp_door.py driver /authorize → Keycloaks HTML-inloggning → /v1/oauth/callback → /token mot en riktig Keycloak och får en plattforms-JWT med aud; auth-api är alltså bevisat både auktoriseringsserver och relying party i samma flöde. Build-avvikelse som bara framkom av att resa ytan: MCP-SDK:ns validate_issuer_url kräver RFC 8414:s HTTPS och undantar bara localhost/127.0.0.1, så AUTH_API_ISSUER=http://auth-api:8080 KRASCHAR tjänsten vid import (ValueError: Issuer URL must be HTTPS) så snart oauth_enabled är sant. Riggen kör därför http://localhost:8080 som issuer i ALLA tjänster; i drift är issuern https och frågan uppstår inte. Undantagsmarkören som tools/checks/check_atlas_freshness.py letar efter är BORTTAGEN här (literalen skrivs medvetet inte ut — vakten gör en naken substrängtest på stämpelraden, så en prosarad som NÄMNER markören stänger av grinden precis som en riktig markör skulle. Det hände i D-77:s första utkast och fångades av arkitekturgranskningen.) — den var permanent eftersom den undantar hela stämpelraden och raden är append-only, se mcp-gateway-sektionens motsvarande not. Ingen kodändring i tjänsten i det här passet. D-76 —
  • Föregående 2026-07-29 (charten kan nu bära OAuth-ytan; ytan är fortfarande INTE i kraft. D-71 Kvarstår 7 var att grep -rn "OAUTH_" charts/auth-api/ gav noll träffar, alltså att en deploy gav en pod där oauth_enabled är falskt och rutterna inte existerar. Nu wirade: fyra config-nycklar i configmap:en (OAUTH_IDP_CLIENT_ID, OAUTH_CALLBACK_URL, OAUTH_DEFAULT_TENANT_ID, OAUTH_RESOURCE) plus hemligheten OAUTH_IDP_CLIENT_SECRET via ExternalSecret. Allt-eller-inget: en DELVIS satt konfiguration FÄLLER helm template (_helpers.tpl::auth-api.oauthEnabled) i stället för att ge en pod som startar fint och saknar /authorize — och hemligheten refereras bara när de fyra är satta, annars fastnar podden i CreateContainerConfigError på en ESO-nyckel som inte synkats. Ingen overlay sätter värdena, och att sätta dem kräver två saker utanför repot: IdP-klientregistrering hos kunden och platform/auth-api/oauth-idp-client-secret i ESO. OAUTH_RESOURCE är dessutom halva ett tvåtjänstkontrakt och sedan nu mekaniskt vaktat mot mcp-gatewayens MCP_RESOURCE_URL (tools/checks/check_mcp_resource_contract.py). Chart-version 0.3.0 → 0.4.0; appVersion MEDVETET orörd — tjänstens image är inte ändrad, och en bump hade fått Argo att rulla ut en tagg som aldrig byggts).
  • Föregående 2026-07-29 (D-71, OAuth-ingången — auth-api är nu ÄVEN OAuth 2.1-auktoriseringsserver för MCP-klienter (SDK:ns egna /authorize, /token, /.well-known/oauth-authorization-server) och samtidigt OIDC relying party mot kundens IdP (GET /v1/oauth/callback); tre nya tabeller oauth_clients/oauth_codes/oauth_refresh_tokens (migrationer 0007–0008), refresh med rotation + kedjeogiltigförklaring, RFC 8707 resource→aud; openid-configuration byte-för-byte oförändrad men länkar vidare till RFC 8414-dokumentet).
  • Föregående 2026-07-26 (D-67, kontraktskärnan paket 3/4 — kontraktet incheckat (openapi.json + driftgrind i CI); tenant-export-svaret till libs.tenant_export (CoverageGap re-exporteras oförändrat i app/schemas/admin.py för SAR-vägens UserExportCoverage; lokala TenantExportCoverage/TenantExport borttagna)).
  • Föregående 2026-07-24 (Wave 3 Post 2, D-60 — ny read-only tenant-export-yta GET /v1/admin/tenants/{tid}/export (app/api/admin/tenant_export.py), scope-grindad tenant:export:read med tenant ur PATH; users/role_bindings/group_mappings/service_clients + kapad audit-sida (500; full sida demoteras till not_covered); inga nya tabeller. Not: entitetsläsningarna är ännu okapade — se D-60:s D-61-kandidater).
  • Föregående 2026-07-14 (fas R3, D-41 — service_clients.allowed_scopes (migration 0006) + mint_service-scope-claimen (D-24:s uppskjutna service-token-scope-beslut fattat), rag_api_url som femte GDPR-nedströms-URL/authoring_export; fas R2:s tools:call-scope-matris/K9/D-40, fas R1:s rag:read/rag:write/authoring-metadata-GDPR-posten/D-39, fas Q:s conversations/uploads SAR-coverage/BFF_URL/D-37 och fas O:s groups-claim + OTel/D-34 och fas M:s SAR-yta/D-31 oförändrade). Samma dygn, D-100: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.
  • Syfte: identitet & RBAC — terminerar IdP-id-tokens till plattforms-JWT:er (token-exchange), JIT-provisionerar användare, resolvar roller, äger admin-planet för medlemmar/gruppmappningar/rollbindningar, och äger SAR-ytan (GDPR export/erasure) för hela plattformen. Plattformens JWKS-utfärdare. Sedan fas R3: äger även den centrala service-token-scope-utfärdningen — enda platsen där en service-klients scope-uppsättning bestäms (D-24:s uppskjutna beslut, nu fattat). Sedan D-71: är tjänsten dessutom OAuth 2.1-auktoriseringsserver för MCP-klienter — och samtidigt relying party mot kundens IdP, alltså två roller i samma process. Ingen ny identitetslogik: OAuth-flödet mynnar ut i exakt samma jit_provisioner → role_resolver → scope_matrix → mint_user som /v1/token/exchange redan använde, och den rutten är orörd.
  • Postgres: schema auth: roles, users, group_mappings, role_bindings, channel_identities (sedan P3/D-146, kanalkonto → users.id), service_clients (sedan fas R3: allowed_scopes TEXT[] | None, migration 0006, ADD-only), audit_events (+ delad audit.chain_heads via libs/audit_chain), sedan D-71: oauth_clients (registrerade MCP-klienter; CHECK kräver hemlighet om auth-metoden inte är none), oauth_codes (auktoriseringskoder, code_hash aldrig klartext, CHECK code_challenge_method='S256') och oauth_refresh_tokens (token_hash, chain_id, chain_expires_at, consumed_at, revoked_at — migrationer 0007–0008). Versionstabell alembic_version_auth_api, 10 migrationer (0010: channel_identities, P3/D-146) (0009: de sju tenant-FK:erna pekade om från platform.tenants till OKVALIFICERAD tenants, D-78 — katalogdriven och idempotent, se stämpeln). users bär idp_groups text[] (snapshot per inloggning) och status active|disabled|invited|erased (terminal, D-31) med partiellt unikt index för öppna inbjudningar (D-29).
  • HTTP-yta:
    • Discovery/health: GET /.well-known/jwks.json, GET /.well-known/openid-configuration, GET /healthz|/readyz — ingen auth. Sedan D-71 även GET /.well-known/oauth-authorization-server (RFC 8414, rest bara när OAuth-ytan är konfigurerad). De två discovery-dokumenten beskriver två olika ytor och pekar med flit på olika token_endpoint: openid-configuration beskriver maskinidentitetsvägen (/v1/token/client, client_credentials, client_secret_basic) och är byte-för-byte oförändrad sedan tjänsten byggdes — den pekas aldrig om, eftersom externa integratörer läser den; 8414-dokumentet beskriver auktoriseringsserverns yta (/token, authorization_code + refresh_token, token_endpoint_auth_methods_supported: ["none"]). openid-configuration bär en länk (oauth_authorization_server_metadata) till det andra dokumentet, satt bara när ytan faktiskt är rest.
    • OAuth-ytan (D-71, rest bara när oauth_enabled, som är all-or-none över fem settings): SDK:ns (mcp 1.28.1) egna rutter GET /authorize, POST /token, monterade av app/api/oauth.py::build_sdk_routes — /authorize wrappad för biljett-cookien, metadatarutten ombyggd för RFC 8707-fältet. Vår egen GET /v1/oauth/callback tar emot användaren tillbaka från kundens IdP. /register (RFC 7591) och /revoke (RFC 7009) är AV: dynamisk registrering är ett driftbeslut (OAUTH_DYNAMIC_REGISTRATION_ENABLED, default falskt), och revokeringsrutten reses inte eftersom access tokens är självbärande JWT:er utan denylist — provider.revoke_token dödar däremot refresh-kedjan om den anropas.
    • Tokens: POST /v1/token/client (Basic client_id:secret — singular token, inte tokens; kontraktsrättelse fas R3) mintar en service-JWT vars scope-claim sedan fas R3 (D-41) bakas in ur klientradens service_clients.allowed_scopes (app/api/tokens.py::token_client → minter.mint_service(scopes=client.allowed_scopes)) — klienten kan aldrig self-select sitt eget scope, radens värde är sanningskällan. POST /v1/token/exchange (IdP-id-token → plattforms-JWT; JIT-upsert + fail-closed invite-claim + roll-resolution; denies user_disabled/no_roles med audit före raise). Sedan fas O (D-34) mintas även en groups-claim i plattforms-JWT:n, filtrerad mot tenantens group_mappings (aldrig hela IdP-gruppslistan — begränsad claim-storlek, ingen läcka av omappade gruppnamn); llm-gatewayens datapath läser claimen vidare som X-User-Groups. GET /v1/me (egen JWT → claims). Sedan D-101: POST /v1/token/delegate (Basic client_id:secret, RFC 8693 — subjekt-token in, delegerat token ut med sub = människan och act.sub = klientens client_id), grindad på token:delegate i service_clients.allowed_scopes; scopet beviljas aldrig via rollmappning och ingen kodväg i repot skriver kolumnen, så det kan bara beviljas med handskriven SQL (ingen admin-yta — D-101 Kvarstår 1). Sex spärrar (den sjätte: subjektets auth.users-rad måste vara active), alla nekanden auditerade och committade före raise; rate_limit_token_delegate default 3000/minute för hela plattformen (D-169). Sedan P3/D-146: POST /v1/token/channel (Basic service-client med token:channel) byter (channel, team_id, external_user_id) mot personens token via auth.channel_identities; omappad = 403 channel_identity_unmapped, audit token.channel.denied|issued.
  • Central service-token-scope-utfärdning (fas R3, ny, D-41, spec V3): löser eval-triggerns auth-söm (granskningsjusteringen K-1 i plangranskningen 2026-07-14 — specens första förslag var ett tredje require_user_scope-mönster i eval-api, fällt mot D-24 som pensionerade per-tjänst-bryggan “innan mönstret kopieras en tredje gång”). rag-api provisioneras en service_clients-rad med allowed_scopes=['eval:write'] (e2e-fröet: tests/e2e/conftest.py::_seed_eval_service_client, direkt-DB-insert som hashar hemligheten med samma hash_secret/argon2 som token_client-vägen verifierar mot — fröet och verifieringen kan aldrig glida isär; prod-motsvarigheten är en runbook-rad, se README), mintar en service-JWT via /v1/token/client, och eval-apis require_scope("eval:write") förblir HELT ORÖRD — den släpper igenom JWT:n för att den nu faktiskt bär scopet.
    • Admin /v1/admin/tenants/{t}/… — gate: platform-user-JWT + tenant-match claim↔path + tenant_admin-roll: users (GET lista med effektiva roller, POST invite, POST /{id}/revoke, POST /{id}/enable), group-mappings (GET aggregerat per grupp, POST {idp_group, roles[]} merge, DELETE ?idp_group=), role-bindings (GET aggregerat per användare, POST {user_id, roles[]}, DELETE /{user_id}), users/{id}/export (POST — subjekt-scopat SAR-dokument: profil, roller, lokala audit-events, policy-beslut, usage, feedback, sedan fas Q: konversationer + uploads; coverage-deklaration för det som inte täcks), users/{id}/erase (POST — GDPR-radering, kräver status=disabled, sveper nedströms innan pseudonymisering; 503 gdpr_downstream_* fail-closed på nedströmsfel).
  • SAR-coverage (fas Q, D-37; utökad fas R3, D-41): app/api/admin/gdpr.py::_COVERED/_NOT_COVERED — conversations (BFF:ns bff_chat-schema, inkl. uploads) flyttad från _NOT_COVERED till _COVERED; täckt via en ny GdprDownstreamClient-metod mot BFF:ns /gdpr/users/{uid}/conversations/export|erase (samma forwardade-JWT-mönster som policy/llm-gateway/feedback). Sedan fas R3: rag.documents.authoring_metadata flyttad från _NOT_COVERED till _COVERED — täckt via GdprDownstreamClient.authoring_export() mot rag-apis GET /gdpr/users/{uid}/authoring/export (samma forwardade-JWT-mönster; export-only, ingen erase — se rag-api-sektionen). Nya config-fält bff_url (fas Q) och rag_api_url (fas R3) — de FEM GDPR-nedströms-URL:erna (POLICY_ENGINE_URL/LLM_GATEWAY_URL/FEEDBACK_API_URL/BFF_URL/RAG_API_URL) är all-or-none: saknas EN, blir klienten None och export/erase 503:ar fail-closed på hela SAR-vägen (chartet sätter alla fem med klusterinterna defaults sedan #408; före dess saknades de i varje instans) (empiriskt e2e-fynd, fas Q task 14 — BFF_URL saknades i e2e-stacken och bröt SAR tyst, träffade även det äldre lås (c); mönstret upprepat vid tillägget av RAG_API_URL). Notera namnkollisionen (D-41): denna RAG_API_URL är en ANNAN setting än mcp-gatewayens RAG_API_URL (kollektionskorts-URL:en för preview, se mcp-gateway-sektionen) — två tjänster, två oberoende betydelser, samma env-namn av en händelse. Sedan D-71: auth.oauth_codes och auth.oauth_refresh_tokens är med i _COVERED — subjektlänkade via user_id, alltså personuppgifter. Ingen egen erasure-väg (D-166): mark_erased pseudonymiserar auth.users med en UPDATE, så kaskaden utlöses aldrig; raderna bär efter radering bara ett UUID utan e-post, idp_sub och namn, en raderad användares kedja kan inte förnyas, och dygnsronden gallrar dem (koder 24 h efter expires_at, refresh-rader efter chain_expires_at, högst 30 dygn). Varken code_hash eller token_hash exporteras: art. 15 ger rätt till uppgifterna OM en kredential, inte till en kredential som fortfarande kan lösas in. chain_id exporteras däremot — den är inte hemlig och är det som gör raderna läsbara som sessioner. . Sedan #655 (D3/D4) tillbaka i _NOT_COVERED (content_disclosure_break_glass_only): utdraget hämtar inte innehållet och conversations är {}; bara erase anropar BFF
  • SAR i två identitetsrymder (P43, stacken#607): policybesluten och deras trace_id hämtas med både auth.users.id (U, skrivet av bff/llm-gateway/agent-runtime) och libs.audit_chain.actor_value(uid) (H, skrivet av mcp-gateway). En ny läsare som filtrerar policy_decisions på en person måste fråga på båda; se D-113 och P43-specen.
  • Anropare & beroenden: BFF (AUTH_API_URL: exchange + admin-passthrough, samt sedan fas Q anropad TILLBAKA av auth-api för SAR — se ovan), rag-api (sedan fas R3: POST /v1/token/client för eval-triggerns service-JWT, egen service_clients-rad med allowed_scopes=['eval:write'] — se ovan och rag-api-sektionen), auth-cli (break-glass sedan paket 5, 2026-07-25: bara bootstrap tenant + keys rotate/show-jwks, skriver direkt SQL — users/service-clients raderade, de dubblerade app/api/admin/users.py och gick runt det; vilka filer som får röra DB utanför en tjänst är uttömmande uppräknat i tools/checks/check_no_db_outside_services.py); alla tjänster konsumerar JWKS:en som issuer. Nedströms: platform-pg + kundens IdP — sedan D-78 via discovery ({IDP_ISSUER}/.well-known/openid-configuration → jwks_uri/authorization_endpoint/token_endpoint), inte via Keycloaks namnkonvention; SAR-ytan anropar policy-engine/llm-gateway/feedback-api/BFF/rag-api (POLICY_ENGINE_URL/LLM_GATEWAY_URL/FEEDBACK_API_URL/BFF_URL/RAG_API_URL) med den forwardade användar-JWT:en (inte egen service-identitet) — osatt URL på NÅGON av de fem ⇒ export/erase fail-closed 503 för hela SAR-vägen.
  • Scope-matrisen (fas R1, D-39): app/services/scope_matrix.py fick rag:read/rag:write — tenant_admin och assistant_editor har båda scopen, user/viewer endast rag:read. app/api/admin/gdpr.py::_NOT_COVERED fick vid byggnaden en fjärde post, rag.documents.authoring_metadata (skäl: “söm byggs i fas R3”) — söm byggd, posten flyttad till _COVERED i fas R3 (se SAR-coverage-bullet ovan). Sedan fas R2 (D-40, K9) fick _ROLE_SCOPES dessutom tools:call för alla fyra roller (tenant_admin, assistant_editor, user, viewer) — förutsättningen för att mcp-gatewayens POST /v1/tools/call-datapath (knowledge-retrieval via BFF:ns orkestrering) är nåbar för en inloggad användare; policy-enginens knowledge-scopade tool_call-gren är det andra, oberoende gate-lagret (auktorisering utöver scopet, se policy-engine-sektionen).
  • Observability: tidigare egen JSON-formatter + request-logg med maskning av hemliga fält (sedan D-52 ersatt, se nedan); x-trace-id; OTel sedan fas O (D-34): FastAPIInstrumentor + HTTPXClientInstrumentor (auth-api gör bara interna anrop, ingen providergräns — till skillnad från llm-gatewayen instrumenteras även utgående httpx). Audit hash-chain auth_events.v1; admin-writes committar rader + audit i samma transaktion (D-29). (sedan D-52: trace/felkuvert OCH loggning via libs/observability)
  • Sömmar: effektiva roller i medlemslistan = direkta bindings ∪ mappings × idp_groups-snapshotten (bindings/mappings live; gruppändring syns vid nästa login); JWT-roller sätts vid exchange (TTL 300 s — D-23-semantiken); scope-matrisen i app/services/scope_matrix.py är sanningskällan (D-24); invite-claim litar på tenantens egen IdP:s e-postclaim, endast mot oclaimade inbjudningar; GDPR-erasure pseudonymiserar auth.users (mappningstabellen) — kedjorna bär endast UUID-pseudonymer; audit-metadata är id-only (D-31).
  • OAuth-ingången (D-71, ny): app/services/oauth_provider.py::StackenOAuthProvider uppfyller SDK:ns OAuthAuthorizationServerProvider-protokoll strukturellt (ingen cast, ingen type: ignore — mypy --strict verifierar det). Bara publika klienter: get_client returnerar None för allt utom token_endpoint_auth_method = "none", eftersom SDK:ns ClientAuthenticator jämför en KLARTEXT-hemlighet och vi lagrar argon2-hash. Scopes ur scope_matrix, aldrig ur klientens begäran — klienten kan bara smalna, och kapningen sker både vid auktorisering och vid varje inlösen/rotation. Engångsinlösen av koder (OAuthCodeRepo.redeem) och rotation av refresh-tokens (OAuthRefreshRepo.rotate) är villkorade UPDATE:ar — villkoret och skrivningen i samma sats, aldrig check-sedan-skriv. Stöldsdetektion: presenteras en redan förbrukad refresh-token dödas HELA kedjan (revoke_chain, RFC 9700 §4.14.2) och en token.refresh.reuse_detected skrivs till auditkedjan; chain_expires_at är ett absolut tak som rotationen inte kan skjuta framför sig. RFC 8707: resource valideras mot OAUTH_RESOURCE (fail-closed åt båda håll — saknad OCH okänd resurs nekas), binds till aud och följer kedjan; SDK:n skickar aldrig resource vidare vid refresh, så bindningen bärs av raden och kan inte pekas om genom att förnya.
  • Tenant-export (Wave 3 Post 2, ny, D-60; kuvert till libbet D-67): GET /v1/admin/tenants/{tid}/export (app/api/admin/tenant_export.py) — read-only metadata-yta för provisioning-apis export-coordinator, grindad require_scope("tenant:export:read") (D-60-plattformsoperatörsplanet). Svaret typas nu av libs.tenant_export.TenantExport/TenantExportCoverage; coverage-/trunkeringsreglerna (limit+1, cap 500, m.fl.) är dokumenterade EN gång i docs/tenant-export-coverage.md i stället för i modulens docstring; ett kontraktstest (test_tenant_export_response_validates_against_the_shared_envelope) validerar svaret mot den delade typen.

services/bff

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. G4:s loggrad g4_signal skrivs med uttryckliga fält (signal, orsaker, land, tak) i stället för **metadata, med samma utdata. K21:s loggrader (instans, regel_sha256, foregaende_regel_sha256, senast_auditerad, skrivna) är id, hashar och räknare och står i listan. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.10.1 → 1.10.2.)

Verifierad/uppdaterad 2026-10-08 (#657, röd zon C1–C3 — en instans kan göra alla konversationer tillfälliga. Ny inställning CONVERSATIONS_ALL_TEMPORARY (chart config.conversationsAllTemporary, standard av). På ⇒ POST /api/chat/v1/conversations skapar alltid en tillfällig konversation, också när klienten skickar temporary: false, med expires_at = skapandet + matrisens chat_conversation-frist (D-94; conversations_ttl_hours är fallback). Fristen sätts fortfarande bara i matrisen, inte i charten. En uppladdning får högst konversationsfristen, och när den skickas i en konversation flyttas dess expires_at fram till konversationens (UploadRepo.cap_expiry, aldrig bakåt), så dygnsronden tar filtexten senast med konversationen. Befintliga sparade konversationer rörs inte. Kontrakten är oförändrade. chat-ui:s lista visar “raderas ” i stället för en fast text om 24 timmar. appVersion 1.10.0 → 1.10.1, chart 0.7.1 → 0.7.2.)

Verifierad/uppdaterad 2026-10-07 (#583, K21 — ändringar av geografiregeln och utgångna undantag blir audit-rader. Ny intern rutt GET|POST /internal/k21/regel (app/routes/geo_rule.py): Plattformen anmäler en instans regel efter sync, med commit och den som godkände PR:en. geo.rule_changed skrivs bara när regel_sha256 skiljer sig från senast auditerade regel (exakt en rad per ändring, transaktionslås per tenant, ordning i sekvens). foregaende_regel_sha256 som inte stämmer ger lucka: true. Nytt CronJob bff-k21-undantagssvep (python -m app.k21_regel, var femte minut) skriver geo.exception_expired en gång per utgånget undantag. Ny kedja geo_rules.v1 i audit.audit_events, registrerad i tools/audit-chain. Rutten och jobbet är av bakom K21_AVSLAG_AKTIV. appVersion 1.9.0 → 1.10.0. Runbook: docs/runbooks/k21-geo-avslag.md.)

Verifierad/uppdaterad 2026-10-08 (#662, K1 G4 — signaler för ovanlig inloggning och hög användning. Ny modul app/g4_signal.py. /auth/callback anropar login_signal efter en lyckad inloggning och före svaret; require_session (SessionDep, nu async) anropar usage_signal för varje anrop med session. En signal är en audit-rad (signal.login_unusual med orsaker och land, signal.usage_high med tak) i kedjan access_events.v1, committad före svaret, och en JSON-loggrad g4_signal som Plattformen larmar på. Regler och värden är inställningar per instans, alla av som standard: G4_VANTADE_LAND, G4_NYTT_LAND, G4_LAND_MINNE_DAGAR, G4_INLOGGNINGAR_TAK, G4_ANVANDNING_TAK (chart config.g4*). Landet läses ur ingressens X-Geo-Land, aldrig X-Geo-Ip; personen nycklas på sha256 av IdP:ns sub. Minnet ligger i den delade limiter-storen; storen nere ⇒ bara listregeln. Runbook: docs/runbooks/g4-signaler.md. appVersion 1.7.2 → 1.9.0.)

Verifierad/uppdaterad 2026-10-08 (#655, krav D3/D4, D-664 — ingen kundroll läser en annan användares konversationer. GET /gdpr/users/{user_id_hash}/conversations/export kräver tillståndet innehall:utlamna (scope-claim, ingen roll räknas) och att tokenets tenant_id är instansens; tenant_admin får 403 scope_required:innehall:utlamna. Varje utlämning skriver conversations.content_disclosed (kedjan conversations.v1, antal och trace_id, aldrig innehåll) och committar före svaret; misslyckad audit ⇒ 503 audit_unavailable utan innehåll. Erasure är oförändrad (tenant_admin). Ny inställning RECORDS_DISCLOSE_AKTIV (chart config.recordsDiscloseAktiv, standard true): av ⇒ GET /api/chat/v1/records/{id}/disclose svarar 403 records_disclose_disabled. auth-api anropar inte längre exportrutten. Runbook: docs/runbooks/innehallsutlamning.md.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 1, D-170 — chatten skickar retrievalpoängen. retrieve_knowledge ger RetrievalOutcome.top_score, högsta sökträffens score (ett get_full-dokument har ingen). En kunskapsbunden assistents modellanrop bär X-Retrieval-Score bredvid X-Has-Citations när det finns en sökträff; utan träff utelämnas headern. appVersion 1.7.1 → 1.7.2.)

Verifierad/uppdaterad 2026-10-06 (#562, filvägen steg 5 — kunskap tar emot PDF, DOCX, XLSX och PPTX. Ny rutt POST /api/rag/v1/documents/file (app/routes/uploads.py::knowledge_file, egen router knowledge_router före passthrough): filens byte som application/octet-stream, collection_id, file_name, title och valfri information_class i frågesträngen. Ordningen är ändelse (415), rag:write i den verifierade JWT:n (403 knowledge_write_denied, eftersom policyns kunskapsgren inte prövar roll), samlingen hos rag-api (404 collection_not_found, MCP-källa 403), sedan samma inläsning, signatur, decide(file_extract) med knowledge_ctx och extraktion som uploads/file med X-Max-Chars 400 000. Texten blir ett utkast via rag-apis befintliga POST /v1/collections/{id}/documents med användarens egen JWT och källmetadata (source_format, source_filename, source_sha256, extractor_version); rag-apis ingest kör pii och knowledge_ingest som för text. rag-api nere ⇒ 503 rag_unavailable; rag-apis 4xx går vidare. Loggraden knowledge_file bär bara format, byte, tecken, samling och utfall. Chart: ingressen bff-file-upload har nu två Exact-vägar. appVersion 1.6.1 → 1.7.0, chart version 0.5.1 → 0.6.0.)

Verifierad/uppdaterad 2026-10-06 (#561 — chattens modellkatalog autentiserar med sessionskakan. GET /api/chat/v1/models var grindad require_scope("policy:read"), som bara läser Authorization-headern. chat-ui skickar bara kakan, så varje riktigt anrop fick 401 missing_bearer och chatten kunde inte välja svarsläge. Rutten tar nu SessionDep, växlar kakan mot en platform-JWT med build_work_context (samma väg och felmappning som konversationsrutterna) och scope-kontrollerar den med app/authz.py::check_minted_scope: policy:read och rätt tenant, som förut. En Bearer-header utan kaka öppnar inte längre ytan. Testerna hade skickat Bearer, ett anrop ingen klient gör. Enhets- och e2e-testerna anropar nu med kakan, och test_chat_surface_never_requires_bearer_header fäller varje rutt under /api/chat/v1/ som läser Authorization. appVersion 1.6.0 → 1.6.1.)

Verifierad/uppdaterad 2026-10-06 (#544, filvägen steg 4, D-172 — chatten tar emot PDF, DOCX, XLSX och PPTX. Ny rutt POST /api/chat/v1/uploads/file (app/routes/uploads.py): filens byte som application/octet-stream, assistant_id och file_name i frågesträngen. Ordningen är ändelse (415), aktiv assistent (404), kroppen med taket 20 MiB medan den strömmar in (413), signaturen mot ändelsen (415), decide(file_extract) på format och storlek (403 upload_denied), extract-api (ny klient app/chat_store/extract_client.py, typade 413/415/422 går vidare med samma kod), formatet ur extract-api mot ändelsen (415), och sedan exakt samma kod som uploads/scan (_scan_and_store). Extract-api, policy eller tokenkällan nere ⇒ 503 extract_unavailable/policy_unavailable med Retry-After, aldrig en textreserv. BFF:s egen tjänste-JWT via client-credentials (svc-bff, app/chat_store/service_token.py, fjärde kopian tills #399). Migration 0009_uploads_source_metadata: nullbara source_format, source_bytes, extractor_version på bff_chat.uploads. Audit upload.file_accepted (kedja conversations.v1) i samma transaktion som raden, bara id, format och antal. Högst FILE_UPLOAD_MAX_CONCURRENT (2) filer i minnet per pod, full ⇒ 503 busy. Chart: ny Ingress bff-file-upload (Exact, proxy-body-size ingress.fileUploadBodySize), egress till extract-api, EXTRACT_API_URL/EXTRACT_CLIENT_ID och den valfria hemligheten EXTRACT_CLIENT_SECRET. appVersion 1.5.0 → 1.6.0, chart version 0.4.1 → 0.5.0. Runbook: docs/runbooks/filuppladdning.md.)

Verifierad/uppdaterad 2026-10-05 (#531, K21 — ingressens geografiska avslag blir en audit-rad. Ny intern rutt GET|POST /internal/k21/avslag (app/routes/geo_denial.py) dit Plattformens ingress omdirigerar nekade förfrågningar (plattformen#331 §7 G2). Av bakom K21_AVSLAG_AKTIV (chart config.k21AvslagAktiv, standard av ⇒ 404 utan audit-rad) tills Plattformens spärr av /internal/k21/ finns (plattformen#332). Påslagen svarar den alltid 403 (503 om audit-raden inte gick att skriva) och skriver geo.access_denied i ny kedja access_events.v1 i audit.audit_events, registrerad i tools/audit-chain. PublicAuditWriter tar chain_name. Ingen klient-IP sparas; en rad per beslut, land och minut, högst 120 per minut, via den delade limiter-storen. Runbook: docs/runbooks/k21-geo-avslag.md.)

Verifierad/uppdaterad 2026-10-05 (D-171, K10 session 1 — GET /api/models läser provider och max_information_class från registerpostens toppnivå i stället för display. Kontraktet är oförändrat. provider är nu avtalsparten, så visningstexten för Berget-modellerna blir “Berget AI” i stället för “Berget (self-hosted…)”. appVersion 1.4.0 → 1.4.1.)

Verifierad/uppdaterad 2026-10-04 (P17, #485, D-169 — anropstaken är ett överbelastningsskydd för hela plattformen. app/rate_limit.py::get_limiter nycklar på konstanten PLATFORM_KEY i stället för peer-adressen, som bakom ingressen ändå alltid var ingressens. Nya standardvärden: AUTH_CALLBACK_RATE_LIMIT 10 → 1200/minute (en callback per inloggning; 10/minute släppte bara in tio personer i minuten i hela plattformen), PUBLIC_SESSION_RATE_LIMIT 30 → 600/minute. Inga chart-values ändrade; appVersion 1.3.3 → 1.4.0.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Stänger samma hål som #143 i auth-api: auth-routern tar emot code i query-strängen. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.3.2 → 1.3.3.)

Verifierad/uppdaterad 2026-09-28 (stacken#391 — assistentens systemprompt når modellen. bff skickade aldrig system_prompt, så en administratörs instruktion till assistenten hade ingen effekt i chatten. AssistantCard läser nu fältet från policy-engine, och chat_store/instructions.system_messages lägger det efter källblocket och före historiken i samtalsvägen (routes/conversations.py) och före klientens meddelanden i den direkta chattvägen (routes/chat.py). Ordningen är densamma som eval (D-156). Publika sajter (routes/public.py) hämtar inte assistentkortet och får inte systemprompten; det är ett eget ärende. appVersion 1.3.1 → 1.3.2.)

Verifierad/uppdaterad 2026-08-30 (D-133 — provisioning blir nionde upstream, och org-adminens aktiveringsknapp får en tjänst att prata med. Ingen ny rutt: catch-allen /{service}/{path:path} fanns redan, och den myntar ANVÄNDARENS egen JWT per anrop (get_or_mint_jwt_with_refresh) — alltså delegering, inte impersonation. Det enda som saknades var en rad i upstream_map; osatt URL gör prefixet oroutbart, samma fail-closed som de åtta andra. Före det här routades /api/policy/v1/assistants/{id}/activate till policy-engine, som inte har rutten — 404 sedan alltid, dolt av ops-uis mock (#317). NetworkPolicy: provisioning-api tillagd som EGRESS-peer; den fanns bara som ingress-peer förut (D-68). appVersion 1.2.5 → 1.3.0, chart version 0.3.1 → 0.4.0.).

  • Föregående 2026-08-25 (D-109 — ny Ingress bff-metrics: /metrics (pathType: Exact) begränsad till ingress.metricsSourceRange, default 10.0.0.0/8. Skälet är att BFF:ns ordinarie Ingress är en catch-all (path: /, Prefix) mot en publik host — uppmätt mot drift 2026-08-25 svarade GET https://api.stacken.eu/metrics med BFF:ns EGET typade 404-kuvert, alltså når varje sökväg fram till appen. Utan regeln hade P27:s skrapyta blivit publik. Samma mönster som auth-apis -internal-ingress redan använder; ingen snippet-annotation, eftersom den kräver allow-snippet-annotations som vi inte kan verifiera. Prometheus skrapar podden direkt via Service-endpointen och passerar aldrig ingressen. BFF:n bär även http_requests_total via sitt befintliga install_observability-anrop — ingen kodändring i tjänsten. Chart version 0.3.0 → 0.3.1; appVersion OFÖRÄNDRAD, eftersom imagen inte ändrats.)
Stämpelhistorik — 13 tidigare verifieringar
  • Föregående Verifierad/uppdaterad 2026-08-17 (D-99 — INGEN tjänständring. Enbart dev-realmen: services/bff/dev/keycloak-realm.json har en ANDRA testanvändare, testuser2. Filen ligger under services/bff/dev/, som check_atlas_freshness.py och check_chart_appversion.py numera undantar (D-99) — den är alltså INTE en verklighetsändring i D-30-vaktens mening: ingen kod, inget schema, ingen chart och ingen appVersion berörs — realmen bootas bara av repots e2e-rigg och av lokal utveckling. Skälet står i D-99: låset test_a_subject_bound_grant_reaches_one_person_and_not_the_other måste bevisa att en subjektbunden grant når EN namngiven person och inte en annan, och det kräver två personer som kan logga in. Båda ligger i /users, så gruppgranten är identisk för dem och skillnaden i testet är subjektgranten och ingenting annat. Bffs egen svit kördes mot den nya realmen: 97/97. Ingen av bffs ytor, tabeller eller anropare ändrade.)
  • Föregående 2026-08-11 (D-94 — gallringen: ett löfte, en rond. Två separata problem stängda i samma pass (stacken#185, stacken#191). Klockorna förenade: matrisen (D-57) slås nu upp EN gång, vid skapandet, och fryses i Conversation.expires_at (samma kolumn som redan visas i chattens bricka) — svepet trösklar bara på expires_at, aldrig på created_at + retention_days, så de två kan inte längre glida isär. Matrisens roll är delad i två, avsiktligt inte spegelvända: längd (vid skapandet, fryst) styr hur länge en NY post lever; broms (archive_hold/oåtkomlig matris, läst LIVE vid varje svep) styr om en kategori får svepas alls, retroaktivt — ett hold stoppar radering, aldrig användning. Matris oåtkomlig vid skapande ⇒ 503 (ingen konversation utan löfte); vid svep ⇒ bevara, oförändrat. preservation_hold (D-58) vinner alltid. Ronden går nu: ny entrypoint app/retention_sweep.py + ett dygns-CronJob (charts/bff/templates/cronjob.yaml, namn <release>-bff-retention-sweep, schema 17 3 * * *, concurrencyPolicy: Forbid, activeDeadlineSeconds: 3600) är EFTER den här ändringen den enda gallringsvägen — de fyra piggyback-anropen (routes/conversations.py × 3, routes/public.py × 1) och sweep_best_effort (svalde alla fel — fel på en användarväg) är borttagna. Fel propagerar nu och loggas under ett av tre namn (retention_sweep_done/_degraded/_failed); en osatt policy-URL är done (avsiktligt läge, exit 0), en oåtkomlig matris är degraded (exit 1, sveppen körde men bevarade allt), ett faktiskt fel (DB nere m.m.) är failed. Den manuella “kör nu”-ytan (POST /api/public-sites/maintenance/sweep) står kvar som uttrycklig operatörshandling — men ENBART för det publika planet (bff_public, sweep_expired); det finns ingen manuell väg att tvinga fram en chattgallring (sweep_expired_chat), den går bara via dygnsronden. conversationsTtlHours exponeras MEDVETET inte i charten — matrisen är enda stället TTL:n sätts. Risk, uttalad: rullas charten inte ut med retentionSweep.enabled: true sker ingen gallring alls (se docs/runbooks/incident-response.md, D-52). appVersion 1.1.7 → 1.2.0, chart version 0.2.9 → 0.3.0.)
  • Föregående 2026-08-10 (D-93 — två hemligheter nådde aldrig podden hos en SOPS-tenant, och retention-sveppen slutade gallra tyst. PII_API_TOKEN och POLICY_SERVICE_TOKEN villkorades i templates/deployment.yaml på .Values.externalSecrets.remoteRefs.* — en ESO-sökväg — medan secretKeyRef pekar på bff-Secreten SJÄLV, som finns oavsett hur den fyllts. Under SOPS (customer-prod, issue #183) är remoteRefs tom: nycklarna låg i Secreten men saknades i podden. Följden för POLICY_SERVICE_TOKEN är dyr — RetentionCache.get_matrix() ger None, och gallringens fail-closed är INVERTERAD (oåtkomlig matris ⇒ bevara), så sveppen slutade radera utan ett enda fel. Uppmätt: tre temporära konversationer 26 timmar gamla under conversations_ttl_hours: 24. Båda injiceras nu alltid, med optional: true på secret-nyckeln — samma form D-87 gav charts/llm-gateway. Ingen kodändring, ingen ny nyckel, inget schema; appVersion MEDVETET orörd (imagen är oändrad), chart version 0.2.8 → 0.2.9. Mönstret är nu mekaniskt vaktat av tools/checks/check_secret_env_gating.py — D-87 rättade instansen, inte klassen, och klassen fanns kvar i två charts.)
  • Föregående 2026-08-10 (D-88 — modellkatalogens tredje kopia avvecklad. models.yaml bakas nu i bff-imagen (Dockerfile: COPY services/llm-gateway/config/models.yaml + ENV MODELS_YAML_PATH=/app/config/models.yaml), exakt det mönster services/policy-engine/Dockerfile använt sedan D-43. Charten monterar INTE längre platform-models-ConfigMappen: volymen, volumeMounten, modelsConfigMapName och config.modelsYamlPath är borttagna, och MODELS_YAML_PATH sätts av imagen i stället för av charten. Registret laddas fortsatt boot-eager (fail-fast). Ingen HTTP-yta, inga tabeller, inget kontrakt ändrat. Bevis: bff-imagens och policy-engine-imagens register ger IDENTISK content_hash (c0b386d474e6a47a, 9 modeller, alla stckn-) — samma bytes ur samma git-fil. Chart version 0.2.6 → 0.2.7, appVersion 1.1.6 → 1.1.7.)
  • Föregående Verifierad/uppdaterad 2026-08-09 (D-86 — migration 0008: bff_public.public_sites.default_model_snapshot skrivs om digi- → stckn-. Schemakvalificeringen (bff_public, inte public) saknades i första versionen och fälldes av repots e2e; båda migrationerna har nu egna test med seedad data. Chart version 0.2.5 → 0.2.6, appVersion 1.1.5 → 1.1.6.)
  • Föregående Verifierad/uppdaterad 2026-08-02 (D-84 — NetworkPolicyns app: keycloak-egress borttagen (issue #157): regeln var korrekt när IdP:n var in-cluster-Keycloak, men D-78 gjorde issuern utbytbar och regeln följde inte med — den matchade en podd-etikett som inte finns i customer-prod, och mot en extern issuer kraschloopade bff i stället. Den ersätts INTE av 0.0.0.0/0:443: ren k8s NetworkPolicy kan inte uttrycka en enskild värd, så den regeln vore BREDARE än vad en CNI med värdnamnsstöd klarar. IdP-egress deklareras vid plattformslagret (docs/runbooks/tenant-onboarding.md steg 4b). Dessutom: main.pys discovery-fel bar bara str(err), och en httpx.ConnectTimeout mot en blockerad host har TOM text — meddelandet slutade i kolon och läste som att IdP:n svarat fel. Bär nu undantagets klassnamn. Chart version 0.2.3 → 0.2.4, appVersion 1.1.3 → 1.1.4.)
  • Föregående 2026-08-01 (D-82 — ny läs-yta GET /api/chat/v1/models (app/routes/models.py::chat_router, registrerad FÖRE catch-all-passthroughen — E4, samma mönster som ops-ytan): chattens smalare projektion av modellregistret (ChatModelView: id, label, intent, residency), grindad require_scope("policy:read") — scopet varje plattformsroll har, till skillnad från ops-ytans llm:admin:read som bara tenant_admin får. Avstängda och intent-lösa poster filtreras BORT här, tvärtemot ops-ytans §6b-regel: chatten adresserar aldrig en modell vid namn, bara via svarsläge, så en post utan intent är oadresserbar. Ytan finns för att chat-ui:s paths.models() pekade på llm-gatewayens /v1/models genom passthrough — en väg som aldrig kunde fungera (gatewayen autentiserar API-nyckel, inte plattforms-JWT ⇒ 401; och OpenAI-kuvert där chatten väntar en array). app/authz.py fick require_scope som det ärliga namnet på kontrollen; require_public_admin är kvar som tunn alias, oförändrat beteende, inga kallar-ändringar. Inga nya tabeller, inga nya beroenden. Chart version 0.2.2 → 0.2.3, appVersion 1.1.2 → 1.1.3 (imagen ändrad, D-69).)
  • Föregående 2026-07-31 (D-81 — utloggningen berodde på att IdP:n samarbetade. app/auth/oidc.py::end_session_url läste metadata["end_session_endpoint"] utan fallback, men RP-initierad utloggning är valfri i OIDC och Google implementerar den inte. Mot Google blev varje POST /auth/logout en KeyError → 500, och eftersom svaret aldrig byggdes kördes _clear_session_cookie aldrig — användaren tryckte Logga ut och satt kvar inloggad. Samma utfall gav en IdP-outage, eftersom oidc.load()s nätfel propagerade rakt igenom. Båda faller nu tillbaka på lokal utloggning (302 + död kaka); end_session_url returnerar str | None och None är ett svar, inte ett fel. Semantiken är utskriven i koden: lokal utloggning lämnar användaren inloggad HOS IdP:n, så nästa inloggningsförsök kan gå igenom utan lösenord — det är den enda semantik som finns att få mot en IdP utan RP-initierad utloggning, och ska inte förväxlas med att IdP-sessionen avslutats. Ingen ny yta, ingen schemaändring, inga nya beroenden. Chart version 0.2.1 → 0.2.2, appVersion 1.1.1 → 1.1.2 (imagen ändrad, D-69). Utloggningsvägen har ingen e2e-täckning — riggens Keycloak HAR end_session_endpoint, så den nya grenen nås aldrig där.)
  • Föregående 2026-07-27 (D-69 — NetworkPolicy-symmetrivakten fann att bffs egress mot eval-api, lifecycle-api och mcp-gateway redan var korrekt (charten ORÖRD); de tre kanterna saknade trots det ett ingress-medgivande hos mottagarna och skulle därför blockeras i varje kluster där NetworkPolicy tillämpas. Detta är en CHARTSANNING — ingen driftverifiering gjordes i passet. Antingen har de tre vägarna varit brutna i customer-prod, eller så tillämpas inte NetworkPolicy där; frågan är öppen och registrerad som D-69 “Kvarstår” punkt 6. Alla tre vägar (/api/eval/..., /api/lifecycle/..., /api/mcp/... via upstream_map-dispatchern) är nu symmetriska I CHARTERNA sedan mottagarnas charts fick ett app: bff-ingress-medgivande).
  • Föregående 2026-07-26 (D-67, kontraktskärnan paket 2/3/4 — kontraktet incheckat; uploads/scan-rutten (app/routes/uploads.py) adopterar libs.policy_client.PolicyDecideClient för action="upload_scan" (deny-verkställighet och pii_verdict-tolkning flyttade IN i rutten, se HTTP-ytan nedan); tenant-export-svaret till libs.tenant_export, response_model=TenantExport deklarativt (rutten returnerar fortsatt JSONResponse för no-store). app/routes/decide.py (DecideTest-proben) adopterar MEDVETET INTE libbet — se egen not nedan).
  • Föregående 2026-07-24 (Wave 3 Post 2, D-60 — ny read-only tenant-export-yta GET /gdpr/tenants/{tenant_id}/export (app/routes/tenant_export.py), scope-grindad tenant:export:read, metadata-only: konversations-KROPPAR deklareras not_covered (aes_gcm_bulk_decrypt_deferred_v2), aldrig content_key_wrapped/ciphertext; inga nya tabeller).
  • Föregående 2026-07-23 (Wave 3 Post 5, D-58 — ny diarie-/utlämnandeyta: fjärde bff_chat-tabellen records (migration 0007) + /api/chat/v1/records (POST register / GET index / PATCH sekretess / GET {id}/disclose; records-officer = tenant_admin, tvär-ägare; utlämnande WORM-auditerat + no-store, ej decide-gate:at per Fork B). Tre bevarande-guards (sweep + user-DELETE→403 + SAR-erasure) pinnar en registrerad allmän handling mot krypto-shred (fail-closed åt bevarande). Wave 3 Post 1, D-57 — BFF-sveparna konsumerar nu policy-engines retention-matris fail-closed-bevarande: cachead service-token-klient chat_store/retention_client.py, archive_hold/oåtkomlig matris → hoppa DELETE, se Gallring nedan; Wave 3 Post 6, D-55 — ny serverad publik GET /.well-known/security.txt (RFC 9116, CVD-discoverability), se HTTP-ytan nedan).
  • Föregående 2026-07-18 (fas S2-ff, D-44 — ny berikande decide-proxy POST /api/policy/v1/decide för ops-ui:s DecideTest-panel, se nedan; fas S2, D-43 — MODEL_META-intent/residency-interimet avvecklat: intent-/modell-resolutionen läser nu det delade modellregistret (libs/model_registry, ny env MODELS_YAML_PATH, boot-eager get_registry() i create_app, fail-fast); ny läs-yta GET /api/models (Org-admin, scope llm:admin:read) serverar registrets governance-projektion till ops-ui; fas S1:s assistants-bindningsgrind/D-42, fas R2:s retrieval-orkestreringen (chat_store/retrieval.py, MCP_GATEWAY_URL), citation-emissionen + persist (CitationAt) på riktigt, X-Has-Citations-headern; citation-radens fas R1-rättelse (D-39, “emitteras ALDRIG”) ERSATT — se nedan; fas Q:s konversations-storen live/uploads-scan/GDPR-sömmen/D-37, titel-maskning fast-follow/D-38, fas P2:s output_check-degraded/D-36, fas P1:s pii_verdict-re-emit/D-35, fas O:s arbetsläges-auth/D-34 och fas N:s publika planet/D-32 oförändrade). Samma dygn, D-100: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning — DET är skälet till att appVersion ändå gick från 1.2.0 till 1.2.1, inte realm-filen.
  • Syfte: webbappens backend — terminerar OIDC mot kundens IdP (D-10: ingen broker), äger den signerade session-cookien, byter session → plattforms-JWT per anrop (via auth-api) och proxar /api/{service}/… till tjänsterna. Sedan fas N även backend för publika planet (D-32): anonym medborgar-chatt mot en whitelistad, gron-klassad assistent, site-administration och en krypterad forensikbuffert. Sedan fas Q (D-37) även backend för arbetslägets konversations-store: krypterade konversationer/meddelanden, uploads/scan, subjekt-scopad GDPR-export/erasure.
  • Postgres — BFF:ns FÖRSTA egna Postgres-yta (D-32): schema bff_public, fyra tabeller: public_sites (site_key-register: allowlistade origins, tak, klartextfälten för service-info/degradering, information_class_snapshot, default_model_snapshot — snapshot av assistentens katalogmodell vid registrering, hotfix 2026-07-11), public_sessions (SHA-256-hash av sessionstoken som PK, förbrukning, wrappad per-session forensiknyckel, TTL 30 min), public_daily_counters (scope site|service, dygnsräknare), public_forensics (krypterad in|out|event-logg, sekvensnumrerad per session). Nyttjar därutöver de delade audit.audit_events/audit.chain_heads (libs/audit_chain, samma mönster som auth-api/policy-engine) för forensik-unlock-spåret (kedjenamn public_forensics.v1).
  • Postgres — BFF:ns ANDRA egna Postgres-yta, arbetslägets konversations-store (fas Q, D-37): nytt schema bff_chat (migration 0006_bff_chat_store, alembic-kedjan bär nu 7 migrationer, ADD-only), fyra tabeller — budgetregelns entabellstak medvetet överskridet (D-32-prejudikatet): conversations (user_id_hash, assistant_id, titel [60 tecken, härledd ur det MASKERADE första meddelandet — D-38, fast-follow på D-37:s dokumenterade klartext-läcka; fail-closed "Ny chatt" när pii-api är okonfigurerad/onåbar], temporary/expires_at, content_key_wrapped — envelope-datanyckeln), messages (FK CASCADE mot conversation, role, payload_ciphertext [content+citations+meta krypterat], degraded, feedback), uploads (verdict approved/masked/blocked, content_key_wrapped/sanitized_ciphertext NULL vid blocked, detected JSONB-signaler, TTL 24 h). Ingen tenant-kolumn (BFF är single-tenant per deployment, D-21-mönstret). Kryptering: AES-GCM/KEK-mönstret från bff_public (app/public/crypto.py, generisk cipher-klass återanvänd) — ny secret CONVERSATIONS_KEK (base64, 32 byte, samma valideringsklass som PUBLIC_FORENSICS_KEK, egen rotation). Gallring: sweep_expired_chat (R7), hård fysisk DELETE. Sedan D-94 trösklas svepet enbart på expires_at — samma kolumn chattens bricka visar — inte längre på created_at + retention_days. Matrisen (D-57) sätter TTL:n en gång, vid skapandet (resolve_effective_ttl, kategori chat_conversation/chat_upload), och fryser den i expires_at; matris oåtkomlig vid skapande ⇒ 503 (ingen konversation utan löfte), aldrig en gissning. archive_hold=true eller oåtkomlig matris läst live vid svepet (category_preserved) → bevara (hoppa DELETE), retroaktivt även för redan skapade rader — bromsen och längden är avsiktligt olika primitiv. preservation_hold-vakten (D-58) vinner alltid. temporary-filtret behållet (dokumenterad invariant, kostar inget). Körs numera enbart av dygnsronden (CronJob <release>-bff-retention-sweep, app/retention_sweep.py) — ingen piggyback på list/create/messages-vägarna längre (sweep_best_effort borttagen, D-94).
  • HTTP-yta — arbetsläget (fas O, D-34: /chat fick typat kontrakt + verifierad user-identitet mot llm-gatewayen): health; OIDC GET /auth/login|callback, POST /auth/logout, GET /auth/logged-out (fanns i koden men saknades i atlasen — fas-M-gap, rättat i fas N); GET /me (roller/namn/tenant ur färsk exchange — D-23); POST /chat — typat kontrakt {assistant_id, messages, model?} (extra="forbid"), auth mot llm-gatewayens datapath = tenant-scopad dgt--nyckel (config-fältet llm_gateway_key, skilt från public_llm_gateway_key) + X-User-Id-Hash/X-User-Role/X-User-Groups-headers härledda ur den JWKS-verifierade platform-JWT:n (app/upstream/user_context.py::derive_user_headers, aldrig trust-by-origin), assistent-/modellresolvering via policy-engines assistant-läsning (AssistantReader, E4-mönstret, ingen cache) när klienten inte anger ett katalog-giltigt stckn--modellnamn; catch-all "/api/{service}/{path}" (GET/POST/PUT/PATCH/DELETE). En global RequestValidationError-handler svarar 422 + Cache-Control: no-store på alla ogiltiga payloads (repo-brett grundkrav, inte /chat-specifik). Gamla /chat står orörd — konversationsrutten (nedan) återanvänder dess extraherade byggstenar (auth-mint, AssistantReader, headers-bygge) som modulfunktioner, ingen brytande ändring.
  • HTTP-yta — publik säkerhetskontakt (Wave 3 Post 6, D-55): GET /.well-known/security.txt (app/routes/well_known.py, RFC 9116) — ingen auth/session/DB, ingen /v1/decide-gate (statisk publik info, samma klass som /healthz//readyz). Returnerar Contact: mailto:security@digitalist.se, dynamiskt beräknat Expires (now+180 d, aldrig stale), Policy: → root SECURITY.md, Canonical: ur BFF_BASE_URL. Registreras före passthrough-catch-allen i main.py. Bär CVD-/supportpolicy-discoverability för den driftsatta produkten (CRA Art 13/14).
  • HTTP-yta — konversations-storen (fas Q, ny, D-37): app/routes/conversations.py (session-cookie-auth som övriga /api/chat-rutter): GET /api/chat/v1/conversations?q= (ägarens lista, updated_at fallande, bar array, q = titel-substring — server-side innehållssök utgår per §2.1, triggar sweep best-effort), POST /api/chat/v1/conversations ({assistant_id, temporary} → 201, mintar datanyckel; retention_policy="metadata-only" + temporary=false ⇒ 422 — sådana assistenter tillåter bara temporary-konversationer), GET .../{id} ({conversation, messages} dekrypterat, 404 vid annan ägare/utgången), DELETE .../{id} (204, hård DELETE-kaskad, ej auditerad — användarens egen data), POST .../{id}/messages ({content, requested_intent, attachment_ids} → SSE: rutten översätter gatewayströmmen till D-14-vokabulären meta/token/degraded/done + re-emitterad pii_verdict — sedan fas R2 (D-40) emitteras citation på riktigt i arbetsläget: en kunskapsbunden assistent (card.knowledge_scope icke-tom) triggar retrieve_knowledge() (app/chat_store/retrieval.py) FÖRE modellanropet — deterministisk retrieval mot mcp-gatewayens POST /v1/tools/call (knowledge.search/get_section/get_full) med användarens plattforms-JWT; träffar injiceras som ett ledande system-meddelande (context_block) och citations emitteras som event: citation (app/chat_store/stream.py::WorkStreamConfig.citations) och persisteras som CitationAt i payload_ciphertext (app/routes/conversations.py::persist, ersätter den tidigare hårdkodade []) — fas R1-rättelsen (D-39 p2/diff 1, “emitteras ALDRIG”) är därmed ersatt; publika planets citation förblir reserverad och oemitterad (ingen kunskapsbindning där). Retrieval-fel eller osatt MCP_GATEWAY_URL/UPSTREAM_RAG_URL ⇒ failed=True ⇒ terminalt degraded (aldrig ett tyst kunskapslöst svar, källtvångets fail-closed-linje). has_citations sätts i headern X-Has-Citations mot llm-gatewayen (true iff citations icke-tom, endast för kunskapsbundna assistenter) — konsumeras av policy-enginens kalltvangs-regel (se llm-gateway-/policy-engine-sektionerna); event: error emitteras ALDRIG — output_check-deny/upstream-fel efter strömstart blir alltid degraded(upstream_degraded); okänt requested_intent ⇒ 422 klarspråk), GET .../{id}/export?format=md|json (dekrypterad export, Accept-headern ignoreras — mockens beteende, default md). app/routes/uploads.py: POST /api/chat/v1/uploads/scan ({assistant_id, file_name, text} → 201 UploadScanResult; egen PiiClient-motsvarighet mot pii-api POST /v1/analyze level="high" följt av POST /v1/decide action="upload_scan" mot policy-engine — sedan D-67 via libs.policy_client.PolicyDecideClient (byggd i lifespan med egen policy_decide_timeout_ms); klientens tre tjänstespecifika ansvar (deny→UploadScanDenied, pii_verdict-validering, verdict-vokabulärbytet) bor i rutten, inte i klienten — motorns legitima pii_verdict=None på icke-PII-vägar tolkas aldrig som godkänt på upload-scan, ett saknat/ogiltigt verdikt ger ett eget fail-closed 503 policy_verdict_missing, skilt från policy_unavailable; trace_id ligger i X-Trace-Id-headern, inte kroppen — D-14 §4.2-formlåset är sex fält ordagrant; approved/masked persisteras krypterat, blocked lagrar ingen text; pii-api/policy nere ⇒ 503 pii_unavailable/policy_unavailable, inget upload-objekt skapas). Filvägen (D-172): POST /api/chat/v1/uploads/file gör text av en fil via extract-api och kör sedan samma svans; svaret är scan-formen plus source_format, pages, sheets, slides och chars (se stämpeln 2026-10-06). attachment_ids konsumeras på riktigt i messages-POST (ägar-/assistentmatch, blocked ⇒ 422, sanerad text injiceras i modellkontexten). Intent-/modell-resolutionen läser sedan fas S2 (D-43) det delade modellregistret (libs/model_registry, boot-eager get_registry() i create_app, ny env MODELS_YAML_PATH) i stället för den nu avvecklade MODEL_META-env-interimen: resolve_intent/available_intents (app/chat_store/intent.py) matchar card.default_model/card.allowed_models mot registrets intent-fält och filtrerar fail-closed bort enabled=false-modeller (R6); human_oversight_required deriveras oförändrat ur ai_act_classification == "high_risk". ConversationRepo.touch/set_title_if_default är ägar-scopade (conversation_id, user_id_hash). Titel-maskning (D-38): _masked_title anropas endast när conv.title == "Ny chatt" (pii-api anropas ALDRIG när titeln redan är satt) och skannar content[:400] via bff_pii_client.analyze_one; fail-closed till "Ny chatt" vid okonfigurerad pii-api, transportfel eller auth-drift — chatten bryts aldrig.
  • HTTP-yta — GDPR (fas Q, ny, D-37; export omgjord #655): app/routes/gdpr.py, prefix /gdpr/users/{user_id_hash}/conversations — GET .../export och POST .../erase, Bearer plattforms-JWT (JWKS-verifierad, inte session-cookie), tenant ur claim måste matcha settings.tenant_id (403 fail-closed vid mismatch); en tenant-UUID-guard (503 tenant_config_invalid) skyddar mot en icke-UUID TENANT_ID i config. Erase (anropare auth-api, tenant_admin) gör hård DELETE av conversations/messages/uploads, svarar med räkningar och auditeras conversations.sar_erased i samma transaktion (kedjan conversations.v1). Export lämnar ut innehåll i klartext och kräver sedan #655 scope innehall:utlamna, som ingen roll ger; anroparen är Digitalists drift via break-glass (docs/runbooks/innehallsutlamning.md), inte auth-api. tenant_admin får 403. Audit conversations.content_disclosed (aktör service:<sub>, antal och trace_id) committas före svaret; audit-fel ⇒ 503 audit_unavailable och inget innehåll. Vanlig användar-DELETE (ovan) auditeras inte.
  • HTTP-yta — K21-avslag (#531, ny): app/routes/geo_denial.py, GET|POST /internal/k21/avslag — ingen session och ingen JWT; rutten nås bara via ingressens interna omdirigering (error_page 403), Plattformen spärrar /internal/k21/ utifrån. Läser X-Geo-Beslut|Land|Profil|Profilversion|Regel|Db och X-Original-URI|Method, validerar formen (avvikande värde ⇒ ogiltig), läser aldrig X-Geo-Ip, sparar sökvägen utan frågesträng. K21_AVSLAG_AKTIV av (standard) ⇒ 404 not_found + no-store, ingen audit-rad. Påslagen svarar den alltid 403 geo_denied + no-store; audit-fel ⇒ 503. Audit geo.access_denied i kedjan access_events.v1, aggregerad till en rad per beslut/land och minut, tak 120 rader/minut, i limiter-storen (storen nere ⇒ varje avslag skrivs). Flaggan slås på först när Plattformens spärr finns (plattformen#332), annars når catch-all-Ingressen rutten utifrån. Audit när en profils geografiregel ändras finns inte än (G4): byggs i egen PR när K10:s versionerade profilfil finns (B1 (a)).
  • HTTP-yta — K21-regeländringar (#583, ny): app/routes/geo_rule.py, POST /internal/k21/regel (Plattformens anmälan, JSON schema: k21-regel/v1, okända fält ⇒ 422) och GET /internal/k21/regel (senast auditerade regel per instans). Ingen session och ingen JWT, samma spärr och flagga som avslagsrutten; inom klustret når bara ingress-nginx porten. Logik i app/k21_regel.py: geo.rule_changed (actor github:<godkand_av>, subject instans:<instans>) när regel_sha256 är ny, annars {"audit": "oforandrad"}. Utgångna undantag i den gamla regeln skrivs före ändringen. CronJob <release>-bff-k21-undantagssvep (templates/cronjob-k21.yaml, */5 * * * *, renderas bara när config.k21AvslagAktiv är på) skriver geo.exception_expired per (instans, undantag, till). Båda i kedjan geo_rules.v1. Undantagens land och CIDR tas inte emot.
  • Signaler — G4 (#662, ny): app/g4_signal.py, ingen egen rutt. login_signal i /auth/callback (efter verifierad id_token, före sessionskakan) och usage_signal i require_session (SessionDep). Audit signal.login_unusual (actor_id = subject_id = idp:<sha256(sub)>, metadata orsaker ⊆ {land_utanfor_lista, nytt_land, manga_inloggningar}, land) och signal.usage_high (samma idp:<sha256(sub)>, metadata tak, högst en per person och takfönster) i access_events.v1, plus loggraden g4_signal (WARNING). Signalen nekar aldrig; audit-fel ger 503 som övriga skrivningar. Alla regler av som standard (G4_*, chart config.g4*), felaktiga tak eller landskoder fäller starten. Landsreglerna förutsätter att ingressen skriver över X-Geo-Land (plattformen#331 §7 G3). Limiter-storen nere ⇒ loggrad g4_store_unavailable, bara listregeln prövas.
  • HTTP-yta — tenant-export (Wave 3 Post 2, ny, D-60; kuvert + response_model D-67): app/routes/tenant_export.py, GET /gdpr/tenants/{tenant_id}/export — plattforms-operatörens tenant-export-plan (provisioning-apis coordinator som anropare), grindad require_scope("tenant:export:read") (tenant-lös platform-service-JWT, tenant ur PATH), metadata-only: läser aldrig content_key_wrapped/payload_ciphertext/sanitized_ciphertext/note/registered_by (egen explicit kolumnlista, ingen ORM-select). Self-check (D-21): path-tenanten måste matcha instansens settings.tenant_id, annars 404 (BFF är single-tenant per deployment). Conversations/uploads/records/audit_events cappade 500 rader (D-31-mönstret: full sida demoteras covered→not_covered, page_truncated_over_500); konversations-KROPPAR (bulk-dekryptering) är coverage.not_covered — v2; klausulerna dokumenterade EN gång i docs/tenant-export-coverage.md (D-67) i stället för i modulens docstring. Sedan D-67: svaret typas av libs.tenant_export.TenantExport (response_model=TenantExport), men rutten returnerar fortsatt JSONResponse för Cache-Control: no-store — FastAPI hoppar då över response-model-validering, så response_model är deklarativt (matar bara OpenAPI-schemat). Den verkliga körtidsgrinden är kontraktstestet (TenantExport.model_validate mot svaret).
  • HTTP-yta — publika planet (fas N, ny; modellval rättat 2026-07-11; PII-verdikt re-emit fas P1, D-35; output_check-deny fas P2, D-36): POST /public/session (anonym: site_key + Origin-allowlist, mintar hashat sessions-id + wrappad forensiknyckel, rate-limit PUBLIC_SESSION_RATE_LIMIT), POST /public/chat (SSE, takhierarki session→source→service, typad degradering, forensik-in garanterad/forensik-out best-effort — SSE-parsern app/public/sse.py::parse_tokens re-emittar llm-gatewayens pii_verdict-event explicit och översätter event: output_check med verdict="blocked" till en payload-lös output_blocked-signal; app/routes/public.py mappar den till ett terminalt degraded(upstream_degraded)-event mot medborgaren — ingen regelnamn/innehållsläcka, samma degraderingsform som övriga uppströmsfel), GET /public/service-info (klartextkort: syfte, not_covered, ai-disclosure, retention-notis, eskalering), POST /public/feedback (skriver ett forensik-event, ingen ny store). Modellvalet mot llm-gateway (hotfix, empiriskt e2e-fynd): /public/chat skickade tidigare hårdkodat {"model": "default"}, vilket llm-gatewayens datapath avvisar med 400 invalid_model_name FÖRE policy-gaten (katalogprefixet stckn- krävs). Skickar nu public_sites.default_model_snapshot (satt vid site-registrering från assistentens riktiga default_model via AssistantReader) — en giltig katalogmodell. Om policyn senare pausar/byter modellen substituerar gatewayen gracefully (200 + X-Substituted-Model) istället för att fela; datapath-regeln är intakt (BFF hårdkodar aldrig förbi policyns substitution).
  • HTTP-yta — publik admin (fas N, ny): POST/GET /api/public-sites (skapa/lista siter — skapande är fail-closed klass-spärrat: kräver assistant.status=active och information_class=gron läst live från policy-engine), POST /api/public-sites/{id}/revoke, POST /api/public-sites/maintenance/sweep (explicit, anropbart retention-svep — operatörens “kör nu”; sedan D-94 den enda triggern utöver dygnsronden, ingen opportunistisk sweep vid session-mint kvar), POST /api/public-sites/{id}/forensics/{session_id}/unlock (dekrypterar ett sessionstranskript åt en tenant_admin, hash-kedjad audit skriven i samma transaktion som läsningen).
  • Assistants-bindningsgrind (fas S1, K5/K6, D-42): BFF fångar POST /api/policy/v1/assistants + PATCH /api/policy/v1/assistants/{id} i app/routes/assistants.py FÖRE den generiska passthrough:en; övriga /api/policy/... orörda. Läser exposure+knowledge_scope ur bodyn (rör inga andra fält, forwardar oförändrat inkl. If-Match), gör en tenant-lokal GET {UPSTREAM_RAG_URL}/v1/collections/{id} (mirror av chat_store/retrieval.py-kortläsningen) och kör assess_knowledge_binding server-side (byte-exakt spegel av ops-ui knowledgeClass.ts). Publik+full_prompt+icke-Klass1 ⇒ 422 {detail}; okänt kort (rag-404) ⇒ 422; PATCH utan exposure re-hämtar nuvarande (V-1-lås). Fail-closed: rag/policy onåbar ⇒ 503 + Retry-After. ETag propageras nu i svaret (If-Match-round-trip, K12.3). Auth: session→minted plattforms-JWT (passthrough-mönstret, K12.2). Ingen ny tabell, ingen ny credential (UPSTREAM_POLICY_URL/UPSTREAM_RAG_URL fanns).
  • Decide-proben (fas S2-ff, ny, D-44; medvetet UTANFÖR libs.policy_client, D-67): berikande proxy POST /api/policy/v1/decide i app/routes/decide.py, registrerad FÖRE catch-all-passthroughen (samma E4-mönster som S1:s assistants-handler / S2:s models-handler) — betjänar ops-ui:s DecideTest-panel (“Testa beslutet”). Inkommande schema {assistant_id, requested_model} med extra="forbid" (avvisar smuggling); bygger upstream-DecideRequest SERVER-SIDE — tenant_id ur settings.tenant_id, trace_id ur middleware, action="assistant_probe" — ALDRIG ur bodyn (await request.body() används aldrig). Adopterar medvetet inte libs.policy_client: proben måste vidarebefordra policy-enginens uppströms-statuskoder VERBATIM till ops-ui (404/403 rakt igenom), medan PolicyDecideClient gör varje icke-200-svar till ett typat undantag som mappas om till servicens EGNA koder — det skulle förstöra passthrough-kontraktet. Priset: upstream_body byggs som en handskriven dict, inte DecideRequest.model_dump() — ingen lokal schemavalidering fångar en motordrift innan anropet går; en omdöpt/tillagd obligatorisk fältnamn i policy-enginens kontrakt skulle tyst 422:a proben i stället för att fela lokalt. bearer_headers(jwt, trace_id, {}) med TOM inbound (annars vidarebefordras ops-ui-requestens inaktuella content-length och trunkerar den berikade upstream-bodyn — granskningsfynd), Content-Type: application/json explicit. Cache-Control: no-store på VARJE svar (200/401/403/404/503/504/502 — de kastade mint-felen 401/403 får det via den globala _http_exc_handler). Felmappning speglar assistants: policy nere → 503 + Retry-After + no-store, timeout → 504, transport → 502; policy-svaret inkl. 404 {detail}/403 passeras rakt. Auth = forwardad user-JWT (mint-mönstret); scopet policy:decide upprätthålls i policy-engine (user/viewer → 403). Ingen ny env, ingen ny credential. Cross-tenant-gränsen vilar helt på att tenant sätts ur settings.tenant_id (single-tenant per deployment) + extra="forbid" — policy-engines /v1/decide korsvaliderar inte body-tenant_id mot claim (latent yta i den befintliga ytan, flaggad D-44, testtäckt).
  • Dispatchern: upstream_map ur env — policy, llm, auth, lifecycle, mcp, feedback, eval, rag; osatt env ⇒ prefixet är inte routbart. rag-prefixet (fas R1, D-39) är kopplat till rag-api via UPSTREAM_RAG_URL — satt i e2e-stacken (tests/e2e/docker-compose.e2e.yml), produktionsvärdet är fortsatt Fabians bord. Fail-closed: okänt prefix ⇒ 404 unknown_service + no-store; uppström ≥500 maskeras till 502 upstream_error + no-store; endast content-type/cache-control forwardas. router-aliaset borttaget (D-27, fas L).
  • Sessionen (D-23, arbetsläget): TTL 8 h idle/24 h hård; transparent IdP-refresh vid exchange-401 (en retry + uppdaterad cookie); nekad refresh ⇒ 401 session_expired; äkta 403 refreshas aldrig (auth_denied); auth-api/IdP nere ⇒ 503 + Retry-After + no-store.
  • Boot (fas O, D-34): OIDC-discovery-krasch under lifespan höjer ett typat RuntimeError (med issuer-URL:en i felmeddelandet) i stället för att starta tyst trasig — fail-closed även vid uppstart.
  • Auth — publika planet-admin (fas N task 5, rutt-inkopplad sedan task 6): app/authz.py bygger libs.platform_auth.build_auth_dependency (config-fält auth_api_issuer/auth_api_jwks_url/jwks_cache_ttl_seconds, mall: policy-engines app/auth.py) och exponerar require_public_admin(scope) — verifierar plattforms-JWT, kräver scopet OCH att JWT:ets tenant_id matchar BFF-instansens konfigurerade tenant_id (BFF är per-tenant, fail-closed på båda). Scopen bff:public_sites:read/write och bff:forensics:unlock ligger på tenant_admin i auth-apins scope-matris. De anonyma publika rutterna (/public/*) identifierar i stället sessionen via x-public-session-headern (SHA-256-hash av ett opakt token) — ingen plattforms-JWT, ingen användaridentitet i den vägen. Nedströms mot llm-gateway (E1, spec §6) autentiserar BFF med PUBLIC_LLM_GATEWAY_KEY (en tenant-scopad dgt-nyckel, utan användaridentitet) — medvetet skilt från arbetslägets forwardade user-JWT.
  • Forensikbufferten (flight recorder, spec §7): AES-256-GCM envelope-kryptering — en KEK (PUBLIC_FORENSICS_KEK) wrappar en per-session datanyckel (public_sessions.forensics_key_wrapped); ingen klartext, IP eller identitet lagras, bara chiffertext + sekvensnummer. Retention: TTL 7 dagar (PUBLIC_FORENSICS_TTL_DAYS), verkställd hård DELETE — sedan D-94 av dygnsronden (CronJob <release>-bff-retention-sweep) plus explicit /maintenance/sweep; ingen opportunistisk sweep vid session-mint kvar (R7-lärdomen: gallring är verkställighet, aldrig bara en etikett). Anonym by design (ingen subjektsidentifierare existerar) — se kvarvarande atlas-diff nedan för GDPR-coverage-sömmen mot auth-apins SAR-export.
  • Anropare: frontends — ops-ui (admin-planet: site-CRUD, revoke, forensik-unlock) och chat-ui (både arbetslägets index.html och publika public-chat.html, sedan fas Q med den riktiga konversations-storen). Sedan fas Q anropar även auth-api (GDPR-nedströms, se auth-api-sektionen) BFF:ns /gdpr/users/{uid}/conversations/erase; exportrutten anropas sedan #655 bara av driftens break-glass-klient.
  • Observability: trace_id per request, felkuvert {error, trace_id}; tidigare egen JSON-logg (pythonjsonlogger, log_format=json default — fanns redan, en fas-M-atlas-lucka rättad här; sedan D-52 ersatt, se nedan); OTel NU närvarande (FastAPIInstrumentor + HTTPXClientInstrumentor, fas N task 11, #T5 3/8→4/8) — BFF är första tjänsten vars utgående httpx-anrop injicerar traceparent automatiskt, men tvärsnittsavsaknaden (ingen nedströms-tjänst läser OTel-context) kvarstår för övriga tjänster. (sedan D-52: libbens trace/422/429/5xx OCH loggning; egen http_exception_handler behållen för dict-wire-kontraktet)
  • GDPR-coverage (rättad, fas O atlas-diff; utökad fas Q, D-37): spec §7.6/D-32:s åtagande att forensikbufferten registreras i auth-apins SAR-export är infriat — services/auth-api/app/api/admin/gdpr.py::_NOT_COVERED bär posten bff_public.public_forensics (skäl anonymous_public_data_no_subject_identifier) sedan samma commit som byggde bff_public-schemat (#58, D-32). Sedan fas Q (D-37) är conversations (inkl. uploads) flyttad till _COVERED i samma auth-api-gdpr.py — konversations-storens SAR-åtagande (D-31 p4) är infriat i samma fas som byggde ytan, inte som uppföljning.
  • Nya env-nycklar (fas Q, D-37): CONVERSATIONS_KEK (secret), PII_API_URL/PII_API_TOKEN, POLICY_SERVICE_TOKEN (redan fanns POLICY_ENGINE_URL). Sedan fas R2 (D-40): MCP_GATEWAY_URL (app/config.py::mcp_gateway_url, valfri men krävs på riktigt för kunskapsbundna assistenter — osatt ⇒ retrieval_unconfigured ⇒ terminalt degraded för varje meddelande till en kunskapsbunden assistent; kompletterar den redan dokumenterade UPSTREAM_RAG_URL, se Dispatchern ovan). Sedan fas S2 (D-43): MODEL_META (JSON, intent/residency) är AVVECKLAD — ersatt av MODELS_YAML_PATH (default ../llm-gateway/config/models.yaml, resolverbar från services/bff; boot-eager fail-fast, se ovan) och en ny läs-yta GET /api/models (app/routes/models.py, registrerad FÖRE catch-all-passthroughen — E4, samma mönster som S1:s assistants-handler; require_public_admin("llm:admin:read")) som serverar registrets governance-projektion (ModelView, 10 fält) till ops-uis Modeller-yta (dedup by id, enabled=false-modeller filtreras EJ bort — ops-admin ska se dem). Runbook-bordet: CHAT_UI_API_BASE_URL AVVECKLAD 2026-07-31 (D-79) — repo-variabeln läste ett buildsteg som försvann med pages-deploy.yml. Chat-uis bas-URL sätts numera i charts/chat-uis config.apiBaseUrl, och rätt värde där är tom sträng (same-origin, delad host).

services/pii-api

Verifierad/uppdaterad 2026-10-08 (#658, D-673, B3 — profilen per instans. Charten har modelProfiles, som i policy-engine: satt ger den ConfigMapen <fullname>-model-profiles, monterad på /app/config-instance/ och utpekad med PROFILES_YAML_PATH. Sätt samma fil i båda charts. Bootkontrollen av NER-modellen är oförändrad och prövas också mot provprofilen för röd zon. appVersion 1.2.0 → 1.2.1, chartversion 0.2.2 → 0.3.0, eftersom imagen bakar in det ändrade registret.)

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Den egna structlog-åtkomstraden i app/middleware/logging.py bär route i stället för path. configure_logging lägger ExceptionTypeOnlyFilter på rotens handler och anropar contain_library_loggers. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.2.0 → 1.2.1.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 3, B5 — NER-modellen prövas mot modellprofilen vid boot. sv_core_news_md har registerposten stckn-sv-core-news-md (type: ner, version: sv_core_news_md-3.8.0) i services/llm-gateway/config/models.yaml och står i ner-primitiven i varje profil i profiles.yaml. Lifespan anropar app/model_profile.py::check_ner_model_in_profiles före motorn: registret och profilen laddas med libs.model_registry (samma fail-fast som policy-engine), och tjänsten startar inte om posten saknas, har en annan version än den installerade wheelen, är avstängd eller saknas i någon profil. Kontrollen kräver varje profil, eftersom samma pii-api skannar innehåll av alla klasser. Ingen decide per anrop; inget innehåll lämnar podden. Filerna bakas in i imagen (MODELS_YAML_PATH, PROFILES_YAML_PATH, /app/config/). Nytt workspace-beroende stacken-model-registry, inget nytt externt. appVersion 1.1.5 → 1.2.0.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.1.4 → 1.1.5.)

Verifierad/uppdaterad 2026-09-27 (D-152, plattformen#207 — ett UUID är inget namn. spaCys svenska NER tog ibland ett UUID för ORGANIZATION, beroende på tecknen; för en grön assistent blockerade det hela anropet. Det slog i riggens agenttur, där modellen måste få samlingens id för att söka (pii_blocked, ORGANIZATION, på 7a1a0667-…; ett annat UUID passerade). Namnträffar (PERSON, LOCATION, ORGANIZATION) vars text är exakt ett UUID tas nu bort; regexbaserade igenkännare — personnummer, e-post, telefon, kort, IBAN — är orörda. engine_version bär regelrevisionen (uuid-undantag 1) så att llm-gatewayens memoisering (D-140) släpper gamla svar. appVersion 1.1.3 → 1.1.4.)

Verifierad/uppdaterad 2026-09-04 (kartläggningsfynd, ingen kodändring: tldextract-varningen vid start är ofarlig och ska inte jagas. Texten “unable to cache publicsuffix.org-tlds … This could refresh the Public Suffix List over HTTP every app startup” dyker upp i startloggen och ser ut som en intern tjänst som ringer ut. Undersökt tre gånger: (1) ingen registrerad recognizer använder tldextract — Presidio importerar det för en URL/domän-recognizer som app/engine.py aldrig registrerar; (2) anropet kan inte nå ut — pii-apis NetworkPolicy släpper egress endast till auth-api (JWKS) och DNS; (3) det kostar ingen starttid — mätt i prod-podden med ordningen KONTROLLERAD och tre upprepningar: skrivbar cache 1377/1452/1416 ms mot dagens 1372/1426/1355 ms, alltså samma tal. Den tredje punkten höll på att bli ett felaktigt fynd: första mätningen gav 2072 mot 1401 ms och såg ut som en tydlig vinst, men var helt och hållet filsystemets cache — varianterna kördes i samma ordning varje gång. docs/capacity-model.md §7:s regel om att en siffra bär sin regim gäller också ordningen MELLAN mätningarna.)

Verifierad/uppdaterad 2026-08-25 (D-109 — GET /metrics + http_requests_total, via ett explicit install_metrics-anrop i app/main.py. Samma form och samma skäl som feedback-api: den egna structlog-varianten med GDPR-redaktion är orörd, 5xx-serien kommer via bibliotekets ohanterade felhanterare. Inga nya rutter i openapi.json, inga nya tabeller.)

Stämpelhistorik — 1 tidigare verifieringar

Föregående Verifierad/uppdaterad 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.)

  • Syfte: statelös PII-detektering/maskering (Microsoft Presidio: presidio-analyzer + presidio-anonymizer) med svensk entitetskatalog. Detekterar och föreslår maskering — äger aldrig dispositionen (approve/mask/block); den ägs av policy-enginens pii_gate.rego/output_gate.rego. Konsumenter: llm-gatewayens ingressflöde (före decide) och sedan fas P2 utflödesvägen (hold-and-release, före action="output_check").
  • Postgres: inga tabeller — plattformens första helt statelösa tjänst (nivå→recognizer-mappningen är kodkonstant; dispositionen ligger i policylagret).
  • HTTP-yta: POST /v1/analyze {texts, language="sv", level="off"|"low"|"medium"|"high"=high} → {results: [{entities, masked_text}], engine_version} (batchat; gatewayens klient begränsar till 64 texter/anrop och 100 000 tecken/text), GET /healthz. Containerport 8080. level-fältet (fas P2, spec §3.4) styr recognizer-urvalet ur resultatet, inte om NER-pipelinen körs (se nedan); default high är P1-paritet (bakåtkompatibelt för anropare utan fältet).
  • Motor: presidio-analyzer/presidio-anonymizer + spaCy-modellen sv_core_news_md, båda pinnade via root-uv.lock. Entitetskatalog: regex-familjen PERSONNUMMER (egen Luhn-PatternRecognizer), EMAIL_ADDRESS, PHONE_NUMBER (SE), CREDIT_CARD, IBAN_CODE — samt NER-familjen PERSON/LOCATION/ORGANIZATION. level="off" kortsluter före analyze() anropas alls (entities==[]); low filtrerar bort NER-familjen ur resultatet; medium/high kör hela katalogen.
  • Viktigt semantiskt fynd (fas P2, Task 6, D-36): nivåstyrningen är skyddsräckvidd, inte latensoptimering. Presidios AnalyzerEngine.analyze() kör hela spaCy-pipelinen (inklusive NER-modellen) ovillkorligt, oavsett entities-filter — verifierat mot presidio-analyzer-källan (analyzer_engine.py filtrerar recognizer-listan men kör nlp_engine.process_text() ändå). Uppmätt: 2.57–2.58 ms/anrop, identiskt över low/medium/high; endast off ger en verklig latensvinst. README.md dokumenterar mätningen ärligt.
  • Modellprofilen (K10, D-171): boot kräver att NER-modellen och dess installerade version står i registret och i ner-primitiven i varje profil (app/model_profile.py). Env MODELS_YAML_PATH och PROFILES_YAML_PATH, i imagen /app/config/models.yaml och /app/config/profiles.yaml. Ett byte av spaCy-modell kräver alltså en ändrad registerpost och profil, inte bara ett nytt lås. En instans kan sätta en egen profil med chartens modelProfiles (#658), utan ny image.
  • Auth: service-token dual-mode — SERVICE_TOKEN (required, ≥16 tecken; accept_legacy_service_token=true för v1-utrullning) eller plattforms-JWT via AUTH_API_ISSUER/AUTH_API_JWKS_URL (samma dual-mode-mönster som policy-engine/feedback-api/provisioning-api, D-24). Env LOG_LEVEL (gemener godtas sedan fixvågen).
  • Anropare: llm-gateway (gateway/pii/client.py, ingress och utflöde), BFF (sedan fas Q: uploads/scan-endpointen, egen klient, level="high", D-37). Väntad framtida konsument: rag-api-ingest (fas R) — inte byggt ännu.
  • Observability: OTel FastAPI-instrumenterad. Loggar aldrig text eller fynd — enbart texts_count/entity_count (GDPR first; rättelse mot tidigare atlas-text: loggraden bär INTE latens eller trace_id, app/api/analyze.py verifierat). README.md har en deploy-kontraktstabell + ett TC-PII-03-avsnitt med uppmätta latenser per nivå (se ovan; NER-nivån dominerar — test_determinism_30_runs ≈2.8 ms/anrop i varmt tillstånd). (sedan D-52: libbens TraceIdMiddleware + felhanterare; egen structlog-logg behållen — dokumenterat undantag)
  • Sömmar: determinism är testkriterium (30-körningars regressionstest över en svensk textkorpus, samma input ⇒ samma output per nivå — TC-PII-02); inga persistensytor för innehåll tillkommer.
  • Live-status: PROD: live sedan D-50 (customer-prod, Hetzner; api.stacken.eu/healthz → 200, sonderat 2026-07-30). Per-tjänst Synced/Healthy är INTE omverifierat i det passet — det kräver klusteråtkomst. Även i e2e-stacken (tests/e2e/, port 18086). Deployordning: pii-api → policy-engine → llm-gateway.

services/extract-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Låst för flottan av tools/checks/check_log_content.py. appVersion 0.2.0 → 0.2.1.)

Uppdaterad 2026-10-06 (#562, filvägen steg 7 — anroparna och e2e: kunskapsvägen (steg 5) är andra anroparen, och tjänsten körs i e2e-riggen. Tjänsten är oförändrad.)

Uppdaterad 2026-10-06 (#544, D-172 — filvägens steg 4: BFF:s chattroute POST /api/chat/v1/uploads/file är första anroparen, med tjänste-JWT:n svc-bff. Tjänsten är oförändrad.)

Verifierad/uppdaterad 2026-10-05 (#544, D-172 — filvägens steg 2: DOCX, XLSX och PPTX, zip-kontrollen och defusedxml. Steg 1 (#528) gav tjänsten och PDF. Ingen anropare ännu: BFF:s filroutes är steg 4–5. appVersion 0.2.0.)

  • Syfte: text ur uppladdade filer, för chatt och kunskap (D-172; specen kom i #529). PDF med pypdf (BSD-3), och DOCX, XLSX och PPTX med python-docx, openpyxl och python-pptx (MIT). XML tolkas utan entitetsupplösning: defusedxml för [Content_Types].xml och openpyxl, resolve_entities=False i python-docx och python-pptx, och lxml ≥ 6.1. Tjänsten tolkar bara. Om en fil får användas avgör decide(file_extract) i BFF (steg 3–4), och därefter gäller de befintliga grindarna (pii-api, upload_scan, knowledge_ingest) oförändrade.
  • Postgres: inga tabeller. Tjänsten är tillståndslös och lagrar varken filen eller texten (B3).
  • HTTP-yta: POST /v1/extract tar filens byte och headern X-Max-Chars och svarar {format, text, chars, extractor_version, pages, sheets, slides}, där pages, sheets respektive slides är satt efter format och övriga är null. GET /healthz. Containerport 8080. Typade fel: 413 file_too_large, 415 unsupported_format (formatet läses ur innehållet, inte ändelsen; makrofiler, mallar och äldre binärformat ingår), 422 too_many_pages, too_large, archive_bomb, text_too_long, no_text, encrypted, extraction_failed, och 503 busy med Retry-After. Text kortas aldrig av.
  • Barnprocessen: python -m app.extract.child, en per fil, med minimal miljö. Stderr kastas. Ett ZIP-arkiv kontrolleras ur centralkatalogen (högst 5 000 poster och 100 MiB okomprimerat, kvot under 100:1 per post) innan något bibliotek öppnar det. Taken (512 MiB, 20 s CPU, 30 s vägg, 500 sidor, 50 blad, 200 000 celler, 500 bilder, 20 MB) är konfigurerbara nedåt men inte uppåt. Varje pod kör högst MAX_CONCURRENT barn (standard 1). Är podden full blir svaret 503.
  • Auth: endast platform-service-JWT, och ingen legacy-SERVICE_TOKEN. Tjänsten har inga hemligheter. Att anroparen är BFF verkställs av NetworkPolicyn (ingress bara app: bff, egress bara auth-api och DNS).
  • Anropare: BFF, och bara BFF, med tjänste-JWT:n svc-bff och efter decide(file_extract): chattens POST /api/chat/v1/uploads/file (steg 4, X-Max-Chars: 100000) och kunskapens POST /api/rag/v1/documents/file (steg 5, X-Max-Chars: 400000).
  • E2e: tjänsten ingår i e2e-riggen (tests/e2e/docker-compose.e2e.yml, port 18092) med standardtaken. tests/e2e/test_file_path.py prövar båda vägarna med PDF, DOCX, XLSX och PPTX, varje gräns i specens §4, en policy-deny före tolkningen, radering och att tjänsten nere ger 503 (#562 steg 7).
  • Observability: libs.observability (JSON-logg, trace_id, /metrics, OTel FastAPI). Loggraden extract innehåller format, byte, sidor, tecken, extraktorversion, utfall och varaktighet, men aldrig filnamn eller text.
  • Runbook: services/extract-api/README.md.
  • Live-status: inte driftsatt. Chart finns (charts/extract-api), men ingen instans.

services/policy-engine

Verifierad/uppdaterad 2026-10-08 (#658, D-673, B3 och E5, vägval — profilen per instans och PII-maskeringen per profil. Charten har modelProfiles: satt ger den en egen ConfigMap (<fullname>-model-profiles, i samma mall som huvud-ConfigMapen så att checksum/config följer den), monterad på /app/config-instance/ och utpekad med PROFILES_YAML_PATH. Tomt = imagens profil. Profilen har ett nytt fält pii_masking (standard true). pii_gate.rego ger approve i stället för masked när klassens profil har pii_masking: false och kanalen inte är publik. Skanningen krävs fortfarande (saknade signaler ⇒ blocked), gron blockeras som förut och utflödesgrinden är orörd. Profilhashen i policies_version byts en gång för varje instans, eftersom fältet nu ingår i data.profiles. Provprofilen services/llm-gateway/config/examples/profiles-rod-instans.yaml (rerank tom i alla klasser, rod = Mistral Medium hos Evroc, e5, lokal NER, ingen transkribering, pii_masking: false) prövas mot den riktiga policyn i tests/unit/test_rod_instance_profile.py och mot torrkörningen. Chartversion 0.3.7 → 0.4.0, appVersion 1.14.1 → 1.15.0.) Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. opa_eval_parse_error loggar undantagets typnamn i exc, inte repr(exc), som för ett UnicodeDecodeError citerar svarskroppen. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.15.0 → 1.15.1.)

Verifierad/uppdaterad 2026-10-08 (#659, krav D2 och D5-D7, vägval 4 A, D-675 — en tom allowed_groups betyder inte längre “alla”. Migration 0014_assistants_shared_with_org lägger till assistants.shared_with_org BOOLEAN NOT NULL DEFAULT false och sätter true på befintliga rader där både allowed_roles och allowed_groups är tomma, så dagens räckvidd blir uttrycklig och ingen assistent försvinner vid utrullning. access_gate.rego: i kanalen internal (user_id_hash satt) räcker tomma listor inte; åtkomst kräver rollträff, gruppträff eller shared_with_org. Kanalen public (ingen användare: publik chatt, eval, pipeline) är oförändrad. DecideService trär in shared_with_org. /v1/assistants POST/PATCH/retire prövar för platform-user utan tenant_admin (app/services/sharing.py): shared_with_org och allowed_roles kräver tenant_admin; grupper får bara läggas till ur anroparens groups-claim och en ny assistent måste ha minst en av dem; PATCH och retire kräver att anroparen tillhör någon av assistentens grupper. Nekat svarar 403 med typad detail och Cache-Control: no-store. Service- och legacy-planet oförändrat (D-24). openapi.json additivt. appVersion 1.14.1 → 1.15.0.)

Verifierad/uppdaterad 2026-10-07 (P43, #596, D-629 — user_id_hash beskrivs som det är. DecideRequest.user_id_hash och user_id_hash-parametern på GET /v1/admin/decisions och /decisions/traces har fått en description (app/schemas/decide.py::USER_ID_HASH_DESCRIPTION). Den namnger båda identitetsrymderna. Fältmängd, trådnamn, policy_decisions.v1-nycklar och kolumner är oförändrade (N2–N3). openapi.json är regenererad, och bara description har ändrats. appVersion 1.14.0 → 1.14.1.)

Verifierad/uppdaterad 2026-10-07 (#591, P32, D-102 Kvarstår 5 — en okänd action nekas som okänd. Chattens allow- och substitute-gren i main.rego kräver nu _chat (ingen action), en positiv kontroll, i stället för en lista not _x per övrig gren som måste växa i båda grenarna för varje ny action. En action utanför _known_actions (output_check, upload_scan, knowledge_ingest, embedding, rerank, transcription, knowledge_preview, tool_call, assistant_probe, file_extract) matchar ingen gren och ger default deny med substitute_reason: "unknown_action", inte ett skäl från modell-, åtkomst- eller profilgrinden. Det gäller också "", "chat" och icke-strängar; ingen anropare i repot skickar dem. model_profile.regos _chat_action är positiv på samma sätt (_chat eller assistant_probe), så dess lista _non_chat_actions är borttagen. En ny action läggs i _known_actions och får en egen gren. Beteendet för kända actions är oförändrat. appVersion 1.13.1 → 1.14.0.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 3 — ingen regeländring. Den bakade profiles.yaml listar ner: [stckn-sv-core-news-md] i varje profil, och registret har modellens post (type: ner), som load_profiles validerar vid boot som alla andra. Regeln per anrop är låst end-to-end av egress-provet tests/e2e/test_egress_profile.py: med den riktiga OPA-policyn och e2e-profilen når en kanarie aldrig providern för en modell utanför profilen, inte heller när en publicerad assistents default_model fallit ur profilen (deny i stället för substitute). appVersion 1.13.0 → 1.13.1, eftersom imagen bakar in de ändrade filerna.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 2, vägval — modellprofilen verkställs. Ny policies/model_profile.rego: profile_allows(model, primitive, class) prövar per anrop samma villkor som load_profiles vid boot (modellen står i data.profiles[class].primitives[primitive], type stämmer, enabled, host/country/provider/version ifyllda, inget rörligt versionsalias, max_information_class minst klassen, profilens countries och training_excluded). Predikatet ligger i chattens allow (chat_profile_ok), i substitute_target_ok (substitut utanför profilen blir deny), i båda assistant_probe-grenarna och i embedding/rerank (klass ur input.knowledge.information_class) och transcription (assistentens klass). Saknad klass eller profil nekas. Ett profilnekande bär profile_missing, model_not_in_profile eller model_attributes_missing i substitute_reason. validate_assistant tar profilen (get_profiles(), ny lru-cachad beroendefunktion bredvid get_registry()) och nekar med 422 modeller i allowed_models utanför chattprofilen för klassen, också efter en PATCH av klassen. Nytt skript scripts/k10_dry_run.py listar, läsande och utan innehåll, de assistenter och nycklar som skulle nekas. Beteendeändring: en gul-assistent med en US-modell nekas nu även med arketyp gron eller ingen arketyp. appVersion 1.12.0 → 1.13.0.)

Verifierad/uppdaterad 2026-10-06 (#562, filvägen steg 6 — ny datakategori knowledge_superseded i retentionsmatrisen (spec B5.2). Migration 0013_retention_knowledge_superseded byter ck_retention_data_category mot samma lista plus den nya kategorin (additivt; nedgraderingen fäller om en sådan rad finns). DataCategory i app/api/admin_retention.py utökad, openapi.json additivt. Konsument: rag-api, som läser GET /v1/admin/retention med sin POLICY_ENGINE_TOKEN (legacy-vägen, samma som BFF), fryser längden vid PATCH och gallrar i en daglig rond. Ingen rad betyder ingen TTL. Chart version 0.3.6 → 0.3.7, appVersion 1.11.0 → 1.12.0.)

Verifierad/uppdaterad 2026-10-06 (D-172 B4, #544 steg 3 — ny decision-gren action="file_extract", beslutet före filextraktionen. DecideRequest.file: FileCtx {format, size_bytes} bär bara metadata (inga byte, filnamn eller hash) och krävs för actionen, tillsammans med exakt ett scope: assistant_id (chattens route) eller knowledge_ctx (kunskapens route); DecideRequest.knowledge_scoped väljer väg i DecideService. Båda vägarna trär in input.file (_file_input, låst av integrationstest mot DecideService och policy_decisions). main.rego: allow iff formatet är pdf|docx|xlsx|pptx och storleken är 1 byte–20 MiB (speglar extract-api:s schemas.py och max_file_bytes), plus access_ok på assistentvägen och input.knowledge.collection_id på kunskapsvägen; allt annat, också ett saknat file, är default deny och syns som en deny-rad. Assistentvägen kräver som förut aktiv och aktiverad assistent. pii_gate är exempt, för det finns ingen text än: dispositionen tas efter extraktionen i upload_scan och knowledge_ingest, oförändrade. not _file_extract i chattens allow- och substitute-lista (P32:s fälla, mutationsprövad). libs/policy_client speglar fältet. Ingen anropare än; BFF:s routes kommer i steg 4. openapi.json additivt. appVersion 1.10.0 → 1.11.0.)

Verifierad/uppdaterad 2026-10-05 (D-171, K10 session 1 — modellprofilen laddas, men ingen regel läser den ännu. Boot laddar PROFILES_YAML_PATH (bakad /app/config/profiles.yaml, källa services/llm-gateway/config/profiles.yaml) med load_profiles mot registret, fail-fast: en listad modell som saknas, är avstängd, har fel type, saknar host/country/provider/version, har ett rörligt versionsalias eller är klassad under profilens klass stoppar tjänsten. data.models bär nu registry.policy_data() (residency, eu_native, enabled, type, max_information_class, host, country, provider, version, training_excluded; display når aldrig OPA), och samma datafil bär data.profiles. policies_version får +prof-<hash[:12]> efter +reg-<hash[:12]>. e2e pekar om profilen till tests/e2e/e2e-profiles.yaml. Regeln och skrivvalideringen kommer i session 2 (vägval). appVersion 1.9.0 → 1.10.0.)

Verifierad/uppdaterad 2026-10-04 (D-170, P7 — avståendetröskeln. Assistenten får runtime.min_retrieval_score (0–1, lagras i default_parameters, ingen migration; null = ingen tröskel) och OutputCheckSignals får retrieval_score. DecideService lägger tröskeln i output_check ur recordet, som knowledge_bound; schemat bär inget tröskelfält, så anroparen kan inte sätta den. output_gate.rego: regeln avstar stoppar ett kunskapsbundet svar vars poäng ligger under tröskeln, och ett som saknar poäng när tröskel finns (fail-closed). Undantar en ren verktygsrunda och graderas inte av outflow-nivån. Ingen anropare skickar retrieval_score än; se D-170 Kvarstår. openapi.json additivt. appVersion 1.8.1 → 1.9.0.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.8.0 → 1.8.1.)

Verifierad/uppdaterad 2026-10-03 (D-165, P47 — en ersatt assistentversion betjänar inte längre decide. AssistantRepo.patch lämnar den gamla raden med status='active' och sätter bara superseded_by, så en omklassning slog inte igenom för en anropare som höll kvar det gamla id:t. decide nekar nu ett id med superseded_by satt (AssistantSupersededError, ärver AssistantNotFoundError — 404 som förut), före aktiveringsprövningen och före eval-undantaget. appVersion 1.7.0 → 1.8.0.)

Verifierad/uppdaterad 2026-09-28 (D-154, vägval #388 — en eval-körning passerar aktiveringsgrinden. Aktivering kräver en godkänd eval, men grinden nekade eval-körningens egna anrop, så efter bytet kunde en ny assistent bara aktiveras med dispens. DecideRequest.purpose ("eval" eller frånvarande) hoppar över is_activated och inget annat: statusprövningen och varje OPA-regel gäller som förut, och purpose skrivs i beslutsradens kedjade input_snapshot. Fältet sätts av llm-gateway ur nyckelns eget attribut och av eval-api:s förkontroll, på samma förtroendenivå som user_role. appVersion 1.6.0 → 1.7.0.)

Verifierad/uppdaterad 2026-09-27 (D-152 — en aktivering med dispens räknas bara till sitt slutdatum. AssistantRepo.is_activated väljer en aktivering utan dispens före en med, annars den dispens som gäller längst, och nekar när expires_at passerats. Cachen bär slutdatumet: ett ja för en dispens gäller bara dit, ett ja utan dispens som förut. Monotonin i D-143 gäller alltså aktivering utan dispens. Tre nya integrationslås. appVersion 1.5.0 → 1.6.0.)

Verifierad/uppdaterad 2026-09-27 (D-149, vägval #379 — källtvånget gäller svar, inte en ren verktygsbegäran. output_gate.rego: kalltvang gäller inte när output_check.answer_kind == "tool_calls"; PII-utflödet och beslutsblockeringen gäller oförändrat, och saknat eller annat värde är ett svar (fail-closed). OutputCheckSignals.answer_kind: Literal["tool_calls"] | None — schemat hade annars tappat fältet tyst (pydantics default ignore), och regeln hade varit onåbar. openapi.json additivt. Fyra nya rego-lås (144/144) och ett enhetstest för att fältet når OPA. appVersion 1.4.6 → 1.5.0. Driftnot: den här versionen bär även D-143:s aktiveringsgrind; en instans som kör 20fa616 och bumpas nekar varje assistent utan assistant_activated-rad.)

Verifierad/uppdaterad 2026-09-04 (D-143 — decide kräver nu en assistant_activated-rad i auditkedjan (P10, F2, vägval #314 (c)). assistants.status var hela grinden och är sann från födseln (server_default 'active', vokabulär utan ett värde för “inte aktiverad”), medan aktiveringsrutten skrev en WORM-rad utan att röra statusen — varje assistent var alltså körbar utan att någon aktiveringskontroll körts. Grinden ligger EFTER statusprövningen, så en retirerad assistent nekas som förut. Felet ärver AssistantNotFoundError, så 404-kontraktet är oförändrat. Två driftkonsekvenser: en PATCH ger ett nytt assistent-id och kräver därför ny aktivering (avsikt — annars kan en granskning av en konfiguration gälla en annan), och ingenting kan aktiveras i drift i dag eftersom noll dossier finns, vilket ger 409 activation_blocked.)

Stämpelhistorik — 6 tidigare verifieringar
  • Föregående 2026-09-04 (D-142 — OPA körs som en långlivad barnprocess i stället för ett kommando per beslut (app/services/opa_runtime.py, vägval #342). opa run --server startas i lifespan mot loopback (127.0.0.1:8181, aldrig 0.0.0.0 — OPA:s dataplan har ingen egen auth) och nås över HTTP; evaluate() behåller signatur och returform, så inget ovanför ändras. Mätt i en tillfällig pod på produktionshårdvara: 45,95 ms → 1,66 ms p50 per beslut, alltså 27,7 gånger; två beslut per chattmeddelande ger 92 ms → 3,3 ms. Av de gamla ~46 ms var ~23 ms processtart och ~17 ms inläsning av de nio .rego-filerna, varje gång. Fail-closed är oförändrat i utfall men har fler fellägen — fem HTTP-fel (vägrad, timeout, icke-200, {} utan result, oparsbart) räknas upp var för sig till samma _FAIL_CLOSED, aldrig ett brett except Exception. Ny koppling: /readyz probar OPA, annars ser en pod vars OPA dött frisk ut medan den nekar allt; barnet startas aldrig om automatiskt (en restart-loop vore en tyst fallback). Ingen --watch och ingen bundle-server — policies_version skrivs i varje beslutspost och får inte kunna glida från laddad policy. Minnestaket höjt 256Mi → 384Mi (OPA tar 41,6 MB RSS). httpx deklareras nu explicit; det fanns redan i runtime-imagen via stacken-platform-auth. openapi.json omgenererad, rent additivt. OPA bumpad 1.19.0 → 1.20.2 i samma ändring (Dockerfilens ARG och workflowens nedladdning i takt): rensar två nya CVE:er vid källan OCH uppfyller villkoret i alla elva OPA-dispenser i .trivyignore.yaml, som därför raderades i stället för att skrivas om — deras argument byggde på att OPA inte är en server, vilket den nu är. Regeltester under 1.20.2: 140/140. Uppföljning samma dag: OPA:s felutskrift KASTADES (stderr=DEVNULL), så en död OPA gav bara opa_unreachable i loggen utan orsak — upptäckt i kapacitetsmätningen, där diagnosen krävde en manuell omkörning. Den fångas nu i en takad buffert och loggas EN gång som opa_server_died med returkod och de sista raderna, bara när processen faktiskt avslutats. Att det är GDPR-säkert är mätt, inte antaget: med --log-level error skriver OPA noll byte till stderr vid normal drift, trasig JSON och okänd datasökväg.)
  • Föregående 2026-08-27 (D-117 — nytt additivt fält DecideResponse.information_class — assistentens informationsklass bärs med decide-svaret, satt ur assistant.information_class på den assistent-scopade vägen och None på den kunskaps-scopade (ingen assistent, D-39). Fjärde gången samma mönster efter pii_verdict, retention_policy och content_trail_enabled: policylagret beslutar, gatewayen lyder, och ingen tjänst läser assistants-tabellen själv. Ingen ny gren, ingen .rego rörd, ingen borttagen rad i kontraktet — openapi.json omgenererad.).
  • Föregående 2026-08-26 (D-115 — ny additiv kolumn assistants.content_trail_enabled (migrering 0011, NOT NULL DEFAULT false) plus två additiva fält i /v1/decide-svaret: content_trail_enabled och retention_policy. Flaggan är innehållsspårets EGNA brytare (vägval #274) — medvetet SKILD från retention_policy, som redan är bff:ns grind för permanenta konversationer och som D-57 förbjöd att överlasta. Default false gör utrullningen till en no-op för hela beståndet. Tidigare formulering, som bara nämnde retention_policy, satt ur assistant.retention_policy på den assistent-scopade vägen och None på den kunskaps-scopade (ingen assistent, D-39). Bärs av llm-gateways innehållsspår som opt-in-grind. Ingen ny gren, ingen .rego rörd, ingen borttagen rad i kontraktet — openapi.json omgenererad.).
  • Föregående 2026-08-24 (D-104 — ny decision-gren action="transcription" (P11 session 3): allow iff model_residency ∈ {eu, on_prem} OCH access_ok — åtkomstgrinden är skillnaden mot _rerank, som är kunskaps-scopad och saknar användare; transkribering är assistent-scopad eftersom utflödesgrinden behöver assistentens protection-konfig. pii_gate är exempt PÅ INGRESSEN — pii-api analyserar text, ljud går inte att skanna före transkriberingen — och grinden FLYTTAR därför till output_check, den försvinner inte. output_gate.rego har fått ett transkriptgolv: varje PII-entitet i ett transkript räknas som träff oavsett assistentens outflow-nivå, styrt av det nya OutputCheckSignals.source-fältet. Asymmetrin mot chatt är avsiktlig: chatt har kvar ingressgrinden om utflödet stängs av, ljud har ingenting. DecideService trädar nu model_residency i opa_input även på den ASSISTENT-scopade vägen — före det gjorde bara _decide_knowledge det, och den nya grenen hade varit död kod i produktion (samma felklass som D-70:s input.grant); låst av integrationstest, inte bara opa-test.).
  • Föregående 2026-08-18 (D-102 — ny decision-gren action="rerank" (P11): allow iff model_residency ∈ {eu, on_prem}, samma regel som embedding; pii_gate-exempt. Se egen bullet nedan.).
  • Föregående 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: apt-get upgrade -y vävd in i det befintliga OPA-installationslagret (i stället för ett andra apt-lager) så att basimagens Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.).
  • Syfte: källa till assistent-konfiguration och policy-beslut (/decide) med hash-kedjad beslutsaudit — datapath-regeln: ingen LLM-/verktygs-/retrieval-väg förbi denna.
  • Ersatt version nekas (P47, D-165): DecideService._serving_assistant kräver status == "active" och superseded_by IS NULL. Bara den senaste versionen i en kedja kan betjäna decide, och den kräver egen aktivering (D-143). Anropare som sparat ett id (bff:s conversations.assistant_id, public_sites.assistant_id) får 404 efter en PATCH tills de pekas om.
  • Aktiveringsgrinden (P10, ny, D-143, vägval #314 (c)): AssistantRepo.is_activated() frågar public.audit_events efter en assistant_activated-rad — provisioning-api skriver den, och den ligger i SAMMA databas som tjänstens enda anslutning (platform_db_url), vilket är skälet att (c) valdes framför en ny kolumn: noll migrering mot befintliga rader. Migration 0012 lägger ett partiellt index på resource_id WHERE action = 'assistant_activated'. Bara JA cachas (per podd): aktivering är monoton — append-only kedja, ingen av-aktiveringspost — medan NEJ aldrig cachas, annars vore en nyss aktiverad assistent blockerad tills podden startats om.
  • Postgres: assistants (nu med protection JSON | None-kolumn — per-assistent-override, ADD-only — sedan fas R2 knowledge_scope TEXT[] NOT NULL DEFAULT '{}', migration 0006, ADD-only, K8 — och sedan fas S1 tre fält för assistents-ytan exposure TEXT NOT NULL DEFAULT 'internal_chat' (CHECK: IN ('public_chat','internal_chat','api','pipeline')) + action_level TEXT NOT NULL DEFAULT 'informs' (CHECK: IN ('informs','supports','acts_with_approval')) + allowed_tool_scopes TEXT[] NOT NULL DEFAULT '{}' (element-enum-check via Python-validator), migration 0007, D-42, K2), policy_decisions, policy_versions, protection_defaults (ny, fas P2: en rad per exposure — public_chat/internal_chat/api/pipeline — med profile jsonb {inflow,outflow,resistance}, deployment-globalt, ingen tenant-dimension), retention_policies (ny, Wave 3 Post 1, D-57: per tenant × data_category → retention_days nullable + archive_hold bool, UNIQUE(tenant,category), CHECK 5-kategori-enum + retention_days>0 — DPO-retention-matris, konsumeras fail-closed-bevarande av BFF-sweparna; sedan migration 0013 sex kategorier, knowledge_superseded konsumeras av rag-apis rond) (+ audit.chain_heads, public.audit_events). Versionstabell alembic_version_policy_engine, 9 migrationer (0004: distinct-trace-lista, D-31; 0005: protection_defaults + assistants.protection, D-36; 0006: assistants.knowledge_scope, fas R2, D-40/K8; 0007: assistants-ytans tre fält, fas S1, D-42; 0008: chain_name-kolumn (+ garanterade prev_hash/self_hash) på public.audit_events, dual idempotent migration med provisioning-api 0005, D-48; 0009: retention_policies, Wave 3 Post 1, D-57).
  • Identitetsrymden i policy_decisions.user_id_hash (P43, D-629): kolumnen bär auth.users.id-UUID:t orört från bff, llm-gateway och agent-runtime (D-31), och aktörsvärdet sha256(canonical_json("platform:" + sub)) från mcp-gateway (D-113). Filtret i /v1/admin/decisions och /decisions/traces är en exakt jämförelse. En personfiltrering måste därför fråga på båda värdena, vilket auth-api:s registerutdrag gör när stacken#607 är åtgärdad. Namnet behålls på tråden, i kedjan och i lagringen.
  • HTTP-yta: datapath POST /v1/decide (scope policy:decide; action är en fri sträng (str | None, ingen enum-validering i schemat — grenlogiken bor helt i rego, kartläggningsdiff 3) med de kända värdena null|"tool_call"|"output_check"|"upload_scan"|"knowledge_ingest"|"embedding"|"knowledge_preview"|"assistant_probe"|"rerank"|"transcription"|"file_extract" (main.rego::_known_actions; annat värde nekas med unknown_action, P32) — fas Q lade till upload_scan/D-37, fas R1 lade till de två därpå/D-39, fas R3 lade till knowledge_preview (app/schemas/decide.py::KNOWLEDGE_ACTIONS, D-41), fas S2-ff lade till assistant_probe (governance-only DecideTest-probe — egen main.rego-gren, INTE i KNOWLEDGE_ACTIONS, kräver därför assistant_id; hoppar över access_ok, D-44), P11 lade till rerank (app/schemas/decide.py::KNOWLEDGE_ACTIONS, D-102 — se egen bullet nedan)); CRUD /v1/assistants (policy:read|write, retire; hela routern kräver dessutom require_service_token på routernivå — samma dual-dependency-mönster som /v1/decisions, inte unikt för läsvägen; POST/PATCH bind-tids-spärrar (fas S1, D-42, K4): server-side public_gate prövning på POST + PATCH via app/services/public_gate.py::public_gate_violation (exponering + informationsklass + handlingsnivå + tillåtna tool-scope-familjer — fail-closed: exponering=public_chat med icke-gron-klass eller skrivande scope eller acts_with_approval → 422 {detail} byte-exakt klarspråk ur publicExposureViolations mot ops-ui) + protectionViolation-spärr på PATCH (app/services/protection.py::protection_violation, säkerställer att överschrivet protection inte bryter public_chat-familj-låsen, samma klarspråksform); AssistantResponse/Create/Patch bär nu (fas S1) exposure/action_level/allowed_tool_scopes + nästlad runtime {temperature, max_tokens, protection} (top-level protection borttaget ur API:t, kolumnen lever på databasklienten men exponeras inte längre HTTP-vägen), AssistantListResponse bär nu total (K12.6); PATCH bär nu protection-fältet, append-only-versionerat som resten av assistenten, och sedan fas R2 knowledge_scope — PATCH på fältet emitterar assistant_knowledge_scope_changed via app/services/lifecycle_events.py::emit_field_changes (lifecycle-reaktorns förberedda mappning aktiveras, D-40 p5); denna GET-rutt är även mcp-gatewayens repekade assistant-lookup, se mcp-gateway-sektionen — OBS (fas S1, D-42): assistant-modellen är konsoliderad HIT — provisionings parallella assistants-tabell (migration 0004) är retirerad (neutraliserad till no-op, död modell/scheman borttagna); policy-engine är enda assistant-modellen på datapathen; sedan fas R3 (D-41, K5): GET /v1/assistants?knowledge_scope_contains=<uuid> — ADD-only query-param, omvänd bindningsuppslagning (“vilka assistenter är bundna till collection X”) som rag-apis eval-trigger frågar (app/api/assistants.py::list_assistants → AssistantRepo.list_by_knowledge_scope); svarsform och auth oförändrade); läsning GET /v1/decisions (dual-dependency: service-token + user-scope — D-22-brygga, filtrerbar på user_id_hash); admin GET /v1/admin/decisions[/verify] (tenant-scopad kedjeverifiering, D-26), GET /v1/admin/decisions/traces?user_id_hash= (distinct trace-lista för subjektet, GDPR-export/erasure-sömmen, D-31), GET/PUT /v1/admin/protection-defaults (ny, fas P2: require_user_scope("policy:read"/"policy:write") per rutt, tenant ur claim — samma auth-form som /v1/admin/decisions, INTE router-nivå-service-token; PUT validerar public_chat-låset — ingen familj får sänkas under high för publik exponering, 422 med klarspråksmeddelande annars — och skriver ett public.audit_events-INSERT i SAMMA transaktion som UPDATE:en, det lätta audit-mönstret, D-36 p8); GET/PUT /v1/admin/retention[/{data_category}] (ny, Wave 3 Post 1, D-57: DPO-retention-matris per tenant × data_category, require_user_scope("policy:read"/"policy:write"), tenant via resolve_tenant_id; PUT upsertar + WORM-auditar retention_policy_changed i SAMMA transaktion (samma lätta public.audit_events-mönster som protection-defaults); platform-service (BFF-sweppen) passerar user-scope via D-24-bypass → samma GET betjänar DPO-user och service-läsning); health.
  • action="knowledge_preview" (fas R3, ny, D-41, spec V2a, granskningsjusterad V-1): egen decision-gren i main.rego för mcp-gatewayens admin-preview-endpoint — decision := "allow" iff input.knowledge.collection_id finns (requested_model="", samma knowledge_ingest-konvention, ingen modell anropas). Grenen prövar MEDVETET INGEN access_ok (regelgranskarens V-1: ett tomt assistant-objekt hade gjort en sådan prövning ovillkorligt sann — en verkningslös grind maskerad som skydd; åtkomstgrinden ÄR gatewayens require_role("tenant_admin"), samma ärliga mönster som knowledge_ingest/embedding-grenarna). pii_gate är exempt (embeddings-motiveringen — frågetexten redan pii-maskerad i rag-apis query-väg före embedding, R1 K2). Beslutet WORM-auditeras med assistant_id=NULL (samma som knowledge_ingest/embedding). Test: policies/tests/knowledge_test.rego.
  • action="rerank" (P11, ny, D-102, stacken#229): egen decision-gren i main.rego — decision := "allow" iff input.model_residency ∈ {"eu", "on_prem"}, samma regel som embedding (ingen chattmodell anropas, ingen assistentkontext finns, modell-/arketyp-/AI-act-grindarna är irrelevanta — residensen är det som grindas, eftersom dokumenten som rangordnas är tenantens eget innehåll, inte prompttext). rerank ligger i KNOWLEDGE_ACTIONS (app/schemas/decide.py, se ovan), så knowledge_ctx {collection_id, information_class} krävs i stället för assistant_id — men collection_id är enbart beslutets scope-etikett; policy-enginen slår aldrig upp collectionens innehåll, gatewayen skickar aldrig med det. pii_gate är exempt (pii_gate.rego::_pii_exempt) — inputen är redan lagrat, tenant-eget innehåll vars PII-disposition togs vid ingest, samma resonemang som knowledge_preview. Beslutet WORM-auditeras med assistant_id=NULL (samma som knowledge_ingest/embedding/knowledge_preview). Test: policies/tests/knowledge_test.rego, tests/unit/test_decide_knowledge.py.
  • action="upload_scan" (fas Q, ny, D-37, spec §2.2 p2): egen decision-gren i main.rego för BFF:ns uploads/scan-endpoint — decision := "allow" när access_ok håller (modell-/geo-/arketypgrindarna är irrelevanta, ingen modell anropas vid scan); pii_gate är AKTIV (inte undantagen — den ÄR dispositionen, samma pii_verdict-beräkning som ingressen: entitetstyp × informationsklass × effektiv inflow-nivå). Beslutet WORM-auditeras som vanligt (input_snapshot->>'action' = 'upload_scan'). Granskningsfynd (K1, fas Q): DecideRequest.requested_model saknade default och gjorde varje riktigt upload_scan-anrop 422 medan unit-mockarna var gröna — rättat i payload/signatur/test före exekvering.
  • action="tool_call" — knowledge-scopad gren (fas R2, ny, D-40): egen decision-gren i main.rego (_tool_call), begränsad till verktyg vars namn börjar på knowledge. (startswith(input.tool_name, "knowledge.")) — icke-knowledge-tool_calls faller igenom till den gamla/oförändrade grenen (PR #51-terräng, i praktiken deny; granskningsjustering 2026-07-14, se D-40). Allow iff access_ok + tool_scope == "read_only" + count(input.assistant.knowledge_scope) > 0 (kunskapsbunden); kollektionsbindningen prövas INTE här (collection_not_bound verkställs i rag-api). Fasstartens fynd som motiverade grenen: den gamla vägen kunde ALDRIG ge allow i live-rego (recordet saknar allowed_tool_scopes, requested_model="" faller på huvudgrenens modellgrindar). Test: policies/tests/main_test.rego::test_deny_substitute_for_tool_call_with_disallowed_scope låser deny för icke-knowledge/otillåtet scope.
  • knowledge_bound-härledningen (fas R2, ny, D-40, K7): DecideService (app/services/decide.py) härleder opa_input["output_check"]["knowledge_bound"] SERVER-SIDE ur assistant.knowledge_scope (len(...) > 0) för action="output_check" — ett eventuellt caller-angivet värde skrivs över (betrodd källa vinner).
  • Kunskaps-scopad decide (fas R1, ny, D-39, kunskapslagrets spec): DecideRequest.assistant_id är nu UUID | None (app/schemas/decide.py) — obligatoriskt för vanliga actions, men hoppas över för action ∈ {"knowledge_ingest", "embedding"} (validator _assistant_or_knowledge) där ett nytt fält knowledge_ctx: KnowledgeCtx {collection_id, information_class} blir obligatoriskt i stället; opa-inputen bär knowledge i stället för assistant, ingen assistant-lookup görs (app/services/decide.py::_decide_knowledge). Rego-grenarna (policies/main.rego): knowledge_ingest allow:ar alltid (modell-/geo-/arketypgrindarna är irrelevanta) med pii_gate AKTIV mot kunskapsklassen (input.knowledge.information_class) — verdiktet ÄR dispositionen, samma D-37-prejudikat som upload_scan; embedding allow:ar endast om input.model_residency ∈ {"eu", "on_prem"}, för alla informationsklasser (v1-regel, strängare än per-klass), och är pii_gate-exempt (dispositionen redan tagen av föregående ingest). Beslutsraden skrivs med assistant_id=NULL (kolumnen redan nullable sedan migration 0001 — inget schemabyte).
  • Modellregistret som data.models (fas S2, ny, D-43): boot-eager (app/main.py::lifespan → get_decide_service(), R1 — tidigare hade policy-engine ingen lifespan alls och registret hade resolvat lat på första /v1/decide) laddar libs/model_registry (MODELS_YAML_PATH, fail-fast RegistryError vid trasig/saknad fil — tjänsten startar inte) och skriver residens-slicen (registry.residency_data(), {name: {residency, eu_native}}) till en STABIL fil (OPA_DATA_DIR/models.json, aldrig en reap-känslig /tmp-tempfil, R5/R9) som OPA:s andra --data-dokument data.models. archetype_gate.rego/geo_gate.rego bytte namn-prefix-heuristiken (is_eu_native_model/is_third_country_model/is_classified_model, tidigare startswith(m, "openai-"|"anthropic-"|"gemini-"|…)) mot en data.models[m]-lookup — samma predikat gäller nu BÅDE requested_model och assistentens default_model (substitut-targeten, main.rego:171-173), uniformt utan decide-input-ändring; en modell frånvarande i registret ⇒ alla tre predikat falska ⇒ gul/rod/geo-eu nekar (samma fail-closed-linje som gårdagens okänt-prefix-fall). Korrigerande biverkan (R8): dagens prefix-heuristik matchade aldrig katalogens stckn-*-namn (bara gron passerade i praktiken för riktiga modeller) — bytet är därför en rättelse, inte bara en omkoppling. Detta FÖRFINAR D-39/D-9:s “ingen katalog i policy-engine” (D-43, VF3): registret är sedan S2 policy-DATA — read-only, versionerat med bundlen, samma fil gateway och BFF läser — inte en frågad andra sanningskälla; embeddingvägens model_residency-i-decide-input-mönster (bullet ovan, D-39) är ORÖRT och sourcas nu ur samma registerfil på gatewayens sida (se llm-gateway-sektionen). policies_version bär sedan S2 registrets content_hash (+reg-<hash[:12]>, append-only, bryter ingen audit-kedja).
  • Auth: dual-mode (D-24) — plattforms-JWT via JWKS eller legacy SERVICE_TOKEN (loggas som varning, utfasning planerad); scope-tvång fail-closed endast för platform-user; legacy-tenant via X-Tenant-Id-header.
  • PII-gate (fas P1, D-35): POST /v1/decide tar ett nytt fält pii: {entities: dict[str, int]} | None (entitetstyp → antal, aldrig innehåll) och svarar med pii_verdict: "approve"|"masked"|"blocked" | None. pii_gate.rego beräknar verdiktet ur input.pii.entities × input.assistant.information_class: gron-klassade assistenter blockerar hellre än maskerar, gul/rod maskerar, tomma signaler ⇒ approve, saknade signaler för en modellbunden decide ⇒ blocked (fail-closed — gatewayen får aldrig “glömma” att skanna), action="tool_call"/action="output_check" är undantagna (pii_verdict = null). Sedan fas P2 är pii_gates nivåstyrning kopplad till den effektiva skyddsprofilens inflow-familj (off ⇒ approve utan skanning, low ⇒ endast regex-familjen räknas, medium/high ⇒ alla). Fail-closed vid motor-fel ärvs av OpaRuntime._FAIL_CLOSED. Signalerna auditeras via input_snapshot — ingen ny hash-kedja, ingen migration. Sedan #658 (E5): en profil med pii_masking: false gör masked till approve för sin klass, utom i publik kanal; gron blockeras som förut.
  • Protection-modellen (fas P2, D-36, spec §3.4/§4.4): app/services/protection.py äger domänkonstanterna (FAMILIES=inflow|outflow|resistance, LEVELS=off|low|medium|high, EXPOSURES=public_chat|internal_chat|api|pipeline) och två rena funktioner: effective_protection(channel, override, defaults) (override ∪ defaults, publik kanal klampad till high per familj, ogiltig/okänd nivå faller fail-closed till high) och protection_violation(exposure, profile) (klarspråket för 422 — strängarna är kontrakt mot ops-uis domänpredikat, byte-exakta). DecideService härleder channel ur user_id_hash-frånvaro, slår upp protection_defaults för exposure, beräknar effektiv profil och lägger den i opa_input["protection"] oavsett action — output_gate.rego läser samma nyckel för action="output_check".
  • output_gate.rego (ny, fas P2, D-36, spec §3.3/§4.3): egen decision-gren i package platform.policy för action="output_check" — modell-/geo-/arketypgrindarna i main.rego är avstängda för denna action. Fail-closed-reglerna: input.protection saknas ⇒ skyddskonfig_saknas-deny; input.output_check-signaler saknas ⇒ signaler_saknas-deny; annars körs tre regelfamiljer mot den effektiva outflow-nivån — pii_utflode (nivåstyrt recognizer-set på output_check.pii_entities), kalltvang (kunskapsbunden tjänst utan citation vid outflow ≥ medium — härdad fail-closed sedan fas R2, D-40: saknad has_citations-signal behandlas som false via object.get-default, stänger den tidigare fail-open-luckan där en utebliven signal gjorde regeln overksam; signalen kommer från llm-gatewayens X-Has-Citations-header, se llm-gateway-sektionen), beslutsblockering (myndighetsbeslutsmönster vid outflow ≥ low). output_check_failures: list[{rule,message}] går med i OpaRuntime._FAIL_CLOSED (motor-fel ⇒ policy_engine_error-deny) och nyckelplockningen i opa_runtime.py.
  • Anropare: llm-gateway (decide + output_check + admin-decisions, sedan fas R1 även action="embedding", D-39; sedan P11 även action="rerank", D-102), eval-api, mcp-gateway (action="tool_call" mot den knowledge-scopade grenen, nu på riktigt sedan fas R2, samt assistant-lookupen via GET /v1/assistants/{id}, repekad från provisioning-api, D-40/K4; sedan fas R3 även action="knowledge_preview" från admin-preview-endpointen, se ovan och mcp-gateway-sektionen), auth-api (SAR-export/erasure via /v1/admin/decisions/traces), BFF (sedan fas Q: action="upload_scan" från uploads/scan-endpointen, ny credential policy_service_token, D-37), rag-api (sedan fas R1: action="knowledge_ingest" från dokument-ingestflödet, D-39; sedan fas R2 även GET /v1/assistants/{id} för retrieval-klasshärledningen, PolicyAssistantReader, D-40; sedan fas R3 även GET /v1/assistants?knowledge_scope_contains= för eval-triggerns omvända bindningsuppslagning, PolicyAssistantReader.list_bound(), D-41). Nedströms: endast Postgres + lokal OPA-runtime.
  • Observability (D-47 — grundkravsparitet nådd, #T5): trace_id lagras per beslut (sökbart) + JSON-logg (configure_logging i den bevarade fail-closed-lifespanen, före eager get_decide_service()) + TraceIdMiddleware + RequestLoggingMiddleware; typat felkuvert {error, detail, trace_id} + Cache-Control: no-store på ALLA 4xx/5xx (uniform nästling — gate/pv/violation är strängar och landar i detail; deny-svar är fortsatt 200 decision=deny, statuskoder orörda — fail-closed-heart-granskning avbockad), DB-otillgänglighet → 503 + Retry-After, 422 saneras; FastAPIInstrumentor.instrument_app(app) — ingen HTTPXClientInstrumentor (ingen utgående httpx-klient; OPA = subprocess, audit = DB). (sedan D-52: trace/felkuvert OCH loggning via libs/observability)
  • Sömmar: policy_decisions är hash-kedjad (policy_decisions.v1, D-26); sedan D-48 är även assistent-fältändringarna hash-kedjade (assistant_events.v1, lifecycle_events.py::emit_field_changes → libs.audit_chain.ChainWriter) — kedjeappendet är atomärt med audit-RADEN (INSERT→append→UPDATE prev_hash/self_hash i SAMMA transaktion), men INTE med assistent-FÄLTUPPDATERINGEN (repo.patch och emit_field_changes körs fortsatt i separata session_scope-block i app/api/assistants.py, pre-existerande sedan fas R2, D-48 löser det inte). Flerfälts-ändringar i en transaktion får strikt stigande created_at (mikrosekund-offset) så validatorns (created_at, id)-läsordning matchar append-ordningen. protection_defaults-PUT (fas P2, D-36 p8) förblir en enkel INSERT, inte kedjad — endast de två spike-korrigerade skrivplatserna omfattades av D-48. En publik/anonym chatt genererar sedan fas P2 två policy_decisions-rader per trace (route-decide + output_check-decide) — observerbar konsekvens av kanal-härledningen, D-36.
  • Tenant-export (Wave 3 Post 2, ny, D-60; kuvert till libbet D-67): GET /v1/admin/tenants/{tid}/export (app/api/tenant_export.py) — read-only metadata-yta för provisioning-apis export-coordinator, grindad require_scope("tenant:export:read") (D-60-plattformsoperatörsplanet). Svaret typas nu av libs.tenant_export.TenantExport/TenantExportCoverage/CoverageGap; modulens docstring hänvisade tidigare till _decisions_coverage_gap som motivering för decisions-sidans trunkering — den funktionen har ALDRIG funnits i policy-engine (ett likadant namn finns i auth-api/app/api/admin/gdpr.py för en orelaterad D-31-mekanism); referensen var doc-drift, borttagen och ersatt av en pekare till docs/tenant-export-coverage.md. protection_defaults alltid not_covered (tjänstespecifikt faktum, oförändrat).

services/llm-gateway

Verifierad/uppdaterad 2026-10-08 (#658, D-673, B1 — Mistral Medium 3.5 hos Evroc i katalogen. Ny post stckn-mistral-medium-3.5-evroc (chatt, provider/host Evroc, country: SE, version = upstream mistralai/Mistral-Medium-3.5, training_excluded: true, max_information_class: rod) i models.yaml och routing.toml. Upstream-namn och land är utkast tills Evroc bekräftat dem, och priset (0.40/2.00 USD per Mtok) är en placeholder. Egen modell-scopad nyckel EVROC_MISTRAL_MEDIUM_API_KEY, i charten med optional: true och ESO-referensen evrocMistralMediumApiKey. Saknas nyckeln är bara modellen otillgänglig (D-105). Modellen står inte i imagens profiles.yaml, så ingen klass kan använda den förrän en instans sätter en egen profil. appVersion 1.15.0 → 1.15.1, chartversion 0.5.1 → 0.5.2. bff-charten bumpas också, appVersion 1.10.0 → 1.10.1, eftersom bff-imagen bakar in models.yaml.) Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. gateway/logging.py skriver inte längre fältet exc med hela tracebacken; ExceptionTypeOnlyFilter sitter på stdout-handlern. WebhookDeliveryError bär httpx-felets typ, inte dess meddelande. Gatewayen har ingen åtkomstrad, så route berör den inte. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.16.0 → 1.16.1.)

Verifierad/uppdaterad 2026-10-08 (#590, P17, D-175 — per-nyckelgränsen räknas i den delade Valkey. gateway/ratelimit.py är inte längre en token-bucket per pod utan limits (glidande fönster) över RATE_LIMIT_STORAGE_URI, samma store som bff, feedback-api, mcp-gateway och auth-api. default_rpm gäller därför per nyckel för hela plattformen och höjs från 60 till 360, det gamla taket vid sex repliker. Är storen nere svarar varje modellväg 503 rate_limit_unavailable med Retry-After: 5 och Cache-Control: no-store. Träffen mot storen körs i en arbetstråd så att eventloopen inte blockeras. Prod-overlayen sätter redis://platform-valkey:6379/0 och requireSharedRateLimit: true; memory:// fäller då starten. NetworkPolicyn har fått egress mot app: platform-valkey på 6379. limits och redis är nya direkta beroenden (båda fanns redan i uv.lock). appVersion 1.15.0 → 1.16.0 (1.15.0 togs av #676).)

Verifierad/uppdaterad 2026-10-08 (#661, K1 röd zon D9 — en ny tenant kan få instansens standardbudget och spärr. Nya inställningar GATEWAY_DEFAULT_MONTHLY_BUDGET_SEK (chart config.gatewayDefaultMonthlyBudgetSek, standard tom) och GATEWAY_DEFAULT_HARD_LIMIT (standard false; true utan budget stoppar uppstarten). Med budgeten satt skapar vakten varje pass en tenant_budgets-rad för varje aktiv tenant som saknar rad (ops.ensure_default_budgets, ON CONFLICT DO NOTHING, runaway_action = instansens), auditerad som system:instance-default / budget.set i samma transaktion. Därefter gäller D-28:s vanliga spärr. En admin-satt rad skrivs aldrig över. PUT .../webhook på en radlös tenant skapar raden med standardbudgeten, och GET .../budget visar den för en radlös tenant. Ounsatt budget ger samma beteende som förut. Ingen migration och inget ändrat kontrakt. appVersion 1.14.0 → 1.15.0 (1.14.0 togs av #680).)

Verifierad/uppdaterad 2026-10-08 (#657, K1 röd zon krav C7, D-680 — användningsstatistiken utan person och med instansens frist. USAGE_WITHOUT_PERSON (config.usageWithoutPerson, av som standard) gör att record_usage skriver usage_events utan user_id_hash och user_groups; decide och auditkedjan behåller aktören, och usage/user blir tom i en sådan instans. Gallringsronden gateway/usage_sweep.py (CronJob usageSweep, dygnsvis 03:41, av som standard) raderar rader äldre än USAGE_RETENTION_DAYS som den nya rollen gateway_usage_sweep (0017: SELECT, DELETE ON usage_events, DSN i en egen Secret <fullname>-usage-sweep). gateway_app är fortfarande utan DELETE på tabellen (0005). Runbook: anvandningsstatistik-gallring.md. appVersion 1.13.2 → 1.14.0.)

Verifierad/uppdaterad 2026-10-08 (#621, P48 O2 — förbrukningsrapporten säger varför verktygshalvan saknas. /admin/tenants/{id}/usage bär det additiva fältet degraded_reasons, en karta från en halva i degraded till en kod: unconfigured (MCP_GATEWAY_URL tom), no_delegated_identity (operatörens env-nyckel eller ingen token att skicka vidare), forbidden (mcp-gateway svarade 401/403) och unavailable (timeout, transportfel, annan status eller oläsbart svar). Tom karta när allt svarade. degraded är oförändrat. ToolUsageClient kastar ToolUsageForbidden, en undertyp av ToolUsageUnavailable, vid 401/403. Koderna är gatewayens egen klassning och bär inget nedströmsinnehåll. Glappet mellan llm:admin:read och rollen tenant_admin syns nu som forbidden. Operatörens läsväg (tenant:usage:read, O1 b) klassas på samma sätt. appVersion 1.13.1 → 1.13.2 (1.13.1 togs av #582).)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 2 — varje chattsvar bär sitt policybeslut. X-Policy-Decision-Id (ingångsbeslutets id, samma som usage-raden) sätts på chattsvaret, också det strömmande, och på 403 output_blocked. Det hållna svaret får dessutom X-Output-Check-Rules med de fällande reglernas namn, kommaseparerade (t.ex. avstar, kalltvang, pii_in_response), aldrig meddelanden eller innehåll. eval-api läser båda per fråga. Additivt, inget anropskontrakt ändras. appVersion 1.13.0 → 1.13.1 (1.13.0 togs av #621).)

Verifierad/uppdaterad 2026-10-08 (#621, P48 O1 b, D-648 — operatörens läsväg till förbrukningsrapporten. /admin/tenants/{id}/usage tar en tredje identitet: ett tenant-löst platform-service-token med tenant:usage:read (gateway/admin/auth.py::_identify_usage, require_usage_read). Tenant-adminens grind (llm:admin:read) prövas först, och bara om den nekar prövas scopen via _identify_export, med tenanten ur sökvägen. GATEWAY_ADMIN_KEY matchar i första steget och ger som förut bara modellhalvan. Verktygshalvan hämtas då med SAMMA token från mcp-gatewayens GET /v1/operator/tenants/{tenant_id}/tool-usage (ToolUsageClient.fetch_for_operator). Ingenting myntas. Övriga admin-rutter har oförändrad grind. appVersion 1.12.3 → 1.13.0.)

Verifierad/uppdaterad 2026-10-07 (P43, #596, D-629 — ingen kodändring. README:ns rad om X-User-Id-Hash påstod “SHA-256 hex of user_id (never plaintext)”. Headern bär auth.users.id-UUID:t orört (D-31, D-34), och raden säger nu det. usage_events.user_id_hash bär samma rymd.)

Verifierad/uppdaterad 2026-10-07 (#622, P49 — ingen SQL-interpolation i förbrukningsrapporterna. gateway/ops/budgets.py räknar ut rapportperiodens fönster i Python (_period_bounds: midnatt den 1:a i Europe/Stockholm, halvöppet [start, end)) och binder det som $n-parametrar i tenant_usage_report, tenant_usage_by_group och usage_summary. De tidigare SQL-byggande hjälparna (_period_start_expr/_period_end_expr) och deras nosec B608 är borta, och docs/negative-constraints.md säger att SQL-regeln saknar undantag. Fönstrets gränser och 400 för ogiltig period är oförändrade; innevarande månad avgörs nu av appens klocka i stället för Postgres now(). appVersion 1.12.2 → 1.12.3.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 1, D-170 — avståendetröskeln får sin poäng. Chat-vägen läser headern X-Retrieval-Score (gateway/api/chat.py::_retrieval_score_header: ett ändligt tal i 0–1, annars None) och lägger retrieval_score i action="output_check"-decidets signaler, i non-stream och genom hold-and-release (OutputCheckContext.retrieval_score). None utelämnar nyckeln, och regeln avstar avstår då för en assistent med tröskel. Tjänsteintygad, samma förtroendeklass som X-Has-Citations; tröskeln läser policy-engine ur recordet. appVersion 1.12.1 → 1.12.2.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 3 — lokala modeller och egress-provet. assert_registry_parity undantar registerposter med type: ner: pii-apis NER-modell stckn-sv-core-news-md står i models.yaml men har ingen rutt. Chattvägens 503 när policy inte nås (policy_unavailable) bär nu Retry-After: 5 och Cache-Control: no-store, satt i appens HTTPException-hanterare för alla 503-svar; tidigare saknades båda där, medan inbäddning, omrangordning och transkribering redan satte dem. Egress-provet (spec §4 punkt 7, tests/e2e/test_egress_profile.py) skickar en kanarie genom gatewayen i chatt, inbäddning, omrangordning och transkribering med modeller utanför profilen, plus ett nekat substitut och en policy-timeout, och visar att mock-providern tar emot noll bytes. appVersion 1.12.0 → 1.12.1.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 2 — nyckel-API:t nekar modeller utanför katalogen. POST /admin/tenants/{id}/keys och PATCH /admin/keys/{id} svarar 422 model_not_in_catalog när allowed_models nämner ett namn som saknas i registret (app.state.registry). null betyder fortfarande ingen begränsning. Policyns nya profilnekanden når klienten som förut, som 403 policy_denied med skälet (profile_missing, model_not_in_profile, model_attributes_missing) i meddelandet. Inbäddning, omrangordning och transkribering skickar redan modellnamn och klass till decide; ingen ändring där. appVersion 1.11.0 → 1.12.0.)

Verifierad/uppdaterad 2026-10-05 (D-171, K10 session 1 — registret bär styrfälten och primitiven. models.yaml har type (speglad ur routing.toml, utelämnad = chat), och provider samt max_information_class ligger utanför display, tillsammans med de nya host, country och version. assert_registry_parity kräver nu att type stämmer mellan filerna. Fingeravtrycket (D-103) räknar in host, country och version, så varje modells X-Model-Fingerprint byts en gång vid utrullningen. Ny fil config/profiles.yaml, som gatewayen inte läser; policy-engine bakar in och validerar den. appVersion 1.10.2 → 1.11.0.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Gatewayen har ingen RequestLoggingMiddleware (undantaget i gateway/main.py), så det här var dess enda rad per anrop; kvar finns MetricsMiddleware och OTel-spann. Audit-sökningens q/actor gick tidigare ut i loggen. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.10.1 → 1.10.2.)

Verifierad/uppdaterad 2026-10-03 (P35, #247 — en kapad SSE-ström slutar med event: error, inte [DONE]. När en adapters parser slår i MAX_SSE_BUFFER_BYTES skickar anthropic.py, google.py och openai.py en terminal felframe med err_response()-kuvertet (code: stream_aborted, gateway/errors.py::stream_aborted_event). Tidigare skickade två av dem [DONE] och den tredje stängde tyst. Taket och avbrottslogiken är oförändrade. Se bulleten om kapad ström nedan.).

Verifierad/uppdaterad 2026-09-28 (D-154, vägval #388 — en nyckel kan ha syftet eval. Migration 0016 lägger api_keys.purpose (nullable, CHECK purpose IN ('eval'), ingen backfill). Bara operatören sätter det vid POST /admin/tenants/{id}/keys; en tenant-scopad admin får 403 operator_only, eftersom en eval-nyckel når oaktiverade assistenter. authenticate läser syftet i samma SELECT som förut, och chattvägen skickar det i båda decide-anropen (inflöde och output_check). Anroparen kan aldrig sätta det. appVersion 1.9.0 → 1.10.0.)

Verifierad/uppdaterad 2026-09-27 (D-152 — valfri egen routing i charten. routingConfig (hela TOML-filen) monteras som ConfigMap och pekas ut med GATEWAY_ROUTING_CONFIG; tom = imagens config/routing.toml och identisk rendering. För en instans vars modeller bor någon annanstans än leverantörernas publika värdar — och för riggen som kör hela aktiveringsflödet mot en modellattrapp. Modellmängden måste vara densamma som i models.yaml; egress till en värd i klustret kräver en egen additiv NetworkPolicy. Chart 0.3.8 → 0.4.0, ingen kodändring.)

Verifierad/uppdaterad 2026-09-27 (D-149, vägval #379 — answer_kind i utflödeskontrollen. gateway/policy/gate.py::answer_kind_of läser PROVIDERNS svar och ger "tool_calls" när completion bara bär verktygsanrop och ingen text; värdet skickas i output_check-payloaden så att källtvånget kan undanta en agents sökbegäran. Anroparen kan inte påstå det. Bara icke-strömmande vägen — strömvägen skickar inget och beter sig som förut (fail-closed); motorn strömmar inte modellanropet. appVersion 1.8.1 → 1.9.0.)

Verifierad/uppdaterad 2026-09-26 (plattformen#207 — stckn-e5-large körs på Evroc, inte Berget. Fabians beslut: kundinstansens BERGET_API_KEY var en platshållare och Berget svarade 401 på varje inbäddning, så kunskapsinläsningen hade aldrig fungerat där. Samma upstream-modell (intfloat/multilingual-e5-large-instruct), samma 1024 dimensioner. routing.toml: provider = "evroc", api_key_env = "EVROC_RERANKER_API_KEY" — omrankarens nyckel, mätt av Plattformen att bära även /v1/embeddings (200, dims=1024), vilket motsäger D-102-korrigeringens “nyckel per modell” för just den nyckeln; namnet är kvar så att ingen SOPS-nyckel flyttas. models.yaml: display.provider "Evroc", hosting_region "eu", training_excluded: true med omrankarens verifiering (2026-08-19). Modellens fingeravtryck (D-103) ändras med provider_name och base_url, så bytet syns i X-Model-Fingerprint — inget tyst aliasbyte. Befintliga vektorer är inte omräknade; kundinstansen hade inga. Vägval (I6, kostnad) i PR:en. appVersion 1.8.0 → 1.8.1, chart 0.3.7 → 0.3.8; bff och policy-engine bakar in registret och fick var sin appVersion-bump.)

*Verifierad/uppdaterad 2026-09-16 (P4 Del A, D-148 — gatewayen lär sig verktyg, additivt. ChatCompletionRequest bär nu tools/tool_choice, ChatMessage.content är nullbar och bär tool_calls/role: tool+tool_call_id (D-40 sköt uttryckligen upp det hit). Ingen nytt policy-input: SJÄLVA verktygsanropen grindas i mcp-gateway (D-70), inte här — datapathen tar bara emot och översätter formen. Anthropic-översättaren mappar parameters → input_schema, assistentens tool_calls → tool_use-block, och slår ihop konsekutiva role: tool- meddelanden till ETT user-meddelande — Anthropics Messages-API kräver alternerande user/assistant-roller, och parallella verktygsanrop är normalt Claude-beteende; två user-meddelanden i rad hade avvisats uppströms (ruling under implementationen, avvek från den ursprungliga planens ett-per-meddelande). Verktygstext skannas som ALLT ANNAT innehåll (pii-ingress/utflöde, D-35/D-36) — tool_calls-argument och tool-radens innehåll är inget undantag. Två nya typade tak: tools mot Google ⇒ tools_unsupported_provider; tools

  • streaming mot Anthropic ⇒ tools_stream_unsupported (gateway/providers/google.py rörs INTE — all kod som når providern passerar taket i chat.py först, så grenen är onåbar med tools i kroppen). Enda anropare: agent-runtime (ny tjänst, gäst i dataplanet, D-72 — se dess egen karta). openapi.json regenererat.)*

Verifierad/uppdaterad 2026-09-04 (D-138 — den provider-vända httpx-klienten får explicita limits (gateway/main.py lifespan): keepalive_expiry=30 s och max_keepalive_connections=max_connections=100, i stället för httpx default 5 s och 20 av 100. Utan dem byggde varje chattanrop som kom glesare än fem sekunder en NY TCP- och TLS-anslutning mot providern — uppmätt handskakning 34-52 ms mot de fyra providrar config/routing.toml använder, i nivå med en hel OPA-evaluering. Ingen rigg kunde se posten: mock-providern i e2e och lasttesterna går på loopback UTAN TLS, så defaulten såg gratis ut i varje mätning. Låset är tests/unit/test_provider_client_keepalive.py, som fångar argumentet där det skickas i stället för att inspektera httpx privata pool. Ingen datapath, ingen policy och inget beroende ändrat; HTTPXClientInstrumentor är fortsatt medvetet AV vid providergränsen (D-34 punkt 5).)

Verifierad/uppdaterad 2026-08-27 (D-117 — ny nullbar kolumn usage_events.information_class (migrering 0015) skriven vid händelsen på alla fyra modellvägarna (chat, embedding, rerank, transcription) ur decide-svarets nya fält — UsageRecord.information_class är obligatorisk utan default, så en ny modellväg kan inte glömma dimensionen. Klassen skrivs, den slås ALDRIG upp vid läsning: gateway/ops/budgets.py aggregerar uteslutande kolumnen utan någon join, och gatewayen har ingen läsväg till assistants. Förbrukningsytan utökad additivt — /admin/tenants/{id}/usage bär nu model_usage (med turns = count(DISTINCT trace_id), by_information_class, unclassified_before), tool_usage och degraded; de tre gamla toppnivåfälten är ORÖRDA eftersom frontends/ops-ui läser dem där (icke-additiv omstuvning hade varit kategori I3). Nytt utgående beroende: mcp-gateway (gateway/tool_usage_client.py, 2 s timeout, inga omförsök, MCP_GATEWAY_URL) med egen NetworkPolicy-egress — faller det svarar ytan "tool_usage": null plus degraded, aldrig en nolla. Känd gräns: en GATEWAY_ADMIN_KEY-anropare har ingen identitet att låna och får permanent degraded: ["tool_usage"].).

Stämpelhistorik — 12 tidigare verifieringar
  • Föregående 2026-09-04 (D-140 — pii-skanningen memoiseras per meddelande (gateway/pii/client.py::SpanCache, instans i lifespan). Bakgrund i D-138: kostnaden är linjär i textlängd och gatewayen skickar HELA historiken vid varje tur, så ett samtal växer kvadratiskt. Cachen är per process, LRU + 1 h TTL, och lagrar spans (typ + offset), aldrig innehåll — nyckeln är en HMAC över texten med en per-process slumpad nyckel, och den maskerade texten återskapas lokalt och sparas aldrig. Tre egenskaper bär den: policybeslutet blir byte-identiskt (pii_gate.rego läser bara _entity_total > 0, ingen ackumulerad tröskel, ingen positionssemantik), motorversionen ingår i nyckeln så en uppgraderad pii-api ogiltigförklarar cachen själv, och den är självverifierande — vi cachar bara när vår återskapning är byte-identisk med pii-apis egen masked_text, så en träff kan aldrig ge en annan text än en miss. Ingen ny datapath, ingen delad store, inget nytt beroende; scan_messages läser cachen via getattr så duck-typade fakes fortsätter bete sig som cache=None.).
  • Föregående 2026-08-26 (D-116 — modellanropet skrivs till auditkedjan (P12s sista del, vägval #272, F5): ny kedjad tabell model_call_events (0014) och ny kedja model_calls.v1, registrerad i validatorn. Raden skrivs före providern och är intyget att anropet lämnade oss — utfallet ligger kvar i usage_events, korrelerat på trace_id. Fail-closed: går raden inte att skriva sker inget modellanrop (503 audit_chain_unavailable). Alla FYRA modellvägar grindas (chat, embeddings, rerank, transcriptions) genom en delad guard_model_call. Kuvertet är fryst och mekaniskt låst mot validatorns whitelist. gateway_app får SELECT, INSERT och aldrig UPDATE/DELETE — hashen räknas före insert:en så raden skrivs komplett i ett anrop, till skillnad från policy-engines insert-hash-update-mönster som kräver UPDATE på en append-only tabell.).
  • Föregående 2026-08-26 (D-115 — innehållsspåret (P12, F5): ny tabell model_call_content och en ny modul gateway/content_trail.py som skriver ett MASKERAT, GALLRINGSBART spår vid sidan av auditkedjan, plus gallringsronden gateway/content_sweep.py med CronJob i charts/llm-gateway. Spåret skrivs BARA för assistenter med retention_policy="full-log-ttl-24h" — värdet fanns deklarerat i policy-engines CHECK sedan 0001 men implementerade ingenting förrän nu. Grinden är fail-closed på innehåll (okänt eller frånvarande värde ⇒ ingenting skrivs), skrivningen är fail-open på anropet, och maskeringen är fail-closed inuti den: kan pii-api inte maskera skrivs raden inte. Se egen bullet nedan.).
  • Föregående 2026-08-25 (D-109 — Gatewayen deklarerar stacken-observability för första gången, och tar /metrics + http_requests_total via install_metrics. Den adopterar fortfarande INTE install_observability: det OpenAI-kompatibla felkuvertet och frånvaron av TraceIdMiddleware är Wave 2:s medvetet avböjda scope (D-52) och står orört. Två trådningar, inte en — gatewayen har en EGEN on_unhandled, och Starlettes ServerErrorMiddleware ligger utanför all användarmiddleware, så en middleware kan aldrig se ett verkligt ohanterat 5xx. Därför anropar on_unhandled observe_request(...) själv; utan den raden vore gatewayens 500:or osynliga för felkvotslarmet. on_http_exception behöver ingen rad (4xx returneras genom stacken) — prövat i tests/unit/test_metrics_wiring.py, inte antaget. openapi.json oförändrad.)
  • Föregående Verifierad/uppdaterad 2026-08-24 (D-104 — ny rutt POST /v1/audio/transcriptions (P11 session 3, tal till text): fjärde modellprimitiven, stckn-kb-whisper → KBLab/kb-whisper-large hos berget (type = "transcription", EU, svensktränad, Apache 2.0). Ordningen är VÄND mot varje annan modellväg: decide(action="transcription") → transkribera → usage → skanna transkriptet → decide(action="output_check", source="transcription"). Ljud kan inte pii-skannas, så ingressgrinden är omöjlig och utflödeskontrollen är OBLIGATORISK — output_check_required läses medvetet inte, och skanningsnivån är hårdkodad high. Rutten är assistent-scopad (till skillnad från embeddings/rerank) eftersom utflödesgrinden behöver assistentens protection-konfig. Ny kolumn usage_events.audio_seconds (NUMERIC(10,3), nullable, migration 0012) — paketets enda schemaändring; tal prissätts per minut i katalogen (audio_cost_per_minute) men RÄKNAS i sekunder, och tokenkolumnerna är 0, aldrig gissade. Usage skrivs FÖRE utflödesgrinden: ett stoppat transkript är fortfarande ett utfört anrop som leverantören tog betalt för. Maxkroppen är nu per rutt (25 MB på ljudrutten, 1 MB kvar överallt annars), och en pre-existerande bugg i middlewarens långsamma väg — _BodyTooLarge som ExceptionGroup gav avbrutet anrop i stället för 413 — är lagad. Nytt direktberoende: python-multipart (fanns redan i uv.lock). Ingen ändring av content_hash() eller fingeravtrycket.).
  • Föregående 2026-08-24 (D-103 — modellidentitet och region: Router beräknar ett per-modell fingeravtryck (Route.fingerprint, 16 hex, SHA-256 av model_name/upstream_name/provider_name/type/base_url/adapter/residency/eu_native/enabled — pris medvetet uteslutet, base_url medvetet inkluderat) vid boot; tre svarshuvuden X-Model-Fingerprint/X-Model-Resolved/X-Model-Region från en delad helper (gateway/api/_helpers.py::identity_headers) på alla tre primitiv (chatt inkl. strömmande, embeddings, rerank) — frånvarande på felvägar med avsikt; kroppens model är katalognamnet på varje adapter, inklusive openai.py (delad av openai/mistral/berget/evroc/cerebras) som saknade svarsöversättning helt innan detta och fick en SSE-medveten omskrivning + CRLF-normalisering (samma buggklass som tidigare bitit google.py). X-Substituted-Model består oförändrad — deklarerad väg, inte den tysta drift F7 gäller. ModelRegistry.content_hash() orörd (policy-engines policies_version, separat kontrakt). Se bullet nedan.).
  • Föregående 2026-08-20 (D-106 — ny leverantör cerebras (US-hostad, eu_native: false) + två chattmodeller, stckn-gpt-oss-120b och stckn-gemma-4-31b, i katalogen. Providerscopad nyckel CEREBRAS_API_KEY (EN nyckel för hela providern, som openai/anthropic/google/mistral/berget) — motsatsen till Evrocs modellscopade EVROC_RERANKER_API_KEY. Tre chart-appVersions bumpade (models.yaml bakas in i llm-gateway/policy-engine/bff). Sekvenserat efter D-105 med avsikt: en saknad CEREBRAS_API_KEY degraderar nu bara dessa två modeller i stället för att krascha gatewayen.).
  • Föregående 2026-08-20 (D-105 — en saknad providernyckel degraderar den ENA modellen (unavailable_models, boot-WARNING, 503 model_unavailable, /healths nya fält) i stället för att krascha hela gatewayen; P11 kom nära att göra det omvänt i produktion. Se bullet nedan.).
  • Föregående 2026-08-20 (D-102-uppföljning — enda ändringen är models.yamls governance-fält training_excluded för stckn-qwen3-reranker-4b: false → true. Stod fail-closed på false sedan P11:s slutgranskning (“en overifierad efterlevnadsclaim får inte defaulta till det gynnsamma värdet”); Evrocs villkor är nu bekräftade av Fabian. Ingen kod-, yt- eller datapath-ändring. Träffar ändå tre chart-appVersions (llm-gateway 1.3.1, policy-engine 1.3.1, bff 1.2.3) eftersom models.yaml bakas in i alla tre images — utan bump ser ArgoCD ingen diff och rullar inte ut något.).
  • Föregående 2026-08-18 (D-102-korrigering, digitalist-se/stacken#231 — Evroc utfärdar en nyckel PER MODELL, inte en delad providernyckel. Provider.api_key_env/Model.api_key_env båda valfria i gateway/config.py, modellens nivå vinner när satt; providers.evroc har inget eget api_key_env i routing.toml längre, stckn-qwen3-reranker-4b bär EVROC_RERANKER_API_KEY direkt. Boten (gateway/main.py::create_app) resolvar och kontrollerar nu env-var per MODELL, inte per provider — en providers-only-loop hade boot:at grönt på en modell med saknad egen nyckel. Se bullet nedan.).
  • Föregående 2026-08-18 (D-102 — omrangordning som primitiv: ny leverantör evroc + modell stckn-qwen3-reranker-4b (type = "rerank") i katalogen, ny datapath POST /v1/rerank.).
  • Föregående 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: det befintliga apt-get upgrade -y-blocket täckte redan CVE-2026-53615 (util-linux); ingen funktionell ändring krävdes, bara en kommentar som pekar hit.).
  • Syfte: OpenAI-kompatibel LLM-proxy (chat/models) med policy-gate (fail-closed mot policy-engine), per-tenant budget/kostnad i SEK och admin-plan för nycklar/budget/usage/audit. Raw asyncpg, ingen ORM. Python-paketet heter gateway/.
  • Postgres: schema public (D-45-korrigering — INTE router): api_keys, usage_events, tenant_budgets, sent_alerts, audit_log, model_call_events (D-116, modellanropets kedjerad — append-only, SELECT, INSERT, gallras ALDRIG), model_call_content (D-115, innehållsspåret — den enda SPÅRBÄRANDE tabellen som får raderas: gateway_app har DELETE på den, till skillnad från usage_events som 0005 fråntog just den rätten. tenant_budgets behöll också sin DELETE i 0005, så “enda tabellen i tjänsten” vore fel). Beläget direkt: alembic skapar alla tabeller okvalificerat (0001_initial.py, ingen schema=), gateway_app-rollen (create-gateway-role) sätter ingen search_path, inget router-schema skapas någonstans (terraform är stub) → tabellerna landar i public; D-45:s riktiga-DB-e2e observerade dem i public. Det tidigare router-påståendet (“spike-korrigering”) vilade på cirkulärt belägg — feedback-api:s egen router.-referens + test-stubben som speglade den, dvs. själva buggen; feedback-api repointad till public.usage_events i D-45. Versionstabell alembic_version_llm_gateway (bytte namn i D-27), 17 migrationer (0009: index (tenant_id, user_id_hash) på usage_events, D-31; 0010: usage_events.user_groups TEXT[], ADD-only — #T2-gruppdimensionen, D-34; 0011 (D-97): tenant_budgets.runaway_action/runaway_hourly_usd_ceiling/runaway_tripped, ADD-only, se kostnadssäkringsbulleten ovan; 0012 (D-104): usage_events.audio_seconds, ADD-only; 0013 (D-115): model_call_content, ny tabell, rent additiv; 0014 (D-116): model_call_events, ny kedjad tabell + audit.chain_heads idempotent; 0015 (P13): usage_events.information_class, ADD-only; 0016 (D-154): api_keys.purpose, ADD-only med CHECK; 0017 (D-680): SELECT, DELETE ON usage_events till rollen gateway_usage_sweep om den finns, ingen schemaändring). Ingen ny tabell för rerank (D-102) — samma usage_events-rad räcker, requested_model bär katalognamnet.
  • HTTP-yta: datapath POST /v1/chat/completions, POST /v1/embeddings (fas R1, ny, D-39 — se egen bullet nedan), POST /v1/rerank (P11, ny, D-102 — se egen bullet nedan; kräver header X-Knowledge-Collection-Id), GET /v1/models (tenant-API-nyckel, HMAC-pepprad; filtrerar bort embedding-typade katalogposter, route.type == "chat" i gateway/api/models.py); health + GET /health/watchdog; admin /admin/… (keys CRUD, GET/PUT budget, webhook, GET usage, GET usage/by-user?user_id_hash= [subjektets usage-metadata, GDPR-export D-31, Cache-Control: no-store], GET usage/by-group [ny, fas O: by-group-aggregation i tokens + SEK som Decimal-strängar, ospecificerad grupp hamnar i en ungrouped-bucket, by-user-svaret oförändrat — #T2, D-34], GET usage/summary [operatörsplan], GET audit).
  • Omrangordningsytan (P11, ny, D-102, digitalist-se/stacken#229): POST /v1/rerank (gateway/api/rerank.py) — samma Authorization: Bearer dgt-*-datapath som chat/embeddings, plus obligatorisk header X-Knowledge-Collection-Id (UUID) och valfri X-Knowledge-Information-Class. Rerank har ingen assistant-kontext, så collection_id är beslutets scope-etikett — decidet bär den som knowledge_ctx, precis som embeddings, men gatewayen hämtar inga dokument ur collectionen: dokumenten som rangordnas kommer i request-body:n. Budgetspärren körs FÖRE decide (samma mönster som chat/embeddings); decide anropas med action="rerank" via evaluate_rerank_policy (gateway/policy/gate.py), som allow:ar endast model_residency ∈ {eu, on_prem} (samma regel som embedding, se policy-engine-sektionen) och är pii_gate-exempt (input är redan lagrat innehåll, dispositionen togs vid ingest). Katalogens enda rerank-post: stckn-qwen3-reranker-4b (provider=evroc, ny provider i routing.toml, adapter="openai" — wire-formatet mot Evroc-värden är Cohere-format trots adapternamnet, se gateway/providers/registry.py; residency=eu/eu_native=true i models.yaml, upstream Qwen/Qwen3-Reranker-4B; priset är placeholder — verifiera mot Evrocs prislista före deploy, runbok-rad, samma disciplin som stckn-e5-large). Cohere-formad adapter (gateway/providers/rerank.py) bevarar leverantörens råa relevance-score oförvanskad. Credential, D-102-korrigering: EVROC_RERANKER_API_KEY — modell-scopad, inte provider-scopad. Evroc utfärdar en nyckel PER MODELL (inte en delad providernyckel som openai/anthropic/google/mistral/berget), så providers.evroc i routing.toml har inget eget api_key_env; stckn-qwen3-reranker-4b bär fältet direkt (gateway/config.pys Model.api_key_env, valfritt, vinner över providerns när satt). En andra Evroc-modell skulle få sitt EGET env-namn, inte återanvända den här (runbok-rad, steg 4d). Enda anropare hittills: ingen i drift — ytan är byggd och testad, konsument (rag-api eller motsvarande) är session 2/3-terräng.
  • Auth: datapath = Bearer-API-nyckel (nu även med forwardade X-User-Id-Hash/X-User-Role/X-User-Groups-headers från BFF:ns arbetsläge, D-34 — se bff-sektionen); admin = plattforms-JWT med scopes llm:admin:read|write + kind-differentiering (D-24/D-26): ensure_tenant på tenant-rutter (claim måste matcha path), ensure_operator på operatörsplanet. Admin-planet monteras enbart på GATEWAY_ADMIN_KEY (satt ⇒ routern registreras, se gateway/main.py); JWT-configen styr bara om plattforms-JWT-vägen fungerar inom det redan monterade admin-planet — en osatt JWT-config stänger tenant-JWT-vägen, inte hela admin-planet. Chartsidan sedan D-87: env-variabeln injiceras ALLTID (optional: true på secretKeyRef), så opt-in avgörs av secretens innehåll. Fram till D-87 grindades den på externalSecrets.remoteRefs.gatewayAdminKey — en ESO-sökväg som är tom under SOPS — vilket gjorde admin-planet omöjligt att aktivera i customer-prod och blockerade den enda stödda vägen att skapa tenantens gatewaynycklar (issue #170; provisioneringssteget står nu som steg 4c i docs/runbooks/tenant-onboarding.md). Boot är fail-closed sedan fas O (D-34): AUTH_API_ISSUER/AUTH_API_JWKS_URL måste båda vara satta vid uppstart — saknas de, kraschar boten (gateway/main.py); den tysta degraderingen som fanns tidigare är borttagen.
  • Saknad providernyckel degraderar en modell sedan D-105, kraschar inte längre boten: gateway/main.py::create_app resolvar varje modells api_key_env (som förut, D-102-korrigeringen), men en env-var som NAMNGES i routing.toml och saknas i processens miljö vid uppstart lägger modellen i app.state.unavailable_models (modellnamn → saknad env-var) i stället för att resa RuntimeError — en WARNING loggas per modell (gateway.startup, modellnamn + env-var-namn, aldrig värdet). Config-felet (VARKEN modell- eller providernivån bär api_key_env) kraschar boten precis som förut (validate_each_model_resolves_api_key, gateway/config.py) — bara miljöluckan mjukades. En modell i listan svarar 503 model_unavailable (Retry-After: 5, Cache-Control: no-store) på /v1/chat/completions (icke-ström och ström — samma kontroll strax före grenarna delar sig, på den EFFEKTIVA modellen så en policy-substitution in i en otillgänglig modell fångas också), /v1/embeddings och /v1/rerank, och utelämnas ur GET /v1/models. GET /health bär fältet unavailable_models (sorterad lista modellnamn, närvarande endast när icke-tom) — svarar fortsatt 200 (en 503 här hade dragit ut podden ur lastbalanseraren). /health/watchdog orört. P11 kom nära att döda hela gatewayen i produktion på exakt den gamla kraschen (ny leverantör, chart inte trådad än) — se D-105.
  • Cerebras, ny leverantör (D-106): [providers.cerebras] i routing.toml (base_url = "https://api.cerebras.ai/v1", adapter = "openai", OpenAI-kompatibelt verifierat) + två chattmodeller: stckn-gpt-oss-120b (upstream gpt-oss-120b, 0.35/0.75 USD per Mtok, intent: fast — PLACEHOLDER, ~3000 tok/s) och stckn-gemma-4-31b (upstream gemma-4-31b, 0.99/1.49 USD per Mtok, intent: balanced — PLACEHOLDER, ~1800 tok/s; OBS ovanlig prissättning — den mindre modellen kostar mer per Mtok än den större, siffrorna som Fabian angav dem). residency: us/eu_native: false i models.yaml — samma klass som openai/anthropic/google, EU-residensbundna tenanter nekas dessa av policylagrets befintliga model_residency-gate (ingen ny rego-gren). Nyckeln CEREBRAS_API_KEY är PROVIDERSCOPAD — EN nyckel för hela providern (som openai/anthropic/google/mistral/berget), motsatsen till Evrocs modellscopade EVROC_RERANKER_API_KEY (D-102-korrigeringen); ingen av de två modellerna bär ett eget api_key_env. training_excluded: true är VERIFIERAT (Fabian, Cerebras tränar inte på kunddata), inte en placeholder. Sekvenserat medvetet efter D-105: en saknad CEREBRAS_API_KEY degraderar nu bara dessa två modeller i stället för att crash-loopa hela gatewayen, den nästan-incident P11 råkade ut för med Evroc. Tre chart-appVersions bumpade (charts/llm-gateway 1.5.0, charts/policy-engine 1.3.2, charts/bff 1.2.4) — models.yaml bakas in i alla tre imagerna (D-88).
  • Modellidentitet och region (P11 session 2, D-103, F7 — “ett modellalias får aldrig tyst peka mot en annan modell, och varje konfigurationsändring ska ge ett nytt fingeravtryck”): gateway/routing.py::model_fingerprint() hashar (SHA-256, 16 hex) model_name, upstream_name, provider_name, type, base_url, adapter, residency, eu_native, enabled PER MODELL (inte per register — ett registeromfattande avtryck hade invaliderat en konsuments sjudagars resultatcache för varje modell så fort en enda ändrades). Pris uteslutet (ett prisbyte ändrar inte vilken modell som körs), base_url inkluderat (att peka om en leverantörs värd är ett tyst modellbyte utan att upstream_name rörs). Beräknas en gång per modell i Router.__init__ (samma plats routing.tomls operativa hälft och models.yamls styrningshälft möts), buret på Route.fingerprint intill Route.residency — residency återinfört av det här paketet, från registret (entry.residency), inte oavbrutet på Route sedan D-43 som en tidigare version av docs/decisions.mds D-103 påstod: fältet infördes i D-39 källat från routing.toml och togs BORT i D-43; den fulla provenienskorrigeringen står i decisions.md D-103. Registret laddas en gång vid boot (load_registry, main.py:69) och aldrig om, med assert_registry_parity (main.py:70) körd innan Router byggs — chattvägens läsning av route.residency motsvarar alltså ett per-anrops-registeruppslag utan den extra beroendekedjan. F7 stängt i gatewayen, inte längre ut: ingen anropare läser de tre identitetshuvudena ännu, och BFF varken läser eller vidarebefordrar dem. Tre svarshuvuden, en delad helper (gateway/api/_helpers.py::identity_headers(route)): X-Model-Fingerprint, X-Model-Resolved (= route.upstream_name), X-Model-Region (= route.residency) — satta på alla tre primitiv (chat.py inkl. strömmande via chat_stream.py, embeddings.py, rerank.py), frånvarande på felvägar med avsikt (ett nekat 403/429/503-svar ska inte annonsera modell/region). X-Substituted-Model (befintlig) består oförändrad — den beskriver en DEKLARERAD substitution, inte den tysta drift F7 gäller; de två headrarna svarar på olika frågor. Kroppens model är katalognamnet på varje adapter: openai.py (delad av openai/mistral/berget/evroc/cerebras) hade INGEN svarsöversättning alls — ren passthrough — och fick en SSE-medveten omskrivning (_rewrite_model_in_stream, event för event på \n\n-gränser) plus CRLF-normalisering (\r\n → \n före delningen; samma buggklass som en gång bitit google.py — en oskiftad CRLF gör att \n\n aldrig matchar, bufferten växer tyst till taket, ingen ström når klienten utan synligt fel). anthropic.py/google.py satte redan katalognamnet (de formöversätter ändå); embeddings.py/rerank.pys API-lager satte redan req.model. ModelRegistry.content_hash() orörd — policy-engine beror på den för policies_version, ett separat kontrakt; det nya fingeravtrycket är additivt bredvid, inte en ersättning. Ingen models.yaml-ändring i paketet ⇒ enda berörda chart är llm-gateway (check_chart_appversion.py bekräftat), appVersion 1.5.0 → 1.6.0 (minor). openapi.json regenererat, noll diff — väntat (svarshuvuden syns normalt inte i ett OpenAPI-schema), inte en bekräftelse av headrarna.
  • Kapad ström signaleras (P35, #247): när en adapters SSE-parser slår i MAX_SSE_BUFFER_BYTES (anthropic.py, google.py, openai.py) avslutas strömmen med en terminal frame event: error + data: {"error": {"message": "Upstream stream aborted", "type": "api_error", "code": "stream_aborted"}} — samma kuvert som err_response(), byggd av gateway/errors.py::stream_aborted_event. Aldrig [DONE] (som betyder komplett svar) och aldrig en tyst stängning; OpenAI-SDK:erna reser fel på framen. Taket och avbrottet är oförändrade, och den överstora bufferten skickas fortfarande aldrig ut. Felet loggas som förut (logger.error, bara bytegräns). Andra avbrott mitt i strömmen (t.ex. ett nätverksfel uppströms) täcks inte av detta.
  • PII-ingress (fas P1, D-35): chat-vägen (arbetsläge + publikt) anropar pii-api POST /v1/analyze efter budgetspärren och före decide (gateway/pii/client.py), lägger entitetssignalerna i samma decide-anrop (pii: {entities}), och verkställer svaret (pii_verdict): masked ⇒ pii-api:ts maskerade text ersätter originalet mot providern, blocked ⇒ 403 pii_blocked (sanerat, ingen innehållsläcka, nolltoken-usage-rad skriven), approve ⇒ orört. Nya obligatoriska env: PII_API_URL, PII_API_TOKEN; PII_API_TIMEOUT_MS (default 1500 ms — hela meddelandehistoriken skannas per anrop). pii-api onåbar/timeout/5xx ⇒ 503 pii_unavailable + Retry-After: 5 (ingen usage-rad, samma mönster som policy_unavailable); auth-drift (401) ⇒ 500 operatörslarm; text över MAX_PII_TEXT_CHARS ⇒ 422 pii_text_too_long. Streamande svar prependeras med SSE-eventet pii_verdict ({"verdict": "approved"|"masked", "detected": [...]} — blocked emittas aldrig som event: blockerade anrop svarar 403 innan någon ström öppnas); non-stream-svar bär samma verdikt i headern X-Pii-Verdict. Deployordning: pii-api → policy-engine → llm-gateway.
  • X-Has-Citations-headern (fas R2, D-40): chat-vägen läser den (om satt) BFF-skickade headern X-Has-Citations (gateway/api/chat.py::_has_citations_header — strikt "true"/"false", annars None, fail-closed på tvetydighet) och lägger has_citations i action="output_check"-decidets opa_input (gateway/policy/gate.py, endast om not None) — signalen policy-engines output_gate.rego läser för kalltvångets has_citations-default (se policy-engine-sektionen). Service-asserted interim, samma klass som embeddings-ytans X-Knowledge-*-headers.
  • X-Retrieval-Score-headern (P7, D-170): samma väg som X-Has-Citations (_retrieval_score_header, ändligt tal i 0–1, annars None); landar som retrieval_score i output_check-signalerna och läses av regeln avstar mot assistentens runtime.min_retrieval_score. bff, eval-api och agent-runtime skickar den.
  • Utflödesgrinden output_check (fas P2, D-35/#T8/D-36): när route-decidets svar sätter output_check_required=true (protection.outflow != "off", eller saknad profil, eller — gammal engine utan fältet — user_id_hash is None) håller chat-vägen tillbaka hela providersvaret i stället för pass-through-streaming: hold-and-release. Ordningen (spärrleden uppdaterad av D-97, se raden nedan — den här meningen sa till 2026-08-13 bara “budget →” och blev en motsägelse mot sin egen granne): runaway-spärr → budgetspärr → pii-ingress → route-decide → provider-anrop → [buffra hela svaret → pii-api-scan (nivåstyrt recognizer-set via level) → action="output_check"-decide → släpp eller stoppa]. Bufferten är en egen konstant HOLD_MAX_BUFFER_BYTES = 512 KiB (gateway/api/chat_stream.py, preciserar D-35:s “KB-klass”; medvetet INTE samma konstant som teens MAX_SSE_BUFFER_BYTES på 10 MiB) — överskrids taket ⇒ fail-closed deny med regelnamnet buffertak. Heartbeat-kommentarer (: hold\n\n) håller SSE-kanalen vid liv under buffring/scan/decide (_with_heartbeat), transparenta för klientparsers. Deny i ström: event: output_check {"verdict":"blocked","failures":[...]} (innehållsfritt, buffrat svar kastas, inget [DONE]); non-stream: 403 output_blocked. Usage vid deny skrivs med FAKTISKA tokens och provider="<output_blocked>" (kostnaden är verklig — avsiktlig skillnad mot ingressens ingen-rad-mönster, D-36 p7); gäller även infra-fel på utflödesvägen efter förbrukning. pii-api-auth-drift (401) loggas error (operatörslarm), timeout/nätverksfel loggas warning — samma deny mot klienten, olika loggnivå (spec §5-symmetrin med ingressen, D-36). Känd avgränsning: _extract_deltas skannar bara choices[0].delta.content — tool-call-argument i deltas skannas inte (ärlig lucka mot en framtida function-calling-yta, D-36).
  • Embeddings-ytan (fas R1, ny, D-39, kunskapslagrets spec; residens-källan bytt fas S2, D-43): POST /v1/embeddings (OpenAI-kompatibelt, gateway/api/embeddings.py) — samma Authorization: Bearer dgt-*-datapath som chat, plus obligatorisk header X-Knowledge-Collection-Id och valfri X-Knowledge-Information-Class. Budgetspärren körs FÖRE decide (samma mönster som chat-vägen); decide anropas med action="embedding" + knowledge_ctx {collection_id, information_class} + model_residency (sedan fas S2 läst ur det delade modellregistret — registry.get(route.model_name).residency, libs/model_registry/MODELS_YAML_PATH — INTE längre ur routing.toml; se policy-engine-sektionens D-43-bullet om registret som samma-fil-policy-data); policy-enginens embedding-gren allow:ar endast model_residency ∈ {eu, on_prem} för ALLA informationsklasser (se policy-engine-sektionen). Usage skrivs med requested_model (befintlig kolumn sedan migration 0006/D-31, ingen ny migration). Katalogen (config/routing.toml) bär fältet type (chat|embedding, kvar — operativt: adaptervalidering + GET /v1/models-filter); residency-fältet är sedan fas S2 (D-43) BORTTAGET ur routing.toml/Model/Route — governance-datan (residency, eu_native, m.fl.) bor nu enbart i config/models.yaml (det delade modellregistret), och create_app korsvaliderar vid boot att routing.tomls modell-set == registrets modell-set samt att varje embedding-post har en registrerad residens (fail-fast, assert_registry_parity, gateway/config.py) — den tidigare “MODEL_META-symmetrin”-interimen (D-37 p10) är därmed löst, inte längre en öppen synkroniseringsrisk. Enda embeddingsposten hittills: stckn-e5-large (provider=evroc i routing.toml sedan 2026-09-26, tidigare berget, residency=eu/eu_native=true nu i models.yaml, upstream intfloat/multilingual-e5-large-instruct; priset är placeholder — verifiera mot Bergets prislista före deploy, runbok-rad). Enda anropare: rag-api, med en egen dgt--nyckel (LLM_GATEWAY_KEY i rag-apis config) — ny credential att provisionera (runbok-rad, se rag-api-sektionen).
  • Innehållsspåret (P12, ny, D-115, F5): gateway/content_trail.py + tabellen model_call_content. Kedjan bevisar att ett anrop skedde och gallras aldrig; spåret återger vad som sades och gallras alltid (TTL 24 h, satt som expires_at vid INSERT — löftet ÄR villkoret, samma mönster som bff:ns chat_store/retention.py). Opt-in per assistent: retention_policy="full-log-ttl-24h", buret hit i DecideResponse.retention_policy (additivt fält, policy-engine beslutar och gatewayen lyder — samma väg som pii_verdict). Default metadata-only och never-log skriver NOLL rader, och ett okänt värde likaså. Tre fångstpunkter, alla bakom samma grind: icke-ström (api/chat.py), ström med hold-and-release och ström som pass-through (api/chat_stream.py; den sista parsar deltan enbart när spåret är på). Ett svar som utflödesgrinden håller tillbaka spåras ALDRIG — det lämnade aldrig plattformen. Ingen identitetskolumn: spåret nycklas på trace_id och ärver aktören via kedjeraden. Felpolicyn är skild med flit: kedjan fail-closed, spåret fail-open — men maskeringen fail-closed inuti spåret, annars vore fail-open en läcka. Gallringsronden: python -m gateway.content_sweep, CronJob 13 3 * * *, batchad DELETE, fel sväljs inte (exit != 0).
  • Anropare: BFF (/api/llm/), eval-api (promptfoo apiBaseUrl), sedan fas R1 rag-api (embeddings, egen dgt-nyckel, D-39). Nedströms: pii-api POST /v1/analyze (ingress och utflöde), policy-engine POST /v1/decide (fail-closed ⇒ 503, aldrig fail-open; anropas två gånger per publik/anonym chatt när output_check krävs — route-decide + output_check-decide), LLM-providers (OpenAI/Anthropic/Google/Mistral/Berget — providerlistan rättad, kartläggningsdiff 4, config/routing.toml), budget-webhooks.
  • Observability: JSON-logg med KeyMaskingFilter (extras via default=str — fas K-fyndet); OTel sedan fas O (D-34): FastAPIInstrumentor — medvetet INGEN HTTPXClientInstrumentor. Gatewayens delade httpx-klient är provider-vänd; global instrumentering skulle injicera traceparent i utgående anrop till Anthropic/OpenAI/Google, vilket bryter mot negative-constraints-regeln om externa gränser. Intern korrelation bärs av X-Trace-Id; en känd uppföljning (ej byggd) är selektiv instrument_client() bara på policy-klienten. (sedan D-52: additivt x-trace-id-svarshuvud; eget OpenAI-kuvert behållet — dokumenterat undantag, importerar ej libben)
  • Sömmar: budget-watchdog i lifespan (budget_exceeded-flaggan sätts/nollställs per pass; chat-vägen nekar 429 före decide — D-28, prejudikat: gater får stoppa före decide men aldrig öppna väg förbi). Sedan D-97: vakten granskar samma pass ALLA aktiva tenanter (LEFT JOIN tenant_budgets, inte INNER JOIN) och sätter/nollställer en andra, egen flagga runaway_tripped enligt tenantens runaway_action; chat/embeddings prövar den i en egen gren FÖRE budgetgrenen med ett eget felkuvert (runaway_stopped, aldrig budget_exceededs text) — se kostnadssäkringsbulleten ovan. boot-fail-fast HMAC-probe + GATEWAY_FX_USD_SEK-krav + (fas O) AUTH_API_ISSUER/AUTH_API_JWKS_URL-kravet ovan. Sedan fas S2 (D-43): boot-fail-fast utökat med modellregisterladdning + korsvalidering (assert_registry_parity) — trasig/saknad models.yaml, ett modell-set-mismatch mot routing.toml, eller en embedding-post utan registrerad residens stoppar boten (samma disciplin som HMAC-probe/FX-kravet). Ordningen i hot path är nu: runaway-spärr (D-97, runaway_stopped) → budgetspärr (D-28, budget_exceeded) → pii-ingress (pii-api + verdiktsverkställighet) → route-decide → provider → [utflödesscan → output_check-decide → släpp/stoppa] (D-35/D-36). Tenant-export (Wave 3 Post 2, ny, D-60; kuvert D-67): GET /admin/tenants/{tid}/export (gateway/admin/tenant_export.py) — read-only metadata-yta för provisioning-apis export-coordinator, grindad require_scope("tenant:export:read"), mountad ovillkorligt under /admin (oberoende av GATEWAY_ADMIN_KEY). Svaret typas sedan D-67 av libs.tenant_export.TenantExport (response_model=TenantExport), men rutten returnerar JSONResponse för no-store — FastAPI hoppar då över response-model-validering (deklarativt schema, inte en körtidsgrind); den verkliga grinden är kontraktstestet, mutationsbevisat (fält omdöpt → response_model fällde ingenting, kontraktstestet fällde omedelbart, se .superpowers/sdd/task-23-report.md).

services/eval-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.5.0 → 1.5.1.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 2 — avstående, källkrav och spår per fråga. expected_behavior: "abstain" godkänns när llm-gateway höll tillbaka svaret med avstar eller kalltvang (X-Output-Check-Rules), eller när bedömaren ser att modellen själv avstod; ett annat stopp, t.ex. PII, är inget avstående. requires_citation (bool, default false) fäller ett annars godkänt svar som saknade källor, eller vars source-avsnitt inte fanns bland träffarna. Varje EvalResult bär trace_id och decision_id ur gatewayens svarshuvuden. Ingen migration: frågor och resultat är JSONB. Ett stoppat svar avbryter inte körningen (se nästa notis). appVersion 1.4.7 → 1.5.0.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 — ett stoppat svar avbryter inte längre körningen. promptfoo 0.121.17 avbröt hela körningen när målet svarade 401, 403 eller 404 (“Aborting scan”), så ett PII-stopp eller avstående (403 output_blocked) fällde alla senare frågor utan resultat. Den bedömda assistenten anropas nu genom en egen provider (engine/promptfoo.py::TARGET_PROVIDER_JS, skriven i körningens tempdir). Den returnerar gatewayens svarshuvuden men inte statusen, och den stoppade frågan fälls som fel. Bedömaren går kvar genom promptfoos openai-provider. Ett afterEach-tillägg prövades och räckte inte: med tillägg laddar promptfoo om resultaten ur databasen, som är tom med --no-write. appVersion 1.4.6 → 1.4.7.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 1, D-170 — eval skickar samma retrievalpoäng som chatten. EvalRunner._sources ger också högsta träffens score per fråga (engine/sources.py::top_score), som EngineRequest.retrieval_scores. promptfoo-konfigen behåller med-kallor och utan-kallor, utan poäng. En fråga med träffscore får dessutom en egen provider (med-kallor-<id>, med X-Has-Citations: true och X-Retrieval-Score). Bedömaren är oförändrad. appVersion 1.4.5 → 1.4.6.)

Verifierad/uppdaterad 2026-10-07 (stacken#575: promptfoos override för sharp 0.35.4 → 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg i en oanvänd bildpipeline). promptfoo står kvar på 0.121.17. appVersion 1.4.4 → 1.4.5.)

Verifierad/uppdaterad 2026-10-06 (stacken#547: promptfoos overrides får simple-git 4.0.2 och därmed @simple-git/argv-parser 2.0.1 (CVE-2026-102826..102829). Bara promptfoo code-scans run använder simple-git, och det kommandot fungerar inte längre med 4.x; eval-api kör bara promptfoo eval. promptfoo står kvar på 0.121.17. appVersion 1.4.3 → 1.4.4.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.4.2 → 1.4.3.)

Verifierad/uppdaterad 2026-10-03 (stacken#461: promptfoos overrides får basic-ftp 6.2.1, som nås via proxy-agent → pac-proxy-agent → get-uri (CVE-2026-102990). promptfoo står kvar på 0.121.17. Arbetsytans uv.lock höjer urllib3 2.7.0 → 2.8.0 (PYSEC-2026-4175..4177). appVersion 1.4.1 → 1.4.2.)

Verifierad/uppdaterad 2026-09-30 (stacken#441: npm tas bort ur imagen efter att promptfoo installerats; den körande tjänsten anropar promptfoo på PATH. promptfoos overrides brace-expansion 5.0.11 och undici 7.29.1. De fyra trivy-dispenserna för npm:s vendrade kopior är borttagna. appVersion 1.4.1.)

Verifierad/uppdaterad 2026-09-28 (D-154, vägval #388 — förkontrollen säger att den är en eval-körning. EvalRunner._decide skickar purpose="eval", så en assistent som ännu inte är aktiverad kan utvärderas. Modellanropen går med tjänstens dgt-nyckel, som måste vara mintad med purpose: eval, annars nekas de av aktiveringsgrinden. appVersion 1.2.0 → 1.3.0.)

Verifierad/uppdaterad 2026-09-27 (D-152, plattformen#207 A6 — en facitfråga bedöms deterministiskt, utan bedömarmodell. build_promptfoo_config gav varje fråga llm-rubric, och bedömaren gick som samma provider med samma X-Assistant-Id, alltså genom den bedömda assistentens PII-grind; promptfoos engelska rubrikmall fälldes som PERSON och ingen grön assistent kunde passera en facitfråga oavsett svar. Facit bedöms nu med icontains (svaret ska innehålla facit, skiftlägesokänsligt) och anropar ingen modell. Beteendefrågor (adversarial) bedöms fortsatt av modellen genom samma grind — de har inget givet svar. appVersion 1.1.7 → 1.2.0.)

Verifierad/uppdaterad 2026-09-27 (D-151, plattformen#207 — en eval-körning kan passera i en riktig instans. Två fel gjorde att varje körning slutade i error, och e2e godtog det: (1) fetch_assistant skickade den statiska policy-token utan X-Tenant-Id, och policy-engine svarar 422 i legacy-läge — nu skickas körningens tenant. (2) promptfoos OpenAI-provider lägger /chat/completions direkt efter apiBaseUrl, och gatewayen svarar bara på /v1/...; chartens exempel saknade /v1. runner.gateway_api_base lägger till /v1 om det saknas. appVersion 1.1.6 → 1.1.7.)

Verifierad/uppdaterad 2026-09-22 (promptfoos npm-override för adm-zip 0.6.0 → 0.6.1: CVE-2026-77301, DoS via deklarerad uncompressed size. appVersion 1.1.5 → 1.1.6.)

Stämpelhistorik — 2 tidigare verifieringar

Föregående Verifierad/uppdaterad 2026-09-17 (promptfoos npm-override för sharp 0.35.3 → 0.35.4: GHSA-rgj7-g3m4-5g8c, libheif i en oanvänd bildpipeline. appVersion 1.1.4 → 1.1.5.)

Föregående Verifierad/uppdaterad 2026-08-27 (D-119 — kartläggningsfynd, ingen kodändring: Postgres-raden nämnde inte att 0001_initial bootstrappar llm-gatewayens api_keys (has_table-grindat) när provisioning-apis kedja körs först i en tom platform-pg. Tabellen ÄGS av llm-gateway och står i dess sektion; det som saknades var att den kan skapas härifrån. Migration 0003 droppade tjänstens EGEN nyckellagring — de två är olika saker och förväxlingen är lätt att göra.)

  • Syfte: kör assistent-evals (eval-sets/runs) via promptfoo mot llm-gateway, med assistent-config/policy från policy-engine; asynkron bakgrundskörning.
  • Postgres: schema eval: eval_sets, eval_runs (med questions-snapshot). Versionstabell alembic_version i schema eval, 2 migrationer.
  • HTTP-yta: GET|PUT /v1/assistants/{id}/eval-set, GET|POST /v1/assistants/{id}/runs, GET /v1/eval-status — scopes eval:read|write, tenant ur claim (saknas ⇒ 401). Inga admin-rutter. Health.
  • EvalQuestion.source (redan byggd, D-19-sömmen; rättelse i atlasen fas R1-kartläggningen, diff 2): app/schemas/eval.py — QuestionSource {collection_id, document_id, section_anchor} (alla str), valfritt fält på varje EvalQuestion. Trigger-enumen för eval_runs bär redan "knowledge_published" (Literal["manual", "assistant_changed", "knowledge_published"] + samma CHECK-constraint i migrationen).
  • knowledge_published-triggerns avsändare (fas R3, D-41): rag-apis publiceringsflöde skickar nu faktiskt triggern (POST /v1/assistants/{id}/runs {trigger:"knowledge_published"}, se rag-api-sektionen) — mottagarsidan här är ORÖRD (require_scope("eval:write") gäller precis som förut). Auth-sömmen (K-1, granskningsjusteringen): specens första förslag var ett tredje require_user_scope-mönster här (platform-service passerar, platform-user kräver scopet) — fällt mot D-24, som pensionerade per-tjänst-bryggan. Lösningen ligger i stället i auth-api: service_clients.allowed_scopes (ADD-only) + mint_service bakar in scope-claimen, så rag-apis mintade service-JWT redan BÄR eval:write när den når denna tjänsts befintliga require_scope-gate (se auth-api-sektionen).
  • Anropare: BFF (/api/eval/, live sedan D-21), lifecycle-api (aktiveringsgrind), rag-api (sedan fas R3: knowledge_published-runs-POST med en mintad service-JWT, D-41 — se rag-api-sektionen). Nedströms: policy-engine (fail-closed ⇒ run-status error), llm-gateway, extern promptfoo-binär (pinnad version).
  • E2e-status (fas R3, ny, D-41): i riggen — eval-api + eval-api-migrate, port 18089, mot den DELADE platform-DB:n med eget eval-schema (CI kör i stället en dedikerad Postgres — skillnaden är avsiktlig, se D-41). Lås (t) (test_knowledge_published_triggers_eval) är den första e2e-verifieringen av triggern. Runbook-notis: eval-api-imagen (node + promptfoo) väger 3,95 GB — diskrisk på små CI-runners, ingen kodfråga.
  • Observability: JSON-logg + trace-middleware (W3C traceparent → x-trace-id, ekas och propageras till policy-engine) + OTel FastAPIInstrumentor + HTTPXClientInstrumentor (app/main.py) — en av sex tjänster med OTel-bas sedan fas O (lifecycle-api, mcp-gateway, eval-api, bff, llm-gateway, auth-api; #T5 6/8). (sedan D-52: trace/felkuvert OCH loggning via libs/observability)
  • Sömmar: runs är bakgrundsjobb (asyncio.create_task, pollas via eval-status); D-22:s roll→scope-brygga uttryckligen pensionerad.

services/feedback-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Den egna structlog-åtkomstraden i app/middleware/logging.py bär route i stället för path. configure_logging lägger ExceptionTypeOnlyFilter på rotens handler och anropar contain_library_loggers. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.1.5 → 1.1.6.)

Verifierad/uppdaterad 2026-10-06 (P75, D-173 — GET /v1/feedback tar ?assistant_id=. Filtret löses mot public.usage_events med samma EXISTS och trace-normalisering som kvalitetssignalerna; betyget får inget nytt fält och ingen migration. Additivt, valfritt. appVersion 1.1.4 → 1.1.5.)

Stämpelhistorik — 3 tidigare verifieringar
  • Föregående 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.1.3 → 1.1.4.)

  • Föregående 2026-08-25 (D-109 — GET /metrics + http_requests_total, via ett explicit install_metrics-anrop i app/main.py. Tjänsten adopterar fortfarande INTE install_observability — den egna structlog-varianten med GDPR-redaktion är ett medvetet, dokumenterat undantag sedan Wave 2 och är orörd. Middlewaren ligger mellan SlowAPIMiddleware och RequestLoggingMiddleware, så en 429 från slowapi också räknas. 5xx-serien kommer via bibliotekets make_unhandled_exception_handler, som tjänsten redan registrerade — prövat, inte antaget. Inga nya rutter i openapi.json (skrapytan är include_in_schema=False), inga nya tabeller.)

Föregående Verifierad/uppdaterad 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.)

  • Syfte: tar emot och lagrar feedback/betyg på assistentsvar per tenant, med GDPR-radering och keyset-paginering; sedan fas S3 även ett gate:at per-assistent kvalitetssignal-aggregat.
  • Postgres: schema feedback: score_definitions, scores. Versionstabell alembic_version i schema feedback, 1 migration (ingen ny migration i S3 eller D-46).
  • HTTP-yta: /v1/feedback (POST, GET med obligatoriskt since och valfritt assistant_id sedan P75, GET per trace, DELETE = GDPR-radering), POST /v1/feedback/query (batch-läsning på trace_ids, 1–1000 st), POST /v1/feedback/erase (batch-erase/GDPR-svep på trace_ids, samma scrub-semantik som DELETE, idempotent) — dual-mode service-token/JWT, tenant endast via X-Tenant-Id-header i legacy-läget (plattforms-JWT läser tenant ur claim). Sedan fas S3 (D-45): GET /v1/assistants/{assistant_id}/quality-signals (egen router, prefix /v1/assistants) — grind require_scope("eval:read") (legacy-token ⇒ 403, stänger X-Tenant-Id-läckan för ytan), tenant ur claim, assistant_id ur path som filter; EXISTS-scopat thumb-aggregat (thumbs_up/down/down_prev_period, escalation_rate+top_missed_questions = null “ej mätt än”), Cache-Control: no-store på 200. Sedan D-46: ytan rate-limitas per verifierad JWT-tenant (signals_tenant_key läser request.state.rl_tenant; uuid-fallback är per-request-unik, aldrig en delad "anonymous"-bucket som hade kopplat tenants). Health.
  • Anropare: BFF (/api/feedback/ — quality-signals via passthrough med delegerad user-JWT + eval:read; gap 16 stängd för läs-ytan; skriv-vägens feedback-attribution kvar som fast-follow), auth-api (SAR-ytan: /query + /erase via forwardad JWT, D-31). Nedströms: endast platform-pg (+ read-only cross-schema-läsning av llm-gatewayens public.usage_events för trace-verifiering och quality-signals-joinen — fas S3-korrigering (D-45): koden läste tidigare ett obefintligt router.usage_events; den riktiga tabellen är public.usage_events. Latent bugg (integrationstesternas router-stub dolde den, ingen tidigare e2e körde läsvägen mot riktig DB); D-45:s e2e mot riktig DB fångade den. feedback.scores.trace_id är text (32-hex utan bindestreck), usage_events.trace_id är uuid — joinen normaliserar replace(lower(x::text),'-','') på båda sidor).
  • Sömmar: DELETE/erase är per-rad soft-delete — nollar comment och client_id, sätter deleted_at, raden och dess övriga fält (score, trace_id) finns kvar (samma scrub-semantik för både /v1/feedback/{id} DELETE och /v1/feedback/erase-batchen).
  • Observability (D-46 — grundkravsparitet nådd, #T5): structlog JSON; TraceIdMiddleware (app/middleware/trace.py, traceparent→x-trace-id→uuid4().hex, ekar x-trace-id) + RequestLoggingMiddleware binder trace_id till structlogs contextvars och skriver en explicit access-loggrad; FastAPIInstrumentor.instrument_app(app) (ingen HTTPXClientInstrumentor — tjänsten är DB-only). Typat felkuvert {error, detail, trace_id} + Cache-Control: no-store på ALLA 4xx/5xx-rutter; DB/transport-otillgänglighet (OperationalError | InterfaceError | OSError) → 503 + Retry-After; generiska fel → 500 utan läckage; RequestValidationError saneras (stripper pydantics input/url-fält, ingen comment-PII i 422-svaret). SlowAPI rate-limit (quality-signals JWT-tenant-nycklad, se ovan). (sedan D-52: libbens TraceIdMiddleware + felhanterare; egen structlog-logg behållen — dokumenterat undantag)
  • Live-status (fas Q, D-37): PROD: live sedan D-50 (customer-prod, Hetzner; api.stacken.eu/healthz → 200, sonderat 2026-07-30). Per-tjänst Synced/Healthy är INTE omverifierat i det passet — det kräver klusteråtkomst. Även i e2e-stacken (tests/e2e/, port 18083) — tjänsten saknades helt i riggen innan fas Q (auth-apins SAR-nedströmsanrop mot feedback-api 503:ade dolt); nu inkopplad med egen migrate-tjänst. Fasfynd: auth-apins FEEDBACK_API_URL hade fel port i e2e-configen (8080→8083), rättat i samma task. Fortsatt ej prod-deployad.
  • Tenant-export (Wave 3 Post 2, ny, D-60): GET /v1/feedback/tenants/{tid}/export (app/api/tenant_export.py) — read-only metadata-yta för provisioning-apis export-coordinator, grindad require_export_scope (tenant:export:read, D-60-plattformsoperatörsplanet).

services/lifecycle-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. readyz loggar undantagets typnamn, inte dess text. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.2.1 → 1.2.2.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.2.0 → 1.2.1.)

Verifierad/uppdaterad 2026-09-27 (D-151, plattformen#207 — activation-check frågar eval-api med en egen plattforms-JWT, inte med EVAL_API_TOKEN. eval-api kräver eval:read och läser status i tokenets tenant; den fasta nyckeln avvisades med 401, så varje activation-check fick blockeraren eval_status_unavailable och ingen assistent kunde bli aktiverbar. app/clients/service_token.py är en ordagrann kopia av provisioning-apis (D-150): client-credentials mot auth-apis POST /v1/token/client, per tenant, släppt vid 401; fel ger fortfarande None → blockeraren (fail-closed). Tenanten är den activation-check redan räknar med (ctx.tenant_id). Config: AUTH_API_URL, EVAL_CLIENT_ID (default svc-lifecycle-api), EVAL_CLIENT_SECRET (optional). EVAL_API_TOKEN läses inte längre och är optional i charten. Chart 0.2.3 → 0.3.0, appVersion 1.1.4 → 1.2.0.)

Verifierad/uppdaterad 2026-08-25 (D-109 — GET /metrics ägs nu av libs/observability. Den handrullade rutten i app/routes/metrics.py var ett RENT omslag runt generate_latest() — läst och kontrollerat innan den togs bort, vilket P27 uttryckligen krävde (issue #216 fråga 2: en rutt som filtrerar serier, kräver behörighet eller använder ett eget register hade behållit sin, med undantaget nedskrivet). Inget sådant fanns, så inget undantag skrevs. Modulen är raderad; de sju domänräknarna (reactor_*, reassessment_*, activation_checks_total, dossier_total) flyttade OFÖRÄNDRADE till app/metrics.py — samma delning mcp-gateway gjorde i D-70 — och skrapas ur samma default-registry, vilket är det som gjorde bytet gratis. Tjänsten får därutöver http_requests_total{service,method,route,status} via sitt befintliga install_observability-anrop, utan egen rad. openapi.json oförändrad: rutten var redan include_in_schema=False. Inga nya tabeller, ingen ytändring utåt utom att /metrics nu bär en HTTP-serie.)

Stämpelhistorik — 1 tidigare verifieringar

Föregående Verifierad/uppdaterad 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.)

  • Syfte: assistenters livscykel — skyldighetsdossierer, ändringshändelser, omprövningsuppgifter och fail-closed aktiveringsgrind; driven av en reaktor mot plattformens audit-events.
  • Postgres: schema lifecycle: obligations_dossier (bär sedan D-56 AI Act-rollfördelning: system_provider platform|tenant|third_party, deployer tenant|platform, user_transparency required|not_required|not_applicable [Art 50], gpai_provider fri text — CHECK ×3, NOT NULL med fail-closed server_default; per-rad p.g.a. Art 25), change_events, reassessment_tasks, audit_checkpoint (reaktorns cursor). Versionstabell alembic_version i schema lifecycle, 5 migrationer (0005 = rollkolumnerna).
  • HTTP-yta: /v1/obligations-dossier (POST/PATCH/activate/archive kräver tenant_admin; GET kräver auth), /v1/reassessment-tasks (POST/GET/PATCH — ingen DELETE, spike-korrigering, tenant_admin), /v1/change-events (GET, tenant_admin), /v1/assistants/{id}/activation… (scope lifecycle:activation_check). Health + Prometheus /metrics.
  • Anropare: BFF (/api/lifecycle/), provisioning-api (assistent-aktivering). Nedströms: eval-api (aktiveringsgrindens eval-status), platform-pg.
  • Observability: JSON-logg + trace-middleware + OTel FastAPIInstrumentor — en av sex tjänster med OTel-bas sedan fas O (lifecycle-api, mcp-gateway, eval-api, bff, llm-gateway, auth-api; #T5 6/8). (sedan D-52: trace/felkuvert OCH loggning via libs/observability)
  • Sömmar: reaktor-bakgrundsjobb pollar audit_events (30 s, batch 1000) och genererar change_events/reassessment_tasks; aktivering är fail-closed mot eval-api. Reactorns hälsa syns i reactor_backlog_age_seconds, inte i /readyz (D-83) — markören och gauge:n delar predikat med flit, så en kö gauge:n rapporterar är en kö reactorn faktiskt tänker beta av.

services/provisioning-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.4.1 → 1.4.2.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.4.0 → 1.4.1.)

Verifierad/uppdaterad 2026-09-27 (D-152, plattformen#207 A7 — dispens för eval-kravet vid aktivering. POST /v1/assistants/{id}/activate tar en valfri kropp {"eval_dispensation": {"reason", "expires_at"}}. Den godtas bara när eval är det ENDA som blockerar (dossier active, inga blockerande uppgifter, minst en eval-blockerare), bara från en användare (403 dispensation_requires_user för tjänst/statisk nyckel), med skäl 20–500 tecken och slutdatum inom 90 dagar. Dispensen står i aktiveringsradens hash-kedjade payload: skäl, slutdatum, de dispenserade eval-koderna och granted_by (sha256 av användarens sub). 409-svaret bär nu eval_blockers — de doldes. En utgången dispens räknas inte som redan aktiverad, så assistenten kan aktiveras igen. appVersion 1.3.0 → 1.4.0.)

Verifierad/uppdaterad 2026-09-27 (D-150, plattformen#207 — aktiveringen frågar lifecycle-api med en egen plattforms-JWT, inte med SERVICE_TOKEN. lifecycle-api tar bara emot en JWT med scopet lifecycle:activation_check och räknar dossiern i tokenets tenant_id; den fasta nyckeln avvisades med 401, så ingen assistent har kunnat aktiveras i en riktig instans. e2e dolde det: lifecycle-stubben krävde just den fasta nyckeln. Nytt: app/clients/service_token.py hämtar tokenet med client-credentials från auth-api (POST /v1/token/client, rag-apis mönster), cachat per tenant till exp − 60 s och släppt vid 401. Tenanten är assistentens, läst ur policy-engines assistants via samma plattformsanslutning (okvalificerat: tabellen ligger i anslutande användares schema, platform i drift; prejudikat D-125) — en användaranropare måste tillhöra samma tenant, annars 404 som för en okänd assistent. Ett påhittat assistent-id är numera 404, inte en aktivering. Config: AUTH_API_URL, LIFECYCLE_CLIENT_ID (default svc-provisioning-api), LIFECYCLE_CLIENT_SECRET (Secret, optional i charten — saknad nyckel ger 503 lifecycle_unavailable på aktiveringen, inte en krasch). Klientraden i auth.service_clients bär scopet; ingen tenant. Chart 0.3.0 → 0.4.0, appVersion 1.2.0 → 1.3.0.)

Verifierad/uppdaterad 2026-08-30 (D-133 — aktiveringsrutten släpper in en org-admin, och grinden flyttade från routern till rutten. Ny dependency require_service_or_assistant_admin: maskin-identitet passerar som förut, en platform-user-JWT måste dessutom bära policy:write. Flytten var nödvändig — en ruttnivå-dependency LÄGGS TILL routerns i stället för att ersätta den, så routergrinden hade nekat användaren innan ruttens ens kördes. Avgränsningen bär mildringen: /v1/tenants, transitions och churn behåller require_service_token, låst av test_operatorsplanet_ar_oforandrat_service_only, och en viewer-JWT nekas fortfarande (D-67:s hål återinförs inte). Rutten är i dag den ENDA på routern och en ny ärver ingenting — låst av test_varje_rutt_pa_routern_bar_en_egen_grind. NetworkPolicyn släpper nu in bff; riktningen fanns bara motsatt förut (D-68, tenant-export). appVersion 1.1.3 → 1.2.0, chart version 0.2.1 → 0.3.0 (templates OCH values ändrade). Svarskroppens {"status": "active"} är fortfarande osann — D-133 Kvarstår 3.).

  • Föregående 2026-08-30 (INGEN D-post — en rättelse av en osann beskrivning, inte ett beslut. app/api/assistants.pys modul-docstring påstod att POST /v1/assistants/{id}/activate anropar lifecycle-api “before setting status → active”. Rutten sätter ingen status. Assistentens status bor i policy-engines assistants, och ingen kodväg i den här tjänsten skriver den — mätt: noll UPDATE assistants, noll update(Assistant. Rutten skriver en hash-kedjad assistant_activated-rad i public.audit_events och inget mer. Den korrekta beskrivningen stod redan hundra rader ned i samma fil; det var docstringen som var fel, och den fick F2:s hål att överleva eftersom den som läste filen uppifrån slutade leta. Svarskroppen bär samma osanning och är INTE rättad — rutten returnerar {"status": "active"}, alltså ett tillstånd den inte gjort sant; att ändra fältet är en icke-additiv kontraktsändring (D-110 kategori 3) och ligger i vägval #314, paket P10. appVersion 1.1.2 → 1.1.3 — patch, ingen beteendeändring; bumpen krävs av check_chart_appversion eftersom imagen ändras även av en docstring. Chartens egen version orörd på 0.2.1: inga templates, inga values.).

  • Föregående 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.)

  • Syfte: provisionerar tenants (inkl. per-tenant Postgres-DB), tenant-tillståndsmaskin, konton och assistent-aktivering (operatörsplanet — org-admin-ytan ska aldrig se detta, O3). Sedan Wave 3 Post 2 (D-60): äger dessutom tenant-exit-export-coordinatorn (GDPR art. 20/SV-8), se nedan.

  • Postgres: OKVALIFICERAT skapade tabeller på platform-pg — alltså det schema anslutningens search_path pekar ut (public i e2e och i tjänstens egna tester; prods faktiska schema är oobserverat, se 0001:s D-50-kommentar): tenants, tenant_events, accounts, tenant_accounts, webhook_events, audit_events, tenant_exports (Wave 3 Post 2, D-60). tenants är sedan D-78 den enda tenant-tabellen i plattformen — auth-apis sju FK:er och llm-gatewayens fyra pekar alla hit, och ingen platform.tenants reses längre någonstans. Versionstabell alembic_version_provisioning_api, 7 migrationer (0004 neutraliserad till no-op, fas S1, D-42 — skapade tidigare en egen assistants-tabell som kolliderade (DuplicateTableError) med policy-engines assistants i den delade platform-DB:n; ingen route i provisioning-api läste/skrev den. Revisions-id + down_revision behållna orörda för kedjeintegritet; migrationen hade aldrig körts i en beständig DB; 0005: chain_name-kolumn (+ garanterade prev_hash/self_hash) på public.audit_events, dual idempotent migration med policy-engine 0008, D-48; 0006: tenant_exports — tenant-portabilitets-export-jobb, ADD-only/has_table-guardad, D-60; 0007 (ny, D-67): tenant_exports.idempotency_key — nullable text-kolumn + partiellt unikt index (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL, ADD-only/idempotent, samma has_table-guard-stil som 0006). Assistant-modellen (app/models/assistant.py) och dess scheman (app/schemas/assistant.py) är borttagna — konsoliderade till policy-engine. Skapar per-tenant-DB:er via TENANTS_DB_URL. Sedan D-48 skrivs audit_events-modellens prev_hash/self_hash-kolumner av applikationskoden: activate_assistant kedjar sin audit-rad som provisioning_events.v1 via libs.audit_chain.ChainWriter, under en fast plattforms-sentinel-tenant (00000000-0000-0000-0000-000000000000) skriven i radens EGEN tenant_id-kolumn (inte NULL) — validatorn scannar/rehashar per tenant_id, en NULL-rad hade varit strukturellt ovaliderbar. Bootstrappar dessutom llm-gatewayens api_keys i 0001_initial, has_table-grindat — tabellen ägs av llm-gateway (dess sektion), men skapas härifrån om provisioning-apis kedja kör först i en tom platform-pg. Inte att förväxla med den EGNA nyckellagring som 0003 droppade (sömmar nedan).

  • Auth (D-67): operatörsplanet (/v1/tenants, transitions, /activate) kräver sedan D-67 MASKIN-identitet, inte bara en giltig signatur — require_service_token (app/auth.py) accepterar kind == "legacy" (dual-mode, loggar varning) och kind == "platform-service"; allt annat 403 service_identity_required. Före D-67 kunde en viewer-rolls platform-user-JWT för valfri tenant nå POST /v1/tenants och transitions/churn — latent enbart p.g.a. avsaknad Ingress. Export-rutterna (require_export_scope, D-60) är kind-agnostiska och orörda av grinden.

  • HTTP-yta: /v1/tenants (POST/GET/GET per id/POST …/transitions/{action}), POST /v1/assistants/{id}/activate (service-token — skriver sedan D-48 en hash-kedjad provisioning_events.v1-audit-rad under sentinel-tenanten, se Postgres; rör aldrig en assistants-tabell; sedan D-67 idempotent: en omkörning slår upp assistentens befintliga hash-kedja i stället för att skriva en ny rad — assistenten är sin egen naturliga nyckel, ingen ny kolumn), webhooks (feature-flagga AV tills signaturverifiering finns). Health. Kontraktet incheckat sedan D-67: openapi.json + check_openapi_drift.py som CI-steg.

  • Tenant-exit-export-coordinatorn (Wave 3 Post 2, ny, D-60, spec §3; koordinatorn validerar sedan D-67): en async export-coordinator-plan — POST /v1/tenants/{tid}/exports (202, WORM tenant_export_requested i samma transaktion; konfig-grind: osatt nedströms-URL ELLER osatt/ogiltig KEK → 503 tenant_export_unconfigured; sedan D-67 valfri Idempotency-Key-header — en omkörning med samma nyckel returnerar samma jobb i stället för en andra fan-out och en andra krypterad artefakt; racet mellan två samtidiga anrop med samma nyckel fångas av migration 0007:s partiella unika index (IntegrityError → läs upp vinnaren, samma mönster som app/api/webhooks.py)), GET /v1/tenants/{tid}/exports/{id} (status, aldrig bytes), GET /v1/tenants/{tid}/exports/{id}/download (dekrypterad ZIP + WORM tenant_export_downloaded endast vid lyckad nedladdning; 410 utgången/nollad artefakt; 409 ej klar/failed) — alla grindade require_scope("tenant:export:read"). Coordinatorn forwardar operatörens tenant-lösa platform-service-JWT (aldrig egen service-identitet) till 5 nedströms read-only export-endpoints (auth-api/policy-engine/bff/feedback-api/llm-gateway, se respektive sektion), och fan-out-klienten (app/clients/tenant_export.py, INTE koordinatorn) validerar varje producentsvar mot libs.tenant_export.TenantExport (model_validate) innan koordinatorn ser det — ett producentfält som byter namn ger sedan D-67 ett namngivet fel ("<service>_malformed") och status="failed", i stället för att tyst tolkas som tomt coverage med status="complete" (ff02041). Svaren assembleras till en AES-256-GCM-krypterad ZIP lagrad i tenant_exports.artifact. Varje livscykelövergång är WORM-auditerad i samma transaktion som mutationen — tenant_export_requested (payload: requested_by) / _completed / _failed / _expired (artefaktförstöringen) / _downloaded (payload: downloaded_by) — så kedjan alene svarar på vem som beställde utlämnandet, vem som tog emot det och när kopian förstördes. Opportunistisk sweep körs på varje /exports-anrop (POST + status + download, en transaktion per rad): TTL-sweep nollar artefakten och stale-sweepen reapar både pending och running (pod-death → fail-closed/omkörbart), och _mark_complete är statusgrindad så ett reapat jobb inte kan återuppstå. Ärligt coverage-manifest: covered = de 5 plattforms-DB-tjänsterna, not_covered = rag-api/mcp-gateway (per-tenant-DB), bff:s konversations-KROPPAR (bulk-dekryptering), protection_defaults (deployment-global) m.fl. — allt v2, dokumenterat samlat i docs/tenant-export-coverage.md (D-67). ExportCoverage (app/schemas/export.py) slås MEDVETET inte ihop med libs.tenant_export (D-67): den läses även mot historiska tenant_exports.coverage-JSONB-rader och måste vara tolerant (extra="ignore") — producentmodellen måste vara strikt (extra="forbid", fångar drift); två olika krav, får inte ärva av varandra. Ny dep cryptography (uv lock); nya config-fält auth_export_url/policy_export_url/bff_export_url/feedback_export_url/llm_export_url + TENANT_EXPORT_KEK (base64, 32 byte).

  • Anropare: mcp-gateway; llm-gatewayens conftest kör dess alembic-kedja i tester. Nedströms: lifecycle-api (aktivering, med egen JWT från auth-api sedan D-150), platform-pg + tenants-pg. Sedan D-60: export-coordinatorn anropar auth-api/policy-engine/bff/feedback-api/llm-gateway (se ovan).

  • Observability (D-47 — grundkravsparitet nådd, #T5): tidigare JSON-logg (_JsonFormatter + configure_logging i lifespan, sedan D-52 ersatt av libs/observability, se nedan) + TraceIdMiddleware (app/middleware/trace.py: traceparent→x-trace-id→uuid4().hex, ekar x-trace-id) + RequestLoggingMiddleware (access-loggrad med trace_id); typat felkuvert {error, detail, trace_id} + Cache-Control: no-store på ALLA 4xx/5xx (app/errors.py — uniform nästling: sträng/dict-detail landar i detail), DB/transport-otillgänglighet (OperationalError | InterfaceError | OSError) → 503 + Retry-After (aktivering-503-dictet får därmed Retry-After), RequestValidationError saneras till {type,loc,msg} (ingen input-PII i 422); FastAPIInstrumentor.instrument_app(app) + HTTPXClientInstrumentor (utgående lifecycle-api-anrop injicerar traceparent). (sedan D-52: trace/felkuvert OCH loggning via libs/observability)

  • Sömmar: migration 0003 droppade API-nyckellagring (dörr som inte får byggas — testvaktad); webhooks får inte aktiveras utan signaturverifiering; testvakt (fas S1, K7): tests/unit/test_no_assistants_surface.py säkerställer att ingen Assistant-modell/scheman återinförs och att /activate-routen överlever konsolideringen. Löst (D-68), tidigare känd lucka (D-67): charts/provisioning-api/templates/networkpolicy.yaml hade bara egress mot platform-pg/tenants-pg, lifecycle-api och auth-api — export-coordinatorns fan-out mot policy-engine, bff, feedback-api och llm-gateway (fyra av fem mål) saknade egress. D-67 formulerade det som att det “skulle fälla varje export i prod”; det var för starkt — exporten var redan AVSTÄNGD (*ExportUrl-fälten utkommenterade, 503 tenant_export_unconfigured), så hålet fälldes aldrig i drift. NetworkPolicy kräver medgivande på båda sidor: en första fixomgång lade bara till egress i provisioning-apis chart och fälldes av en efterföljande arkitekturgranskning eftersom de fyra mottagarna (policy-engine, bff, feedback-api, llm-gateway) fortfarande inte släppte in app: provisioning-api i sin ingress — samma symmetrikrav som D-49 Task 0 löste för auth-apis SAR-fan-out mot exakt samma fyra tjänster. Fem charts är nu ändrade, inte en: egress i provisioning-apis chart (policy-engine 8080, bff 8084, feedback-api 8083, llm-gateway 8082; auth-api täcks av JWKS-regeln) OCH en ingress-post på samma portar i vart och ett av de fyra mottagarcharten. Exporten går därmed att slå på, men förblir medvetet avstängd: de fem *ExportUrl-fälten i values-prod.yaml är fortsatt utkommenterade, så endpointen fail-closar till 503 tenant_export_unconfigured tills någon fyller i URL:erna (driftbeslut, inte kodändring).

services/mcp-gateway

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. readyz-kontrollerna i app/routes/health.py loggar undantagets typnamn, inte dess text. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.16.0 → 1.16.1.)

Verifierad/uppdaterad 2026-10-08 (#621, P48 O1 b, D-648 — ny rutt GET /v1/operator/tenants/{tenant_id}/tool-usage med egen auth-väg (app/deps/auth.py::require_operator_usage_read). Den tar bara platform-service-token med tenant:usage:read. En annan tenant än instansens TENANT_ID i sökvägen ger 404 och en annan tenant-claim 403 tenant_mismatch. Svaret är samma ToolUsageOut som /v1/admin/tool-usage, som är orörd. Läsningen skrivs som tool_usage.operator_read i auth_events.v1 (aktör = klientens sub orört, actor_type service) före svaret. Om raden inte går att skriva svarar rutten 503 audit_unavailable med Retry-After. Händelsen hamnar aldrig i mcp_tool_calls.v1 och ändrar alltså inte total_calls. Anropare: llm-gatewayens förbrukningsrapport. appVersion 1.15.1 → 1.16.0.)

Verifierad/uppdaterad 2026-10-07 (#620, P61 — NetworkPolicyns peers väljs på app, som i övriga charts. De sex hjälparanropen (ingress från llm-gateway, bff och agent-runtime; egress till policy-engine, rag-api och auth-api) valde på app.kubernetes.io/name. Varje tjänstepodd bär båda etiketterna, så Deployment-trafiken är oförändrad. Nytt är att gallringsronderna i bff och llm-gateway, som bara bär app: <tjänst> (D-94), släpps in på 8080 som i alla andra charts. Policyns egen podSelector är orörd. Chart version 0.6.0 → 0.6.1, appVersion orörd.)

Verifierad/uppdaterad 2026-10-07 (P43, #596, D-629 — ingen kodändring i tjänsten. Tjänstens user_id_hash mot /v1/decide och i _platform_context.user_context är aktörsvärdet (D-113). Det är en annan identitetsrymd än auth.users.id, som resten av plattformen skickar under samma namn. Se bulleten Identitetsrymden nedan.)

Verifierad/uppdaterad 2026-10-07 (P43, #607 — aktörsvärdet bor i libs/audit_chain. actor_value, platform_subject och PLATFORM_SUBJECT_PREFIX flyttade från app/services/hashing.py till libs.audit_chain.actor och re-exporteras därifrån. Formeln är oförändrad och låst mot ett känt värde (libs/audit_chain/tests/unit/test_actor.py). auth-api:s registerutdrag räknar fram samma värde för att hitta verktygsvägens policybeslut. appVersion 1.15.0 → 1.15.1.)

Verifierad/uppdaterad 2026-10-07 (#592, P59, D-177 — en grupp-grant visar om tenanten har mappat gruppen. GrantOut har ett additivt fält, group_mapped: true/false för grupp-grants, null för person-grants, i både POST och GET på /v1/admin/mcp-servers/{server_id}/grants. En omappad grupp kan aldrig matcha, eftersom groups-claimet är snittet mot auth.group_mappings (D-34). Den avvisas inte men visas, eftersom en mappning kan tas bort efter att granten skapats. Uppslaget är platform_users.mapped_group_names: ett tenant-predikerat SELECT DISTINCT per anrop över den befintliga plattformsanslutningen, med exakt jämförelse. Fallerar det svarar ytan 503 group_lookup_unavailable med Retry-After, och skapandet gör uppslaget före skrivningen. Ingen migrering, ingen ändring av matchningen. appVersion 1.14.2 → 1.15.0, chart version orörd.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.14.1 → 1.14.2.)

Verifierad/uppdaterad 2026-10-03 (P54, #468 — ett anrop som stoppas före dispatch lämnar nu samma spår som ett policy-nej. På REST-vägens tjänsteidentitetsgren (app/services/tool_call.py) låg registry.get() → client.connect() och pool.acquire() utanför insert_log och audit.write_tool_call_audit, så ServerAuthUnavailable (P15), ServerBusy (PoolSaturated) och varje otypat anslutningsfel lämnade varken loggrad eller auditrad (D-121 Kvarstår). Nu fångas de i samma dispatch_error-mönster som delegeringsgrenen: tool_call_failed och en tool_call_log-rad med outcome_reason server_auth_unavailable, server_busy resp. connect_failed före svaret. Ett otypat fel bär en fast kod, aldrig str(exc). Svaret är oförändrat: samma undantag reses efter audit-raden (429, 503, eller observability-libbets 503 service_unavailable för OSError). Följden är att ett misslyckat audit-skriv nu ger 500 audit_write_failed även här, som på varje annan gren. Preview-vägen (knowledge_preview.py) är orörd: den skriver inte heller rader för sina avstängd/egress-stopp, och WORM-belägget bärs där av decide-raden. appVersion 1.14.0 → 1.14.1.)

  • Föregående 2026-09-26 (plattformen#207, första verktyget — rag-api går att registrera och nå som MCP-server i en riktig instans. Tre fel som e2e-riggen inte kunde se (compose saknar NetworkPolicy och kör MCP_GATEWAY_DEV_INLINE_TOKENS=true): (1) Hemlighetsfilerna. _read_secret löser token_secret_ref till en fil i /var/run/secrets/mcp-gateway, men charten monterade ingenting där. Nytt värde secretFiles (ref/secretName/key) projicerar nycklar ur BEFINTLIGA Secrets under refens namn — rag-apis MCP_BEARER_TOKEN behöver ingen kopia. ref prövas i mallen mot samma mönster som SecretRef. Tom lista renderar identiskt med förut. (2) Inneslutningen avvisade varje monterad nyckel. Kubelet skriver volymen som symlänkar (ref -> ..data/ref -> ..<tidsstämpel>/ref); att upplösa sökvägen och kräva att föräldern ÄR katalogen föll alltid. Nu prövas refen lexikalt (ett namn direkt i katalogen) och den upplösta filen måste ligga kvar under katalogen — en symlänk UT stoppas fortfarande. (3) platform_context syntes i verktygsschemat. Discovery sparar serverns inputSchema ordagrant, och rag-api deklarerar argumentet som obligatoriskt; en modell som ser det fyller i det och K1-guarden svarar 422. caller_schema (app/services/context_injection.py) tar bort det ur schemat i BÅDA listningarna — GET /v1/tools och MCP-dörrens list_tools. Det lagrade schemat rörs inte. Plus en egress-peer app.kubernetes.io/name: rag-api (sedan P61 app: rag-api) på 8080: den breda MCP-server-regeln väljer component: mcp-server, som rag-api inte bär. Chart 0.5.0 → 0.6.0, appVersion 1.13.1 → 1.14.0.)

  • Föregående 2026-09-26 (plattformen#207 — tjänsten har ett migrate-jobb, och imagen bär migreringarna. Charten hade aldrig ett: alembic/ och alembic.ini kopierades inte in i runtime-steget, så schemat gick bara att skapa från builder-steget, som e2e-riggens mcp-gateway-migrate gör. I customer-prod (stacken-digitalist) hade mcp_servers, mcp_server_tools, tool_call_log, access_grants och alembic_version därför aldrig skapats — mätt 2026-09-26: inga träffar i något av de fyra Postgres-klustren i någon av de två namnrymderna. Tjänsten svarade på /healthz utan tabeller eftersom den inte hade någon trafik; agentmotorns GET /v1/tools hade varit första läsningen. Nytt: charts/mcp-gateway/templates/migrate-job.yaml (PreSync, våg 4, bara DATABASE_URL via secretKeyRef — ingen envFrom mot ConfigMapen, regeln check_hook_dependencies.py håller), migrate.enabled/syncWave/backoffLimit i values, och två COPY-rader i Dockerfilen. Prövat: den byggda imagen kör alembic upgrade head från tom databas till 0004 med skrivskyddat rotfilsystem och uid 1001, och en andra körning är en no-op. Chart 0.4.3 → 0.5.0, appVersion 1.13.0 → 1.13.1.)

  • Föregående 2026-09-16 (P4 Del B, D-148 — REST-vägen får en grant-filtrerad verktygslista och ett grant-uppslag för namespacade anrop, agent-runtimes enda väg hit (D-70: motorn får inte bli en andra MCP-klient). Ny GET /v1/tools (app/routes/tool_call.py) återanvänder befintlig visible_tools-uppslagning — samma grant-filtrering som MCP-dörrens list_tools, nu även på REST-planet. POST /v1/tools/call läser ett namespacat <server>.<tool>-namn (partition på första punkten, samma regel som MCP-frontenden), slår upp servern i tenanten, hämtar grant_scope_cap och skickar granted/scope_cap till execute_tool_call — SAMMA _general_tool_call-gren i OPA som dörren, inte en egen kortslutning. Ett obeviljat namespacat verktyg går ÄNDÅ genom decide med granted=False och blir ett AUDITERAT 403 i WORM-policy_decisions — inte ett 404 före policyn. Ruling under implementationen: planens test krävde att obeviljat och okänt gav identiskt 404, vilket bara går om decide hoppas över; det bryter D-70 (grant-deny ska landa i policybeslutet) och audit-före-svar, och hade gett motorn — en icke-deterministisk anropare — en genväg dörren själv inte har. En OKÄND server ger fortsatt 404 tool_not_registered (uppslagsfel FÖRE decide, samma som MCP-dörren). REST-vägen fick AuthContext.subject (RAW, platform:<uuid>, speglar MCP-dörrens Caller.subject) — plandefekt bekräftad: user_id_hash är JWT-sub, aldrig en hash på REST-vägen (se D-113; gällde då, sedan D-113 är det aktörsvärdet, se Identitetsrymden nedan), och kan därför aldrig matcha access_grants.subjects platform:<uuid>-form (D-99). knowledge.* är OFÖRÄNDRAT — grant-fältet rörs inte för de verktygen, bff:s retrievalväg och dess tester är precis som innan. Enda anropare av de nya rutterna: agent-runtime (ny tjänst, gäst i dataplanet — se dess egen karta). openapi.json uppdaterat.)

  • Föregående 2026-09-14 (P2b, D-145): HTTP-MCP-dörren för nu sitt verifierade råtoken till befintlig tokenväxling för identity_mode=delegated. Varje anrop får sin egen uppströmssession och aud sätts till registrerad endpoint. Den interna frontend-legitimationen används fortsatt för assistant-/policyuppslag. Stdio saknar subject-token och nekar delegering. Nekad växling, trasigt växlingssvar och sessionsfel går genom failed-audit före typat fel; auditfel stoppar svaret. Allowlistens tomma default och produktionskonfiguration är orörda. Detta är kodförmåga, inte driftbevis eller full K1-acceptans. Prov och begränsningar anges i specen och PR:ens verifiering. appVersion 1.12.0, chart version 0.4.2.

  • Föregående 2026-08-30 (D-134 — act-formkontrollen i Caller.from_claims är nu en RESERV, inte en andra grind, och dörrens grind har täckning för första gången. P62/D-130 flyttade kontrollen in i JWTVerifier, och token_verifier.py bygger sin verifierare direkt — så ett malformat act avvisas redan vid tokenverifieringen (verify_token fångar varje fel och returnerar None ⇒ 401) och når aldrig from_claims. Fyndet var att den egenskapen var oprövad: det enda testet låg på det numera onåbara lagret. tests/unit/test_token_verifier.py kör nu den RIKTIGA JWTVerifier genom verify_token och prövar alla fem malformade formerna — gröna på orörd kod, vilket var beviset för att dubbleringen fick krympa. Kvar är två rader som fäller på det som skulle ge en FALSK AKTÖR (icke-objekt, icke-sträng, TOM sträng); nästlad act ägs av dörren. from_claims har EN produktionsanropare (server.py:167) med claims ur en redan verifierad AccessToken; stdio-vägens resolve_caller rör aldrig act. Ingen beteendeändring — 401 före, 401 efter. D-101 Kvarstår 6 är stängd. app/ 6 225 → 6 500 rader (+20 netto i passet, en ökning på en städning: förklaringen är längre än kontrollen, samma avvägning som D-121). appVersion 1.11.0 → 1.11.1, chart version orörd på 0.4.2 — inga templates, inga values.).*

  • Föregående 2026-08-29 (D-129 — identity_mode: vem uppströms ser. Ny kolumn på mcp_servers (service | delegated, migration 0004), default delegated — och migrationen satte service UTTRYCKLIGEN på varje befintlig rad, så beteendet var oförändrat den dag kolumnen infördes. delegated växlar anroparens token mot ett audience-bundet (aud = uppströms endpoint, RFC 8707) via auth-apis /v1/token/delegate, och anropar i en kortlivad session utanför registret. Skälet är mätt: MCPRegistry cachar en klient per server_id UTAN användardimension, och headers byggs en gång vid connect() — ett per-användare-token går inte att fästa på en delad session utan att varje anropare ärver den förstas identitet. Priset, en handskakning per delegerat anrop, betalas bara av de anrop som valt delegering; service behåller poolen orörd. Ingen fallback finns: går växlingen inte igenom svarar dispatchen 503 delegation_unavailable och gör inget anrop — en fallback hade gjort brytaren till en rekommendation. MCP-dörren kan inte delegera (dess tokens bär aud, D-101 Kvarstår 7) och nekar med 403 delegation_not_possible; vägval #302 (c) valde att låta den stå på service. subject_token är en EGEN parameter, skild från bearer, eftersom MCP-dörrens bearer är en tjänstetoken. Chartet fick authApiUrl/delegationClientId/delegationClientSecretRef, alla valfria. K1 är delvis uppfyllt — den statiska bearern finns kvar som väg på MCP-dörren. appVersion 1.10.0 → 1.11.0.).

Stämpelhistorik — 16 tidigare verifieringar
  • Föregående 2026-09-15 (D-147, P2:s kedjeprov): e2e-riggen bevisar nu delegerad identitet genom HELA kedjan med riktiga tjänster i varje led: Keycloak-inloggning → auth-apis OAuth-dörr → /mcp mot en identity_mode: delegated-server → auth-apis /v1/token/delegate (riggen sätter DELEGATABLE_AUDIENCES = dörrens resurs, D-131) → uppströms mcp-echo-delegated som verifierar signatur, utfärdare och audience med libs.platform_auth.JWTVerifier och svarar med det tokenets sub/act/aud. Två testanvändare får varsitt svar med sitt eget sub, gatewayen som act och uppströms endpoint som aud; auth-apis token.minted.delegated-rader och tool_call_log skiljer sig per person; dörrens token direkt mot uppströms avvisas på audience. Gatewayen får AUTH_API_URL/DELEGATION_CLIENT_ID/DELEGATION_CLIENT_SECRET_REF i riggen (hemlighet via compose secrets:) och en tenant-bunden service_clients-rad med token:delegate. Ingen produktkod ändrad; ingen riktig källa verifierar delegerade token än (rag-apis /mcp bär statisk bearer).).
  • Föregående 2026-08-29 (D-125 — access_grants får sin första skribent. Tre rutter på den befintliga admin-routern (app/routes/admin.py): GET/POST /v1/admin/mcp-servers/{server_id}/grants och DELETE .../grants/{grant_id}. Fram till nu fanns ingen väg alls dit — ingen route, inget ops-ui-fönster, ingen seed; AccessGrant konstruerades bara i testkod, och per-person-behörighet var en förmåga plattformen hade och inte kunde sälja. Ytan tar en E-POST, aldrig ett subject: personen slås upp i auth.users via den plattforms-anslutning tjänsten redan har (app/platform_db.py, ny läsmodul app/services/platform_users.py), och subjektet konstrueras med platform_subject() — access_grants.subject är definierad att bära platform:<auth.users.id> (D-99) och ska inte klistras in av en människa. Formen prövas dessutom i kod mot ett predikat som är teckenidentiskt med migration 0003:s (låst av tests/unit/test_grant_subject_predicate.py), eftersom modellens CheckConstraint är dokumentation utan grind (D-99 Kvarstår 3). Tenant-isoleringen är kodvägen, inte en constraint: servern laddas med _load_tenant_server(..., ctx.tenant_id) och tenant_id sätts ur ctx, aldrig ur bodyn — den sammansatta FK:n från D-70 Kvarstår 7 läggs INTE här (I2-migrering, eget vägval). Okänd e-post ⇒ 404 user_not_found som aldrig ekar e-posten; dubblett ⇒ 409 grant_exists (tabellen saknar unik-constraint). Grant-händelserna skrivs till auth_events.v1, INTE tjänstens egen mcp_tool_calls.v1 — tool_usage.py:115 räknar varje rad i den kedjan som ett verktygsanrop utan event_type-predikat, så en grant-rad där hade tyst höjt tenantens förbrukningssiffror (P46:s felmönster). app/services/audit.py bröt ut INSERT-och-kedja-kroppen till _append_event; write_tool_call_audit behöll signatur och kontrakt. Skrivordningen är audit FÖRE commit() (två databaser, en transaktion är fysiskt omöjlig) — faller kedjeskrivningen finns ingen grant, bara ett 500 audit_write_failed. Raderingen kaskaderar inte hit, och det är ett beslut: granten blir verkningslös (D-99 Kvarstår 6) och visas i listan som en grant utan upplösbar användare, så en administratör kan ta bort den. Ops-ui fick ett behörighetsfönster i det befintliga connectors-området; BFF:n behövde ingen ändring (passthrough är sökvägsagnostisk). E2E-låset för subjektbunden grant går numera GENOM ytan i stället för att seeda SQL. Rutterna syns inte i docs/control-surfaces.md: paritetsvakten enumererar admin-KATALOGER, och den här filen heter admin.py — en matrisrad hade fällt vakten som stale. Noll nya tabeller, noll migrationer).
  • Föregående 2026-08-28 (D-121 — registreringsytan härdad; registret var byggt hela tiden, men gick inte att peka mot en kundägd källa utan att öppna hål. P15/K20. Fem mätta fynd, noll migrationer, noll nya tabeller, noll nya beroenden. (1) RegisterServerRequest och PatchServerRequest har extra="forbid". Ops-ui skickade auth_ref, API:t har auth_config, och pydantics default ignore tappade fältet tyst — VARJE källa registrerad genom gränssnittet blev {"type":"none"}. Sviten var grön eftersom msw-mocken tog emot fältet villigt; mocken var det handbyggda objektet. Bunden nu av en ny vakt, tools/checks/check_ui_contract.py (pre-commit, nio egna tester), som läser den incheckade openapi.json och kräver att msw-mockens accepterade fältlista är IDENTISK med RegisterServerRequest — åt båda håll, plus att additionalProperties är false. (2) transport i admin-API:t är Literal["sse","http"] — stdio går inte längre att registrera (vägval V1(a), #284): endpoint är där en kommandorad som register() startar INNE i create-transaktionen, alltså kodexekvering för en konfigurationsroll och förbi D-98:s egress-kontroll. Databasens CHECK och StdioMCPClient är MEDVETET orörda — befintliga rader fungerar, backningen är en enum-post, och en CHECK-ändring hade rört befintliga rader (kategori I2) för att spara en rad kod. Följden: StdioMCPClient är onåbar för nya registreringar men inte död — den städas inte här. (3) token_secret_ref/username_secret_ref/password_secret_ref är typade SecretRef (Kubernetes secret-nyckelsyntax, ingen /, inledande alfanumerisk). Refen sammanfogades ovaliderad med /var/run/secrets/mcp-gateway, och en ABSOLUT ref kastar katalogen helt: /etc/hosts lästes och innehållet gick ut som Authorization: Bearer till en endpoint registreraren själv angav — exfiltrering med tenant_admin i båda ändar. Spärren ligger i TVÅ lager, efter reject_inline_tokens prejudikat: schemat stoppar kroppen, _read_secret innesluter en rad som redan ligger i databasen. (4) basic fungerar för första gången. Refarna lagrades sedan migration 0001 och _auth_headers() returnerade {} för typen i BÅDA transportklienterna; AB-0008:s påstående att användarnamn och lösenord fungerar var falskt. Bygger nu Authorization: Basic <b64(u:p)>. (5) Fail-closed på legitimationen. _resolve_bearer_token loggade och returnerade tom sträng vid oläsbar hemlighet, headern föll bort och anropet gick ut OAUTENTISERAT — den enda felmod hela registret finns för att förhindra. Ny typad ServerAuthUnavailable (503, Retry-After sätts redan av handler:n); saknad ref, oläsbar fil, TOM fil och okänd auth-typ stoppar alla anropet. De två nätverkstransporterna delar nu EN build_auth_headers — sse_client hade en egen kopia utan basic-gren, och ett test binder ihop dem. Svarsvägen använder AuthConfigView, inte AuthConfig: strikthet hör hemma där ny data kommer in, och en strikt svarsmodell hade 500:at på rader skrivna före reglerna — precis de rader någon behöver kunna läsa för att städa registret (vägval V2(c), #285: none-rader stängs INTE av, de märks ut i ops-ui). Vyn saknar dessutom token-fältet helt och KAN därför inte eka en hemlighet. Ops-ui-halvan var större än ett fältnamn: McpServer påstod status: connected|error, enabled och tools[].name — tre former API:t aldrig har svarat — och setMcpServerEnabled PATCHade {enabled} mot ett schema utan det fältet, alltså en knapp som låtsades fungera. Alla tre rättade. Vad som INTE ingår: skribenten för access_grants (P19) — en registrerad servers verktyg kan fortfarande inte beviljas någon, så kundfallet fungerar inte hela vägen; OAuth-klientmekaniken (K1/P2), som auth_configs typdrivna _REQUIRED_REFS är förberedd för som en ADDITIV rad. appVersion 1.8.1 → 1.9.0).
  • Föregående 2026-08-27 (D-117 — ny läsyta GET /v1/admin/tool-usage (app/routes/tool_usage.py, app/services/tool_usage.py) — aggregering av tjänstens EGNA kedjade verktygsrader ur auth.audit_events (chain_name='mcp_tool_calls.v1'), grindad tenant_admin + plattforms-JWT, periodfönstret Stockholm-ankrat och halvöppet precis som llm-gatewayens. Auth-api äger tabellen, mcp-gateway äger raderna — och bara den kan TOLKA dem, eftersom server, scope och klass ligger i metadata som den själv skrev. Informationsklassen skrivs nu i metadata på verktygsraden (sex nyttolastställen); det bryter inte kedjan, eftersom metadata redan är ett whitelist-fält i mcp_tool_calls.v1 — ett eget fält hade krävt mcp_tool_calls.v2 och en skarv i auditkedjan för en rapportdimension.).
  • Föregående 2026-08-26 (D-113 — de två dörrarna skriver nu SAMMA aktörsvärde. HTTP-dörren (app/deps/auth.py) skickade fram till nu JWT-sub ORÖRT som user_id_hash, medan MCP-dörren (app/frontend/caller.py) skickade SHA-256 av det namnrymdstaggade subjektet — samma person fick två aktörer i auditkedjan beroende på vilken dörr hon kom in genom, och fältnamnet ljög på HTTP-vägen. Båda anropar nu app/services/hashing.py::actor_value, identitetens enda väg till ett aktörsvärde. Det gäller även den plattformskontext som injiceras in i verktygen (app/services/context_injection.py), som därmed också blivit konsekvent. Skarven mot historiska rader är permanent och dokumenterad (vägval (i) i #263): kedjan är append-only och hashar radens innehåll, så backfill hade ogiltigförklarat varje efterföljande hash. Rader före 2026-08-26 bär det råa värdet på HTTP-vägen, och forensik över skarven behöver båda. user_id_hash heter fortfarande fel i sju tjänster och i /v1/decide-kontraktet — det är P43 (hette P39 tills numret visade sig krocka med Plattformens preflight-paket). Verifierad/uppdaterad 2026-08-25 (D-109 — GET /metrics ägs nu av libs/observability. Samma kontroll som lifecycle-api: app/routes/metrics.py var ett rent omslag runt generate_latest(), inget undantag behövdes, modulen är raderad. Räknardefinitionerna låg redan i app/metrics.py (D-70) och är orörda. /metrics LÄMNAR det publicerade kontraktet: den gamla rutten saknade include_in_schema=False och låg i openapi.json; bibliotekets sätter flaggan, så kontraktfilen krymper med en rutt. Skrapytan är inte tjänstens API. Tjänsten får http_requests_total via sitt befintliga install_observability-anrop.)
  • Föregående Verifierad/uppdaterad 2026-08-18 (D-101 — auditraden slutar ljuga om vem som handlade: actor_type HÄRLEDS i stället för att hårdkodas, och act skrivs till kedjan. Före det här passet satte app/services/audit.py actor_type: "user" på två ställen — i hash-envelopet och i INSERT-satsens bind — så ett verktygsanrop en agent gjorde åt en människa lagrades i den manipuleringssäkra kedjan som om människan gjort det själv. Nu bär write_tool_call_audit två nya parametrar, actor_type och act, som anroparen levererar och funktionen aldrig gissar; sex anropsställen i app/services/tool_call.py (tre tool_call_denied, två tool_call_failed, ett tool_call_allowed — räknat i filen; en tidigare version av den här meningen skrev fyra/två och fick totalen rätt av fel skäl) skickar dem vidare. Ingen av de två parametrarna har default — i något av de FYRA lagren, och det är avsiktligt: actor_type: str = "user" hade varit en tyst fallback till exakt det hårdkodade värde paketet finns för att avskaffa, så ett sjunde anropsställe eller en tredje transport som glömmer kwargen faller vid anropet i stället för att skriva människan för agentens handling. Lagren är write_tool_call_audit, execute_tool_call och de två dataklasser som konstruerar anroparen — Caller (app/frontend/caller.py) och gatewayens egna AuthContext (app/deps/auth.py). De två sista bar sina defaultar kvar när de två första förlorade dem, alltså samma hål ett lager upp: en tredje transport som skriver Caller(...) utan actor_type hade kompilerat och passerat granskning. AuthContext är därför kw_only=True — utan nyckelordstvånget kan ett obligatoriskt fält inte stå efter groups, som behåller sin default. Låst mekaniskt av tests/unit/test_no_actor_defaults.py, som läser dataclasses.fields och inspect.signature på alla fyra: påståendet har fallit tre granskningsronder i rad, en gång per lager, och är nu prövbart i stället för granskningsberoende. Precedensregeln bor på EN plats: app/services/actor.py::derive_actor_type(sub=, typ=, act=) — inget subjekt ⇒ anonymous (saknad identitet FLAGGAS, inte tigs ihjäl), act finns ⇒ agent, tjänste-token ⇒ service, annars user. Ren funktion, ingen I/O, elva rader kod. Att den är ett eget modul är motiverat i D-101 mot tjänstens egen budgetregel (services/mcp-gateway/CLAUDE.md): båda transporterna behöver samma regel — HTTP via app/deps/auth.py, MCP via app/frontend/caller.py — och två kopior av en auth-regel i två filer är exakt hur de fyra auth-forkarna i D-67 uppstod. Att funktionen läser typ för att sätta en etikett är inte att kontrollera typ vid dörren; den kontrollen är D-99 Kvarstår 10 och hör inte hit. act läggs i metadata, inte i chain-whitelisten, och det är inte en stilfråga: validatorns register (tools/audit-chain/audit_chain_cli/validator.py) mappar chain_name → (table, whitelist) utan per-rad-versionering, och mcp_tool_calls.v1 och auth-apis auth_events.v1 delar whitelist-konstant — ett sjätte fält där hade räknat om varje historisk rad med fältet satt till None och rapporterat BÅDA kedjorna som manipulerade från rad ett. metadata hashas redan; nyckeln sätts alltid, även som None, samma konvention som payloadens outcome_reason/result_hash. Låst mekaniskt från andra sidan: services/auth-api/tests/integration/test_audit_chain_act.py läser den här filens _WHITELIST som text (tjänsten är inte importerbar därifrån) och faller högljutt även om filen döps bort. act HASHAS INTE, till skillnad från Caller.subject_hash: ett client_id är en maskinidentitet, inte en personuppgift, och en oläsbar robot i kedjan hade gjort kedjan oanvändbar för den enda fråga den finns för att besvara. Subjektet hashas fortsatt. Formkontrollen av act finns på TVÅ ställen och det är lastbärande, inte dubbelarbete: app/frontend/token_verifier.py bygger sin JWTVerifier direkt i stället för via build_auth_dependency, så libs/platform_auths _parse_act körs aldrig på MCP-vägen — utan kontrollen i Caller.from_claims hade fail-closed på malformad act gällt enbart HTTP-transporten, och MCP-dörren är den en agent faktiskt knackar på. Malformad eller nästlad act ⇒ ValueError ⇒ bind_caller_from_access_token nollar anroparen ⇒ _require_caller nekar. Tjänsten upprätthåller ingenting nytt — ingen väg avvisar ett anrop för att act saknas, ingen policyregel läser fältet; det är P2. Beviset ligger på båda transporterna mot verkligt lagrade rader, inte på fixturkwargs: tests/integration/test_act_audit.py SELECT:ar raden ur auth.audit_events och re-deriverar hash-envelopet ur den, och MCP-halvan går genom SDK:ns egen handlarregistrering med en Caller byggd via Caller.from_claims ur råa claims — så det är dörrens egen act-parsning som driver raden. Två kända asymmetrier som INTE lagades här: HTTP-vägen skickar rått sub som user_id_hash medan MCP-vägen skickar caller.subject_hash, alltså två sorters aktör i samma kedja (D-101 Kvarstår 3, egen köpost); och actor_type har ingen CHECK-constraint i auth.audit_events — vokabulären hålls av kod, inte av databasen (Kvarstår 4). Noll nya tabeller, noll nya kolumner, noll migrationer, noll nya rutter; app/ växte 5026 → 5179 rader (+153), deklarerat i D-101 och i tjänstens CLAUDE.md. appVersion 1.6.1 → 1.7.0 (minor: nytt beteende i auditskrivningen, inte en bugfix) — appkoden ändrades i sju filer, och utan bump ser Argo ingen diff (D-89). Chartens egen version är MEDVETET orörd på 0.4.0: inga templates och inga values ändrades, samma prejudikat som D-99 satte för samma chart.)
  • Föregående 2026-08-17 (D-99 — access_grants.subject är plattformens användar-id, och det är nu en databasinvariant. Kolumnen bär platform:<auth.users.id> — namnrymdstaggen följd av ett gement, bindestreckat UUID — och migration 0003_access_grants_subject_namespace.py lägger CHECK-constrainten access_grants_subject_namespace_ck (subject IS NULL OR subject ~ '^platform:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), som gör fel rymd omöjlig att spara. Predikatet kräver HELA formen, inte bara taggen: platform: med tomt id, platform:<e-post>, versal-UUID och dubbeltaggat platform:platform:<id> passerar alla ett LIKE 'platform:%' och matchar ändå aldrig någon anropare. Gruppgrants har subject IS NULL och berörs inte. Rättelse av vad den här sektionen och D-74 påstod: subjektbundna grants matchade INTE “aldrig” en HTTP-anropare — det gjorde bara e-postbundna grants. En platform:-formad grant matchade redan (mätt 2026-08-17: _matches gav True för platform:<sub>, False för anna@example.com); vad som saknades var deklaration, upprätthållande, och att stdio-vägen producerade en ANNAN rymd. Båda anroparvägarna går nu genom samma helper, app/frontend/caller.py::platform_subject (Caller.from_claims för HTTP, resolve_caller för stdio) — en rymd, inte två som råkar mötas; gemenskapning före taggning, så samma person ger samma subject_hash. GATEWAY_SUBJECT valideras OCH kanoniseras vid uppstart: app/frontend/__main__.py::require_gateway_subject vägrar starta stdio-entrypointen om värdet inte parsar som UUID, och returnerar str(uuid.UUID(raw)) — uuid.UUID() accepterar former (utan bindestreck, klammer, urn:uuid:) som DB-predikatet avvisar, och en sådan anropare hade fått grants som inte ens GÅR att skapa. Felmeddelandet ekar medvetet inte värdet (GDPR first). build_frontend(session_factory) tar inte längre en DirectoryPort — parametern lästes aldrig (D-74 Kvarstår 5 stängd) och båda produktionsanropen mättade den med FakeDirectory({}), vilket felaktigt signalerade att HTTP-dörren slår upp en katalog. DirectoryPort och FakeDirectory STANNAR: porten har en levande användare i resolve_caller på stdio-vägen. Att HTTP-dörren saknar kataloguppslag är ingen lucka — grupperna kommer ur JWT:ns groups-claim, som auth-api redan snittat mot tenantens group_mappings; skälet står nu utskrivet i app/frontend/directory.py. app/models/access_grant.py speglar constrainten teckenidentiskt, men speglingen är DOKUMENTATION utan grind: alembic äger schemat, Base.metadata.create_all körs aldrig i sviten, så en modellrad som glider isär bryter ingenting i drift och inget test blir rött (compare_metadata-vakt = kandidat för tools/checks/, ej byggd). REST-vägen (app/routes/tool_call.py) har fortfarande inget grant-begrepp och rörs inte. Noll nya tabeller, noll nya kolumner; migrationen körs av det befintliga migrate-jobbet. appVersion 1.5.1 → 1.6.1 (minor: nytt beteende plus ett nytt schemavillkor) — appkoden ändrades i app/frontend/ och app/models/access_grant.py, och utan bump ser Argo ingen diff och rullar inte ut den (D-89). Chartens egen version är MEDVETET orörd på 0.4.0: inga templates och inga values ändrades, samma prejudikat som D-77 och D-70 PR 2. Migration 0003 FALLER högljutt om drift bär rader i fel form; avsiktligt, se D-99 Kvarstår 1. access_grants har fortfarande ingen skribent — ingen REST-yta, inget ops-ui-fönster, ingen seed; handskriven SQL är hela adminytan och kräver numera ett uppslag av användar-id (D-99 Kvarstår 4, köpost P19).)
  • Föregående 2026-08-16 (D-98 — av-knappen för policygrinden är borta, och egress-allowlisten verkställs. (1) enforce_policy är borttagen ur Settings. Flaggan lästes på ett ställe (services/tool_call.py) och hoppade, om falsk, över hela decide-anropet: ingen WORM-rad, ingen policy_decisions_total, ingen möjlig denied — bara en dry_run-loggrad. Den fanns inte i charten, så enda vägen dit var kubectl set env, vilket tjänstens egen README instruerade. Decide-anropet ligger nu på funktionens toppnivå, decision är icke-Optional och policy_decision_id har ingen None-fallback. Namnet är pensionerat, inte bara borttaget: en model_validator fäller starten om ENFORCE_POLICY finns i miljön, oavsett värde (även true) — utan den hade extra="ignore" svalt variabeln TYST, precis den README:s runbook bad om. Samma mönster som D-78 gav KEYCLOAK_*. .env.example, README:s rollout-avsnitt och workflow-raden städade i samma svep. (2) egress_allowlist verkställs vid dispatch. Fältet fanns i schema, modell, migration 0001 och admin-API sedan starten utan att någon kodväg läste det — tjänstens enda exfiltreringskontroll var dekorativ. Predikatet bor nu på EN plats, app/services/egress.py (egress_permitted, endpoint_host) bakom ett read-only Protocol så att både SQLAlchemy-modellen och pydantic-requesten uppfyller det, och grindas vid BÅDA dispatch-vägarna: services/tool_call.py (REST + MCP-frontend) och routes/knowledge_preview.py::_dispatch_knowledge_tool (som bygger egen ServerSpec och missades i första ronden). discovery.py är den tredje ServerSpec-platsen men gör bara tools/list — se D-98:s Kvarstår. Tom lista nekar allt (ops-ui lovade redan det i klartext, och tom är databasens default: en server ingen tagit ställning till ska stoppas), stdio är undantaget (endpointen är en kommandorad, inte en nätverksdestination), jämförelsen sker på VÄRD — skiftlägesokänsligt, oparsbar endpoint ⇒ neka. Nya fel: EgressDenied (403 egress_denied) och EgressAllowlistExcludesEndpoint (422). Deny-hanteringen skiljer sig äkta mellan vägarna: tool_call skriver WORM-rad + tool_call_log + två metrics och namnger den nekade värden i auditraden; preview reser sitt fel och skriver ingen auditrad, precis som dess egen server_disabled-gren (asymmetrin är namngiven i D-98). Preview svarar nu 403 i stället för sitt vanliga 503 rag_unavailable — Retry-After är falskt råd vid ett permanent avslag. (3) Registreringsinvarianten: RegisterServerRequest avvisar en server vars egen endpoint-värd saknas i dess allowlist (samma form som no_rfc1918_for_http) — annars hade POST /v1/admin/mcp-servers svarat 201 med upptäckta verktyg för en server som sedan 403:ar varje anrop. PatchServerRequest bär ingen endpoint, så PATCH korskontrollerar den LAGRADE endpointen före commit och reser 422 i stället för att lämna servern odispatchbar. Ops-ui:s registreringsformulär kräver därför nu fältet och har en felyta (D-82:s mönster). Chart version 0.3.1 → 0.4.0, appVersion 1.5.0 → 1.5.1.)
  • Föregående 2026-07-30 (D-77 — DRIFTBEVISET FINNS: en MCP-klient går hela vägen genom dörren i e2e-riggen. Ytan är fortfarande INTE i kraft i drift. D-76 Kvarstår 2 stängd. Före det här passet satte riggen ingen av dörrens fyra nycklar, så mcp_surface_enabled var falskt och mount_frontend returnerade direkt: e2e övade aldrig dörren, och “e2e är grönt” sa ingenting om den. Nu driver tests/e2e/test_mcp_door.py hela kedjan — /authorize → Keycloaks HTML-inloggning → /v1/oauth/callback → /token (JWT med aud) → POST /mcp tools/list + tools/call → tool_call_log + WORM-rad — mot en ny compose-tjänst mcp-echo-server (mcp-gatewayens EGEN allowlistade mock-MCP-server, körd som container; make_server fick en host-parameter, defaulten oförändrad). Det är första gången OPA:s _general_tool_call-gren körts live. Två produktfel som bara ett driftbevis kunde hitta: (1) AssistantReader.fetch skickade ingen X-Tenant-Id, så policy-engine svarade 422 på varje läsning med en legacy service-token — och FRONTEND_SERVICE_TOKEN ÄR en sådan; följden var att varje verktygsanrop på dörren blev 503 policy_unavailable. REST-vägen märkte inget, eftersom den forwardar anroparens plattforms-JWT och då vinner tenant-claimet. (2) mount_frontend reste dörren utan att kontrollera FRONTEND_ASSISTANT_ID/FRONTEND_SERVICE_TOKEN — stdio-entrypointen vägrade starta utan dem, HTTP-dörren monterade och 500:ade sedan på uuid.UUID(""). Grinden bor nu i app/frontend/server.py (en definition, båda transporterna) och monteringen uteblir + loggar ERROR som namnger fältet, i stället för att fälla podden — samma avvägning som D-76 gjorde för auth-apis tomma ESO-hemlighet. appVersion 1.4.0 → 1.5.0. Charten kan fortfarande inte konfigurera dörren fullt ut: frontendAssistantId/frontendServiceToken saknas helt (den senare är en credential och hör i ESO) — se D-77 Kvarstår).
  • Föregående 2026-07-29 (D-76 — dörren är rate-limitad; den är fortfarande INTE i kraft. D-74 Kvarstår 2 var att /mcp saknade limit medan tvillingen POST /v1/tools/call bar @limiter.limit per tenant — samma nedströmsväg kapad på ena dörren och otyglad på den andra. Nu TVÅ ASGI-lager (app/middleware/mcp_rate_limit.py), och ordningen är hela poängen: _HttpRequestContext → RateLimitedASGI[ip] → auth-stacken → RateLimitedASGI[tenant] → transporten. Tenant-identiteten uppstår först inne i auth-stacken, så ett lager utanför kan bara nyckla på IP och ett innanför ser aldrig en 401-flod; D-74 namnger båda som exponeringar. Samma store och samma FixedWindow-strategi som REST-vägen, ur samma RATE_LIMIT_STORAGE_URI (D-53). Ny tunbar nyckel MCP_DOOR_IP_RATE_LIMIT_RPS (default 500). Kända egenskaper, inte buggar: storage-slaget är synkront (ärvd paritet — slowapi på REST-vägen blockerar redan), X-Forwarded-For läses medvetet inte (spoofbar ⇒ hade gett en färsk hink per request), vilket gör IP-lagret till ett globalt tak bakom en ingress. Död store ⇒ 503 + Retry-After; token utan tenant_id ⇒ 403. values-prod.yaml sätter fortfarande varken mcpResourceUrl eller oauthIssuerUrl, så mcp_surface_enabled är falskt och dörren existerar inte i drift — D-74:s Kvarstår 1 står kvar, och driftbeviset (MCP-klient genom OAuth-flödet till ett verktyg) är INTE gjort. Chart 0.2.1 → 0.3.0, appVersion 1.3.0 → 1.4.0).
  • Föregående 2026-07-29 (D-74 — tjänsten är nu MCP-resursserver, inte bara MCP-klient: ny HTTP-yta POST /mcp (streamable HTTP, stateless=True) plus GET /.well-known/oauth-protected-resource/mcp (RFC 9728) som pekar ut auth-api som auktoriseringsserver. Rest av mount_frontend(app, settings) i app/frontend/server.py — samma allowlistade fil som stdio-transporten — och anropad ur lifespan, inte create_app(). Auth: egen audience-bunden JWTVerifier bara för dörren; REST-vägens verifierare och libs/platform_auth är ORÖRDA. Noll nya tabeller; chart 0.2.1/appVersion 1.3.0 bär MCP_RESOURCE_URL + OAUTH_ISSUER_URL).
  • Föregående 2026-07-27 (D-70, MCP-chokepointen i två PR:er — PR 1 reste vakten tools/checks/check_no_mcp_outside_gateway.py (fyra ben: klienttransport, verktygsyta, Python- och npm-beroende); PR 2 byggde MCP-server-frontenden: dispatch-kärnan extraherad till app/services/tool_call.py som EN datapath för både REST och frontend, K1-guarden flyttad dit från HTTP-adaptern, fjärde tabellen access_grants, och grant-existensen lyft till policyinput så OPA — inte gatewayen — avgör verktygsbehörigheten).
  • Föregående 2026-07-27 (D-69 — NetworkPolicy: ingress släpper nu in bff (MCP_GATEWAY_URL-dispatchen + retrieval-orkestreringens direkta POST /v1/tools/call, båda redan live); egress mot provisioning-api BORTTAGEN — samma dead-code-fynd som redan stod i denna sektion (app/clients/provisioning.py anropar en rutt som aldrig funnits), nu även stängt på nätverksnivå, ingen kodändring).
  • Föregående 2026-07-26 (D-67, kontraktskärnan paket 1/2/3 — kontraktet incheckat, 9 paths; lokal _extract_bearer-kopia borttagen (app/errors.py::auth_error(status, slug) mappar libs.platform_auths (status, slug) till tjänstens platta {error, message}-kuvert, tool_call.py använder nu extract_bearer(error_factory=auth_error), forwardad bearer-strip testlåst); BÅDA decide-sömmarna till libs.policy_client — tool_call.py (klienten fångade tidigare inte trasig JSON från policy-engine: ohanterad 500 i stället för fail-closed 503, nu åtgärdat) och knowledge_preview.py (den SJÄTTE ad-hoc-sömmen specens ursprungliga inventering missade). Fjärde, KVARSTÅENDE fork (ej rörd i denna PR, D-67): app/deps/auth.py::require_auth bygger redan på libs.platform_auth.build_auth_dependency, men except Exception: raise Unauthorized() from None sväljer VARJE fel från libbet — inklusive dess fail-closed 503 vid JWKS-onåbarhet — till ett 401. Skiljer sig från de tre borttagna forkarna (som var egna implementationer); det här är ett wrapper-lager runt libbet som tappar feltypen.
  • Föregående 2026-07-14 (fas R3, D-41 — preview-routern /v1/admin/knowledge (K3), CollectionReader, RAG_API_URL-settingen, decide-sömmen mot action="knowledge_preview"; fas R2:s D-40/K4-fynd (assistant-lookupen repekad till policy-engine, user_groups ur JWT-claim, platform_context-injektionen, rag-api-registreringen) och fas M:s spike-korrigering oförändrade; PR #51 draft utökar — rör ej oombedd). Undantagsmarkören är BORTTAGEN i D-77 (2026-07-30): den sattes 2026-07-27 för att sektionen stämplats tre gånger samma dygn, men eftersom markören undantar hela stämpelRADEN och raden är append-only blev undantaget permanent — friskhetskontrollen för den här sektionen var alltså avstängd i tre dygn (upptäckt i D-76, åtgärdad här). D-77 stämplar ett nytt dygn och behöver den inte. Sätt den aldrig utan att samtidigt planera hur den tas bort. Kandidat för tools/checks, fortfarande obyggd: låt markören bära ett datum och gälla bara det dygnet. Samma dygn, D-100: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning — och den patchen är varför bumpen slutar på 1.6.1 och inte 1.6.0.
  • Syfte: plattformens enda utgång för MCP-verktygsanrop — auktoriserar via policy-engine (den knowledge-scopade action="tool_call"-grenen samt, sedan fas R3, action="knowledge_preview" för admin-planet, se policy-engine-sektionen), proxar godkända anrop till registrerade MCP-servrar (stdio/sse/http), audit-loggar varje anrop. Sedan fas R3: äger dessutom en egen admin-preview-yta som återanvänder knowledge.search/get_full mot rag-api (D-12 p4 — provtryckning genom policylagret, aldrig en väg förbi).
  • Postgres: tenant-lokal DB: mcp_servers, mcp_server_tools, tool_call_log, access_grants (versionstabell alembic_version, 4 migrationer — 0002 lade grants, D-70; körs av chartens PreSync-migrate-jobb sedan 2026-09-26). access_grants bär subject XOR group_name, server_id (FK → mcp_servers, CASCADE), nullbart tool_name (NULL = hela servern) och scope_cap som bara kan SMALNA verktygets egen nivå. Skribenten finns sedan P19/D-125 (app/routes/admin.pys grants-rutter); dessförinnan konstruerades AccessGrant bara i testkod och handskriven SQL var hela adminytan. Tabellen har varken unik-constraint (dubblettskyddet ligger i ytan, 409 grant_exists) eller en FK som binder tenant_id till serverns ägare (D-70 Kvarstår 7 — isoleringen är SQL-predikatet plus kodvägen, inte schemat). Fjärde tabellen mot budgetregelns en — motiverad i D-70: verkställighetspunkten bär registret över det den verkställer, aldrig beslutet. Ingen egen audit-tabell — audit skrivs till platform-pg:s auth.audit_events (auth-apins tabell, app/services/audit.py), hash-kedjad via libs/audit_chain under kedjan mcp_tool_calls.v1 (spike-korrigering: tidigare beskrivet som en egen audit-yta). Sedan D-101 bär raden en HÄRLEDD actor_type (user | agent | service | anonymous, ur app/services/actor.py) i stället för konstanten "user", och RFC 8693:s act.sub ligger i metadata — aldrig som ett sjätte whitelist-fält, eftersom whitelist-konstanten delas med auth_events.v1 och ett nytt fält hade rapporterat båda kedjorna som manipulerade från rad ett. user_id_hash i policy-input/audit-metadata är JWT-sub (UUID-pseudonym) på REST-vägen — inget beräknat hash, trots namnet — medan MCP-vägen skickar Caller.subject_hash (en riktig SHA-256). De två transporterna skriver alltså olika sorters aktör i samma kedja; känt och olagat (D-101 Kvarstår 3).
  • Identitetsrymden (P43, D-629): user_id_hash mot /v1/decide, actor_id i auditkedjan och _platform_context.user_context.user_id_hash till kundens MCP-servrar är aktörsvärdet actor_value(sub) = sha256(canonical_json("platform:" + sub)), från båda dörrarna (D-113). Resten av plattformen skickar auth.users.id orört under samma fältnamn. policy_decisions.user_id_hash bär alltså båda rymderna, och en personfiltrering måste fråga på båda värdena.
  • HTTP-yta: admin /v1/admin/mcp-servers (CRUD + rediscover + per-tool-scope + sedan P19/D-125 behörigheter: GET/POST /{server_id}/grants, DELETE /{server_id}/grants/{grant_id}; tenant_admin-roll — rag-api registreras här, se runbok i README), datapath POST /v1/tools/call (scope tools:call, app/routes/tool_call.py). Health + /metrics. Sedan D-74 dessutom MCP-ytan POST /mcp + GET /.well-known/oauth-protected-resource/mcp — Starlette-rutter, alltså utanför openapi.json och inte driftgrindade av kontraktsvakten (samma form som D-71:s SDK-rutter i auth-api); se egen bullet nedan.
  • Preview-routern (fas R3, ny, D-41, K3): app/routes/knowledge_preview.py, egen APIRouter(prefix="/v1/admin/knowledge") bredvid den befintliga admin-routern (PR #51-koordinering per D-40 p6 — rör aldrig admin.py/tool_call.py/policy_engine.py/policy_input.py, endast nya filer + ADD-only i config.py/main.py). POST /v1/admin/knowledge/preview (require_role("tenant_admin"), samma dep som admin-ytan): (1) hämtar kollektionskortet ur rag-api med ANROPARENS forwardade JWT via ny app/clients/collection_reader.py::CollectionReader (404 ⇒ collection_not_found, transport/timeout/oväntad status ⇒ 503 rag_unavailable — okonfigurerad RAG_API_URL räknas som unavailable); (2) POST /v1/decide med action="knowledge_preview" + knowledge_ctx {collection_id, information_class} (kollektionens effektiva maxklass, fail-closed rod vid oklassade dokument) — deny ⇒ 403 med beslutet redovisat, policy-engine onåbar ⇒ 503, aldrig förbi; (3) knowledge.search/get_full via befintlig MCP-dispatch med gateway-konstruerat platform_context.preview (se rag-api-sektionens PlatformContext-XOR); (4) svar {answer, citations, policy {decision, decision_id, policies_version}, expired_amounts} + Cache-Control: no-store — answer byggs deterministiskt ur träffarnas snippets (ingen LLM på admin-planet). Ny setting RAG_API_URL (+ RAG_API_TIMEOUT_MS, default 5000 ms) — tom sträng (default) ⇒ preview-endpointen svarar 503 rag_unavailable fail-closed; notera namnkollisionen: detta är EN annan RAG_API_URL än auth-apis GDPR-nedströms-URL med samma namn (se auth-api-sektionen) — två tjänster, två oberoende settings, samma env-namn av en händelse.
  • Assistant-lookupen (fas R2, K4 — repekad): app/clients/assistant_reader.py::AssistantReader.fetch() — GET {POLICY_ENGINE_URL}/v1/assistants/{id} med anroparens forwardade bearer plus X-Tenant-Id (D-77); 404 ⇒ None (deny uppströms), övriga fel ⇒ PolicyUnavailable (503). Headern är bärande, inte pynt: policy-engines resolve_tenant_id läser tenanten ur JWT-claimet för en plattforms-JWT men ur headern för en legacy service-token, och svarar 422 utan den. MCP-frontendens FRONTEND_SERVICE_TOKEN ÄR en service-token, så före D-77 blev varje verktygsanrop på /mcp ett 503 policy_unavailable — osynligt för REST-vägen, som forwardar en JWT och därför aldrig behövde headern. Ersätter den gamla provisioning-lookupen: GET /v1/assistants/{id} existerade aldrig i provisioning-api — den gamla vägen var monkeypatchad i alla tester och hade aldrig fungerat live. app/clients/provisioning.py (ProvisioningClient, TTL-cache) är därmed orphaned dead code — inte borttagen i R2 (utanför denna dokumentations-tasks scope), men inget anropar den längre.
  • user_groups (fas R2, K4): kommer nu ur den verifierade plattforms-JWT:ens groups-claim (app/deps/auth.py::_parse_groups, saknad/felformad ⇒ tom tuple) i stället för de två tidigare hårdkodade user_groups=[]-ställena (build_tool_call_input + decide-anropet).
  • platform_context-injektionen (fas R2, D-40): app/services/context_injection.py — endast verktyg vars namn börjar på knowledge. får {assistant_id, user_context, trace_id} injicerat i args; caller-angivet platform_context fångas av assert_no_caller_platform_context() och ger 422 ReservedToolArgs (fail-closed, prövas före policy-steget) — aldrig tyst overwrite.
  • Anropare: BFF — dels generiskt via dispatchprefixet mcp (/api/mcp/, admin-passthrough — sedan fas R3 även ops-uis Kunskap-provtryckare mot /v1/admin/knowledge/preview via denna dispatch, ingen ny BFF-kod krävs), dels sedan fas R2 direkt mot POST /v1/tools/call från retrieval-orkestreringen (chat_store/retrieval.py, egen MCP_GATEWAY_URL-config, användarens plattforms-JWT som bearer — se bff-sektionen). Nedströms: policy-engine (fail-closed gate + assistant-läsning + knowledge_preview-decidet), registrerade MCP-servrar (rag-api, nu på riktigt, se rag-api-sektionen), sedan fas R3 rag-apis REST-kortyta (CollectionReader, se ovan).
  • Observability: JSON-logg + trace-middleware + OTel FastAPIInstrumentor. SlowAPI rate-limit. (sedan D-52: trace/felkuvert OCH loggning via libs/observability)
  • E2e-status (fas R2 Task 14, kompletterad fas R3 Task 10, dörren D-77): i riggen — mcp-gateway + mcp-gateway-migrate + egen mcpgw-databas i rag-pg, port 18088, rag-api-registreringsfrö vid stack-boot; sedan fas R3 även BFF:ns UPSTREAM_MCP_URL (e2e-lås (s) går genom BFF:ns dispatch, se tvärsnittets e2e-rad). Sedan D-77 är dörren rest i riggen: MCP_RESOURCE_URL/OAUTH_ISSUER_URL/FRONTEND_ASSISTANT_ID/FRONTEND_SERVICE_TOKEN satta, en ny tjänst mcp-echo-server (mcp-gatewayens allowlistade mock-MCP-server som container) registrerad som tenantens verktygsserver, en grupp-bunden access_grants-rad för /users → echo, och en auth.oauth_clients-rad för den klient testet spelar. Sju lås i tests/e2e/test_mcp_door.py. Sedan D-147 även mcp-echo-delegated (samma mockfil med verifieringsflaggor, port 18090) registrerad som identity_mode: delegated med gruppgrant på whoami, och två lås i tests/e2e/test_delegation_chain.py. FRONTEND_ASSISTANT_ID provisioneras i FAS 1 (id:t måste vara känt när containern bootar) och skickas in vid fas 2-uppen, samma tvåfasmotiv som TENANT_ID.
  • Sömmar: audit-first-invariant — audit skrivs före svar; audit-fel ⇒ 500 audit_write_failed, resultatet propageras aldrig. Rediscovery-bakgrundsjobb (15 min). Transport-differentiering stdio/sse/http. Känd uppföljning (Task 9-granskningens F-1, fas R3): felkuvertet på preview-endpointen saknar Cache-Control: no-store på 401/403/422 — de svaren går genom en DELAD förbefintlig felhanterare som redan bar samma brist före R3 (sammanfaller med R2-ärvda err_response-no-store-uppföljningen); egen ticket, inte löst här.
  • Chokepointen (D-70): den här tjänsten är plattformens enda MCP-klient. Vaktat av tools/checks/check_no_mcp_outside_gateway.py (klienttransport bara under app/mcp/; verktygsyta bara där ALLOWLIST namnger — i dag rag-apis nedströmsyta plus testfixturer, och från PR 2 gatewayens egen frontend; Python-beroendet mcp bara i mcp-gateway, rag-api och auth-api (DEP_ALLOWED_SERVICES; auth-api sedan D-71 för SDK:ns OAuth-maskineri mcp.server.auth, utan transport och utan verktygsyta — importbenen vaktar det); npm-beroendet @modelcontextprotocol/sdk/fastmcp bara där NPM_DEP_ALLOWLIST namnger — tom i dag, stänger F1 för frontends/*). Utfästelsen binder vår kod, inte vad en användare kopplar sin egen klient mot. Ej vaktat, se D-70 Kvarstår 1: rag-apis /mcp och /v1/... delar port 8080, så NetworkPolicy kan inte skilja dem åt. PR #51 är ersatt av det här spåret och kan stängas — dess extraherade service var en fork av tool_call.py från 10 juli och saknade K1-guarden, inject_platform_context, libs.policy_client, assistant_reader och ReservedToolArgs; extraktionen gjordes om ur dagens fil i stället.
  • MCP-server-frontenden (D-70, PR 2): app/frontend/ — en användare kopplar EN MCP-klient (Claude Code m.fl.) och når bara de verktyg hen är beviljad. list_tools är grant-filtrerad och namespacad <server>.<verktyg> (synlighetsfiltrering, ingen beslutsyta — den går medvetet inte genom decide); call_tool går genom samma app/services/tool_call.py::execute_tool_call som REST-vägen. Grant-existensen är policyinput, inte ett gateway-beslut: granted/scope_cap skickas som DecideRequest.grant. Kunskapsverktyg (knowledge.*) prövas i _tool_call-grenen mot grant_ok (access_gate.rego) — fältet saknas ⇒ ingen begränsning, eftersom REST-anropare legitimt saknar grant-begrepp. Alla andra verktyg prövas sedan D-70 i en egen gren, _general_tool_call (main.rego), som kräver input.grant.granted: frontenden sätter alltid fältet, REST-anropare gör det aldrig och förblir nekade — samma tillstånd som innan, nu av en regel i stället för av att requested_model="" råkade falla på modell-allowlisten. En grant-deny landar därmed i WORM-policy_decisions, inte bara i tool_call_log — det skiljer sig från PR #51, som nekade lokalt före decide. Frontenden anropar med en konfigurerad assistent (FRONTEND_ASSISTANT_ID + FRONTEND_SERVICE_TOKEN) och vägrar starta utan dem. Inte för att access_ok binder allowed_roles/allowed_groups mot frontend-anroparen — frontenden skickar user_role=None och FakeDirectory({}) ger inga grupper, så den grenen är villkorslös här. Det som faktiskt bär: tools_ok binder allowed_tool_scopes mot verktygets nivå, och assistentens EXISTENS är en egen grind (assistant_not_found ⇒ deny, auditerat); utan assistent vore frontenden den svagast grindade vägen in i verktygslagret. server_id smalnar verktygsuppslaget till den server granten prövades mot. På stdio-vägen kommer identiteten ur processens GATEWAY_SUBJECT och grupperna ur DirectoryPort. På HTTP-vägen binds identitet och grupper per anrop ur verifierade tokenclaims; gruppändringar kräver ett nytt token. Rå e-post når varken audit-kedjan eller policyinputen — Caller.subject_hash är det som skickas. Runbok: services/mcp-gateway/docs/register-upstream.md.
  • Live-status för MCP-resursservern: BYGGD OCH BEVISAD I RIGGEN, EJ I KRAFT I DRIFT (2026-07-30). Kedjan HÅLLER — tests/e2e/test_mcp_door.py driver en MCP-klient från /authorize till ett utfört verktygsanrop och en WORM-rad, mot en riktig Keycloak. Det är ett bevis om att kedjan fungerar, inte att plattformen tar emot klienter: charts/mcp-gateway/values-prod.yaml sätter fortfarande varken mcpResourceUrl eller oauthIssuerUrl, så mcp_surface_enabled är falskt i drift och rutterna existerar inte där. Charten kan dessutom inte uttrycka FRONTEND_ASSISTANT_ID/FRONTEND_SERVICE_TOKEN alls (D-77 Kvarstår), och utan dem monteras dörren numera inte ens om resurs-nycklarna sätts. Ytan tas i kraft först när auth-apis OAUTH_-värden fylls i (D-71 Kvarstår 7, charten är wirebar sedan D-76) och en IdP-klientregistrering plus ESO-hemligheten finns — allt utanför den här kodbasens kontroll. Plattformen tar alltså ännu inte emot MCP-klienter i drift.
  • MCP-resursservern — dörren D-71:s tokens landar på (D-74): mount_frontend(app, settings) i app/frontend/server.py reser POST /mcp över StreamableHTTPSessionManager(stateless=True, json_response=True) runt samma build_frontend-server som stdio-vägen kör, plus RFC 9728-rutten GET /.well-known/oauth-protected-resource/mcp som namnger auth-api i authorization_servers. Auth-modellen: plattforms-JWT med aud = MCP_RESOURCE_URL, scope tools:call, verifierad av app/frontend/token_verifier.py::PlatformTokenVerifier (uppfyller SDK:ns TokenVerifier-protokoll strukturellt; importerar aldrig mcp.* — AccessToken injiceras från den allowlistade filen). Verifieraren är en egen JWTVerifier-instans med expected_audience — REST-vägen (app/deps/auth.py) och libs/platform_auth är orörda, och måste vara det: JWTVerifier är binär, så satt audience avvisar även tokens UTAN aud, alltså varje REST-anropares token från /v1/token/exchange. Två ytor, två verifierare (tests/unit/test_audience_isolation.py). Middleware-stacken (AuthenticationMiddleware(BearerAuthBackend) → AuthContextMiddleware → RequireAuthMiddleware) wrappas runt ENBART /mcp, aldrig via app.add_middleware — annars hade varje REST-request betalat en JWT-verifiering mot dörrens verifierare. Identitet per anrop: handlarna anropar bind_caller_from_access_token först och bygger Caller.from_claims() ur SDK:ns auth-contextvar; grupperna kommer ur tokens groups, inte ur DirectoryPort. Caller.subject = platform:<sub>. Sedan D-99 matchar både person- och gruppgrants på HTTP-vägen. Admin-API:t slår upp user_email och lagrar plattformens användar-UUID (D-125); rå e-post hör inte hemma i subjektkolumnen. Fail-closed genom frånvaro: MCP_RESOURCE_URL + OAUTH_ISSUER_URL är all-or-none, osatta ⇒ ingen rutt monteras alls. Monteringen sker i lifespan (create_app() körs vid import och får inte läsa settings) och transportens session-manager körs i appens BEFINTLIGA lifespan. Ärvd känd gräns: dörren delar port 8080 med /v1/admin/*, precis som rag-apis /mcp — NetworkPolicy kan inte skilja dem åt (D-70 Kvarstår 1, vaktens dokumenterade tak). Admin-rutterna är JWT- och scope-grindade, så exponeringen är “nåbar”, inte “öppen”. Chart: config.mcpResourceUrl / config.oauthIssuerUrl, tomma som default; MCP_RESOURCE_URL måste vara byte-identisk med auth-apis OAUTH_RESOURCE — ett kontrakt över två tjänster utan mekanisk vakt.

services/agent-runtime

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Låst för flottan av tools/checks/check_log_content.py. appVersion 0.2.3 → 0.2.4.)

Verifierad/uppdaterad 2026-10-07 (P43, #596, D-629 — bara en docstring. app/doors/llm_gateway.py påstod att motorn skickar “användarens hashade id”. Den skickar auth.users.id orört i X-User-Id-Hash, och samma värde ligger i sessions.user_id_hash. appVersion 0.2.2 → 0.2.3.)

Verifierad/uppdaterad 2026-10-07 (#582, P7 punkt 1, D-170 — motorn skickar retrievalpoängen. knowledge_score läser högsta score ur results[] från ett <server>.knowledge.*-verktyg. Turens högsta värde går som X-Retrieval-Score på varje följande modellrunda (LLMGatewayClient.chat(retrieval_score=...)). Utan sökträff utelämnas headern. appVersion 0.2.1 → 0.2.2.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 0.2.0 → 0.2.1.)

Verifierad/uppdaterad 2026-09-27 (D-149, vägval #379 — motorn intygar källor som bff. Varje modellanrop bär X-Has-Citations: true när ett <server>.knowledge.*-resultat i den HÄR turen bar minst en citation (rag-apis former: results[], citation, documents[]), annars false. Nekade resultat och andra verktyg räknas inte. Tjänsteintygat, samma förtroendeklass som bff; att dörren själv ska veta det är en fråga för harness-utredningen (#379). LLMGatewayClient.chat kräver has_citations utan default. appVersion 0.1.0 → 0.2.0.)

Verifierad/uppdaterad 2026-09-16 (P4, D-148 — ny tjänst. Motorn kör agentloopen (en tur i taget) som en gäst i dataplanet (D-72): den fattar inga policybeslut, myntar ingen identitet och skriver ingen audit själv. tool.result.audit_event_id är alltid mcp-gatewayens egen rad — motorn kan bara vidarebefordra eller vägra ett resultat utan bevis (bevisväggen, D-72), aldrig skapa beviset. GET /v1/tools läser mcp-gatewayens grant-filtrerade lista, så verktygsytan modellen får se är personlig för den inloggade personen, inte tjänstens egen konfiguration.)

  • Syfte: kör en modellstyrd verktygsloop bakom plattformens två dörrar — fråga llm-gateway, kör de verktyg modellen begär genom mcp-gateway, spara sessionen krypterad i egen Postgres, strömma events tillbaka som SSE. Ett anrop, en session, en tur i taget. Python/FastAPI, uppbyggd som feedback-api (SQLAlchemy async, Alembic, libs.platform_auth, libs.observability). Inget mcp-beroende — motorn är REST-klient mot mcp-gateway, inte en andra MCP-klient (check_no_mcp_outside_gateway.py är grönt av just den anledningen).
  • Postgres: egen logisk databas (DATABASE_URL) på klustret vars poddar bär app: agent-runtime-pg — INTE platform-pg, INTE mcp-gatewayens. Schema agent_runtime: sessions (id, tenant_id, user_id_hash, assistant_id, wrapped_key, created/updated/expires_at), turns (id, session_id → sessions CASCADE, trace_id, status running|completed|failed, model_calls, tool_calls, error_code, started/completed_at) och messages (id, session_id → CASCADE, turn_id → CASCADE, seq, role, payload_ciphertext, tool_call_id?, audit_event_id?, created_at). Rå migration (target_metadata = None i alembic/env.py) — ORM-modellerna i app/store/models.py speglar schemat, styr det inte. Ett partiellt unikt index, turns(session_id) WHERE status='running', ger 409-låset (en körande tur per session) utan podlokalt tillstånd. En strandad running-tur (podkrasch, ett undantag som aldrig nådde motorns finally) hade annars låst sessionen med 409 för alltid: start_turn markerar därför, i SAMMA transaktion som nästa turs INSERT, varje running-tur äldre än TURN_LEASE_S (default (MODEL_TIMEOUT_S+TOOL_TIMEOUT_S)*MAX_TOOL_ROUNDS+MODEL_TIMEOUT_S) som failed/lease_expired, innan den försöker reservera en ny. finish_turn är villkorad på status='running', så en sent anländande, okunnig finish_turn inte kan återuppliva en rad leasen redan tagit. Två skyddsnät i lager: motorns try/finally med en skyddad (anyio.CancelScope(shield=True)) finish_turn(..., "aborted") är det FÖRSTA och fångar allt utom en podkrasch; lease-återtaget i start_turn är det ANDRA och täcker just podkraschen — kod i motorn kan per definition aldrig hinna köra klart då. Assistant-rader med verktygsanrop lagras som JSON i content, märkta tool_call_id="__tool_calls__" — history_to_messages expanderar dem tillbaka till OpenAI-formen när historiken byggs, och fyller i {"error":"unanswered"} för varje tool_calls-id som inte besvarades av en direkt följande tool-rad (en tur kan stranda mellan ett verktygsanrop och dess svar; utan syntesen blir nästa turs meddelandesekvens providerogiltig och faller med 503 för alltid).
  • HTTP-yta: POST /v1/sessions {assistant_id} → 201; GET /v1/sessions/{id} → metadata; DELETE /v1/sessions/{id} → 204, fysisk radering; POST /v1/sessions/{id}/turns {content} → text/event-stream med turn.started, model.called, tool.called, tool.result, assistant.message, turn.completed eller turn.failed. Alla fyra rutter går via en gemensam ägarkontroll — session finns bara om (session_id, tenant_id, user_id_hash) matchar anroparen, annars 404 (avsiktligt samma svar för “finns inte” och “tillhör någon annan”, annars blir rutten ett uppräkningsorakel). HTTP-lagret hämtar turrutens FÖRSTA event innan StreamingResponse byggs — StreamingResponse låser status 200 innan sin body-iterator körs en enda gång, så utan det hade 409 turn_in_progress, 503 door_unavailable och andra fel före första eventet blivit en tyst avbruten ström i stället för en riktig HTTP-status. Strömmen har därefter ett eget skyddsnät: ett oväntat undantag mitt i ger turn.failed{error_code: "engine_error"} i stället för en tyst avbruten ström (motorn ska ändå alltid yielda turn.failed själv — nätet är för det den inte fångar). Rundtak: MAX_TOOL_ROUNDS (default 8 modellanrop per tur) fäller turen med turn.failed{error_code: "tool_rounds_exceeded"} om modellen fastnar i verktyg — taket räknar RUNDOR, inte anrop: sista rundans verktyg hinner köras och sparas i historiken, men modellen får ALDRIG se resultatet, turen är redan fälld när loopen skulle frågat den igen. /healthz, /readyz, /metrics.
  • Utgående beroenden — bara de två dörrarna: llm_gateway.py mot POST /v1/chat/completions med tjänstens EGNA dgt--nyckel i Authorization (gatewayens autentisering av agent-runtime som tjänst) plus X-Assistant-Id, X-User-Id-Hash (= sub), valfria X-User-Role/X-User-Groups, X-Trace-Id; timeout model_timeout_s (default 60 s), inga omförsök. mcp_gateway.py mot GET /v1/tools och POST /v1/tools/call med ANROPARENS EGEN plattforms-JWT som bearer (det är subjekt-tokenet mcp-gateway delegerar vidare, D-129/D-145) — motorn har ingen egen verktygsidentitet; timeout tool_timeout_s (default 30 s), inga omförsök. Ett nekande (4xx från mcp-gateway) är ett SVAR (ToolCallOutcome(ok=False)), redan auditerat av dörren — bara att dörren är nere (nätverksfel/5xx) är ett DoorUnavailable-undantag som fäller turen. Auth-api nås indirekt via libs.platform_auth (JWKS) för att verifiera inkommande plattforms-JWT. Ingen providernyckel, ingen annan tjänst.
  • Bevisväggen (audit_missing): tool.result{ok: true} sänds ALDRIG utan audit_event_id. Svarar mcp-gateway 200 utan fältet, eller ser motorn ok=True utan id (försvar i djupet — dörrens kontrakt lovar att ett OK-svar alltid har en auditrad, men bryts det löftet ändå räknas utfallet likadant), fälls HELA turen: turn.failed{error_code: "audit_missing"}, och ingen tool-rad sparas för det anropet — det finns inget granskningsbart att spara. Nästa turs historikläsning syntetiserar då {"error":"unanswered"} för det obesvarade anropet (se Postgres ovan). Ett vanligt policy-nekande (4xx, redan auditerat av dörren) är däremot inget audit_missing — det är ett svar modellen får se och kan resonera om.
  • Identitet: motorn myntar ingen egen. Den bär anroparens redan verifierade plattforms-JWT (SessionRef.bearer, aldrig persisterad eller serialiserad) rakt igenom till mcp-gateway, som är det som gör GET /v1/tools-listan (och därmed vad modellen får se) personlig för den inloggade personen. Mot llm-gateway används i stället tjänstens egna dgt--nyckel plus X-headrar som beskriver personen. Känt hål i v0: SessionRef bär varken roll eller grupper — ToolLoopEngine.run_turn anropar llm.chat(..., role=None, groups=[]) ovillkorligt, trots att app/deps/auth.py::caller_groups(ctx) finns och kan läsa groups-claimet. Motorn känner alltså inte anroparens roll i v0.
  • Sessionsminnets gräns (AGENTS §Minnesregimer): SessionStore är den ENDA vägen in, och bär tre regler i sin egen form. (1) Varje läsning nycklas på (session_id, tenant_id, user_id_hash) — ingen metod listar en persons sessioner, ingen söker i innehåll. (2) Innehåll krypteras med sessionens egen datanyckel (AES-256-GCM, samma kuvertform som bff:s app/public/crypto.py, kopierad — INTE lyft till libs/, se Kända tak); en läckt bordump ger ingen text. (3) expires_at sätts vid skapandet ur SESSION_TTL_DAYS och är det ENDA gallringsvillkoret (retention_sweep.py, timvis, fail-open med flit — en misslyckad runda får inte döda tjänsten, bara försöka igen nästa timme); raderingen är fysisk, meddelanden och turer följer via CASCADE. Loggar bär bara metadata (session/turn/ trace-id, räknare, felkoder), aldrig innehåll.
  • Kända tak (D-148 §Kvarstår):
    • K1 — delegerad identitet onåbar via motorn i v0: ett token ur /v1/token/exchange saknar aud och kan därför inte växlas av auth-api. En mcp-gateway-server registrerad med identity_mode: delegated nekar därför motorns anrop; samma hål som D-147:s kedjeprov lämnade öppet, inte ett nytt.
    • role/groups mot llm-gateway är alltid None/[] — se Identitet ovan.
    • Systemprompten är bara chart-värdet SYSTEM_PROMPT; assistentens egen intended_use (specens plan) är inte inläst.
    • Ingen Google-verktygsstöd, ingen streaming med verktyg (modellanropet är alltid stream: false i v0 — eventströmmen är per loopsteg, inte per token).
    • Ingen egen rate limit i motorn — dörrarnas budget, runaway-spärr och rate limits är gränsen i v0; en delad-store-rate-limit per tenant är rapporterad som eget fynd.
    • Deklarationsblocket (owner, information_class, retention) renderas inte in i klustret härifrån — GitOps-deklarationen görs i Plattformens repo, i deploy-PR:en.
    • Kuvertkryptot i app/store/crypto.py är kopierat från bff:s app/public/crypto.py, inte lyft till libs/ — två användare är för få för att veta vilken abstraktion som håller.
    • anyio är deklarerat som direktberoende i pyproject.toml trots att paketet redan är transitivt i trädet via starlette/httpx — noll ny yta, en rad.
    • session_kek/llm_gateway_key är str i Settings, inte SecretStr — inget strukturellt skydd mot att en loggad Settings-instans läcker dem.
    • CancelledError-vägen vid klientnedkoppling (Starlettes anyio-cancellering under en strömmande tur) är resonerad i koden (anyio.CancelScope(shield=True) runt den sista finish_turn) men inte testbevisad.

services/memory-api (EJ BYGGD — ingen kod i trädet)

Verifierad/uppdaterad 2026-07-25 (paket 7): katalogen raderad.

Stämpelhistorik — 1 tidigare verifieringar

Föregående: 2026-07-11 (fas L-atlas-kartläggning): tom katalog (.gitkeep).

  • Ingen kod finns. services/memory-api/ togs bort i paket 7 — en tom tjänstekatalog ser ut som en komponent utan att vara det, och dolde att tjänsten aldrig påbörjats. Sektionen står kvar som den reserverade ansvarsytan, inte som en beskrivning av något som finns.
  • memory-api: avsedd för sessionsminne (“sandbank”, TTL) — plattformens ansvar enligt Shared-arkitekturen. Får inte växa till arbets- eller företagsminne (gränsen mot Shatterling/Shared, D-8).
  • När den byggs: börjar i docs/superpowers/specs/ som varje ny tjänst (CLAUDE.md, spec före kod), och återfår då en katalog + en pyproject.toml som gör den till workspace-medlem.

services/rag-api

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — Loggen är utan innehåll. Åtkomstraden bär route, den matchade vägmallen, i stället för path, den råa sökvägen. Undantag loggas som exc_type och exc_frames utan meddelande, httpx skriver inte längre URL:er med query-sträng och uvicorns egna rader går genom JSON-handlern. Ändringen ligger i libs/observability. Avsnittsankaret i /v1/documents/{doc_id}/sections/{anchor}/verify är rubriktext och nådde tidigare loggen genom sökvägen. _sanitize_cause i app/mcp_server.py är borttagen; MCP-verktygens felrad skriver type(exc).__name__ direkt. Låst för flottan av tools/checks/check_log_content.py. appVersion 1.4.1 → 1.4.2.)

Verifierad/uppdaterad 2026-10-06 (#562, filvägen steg 6 — dokument kan raderas, ersatta versioner kan gallras och kunskapsytan har audit (spec B5, §6). DELETE /v1/collections/{id}/documents/{doc_id} (rag:write, MCP-källor 403) raderar hela versionskedjan hårt i en transaktion; chunkar och ingest-jobb följer via ON DELETE CASCADE, och knowledge.document_deleted (samling, antal versioner och chunkar) skrivs i samma transaktion. POST …/documents med källmetadata skriver knowledge.document_created_from_file (samling, format, extraktorversion, tecken; inget filnamn, ingen hash). Båda i kedjan knowledge_events.v1 (app/audit.py, libs/audit_chain, registrerad i tools/audit-chain). Migration 0003_audit_and_superseded_expiry: audit.chain_heads och audit.audit_events (rag-apis första audit-tabell, BFF:s form) samt nullbar documents.expires_at. PATCH slår upp knowledge_superseded i policy-engines retentionsmatris (app/clients/retention.py, cache 300 s) och fryser den ersatta versionens datum (D-94); ingen rad ger ingen TTL, oåtkomlig matris ger 503 retention_policy_unavailable. Dygnsronden python -m app.retention_sweep (CronJob retentionSweep, 03:37) raderar ersatta versioner vars datum passerat; archive_hold eller oåtkomlig matris bevarar allt (D-57), det senare med exit 1. NetworkPolicy selekterar på app: rag-api ensam så att sweep-podden får samma utgående trafik. Chart version 0.3.0 → 0.4.0, appVersion 1.2.0 → 1.3.0.)

Verifierad/uppdaterad 2026-10-06 (#562, filvägen steg 5 — dokument kan bära sitt filursprung. Migration 0002_documents_source_metadata: fyra additiva, nullbara kolumner på documents (source_format, source_filename, source_sha256, extractor_version); befintliga rader berörs inte. POST …/documents tar DocumentCreate (DocumentDraft plus de fyra fälten, alla eller inget, source_format i pdf|docx|xlsx|pptx); PATCH tar dem inte, och båda raderna i en PATCH behåller ursprunget. DocumentOut visar fälten. Rag-api tolkar inga filer och får ingen ny trafik: BFF:s POST /api/rag/v1/documents/file extraherar och anropar den befintliga POST-routen med användarens JWT. Audit kom i steg 6. Chart version 0.2.4 → 0.3.0, appVersion 1.1.4 → 1.2.0.)

Verifierad/uppdaterad 2026-10-04 (#499, D-168 — uvicorns egen access-logg är avstängd (--no-access-log i Dockerfilens CMD). Den skrev query-strängen i klartext, utanför JSON-loggen och utan trace_id. Anropen loggas som förut path-only av RequestLoggingMiddleware. Låst för hela flottan av tools/checks/check_uvicorn_access_log.py. appVersion 1.1.3 → 1.1.4.)

Verifierad/uppdaterad 2026-10-07 (P43, #596, D-629 — UserContext.user_id_hash i POST /v1/collections/{id}/query har fått en description. Värdet är mcp-gatewayens aktörsvärde (D-113), inte auth.users.id. Fältmängden är oförändrad, och openapi.json är regenererad. appVersion 1.4.0 → 1.4.1.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 2, B6 — frågevägen skickar klass. run_query sätter X-Knowledge-Information-Class på frågans inbäddning till samlingens högsta klass (oklassat och tom samling räknas som rod), aldrig lägre än den frågande assistentens. Det gäller både POST /v1/collections/{id}/query och MCP-verktyget knowledge.search. Tidigare skickades ingen klass, och efter K10 nekar policy en inbäddning utan klass. Ett nekande ger som förut degrade: embedding_unavailable med de lexikala träffarna kvar. collection_max_class_rank flyttad från mcp_server.py till services/derived.py. appVersion 1.3.0 → 1.4.0.)

Verifierad/uppdaterad 2026-09-28 (D-156 — eval-api är en ny anropare av /query. Chartens ingress tar emot eval-api på 8080. Ytan var redan dokumenterad för “tester/eval” och kräver bara en tjänsteidentitet. Ingen kodändring, imagen är oförändrad. Chart 0.2.3 → 0.2.4.)

Verifierad/uppdaterad 2026-08-17 (D-100 — ingen kodändring, ingen ytändring: basimagen uppgraderas i runtime-steget (apt-get upgrade -y) så att en Debian-CVE (CVE-2026-53615, util-linux) inte blockerar utrullning.)

  • Syfte: tenant-lokal kunskaps-store (RAG) — dokumentauthoring, en ingest-datapath (skanna→besluta→chunka→embedda→indexera), en intern retrieval-yta och sedan fas R2 en extern MCP-yta. Varje deployment äger sin egen pgvector-databas och är låst till en TENANT_ID (app/main.py modul-docstring; D-9/D-39). memory-api-stubben (ovan) förblir orörd.
  • Postgres: tenant-lokal pgvector-DB (RAG_PG_URL, mcp-gateway-mönstret — starkaste isoleringen för kunddokument), 4 tabeller: collections, documents (append-only via superseded_by — PATCH uppdaterar in place och arkiverar ögonblicksbilden, dokument-id är stabilt över versioner, K-1-granskningsfyndet), chunks (delat authz-predikat i alla frågor, se Sömmar — sedan fas R2 chunkas även full_prompt-collections, men UTAN embedding, se ingest-noten nedan), ingest_jobs (outbox + durable dead-letter), samt sedan filvägen steg 6 schemat audit (chain_heads, audit_events, kedjan knowledge_events.v1) och documents.expires_at (bara ersatta versioner, NULL = ingen TTL). Alembic default-namn alembic_version (standardschema public, ingen egen version_table_schema — alembic/env.py), 3 migrationer (0001_initial.py, oförändrad i R2; 0002_documents_source_metadata.py, filvägen steg 5; 0003_audit_and_superseded_expiry.py, filvägen steg 6).
  • HTTP-yta — authoring (rag:read/rag:write + tenant-match, D-39): GET/POST /v1/collections, GET/PATCH /v1/collections/{id} (app/routes/collections.py); GET/POST /v1/collections/{id}/documents, GET/PATCH/DELETE .../{doc_id} (DELETE hård radering av hela kedjan med audit, filvägen steg 6), .../versions, .../sections/{anchor}/verify, .../ingest-status (app/routes/documents.py) — svarsformerna är byte-exakta mot ops-ui:s Kunskap-mock (§6.2, produkt-ui-spec; verified_at date-only per D-39-vägvalet — fältet är null tills verify-endpointen anropats, section_anchor translittererad slug). Inga separata ingest-endpoints — ingest-jobb skapas som en outbox-rad vid dokument-create/patch. Intern query-yta, service-only: POST /v1/collections/{id}/query (app/routes/query.py, require_service() — aldrig användarscope; anropas av mcp-gatewayen (nu på riktigt, se nedan) samt tester/eval). QueryRequest bär sedan R2 inte längre assistant_information_class — klassen härleds server-side ur assistant-recorden (se Sömmar). Health: /healthz, /readyz, ingen auth.
  • HTTP-yta — MCP (fas R2, ny, D-40, spec §6.3; preview-läget fas R3, ny, D-41, spec V2a): /mcp — FastMCP streamable-http (stateless_http=True, json_response=True), monterad av app/mcp_server.py::mount_mcp i den befintliga ASGI-appen. Gate: statisk bearer MCP_BEARER_TOKEN — osatt ⇒ rutten monteras aldrig (fail-closed by omission, inte 401); fel token ⇒ 401 invalid_token + no-store. Tre verktyg: knowledge.search, knowledge.get_section, knowledge.get_full, vart och ett med ett obligatoriskt platform_context {assistant_id, user_context, trace_id}-argument (extra="forbid" — caller kan inte lägga till egna fält). retrieval_mode_mismatch är en SYMMETRISK invariant över alla tre verktygen, inte en get_section-specifik spärr (rättelse mot tidigare atlas-formulering — granskningsfynd task 3 i R2, bekräftat i R3-atlaspasset): search kräver retrieval_mode == "retrieval", get_section nekar full_prompt-collections (kräver samma "retrieval"-läge som search), get_full kräver omvänt retrieval_mode == "full_prompt" — vart och ett av de tre verktygen är låst till exakt ett av kollektionens två lägen, ingen retrieval_mode-kombination kan hämta innehåll via fel verktyg. get_full läser chunkar i document_id, seq-ordning (app/repositories/search_repo.py::fetch_collection_chunks). Preview-läget (fas R3, D-41): PlatformContext fick assistant_id: UUID | None (tidigare obligatoriskt) + nytt fält preview: {include_drafts: bool} | None, med en model_validator som kräver exakt ett av de två (XOR, fail-closed 422 annars) — endast mcp-gatewayens admin-preview-endpoint konstruerar preview-läget (se mcp-gateway-sektionen), verktygen själva vet inte vem som anropar dem. I preview-läge: _authorize sätter klasstaket till kollektionens EGNA effektiva maxklass (bindningskravet hoppas — previewn är collection-scopad, före/utan bindning), include_drafts styr synligheten (annars endast publicerat), och knowledge.search/get_full returnerar även expired_amounts (_preview_expired_amounts — beloppsblock med passerat giltig-till bland de SYNLIGA aktuella dokumenten, samma synlighetsregler som chunk-predikatet).
  • HTTP-yta — GDPR (fas R3, ny, D-41, spec V4a): app/routes/gdpr.py, GET /gdpr/users/{user_id_hash}/authoring/export (BFF-/policy-engine-GDPR-mönstret: require_tenant_admin(), tenant-match ur TENANT_ID, Cache-Control: no-store) — listar dokument (och arkivversioner, nycklat på det STABILA dokument-id:t) där subjektet är updated_by och/eller classified_by, metadata-only (document_id, collection_id, title, roles[], version, archived, updated_at — aldrig content_markdown). Ingen erase-endpoint: _actor skriver numera ALLTID str(ctx.sub) (auth.users-UUID:t — name-claim-läsningen borttagen, D-31 p2 verkställt by construction i app/routes/documents.py::_actor), så pseudonymen dör med auth.users-raden när subjektet raderas i auth-api (usage_events-prejudikatet, _COVERED utan radering här). Befintliga dev-dokumentrader kan fortsatt bära klartextnamn skrivna före denna ändring — ingen produktionsdata, ingen migration (runbook-notis, se README).
  • knowledge_published-triggerkroken (fas R3, ny, D-41, spec V3a): app/services/ingest.py::_notify_eval_after_index — anropas EFTER indexed-commit (aldrig i transaktionen, ingest-durabiliteten får inte bero på eval-apis tillgänglighet), bara för doc_status == "published": slår upp bundna assistenter via PolicyAssistantReader.list_bound() (GET /v1/assistants?knowledge_scope_contains=<collection_id> mot policy-engine) och POST:ar {trigger: "knowledge_published"} till /v1/assistants/{id}/runs per assistent via ny app/clients/eval.py::EvalTriggerClient. Klienten mintar/cachar en service-JWT via auth-apis POST /v1/token/client (client-credentials, Basic client_id:secret, cache till exp - 60s) — fyra env-nycklar krävs ALLA (EVAL_API_URL/AUTH_API_URL/EVAL_CLIENT_ID/EVAL_CLIENT_SECRET, app/main.py); saknas någon är triggern helt avstängd (loggad warning per publicering, aldrig en grind — grinden är redan fail-closed vid aktivering, D-19). Felsemantik: transportfel/nätfel ⇒ warning; 401 (auth-drift) ⇒ error + token-cache nollställd (operatörslarm, D-36-symmetrin); 409 run_in_progress/404 (assistent utan eval-set) ⇒ tolererade, loggade info. Ingen ny tabell.
  • Gallring av ersatta versioner (filvägen steg 6, spec B5.2): CronJobbet retention-sweep kör python -m app.retention_sweep dagligen. Längden fryses vid PATCH ur matrisens knowledge_superseded (D-94); bromsen (archive_hold, oåtkomlig matris) läses live av ronden. Utfall: retention_sweep_done (exit 0), retention_sweep_degraded (matris oåtkomlig, inget raderat, exit 1), retention_sweep_failed (exit 1). Versioner som ersattes före migration 0003 har expires_at NULL och gallras inte; de försvinner bara med DELETE.
  • Anropare: BFF:s dispatch (upstream_map["rag"] via UPSTREAM_RAG_URL, se bff-sektionen) proxar /api/rag/... för authoring — kopplat i e2e-stacken, produktion är fortsatt Fabians bord. mcp-gateway anropar rag-api nu på riktigt (fas R2) — MCP-transport http mot <rag-api>/mcp, registrerad via gatewayens befintliga admin-API (/v1/admin/mcp-servers, ingen ny gateway-kod för registreringen); §6.3-exponeringens per-kollektion-ACL är fortsatt en uttalad avgränsning (D-39 p4). auth-api anropar rag-api (sedan fas R3, D-41) för SAR-exportens authoring-metadata (se auth-api-sektionen, femte all-or-none-URL:en).
  • Nedströms: pii-api (app/clients/pii.py::RagPiiClient.analyze() — ingest-scanning, samma kontrakt som gatewayens ingress), policy-engine — sedan filvägen steg 6 även GET /v1/admin/retention (app/clients/retention.py, POLICY_ENGINE_TOKEN + X-Tenant-Id, samma kontrakt som BFF:s klient), dels app/clients/policy.py::RagPolicyClient.decide_knowledge_ingest() (action="knowledge_ingest", se policy-engine-sektionen), dels sedan fas R2 app/clients/assistant_reader.py::PolicyAssistantReader.fetch() (GET /v1/assistants/{id}, läsning för retrieval-klasshärledningen — kräver X-Tenant-Id-headern mot policy-engines legacy-tenant-auth, en R2-fasstartsbugg fixad TDD i task 14, commit f233787), dels sedan fas R3 PolicyAssistantReader.list_bound() (GET /v1/assistants?knowledge_scope_contains=, eval-triggerns omvända bindningsuppslagning), llm-gateway (app/clients/embeddings.py::EmbeddingsClient.embed_batch(), POST /v1/embeddings med en egen dgt--nyckel, LLM_GATEWAY_KEY — se llm-gateway-sektionen), auth-api (app/clients/eval.py::EvalTriggerClient, POST /v1/token/client — client-credentials, se ovan).
  • Observability: JSON-logg + OTel FastAPIInstrumentor + HTTPXClientInstrumentor (app/main.py) — rag-api föds instrumenterad (se tvärsnittets #T5-räkning, 8/9). (sedan D-52: trace/logg/felkuvert via libs/observability — det egna {detail}-only-kuvertet migrerat till plattformens {error,detail,trace_id}, logging_setup.py retirerad)
  • Sömmar: delat authz-predikat verkställt av arkitekturtester (tests/unit/test_architecture.py — varje FROM chunks-fråga i search_repo.py måste använda samma predikat; Chunk-ORM-referenser är allowlistade till specifika moduler); outbox/dead-letter i ingest_jobs, atomiskt claimat (UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING — K-2-granskningsfyndet, undviker dubbelkörning mellan repliker), bakgrundsarbetaren styrs av INGEST_WORKER_ENABLED (default true, app/config.py); chunker-cappen är en uttalad teckenproxy (default 1000 tecken ≈ 285–400 XLM-R-tokens för svenska, CHUNK_CHAR_CAP, app/domain/chunker.py) — ett avsteg från D-9 §2.7:s tokenizer-exakta bokstav, dokumenterat i koden; golden-set-eval-grinden (tests/eval/, CI-tröskel Recall@5≥0.8/MRR≥0.6, uppmätt 1.0/0.95) körs i CI (.github/workflows/rag-api.yml, pgvector-service) och fick sedan fas R3 (D-41, V5) två nya mått med kalibrerade trösklar i samma grind: ndcg_at_5 (binär relevans ur qrels, log2-diskontering; uppmätt 0.978 ⇒ tröskel 0.87) och citation_precision_at_5 (andel av topp-5 vars (dokument, ankare) matchar facit — mäter citatkedjans träffsäkerhet, inte bara rätt dokument; uppmätt 0.200 ⇒ tröskel 0.16), plus ett per-query-assert (döljer inte att enskilda frågor kan missa helt bakom ett medelvärde). Riktig-modell-eval är opt-in, aldrig CI-grind: pytest-markören eval_live (kräver BERGET_API_KEY + nät) körs manuellt enligt README-runboken (D-19-principen — grinden får inte vila på en extern betaltjänsts tillgänglighet). Klasshärledning + bindning (fas R2, D-40): retrieval-klassen härleds i app/services/assistant_authz.py ur assistant-recorden (PolicyAssistantReader, ocachad) i stället för att tas emot från anroparen — assistant_information_class är RADERAT ur QueryRequest, invarianten hålls by construction; samma modul verkställer bindningen (CollectionNotBoundError/collection_not_bound när collection_id ∉ record.knowledge_scope). full_prompt-ingest-ändringen (R2-fasfynd): R1 lagrade ingen dispositionerad fulltext för full_prompt-collections (text_for_index slängdes) — app/services/ingest.py chunkar nu full_prompt UTAN embedding (embedding=None); redan ingestade full_prompt-dokument saknar chunkar och kräver re-ingest (runbook-rad, se README).

libs — delade paket (PEP 420-namespace, ingen libs/__init__.py)

Verifierad/uppdaterad 2026-10-08 (#658, D-673 — libs/model_registry: profilfältet pii_masking (Profile, standard true) ingår i ProfileSet.policy_data() och därmed i data.profiles och profilhashen. false stänger av ingressens PII-maskering för klassen i pii_gate.rego.)

Verifierad/uppdaterad 2026-10-08 (#683, krav C6, D-684 — libs/observability håller innehåll ute ur loggen. RequestLoggingMiddleware och det ohanterade felets åtkomstrad i errors.py loggar route (metrics.route_template: mallen, __unnamed__ eller __unmatched__) i stället för path. Nytt ExceptionTypeOnlyFilter: byter en posts undantag mot exc_type och exc_frames (fil:rad:funktion), utan meddelande och utan kedjade orsaker; det sitter på stdout-handlern i configure_logging och på OTLP-handlern i configure_otlp_export. Nytt contain_library_loggers: httpx och httpcore på WARNING, uvicorn och uvicorn.error utan egna handlers och med propagering till roten. Båda exporteras och används också av llm-gateway, pii-api och feedback-api. Alla tretton tjänsters appVersion bumpas så att ändringen rullas ut. Låst av tests/unit/test_log_content.py och tools/checks/check_log_content.py.)

Verifierad/uppdaterad 2026-10-07 (P43, #607 — libs/audit_chain äger aktörsvärdet. libs.audit_chain.actor har actor_value(sub) = sha256(canonical_json("platform:" + sub)), platform_subject och PLATFORM_SUBJECT_PREFIX. De flyttade från mcp-gatewayens app/services/hashing.py, som re-exporterar dem. Formeln är oförändrad och låst mot ett känt värde i tests/unit/test_actor.py. Anropare: mcp-gateway (båda dörrarna, D-113) och auth-api:s registerutdrag, som frågar policy_decisions med både auth.users.id och aktörsvärdet.)

Verifierad/uppdaterad 2026-10-06 (D-171, K10 session 2 — libs/model_registry: ProfileSet.allowed(information_class, primitive) ger de modeller en primitiv får använda för en klass, tom mängd när klassen saknar profil eller primitiven är avstängd. Läses av policy-engines skrivvalidering och torrkörning.)

Verifierad/uppdaterad 2026-10-06 (D-172 B4, #544 steg 3 — libs/policy_client speglar den nya actionen file_extract. FILE_EXTRACT_ACTION och FileCtx {format, size_bytes} (extra="forbid", bara metadata: inga byte, filnamn eller hash) finns i schemas.py och i den publika ytan, och DecideRequest.file är additivt. Vilka format som är öppna avgör policy-enginens rego, inte klienten. Ingen anropare än; BFF:s routes kommer i steg 4. Nya schematester.)

Verifierad/uppdaterad 2026-10-05 (D-171, K10 session 1 — libs/model_registry bär styrfälten och modellprofilen. ModelEntry får type, host, country och version, och provider och max_information_class flyttas från ModelDisplay till toppnivån. residency_data() ersätts av policy_data(), som ger OPA hela styrslicen men aldrig display. Ny modul profiles.py: load_profiles/ProfileSet validerar profiles.yaml mot registret fail-fast och ger data.profiles och +prof-<hash>. content_hash() hashar hela posten och flyttas därför med de nya fälten. Konsumenter: bff, llm-gateway och policy-engine, var och en stämplad ovan.)

Verifierad/uppdaterad 2026-10-04 (D-170 — libs/policy_client speglar det additiva fältet OutputCheckSignals.retrieval_score (float | None, 0–1), så att llm-gateway kan skicka retrievalpoängen till avståendetröskeln. Tröskeln själv läser policy-engine ur assistentens record. None utelämnas och ger dagens beteende.)

Verifierad/uppdaterad 2026-09-28 (D-154 — libs/policy_client speglar det additiva fältet DecideRequest.purpose (Literal["eval"] | None). llm-gateway sätter det ur nyckelns syfte och eval-api i sin förkontroll. Klienten skickar med exclude_none, så vanlig trafik ser ut som förut, även mot en äldre policy-engine. Tre nya schematester.)

Verifierad/uppdaterad 2026-09-27 (D-149 — libs/policy_client speglar det additiva fältet OutputCheckSignals.answer_kind (schemas.py, Literal["tool_calls"] | None), så att llm-gateway kan tala om för källtvånget att en completion bara bär verktygsanrop. extra="forbid" på klientsidan gör att fältet MÅSTE finnas här för att gatewayen ska kunna skicka det. Additivt; None utelämnas och ger dagens beteende.)

Verifierad/uppdaterad 2026-08-29 (D-130 — libs/platform_auth bär nu formkontrollen av act i VERIFIERAREN, inte i middlewaren. JWTVerifier.verify() kör _validate_act på varje token den släpper igenom, så kontrollen ligger i det lager varje väg passerar per definition. Fram till nu bodde den i build_auth_dependencys _parse_act, och fem produktionsvägar som verifierar direkt mot JWTVerifier såg den aldrig — ett malformat act ignorerades tyst där, vilket gör ett agentanrop till ett människoanrop hos konsumenten. Två nya felklasser, InvalidActError och NestedActError, båda subklasser av InvalidTokenError — och det är mekanismen, inte en detalj: de fem fångar redan basklassen, så de får rejektionen utan att någon rör dem, och en sjätte väg får den utan att någon minns att den ska. build_auth_dependency fångar subklasserna FÖRE den breda grenen och behåller sluggarna malformed_act och nested_act_unsupported, så de tio konsumenternas felkoder är oförändrade (låst av lib-sviten). _parse_act är kvar men validerar inte längre — den läser bara ut värdet, eftersom två kontroller på två ställen är samma klass av fel i ny skepnad. Den vakt P2 bad om byggdes INTE, och skälet står i D-130: flytten gör den överflödig. Ingen publik yta borttagen, inga anropare tvingas ändra.).

Stämpelhistorik — 9 tidigare verifieringar
  • Föregående 2026-08-27 (D-117 — libs/policy_client speglar det additiva fältet DecideResponse.information_class (schemas.py) — samma spegling som retention_policy fick i D-115 och av samma skäl: klienten är den yta llm-gateway läser decide-svaret genom, så ett fält som inte finns här finns inte för gatewayen. Additivt, ingen anropare tvingas ändra).
  • Föregående 2026-08-26 (D-115 — libs/policy_client speglar det additiva fältet DecideResponse.retention_policy (schemas.py), så att llm-gateways innehållsspår kan läsa assistentens gallringspolicy ur policybeslutet i stället för ur assistants-tabellen. Rent additivt, extra="ignore" gör en äldre engine utan fältet till None — vilket läses som “inte opt-in”, aldrig som opt-in.).
  • Föregående 2026-08-25 (D-109, P27 — libs/observability gör nu MÄTNING, inte bara loggning och spårning. Namnet bar två av tre saker och lurade både oss och Plattformen; utgångsläget mätt mot customer-prod 2026-08-16 var att nio av elva tjänster svarade 404 på /metrics och att http_requests_total inte fanns någonstans i flottan. Fyra tillägg: ett femte beroende (prometheus-client>=0.21 — samma golv lifecycle-api och mcp-gateway redan deklarerade var för sig, alltså ingen ny artefakt i supply-chainen), en ny modul metrics.py, och två namn i den publika ytan: install_metrics(app, service_name) och observe_request(request, service_name, status_code). install_observability foldar in mätningen, vilket är en beteendeändring i ÅTTA tjänster ur en fil — de får GET /metrics och http_requests_total{service,method,route,status} utan egen rad, och det är hela skälet att paketet var en session och inte elva. Två inspelningsställen, av en uppmätt orsak: Starlettes ServerErrorMiddleware ligger ytterst, utanför allt add_middleware lägger till, så ett verkligt ohanterat 5xx passerar ALDRIG MetricsMiddleware (probe 2026-08-25: middlewaren registrerade ingenting för ett svar klienten fick som 500). Middlewaren räknar därför allt som returneras genom stacken, och make_unhandled_exception_handler räknar bypass-vägen med den statuskod svaret faktiskt bär — 503 vid DB-otillgänglighet, inte 500. Middlewaren räknar MEDVETET inte på sin undantagsgren: det hade dubbelräknat och tvingat den att gissa statuskoden. Samma delning biblioteket redan gör för åtkomstloggen (D-47:s 5xx-bypass-fix). Vägetiketten är den matchade vägmallen, aldrig den råa sökvägen (scope['route'].path, verifierat mot starlette 1.3.1). TRE utfall, inte två: mallen, __unnamed__ för en rå starlette.routing.Route/Mount som matchade men inte kan namnge sig — MCP-dörren i mcp-gateway och rag-api är sådana, och att slå ihop dem med 404:orna hade lagt hela verktygsytan i skanningshinken (fynd ur arkitekturgranskningen) — och __unmatched__ för äkta 404. Aldrig sökvägen. method gardas likadant: en metod utanför de nio standardmetoderna blir __other__. Sex tjänsters egna sektioner är INTE omstämplade trots att de fick en ny publik rutt (auth-api, bff, eval-api, policy-engine, provisioning-api, rag-api) — D-108 stämplar libbet i stället, och gränsen står i vaktens docstring. /metrics blir publikt nåbart på bff (catch-all-ingress); öppet vägval, D-109 Kvarstår 4. Rutten är include_in_schema=False och avsiktligt OAUTENTISERAD (issue #216, “Rör inte: Auth”). Inte byggt: http_request_duration_seconds (i #216:s tabell men inte i P27:s Klart-när — histogrammet multiplicerar serieantalet och felkvotslarmet hänger på räknaren) och multiprocess-registry (en uvicorn-process per pod, Prometheus skrapar per pod). Se egen bullet nedan.)
  • Föregående Verifierad/uppdaterad 2026-08-18 (D-102 — policy_clients kontraktskopia KNOWLEDGE_ACTIONS (libs/policy_client/libs/policy_client/schemas.py) rymmer nu "rerank" — rerank är kunskaps-scopat: knowledge_ctx {collection_id, information_class} krävs i stället för assistant_id, men collection_id är enbart beslutets scope-etikett, inte en pekare biblioteket eller policy-enginen slår upp innehåll genom. Ingen ändring i klientens felhantering eller timeout-semantik. Se egen bullet nedan.).
  • Föregående 2026-08-18 (D-101 — platform_auth bär RFC 8693 act, och det ändrar ALLA ~39 anropares beteende på en gång. Tre tillägg i den publika ytan: AuthContext.act (RFC 8693 §4.1 act.sub, en platt sträng — inte dicten ur tokenet, så en konsument kan aldrig läsa fel nivå), predikatet AuthContext.is_delegated() (act is not None; ingen produktionskonsument i dag — den är P2:s avsedda ingång, samma läge som syskonen is_user()/is_legacy() redan har), och två nya plattformsbreda 401-slugs ur middlewarens _parse_act: malformed_act (claimen är inte ett objekt, eller sub saknas/är inte en icke-tom sträng) och nested_act_unsupported (act inuti act — delegeringskedjor bärs inte, eftersom en kedja låter ett led tvätta bort sitt eget spår). Följden är räckvidden, inte fältet: varje tjänst som bygger sin auth via build_auth_dependency avvisar från och med nu ett malformat act med 401 i stället för att ignorera claimen — fail-closed, och en beteendeändring i elva tjänster ur EN fil. Formkontrollen ges bara till den vägen: fem produktionsvägar verifierar med JWTVerifier.verify() direkt och ser den aldrig (D-101 Kvarstår 6, köpost P22); mcp-gatewayens MCP-transport gör därför sin egen, avsiktligt duplicerade kontroll i app/frontend/caller.py. Docstringen på AuthContext.act säger ut vilken av de två vägarna garantin gäller, så att en ny konsument inte antar den gratis. Ingen ändring i JWTVerifiers signaturkontroll, inga nya beroenden.).
  • Föregående 2026-07-29 (D-71 — platform_auths JWTVerifier fick ett valfritt expected_audience: utan deklarerat namn avvisas aud-bärande tokens fortsatt, nu AVSIKTLIGT i stället för av en bugg (allowed_audiences var död sedan den byggdes — /v1/token/client utfärdade redan aud, och verifieraren avvisade varje token som bar det). Ingen tjänst sätter parametern än: build_auth_dependency/AuthConfig — den enda ingång tjänsterna använder — har inget fält att bära den i, så rördragningen kvarstår som eget pass, se D-71 Kvarstår 3.).
  • Föregående 2026-07-26 (D-67, kontraktskärnan paket 1–4 — två nya paket: policy_client (EN decide-klient, ersätter sex) och tenant_export (EN kuverttyp, ersätter sex deklarationer, provisioning-apis ExportCoverage medvetet undantagen); platform_auth fick en uttalad publik yta och tre konsumenters lokala auth-forkar togs bort).
  • Föregående 2026-07-25 (paket 7 — CI:s lib-fan-out ersatt av uv sync från workspace-roten).
  • Föregående 2026-07-24 (Wave 3 Post 4, D-59 — libs-sektionen tillagd (kartläggningens D-30-fynd: atlasen saknade den); libs/observability fick app-sidans OTLP-export).

Hur libben når konsumenterna (2026-07-25): varje tjänst deklarerar sina libb med paketnamn (stacken-platform-auth m.fl.) i sin pyproject.toml; rotens [tool.uv.sources] binder namnen till workspacet. CI kör uv sync --frozen --extra dev i medlemskatalogen och uv run --frozen --no-sync <kommando> per steg — beroendena läses ur rotens uv.lock, aldrig ur en uppräkning i workflowen. En ny lib-dep kräver därför noll workflow-ändringar. Ratchet: pre-commit-hooken no-workflow-lib-fanout fäller om ett pip install -e återinförs i en workflow. Repo-sviten tests/e2e och load-gatens tests/load/k6/setup_site.py (inga egna paket) synkar rotens e2e-extra. tests/load/test_global_rate_limit_proof.py ligger medvetet utanför — den kräver testcontainers, vars inlyftning i extran nedgraderar paketet globalt; testet körs manuellt (D-62).

Lokal fälla (inte CI): tio av elva tjänster installerar ett toppnivåpaket som heter app, och uv-workspacet har ett delat .venv i repo-roten. uv sync (exakt) installerar rätt app och rensar de andra; uv run (inexakt) rensar inte en kvarliggande app från en tidigare synkad tjänst. Kör därför alltid uv sync --frozen --extra dev i tjänstens katalog innan dess svit — annars importeras fel tjänsts app tyst. CI är opåverkad (ett jobb per tjänst, ren runner).

  • platform_auth (mest anropad, ~39 anropare): JWT-verifiering (JWKSClient, JWTVerifier), scope-/tenant-auth (require_service_token, D-24-scope-bypass), typade auth-fel. Auth-ryggraden i varje tjänst. Sedan D-67: uttalad publik yta (__all__: extract_bearer, require_role, require_scope, build_auth_dependency, injicerbar AuthErrorFactory för tjänstespecifika felkuvert) vaktad av tools/checks/check_lib_public_surface.py. Tre lokala auth-forkar togs bort mot den publika ytan: eval-api och lifecycle-api (motiverade sig tidigare med ett felkuvert install_observability redan gav identiskt — borttagningen lagade en latent 500 i lifecycle-api, icke-sträng scope-claim → AttributeError) och mcp-gateway (app/deps/auth.py, som fortsatt är en EGEN, fjärde fork som sväljer libbets 503 till 401, se mcp-gateway-sektionen). Inget bakåtkompatibelt _extract_bearer-alias behölls — symbolen importerades av exakt två filer, båda raderade i samma pass. Sedan D-101: AuthContext bär act (RFC 8693 §4.1 act.sub, platt sträng) och predikatet is_delegated(); middlewarens _parse_act avvisar ett malformat claim med 401 malformed_act och en delegeringskedja med 401 nested_act_unsupported — två slugs som gäller i varje tjänst som bygger sin auth via build_auth_dependency, alltså libbets största beteendeändring per rad kod. Saknat act är fortsatt giltigt: paketet BÄR delegeringen, det kräver den inte (P2 upprätthåller). Formkontrollen följer med vägen, inte tokenet — de fem tjänsteställen som verifierar med JWTVerifier.verify() direkt får den aldrig (Kvarstår 6, köpost P22).
  • policy_client (ny, D-67, 5 anropare: bff, eval-api, mcp-gateway ×2, rag-api, llm-gateway): PolicyDecideClient — EN decide-klient mot policy-engines POST /v1/decide, ersätter sex tidigare implementationer som var oense om timeout (100 ms–10 000 ms), returtyp och felhantering. timeout_ms obligatoriskt (inget biblioteksdefault), inga retries, ingen circuit breaker. Fail-closed-feltaxonomi: PolicyUnavailable (onåbar/timeout/5xx/malformed → 503 hos anroparen), PolicyRejected (ärver MEDVETET PolicyUnavailable — en 4xx som inte är 401 stannar fail-closed även om anroparen inte skiljer på dem), PolicyAuthError (401, ärver INTE PolicyUnavailable — operatörslarm). _safe_body stripper 422-svarets input-fält (samma PII-fälla D-46 fann i feedback-api) — bara error/detail-STRÄNGAR passerar. Deny är alltid ett returvärde, aldrig ett undantag. Sjunde ytan som medvetet INTE adopterar libbet: BFF:ns app/routes/decide.py (ops-ui:s DecideTest-probe) måste passera policy-enginens uppströms-statuskoder verbatim, vilket libbets exception-översättning skulle förstöra — bygger sin request som en handskriven dict, ingen lokal schemavalidering (D-67). Sedan D-102 (P11): KNOWLEDGE_ACTIONS rymmer "rerank" — llm-gateways rerank-anrop skickar knowledge_ctx {collection_id, information_class} i stället för assistant_id, samma väg som embeddings redan tar; collection_id är beslutets scope-etikett, aldrig en pekare biblioteket följer för att hämta innehåll.
  • tenant_export (ny, D-67, enbart pydantic-modeller — ingen logik): TenantExport/TenantExportCoverage/CoverageGap, ersätter tre typade kuvertkopior (policy-engine, feedback-api, auth-api) och två råa dict-kuvert (bff, llm-gateway). Adopterad av alla fem producenter + provisioning-apis export-coordinator. response_model=TenantExport på bff/llm-gateway är deklarativt, INTE verkställande — båda returnerar JSONResponse/Response för Cache-Control: no-store, och FastAPI hoppar över response-model-validering för ett redan-Response-värde; den verkliga körtidsgrinden är ett kontraktstest per producent (TenantExport.model_validate mot svaret, mutationsbevisat i llm-gateway). Coverage-/trunkeringsreglerna (limit+1, covered→not_covered-flytt, identiskt store-namn, statisk lucka) är dokumenterade EN gång i docs/tenant-export-coverage.md i stället för i fem module-docstrings; namnrymden för covered (fyra olika namn på fyra WORM-audit-tabeller) är en öppen, dokumenterad skuld. Provisioning-apis ExportCoverage slås MEDVETET inte ihop hit — den läses även mot historiska JSONB-rader och måste vara tolerant (extra="ignore"), medan producentmodellen måste vara strikt (extra="forbid", fångar drift); två krav, två modeller.
  • observability (~17 anropare, 8 via install_observability + 3 via install_metrics — mätningen har noll undantag i flottan sedan D-109): configure_logging (enradig JSON→stdout), TraceIdMiddleware (W3C traceparent → stabilt trace_id), typat felkuvert + no-store + 422-PII-strip (errors.py), install_observability(app, service_name) enkelanrop (D-52, ersatte kopiera-per-tjänst D-47). Sedan D-59: configure_otlp_export — pluggbar OTLP-export (trace + log) bakom OTEL_EXPORTER_OTLP_ENDPOINT, default-av + fail-open, foldat i install_observability. Sedan D-109 (P27): metrics.py — http_requests_total{service,method,route,status}, MetricsMiddleware och GET /metrics (include_in_schema=False, oautentiserad), plus install_metrics och observe_request i den publika ytan; foldad i install_observability. Deps: fastapi + sqlalchemy + (D-59) opentelemetry-sdk + opentelemetry-exporter-otlp-proto-http + (D-109) prometheus-client. Undantag: feedback-api/pii-api egen structlog-variant, llm-gateway eget felkuvert och ingen app-OTel (providergräns, D-52) — men alla tre anropar install_metrics direkt sedan D-109, och llm-gateway bär dessutom observe_request i sin egen on_unhandled, eftersom en middleware inte kan se bypass-vägens 5xx.
  • audit_chain (~12 anropare): ChainWriter — hash-kedjad WORM-rad i audit.audit_events/public.audit_events i samma transaktion som händelsen (kedjor policy_decisions.v1, auth_events.v1, conversations.v1, assistant_events.v1, provisioning_events.v1, mcp_tool_calls.v1).
  • model_registry (~7 anropare, boot-eager i llm-gateway/bff/policy-engine): ModelEntry/ModelAiAct/ModelDisplay (frozen, extra=forbid), policy_data() (OPA data.models-slicen, sedan K10 med type, enabled, max_information_class, host, country, provider, version och training_excluded), content_hash() (→ policies_version +reg-<hash>). D-43. load_profiles/ProfileSet (D-171, K10) validerar profiles.yaml mot registret fail-fast och ger data.profiles och +prof-<hash>.
  • Tidigare tomma platshållare — RADERADE 2026-07-25 (paket 7): api-contracts, dgt-auth, telemetry fanns bara som .gitkeep-kataloger och såg ut som komponenter utan att vara det. Borttagna ur trädet och ur uv-workspacets exclude-lista (globarna libs/*/services/* matchar inte längre något som inte finns). Ett framtida libb börjar med en pyproject.toml och blir workspace-medlem direkt.

frontends/

Verifierad 2026-07-11 (fas L); deployrättelsen 2026-07-12 (fas P1 atlas-diff, D-35); Skydd-ytan live + pii_verdict-notis 2026-07-12 (fas P2, D-36); chat-ui arbetsläget live 2026-07-13 (fas Q, D-37); chat-ui-fixturens verified_at date-only 2026-07-14 (fas R2, D-40 — D-39-vägvalet infriat); ops-ui Kunskap-ytan live 2026-07-14 (fas R3, D-41); ops-ui assistants live 2026-07-16 (fas S1, D-42); ops-ui Modeller-ytan live 2026-07-17 (fas S2, D-43), Model-typen 8→10 fält; ops-ui decide-ytan (DecideTest “Testa beslutet”) live 2026-07-18 (fas S2-ff, D-44) — decide-flippen infriad, inte längre fast-follow; RÄTTAD OCH OMSTÄMPLAD 2026-07-31 (D-79): sektionen påstod en deployväg som var raderad — se deploystycket nedan — och sista stämpel var 2026-07-18, alltså äldre än D-64/D-65/D-66 som alla ändrade frontends verklighet. portal raderad, chat-ui har chart + image.

Uppdaterad 2026-10-08 (#697 — stacken.ai har fyra nya sidor och en europeisk ram. /problemet, /historien, /inforande och /pris har var sin nginx-location enligt mönstret för /sa-fungerar-stacken; startsidan får EU-ramen, “I drift i dag”, berättelsen, fyra fördjupningskort, åtta frågor, Pris i menyn och sidfoten. a11y-marketing.mjs täcker de nya rutterna. Bara statiskt innehåll; inga skript och ingen CSP-ändring. marketing-site appVersion 1.6.2 → 1.7.0. Inte driftsatt.)

Uppdaterad 2026-10-08 (#657, röd zon C2 — chat-ui:s konversationslista visar raderingsdatumet. En tillfällig konversation märks “raderas ” ur expires_at i stället för “tillfällig”, och knappen Tillfällig chatt lovar inte längre 24 timmar. chat-ui appVersion 1.3.0 → 1.3.1.)

Uppdaterad 2026-10-08 (#659, D-675 — ops-ui:s behörighetssteg följer redaktörsgrupperna. Assistant har fått shared_with_org; guiden sätter inte längre rollen user som förval, kräver minst en grupp eller organisationsdelning och märker roller och organisationsdelning som admin-val. Detaljsidan visar delningen. Mockens fixturer bär fältet. charts/ops-ui appVersion 0.1.3 → 0.2.0.)

Uppdaterad 2026-10-08 (#661, K1 röd zon F1 — chat-ui markerar en instans i röd zon på varje vy. config.json får fältet zone (chart config.zone, standard tom). "rod" ger src/shared/zone/ZoneBanner.tsx, en remsa överst i arbetsläget och det publika läget (“Röd zon · Klass 3 · Känslig”, role="note", --klass-rod-*-tokens), oberoende av vald assistent. Zonen följer med också i helmockat läge. Ett okänt värde är fail-closed och ger ett startfel, som ett okänt ytnamn gör. Tillgänglighetsredogörelsen läser ingen konfiguration och har ingen remsa. chart-appVersion 1.2.0 → 1.3.0.)

Uppdaterad 2026-10-08 (#670 — marketing-sitens startsida säger varför: sektionen “Därför” (#darfor) med fyra nyttor, en svarsrad (p.answer) under var och en av de tre frågorna och nyttorubriker i stället för “Regler före modellens svar” och “Spår som går att följa”. Bara statiskt innehåll i public/landing/; inga skript, bilder eller CSP-ändring. Inte driftsatt.)

Uppdaterad 2026-10-08 (P48 O2, #621 — ops-ui visar orsaken när verktygssiffrorna saknas. Bannern Verktygsanvändningen saknas på Förbrukning & budget läser degraded_reasons.tool_usage och skriver orsaken: kontot saknar rollen tenant_admin (forbidden), operatörsnyckeln har ingen identitet att skicka vidare (no_delegated_identity), ingen mcp-gateway är konfigurerad (unconfigured) eller mcp-gateway gick inte att nå (unavailable). Saknas fältet visas den allmänna texten. UsageSummary.degraded_reasons är valfritt i typerna. charts/ops-ui appVersion 0.1.0 → 0.1.1.)

Uppdaterad 2026-10-08 (#639, P50 — docs-site har Dockerfile, nginx.conf och chart; urvalsgrinden och läcktestet körs i image-bygget på samma dist som läggs i imagen. Ej i drift.)

Uppdaterad 2026-10-08 (#649 — stacken.ai:s startsida har illustrationer som följer texten; sju bilder under animation/illustrationer/, två gamla utsnitt borttagna; marketing-site appVersion 1.6.0 → 1.6.1.)

Uppdaterad 2026-10-07 (P78, #624 — ops-ui har en driftväg på /admin under tenantens värd (D-90): frontends/ops-ui/Dockerfile, nginx.conf och charts/ops-ui. Produktionsbygget har inga mockar och ingen mockServiceWorker.js, prövat av src/prodBuild.test.ts mot den byggda artefakten; granskningsbygget med mockar byggs med --mode granskning. En inloggad användare utan adminroll får en nekandesida. BFF:ns postLoginRedirectAllowlist har /admin. Inte driftsatt.)

Uppdaterad 2026-10-07 (P77 Förbrukning & budget, #595 — ops-ui visar förbrukningen per grupp och sätter larmadressen. Förbrukningsfliken anropar llm-gatewayens GET usage/by-group (paths.usageByGroup, ny typ UsageByGroup) och PUT webhook (paths.webhook, setWebhook, ny typ WebhookSetting). Anrop utan grupp står på en egen rad, och en grupptabell som inte svarar visas som en lucka. Mocken avvisar adresser utan https, spärrade värdnamn och IP-literaler med 422; DNS-kontrollen finns bara i gatewayen. Täckningslistan har två rader färre.)

Uppdaterad 2026-10-07 (P7 punkt 5, #628, D-170 — ops-ui visar och redigerar avståendetröskeln per AI-tjänst. Tjänstesidans Körningsinställningar visar runtime.min_retrieval_score (Ingen när null), och wizardens steg LLM-inställningar & skydd har ett fält för den: 0–1, tomt betyder ingen tröskel, värden utanför stoppas före sparande. Fältet går med i PATCH som en del av runtime. Märkningen Föreslaget kontrakt på körningsinställningarna är borttagen, eftersom policy-engines RuntimeModel bär fälten.)

Uppdaterad 2026-10-07 (#613 — stacken.ai:s startsida visar animationen Så fungerar Stacken; apex-CSP:n tillåter skript från samma ursprung; marketing-site appVersion 1.5.0 → 1.6.0.)

Uppdaterad 2026-10-07 (P59, #592, D-177 — ops-ui:s behörighetsfönster märker en grupp-grant mot en omappad grupp med chipet Omappad grupp. McpGrant har fältet group_mapped, och mocken räknar det ur sina gruppmappningar med exakt jämförelse, som mcp-gatewayen gör.)

Uppdaterad 2026-10-07 (P77 steg 1, #595 — ops-ui har en täckningslista för tjänsternas rutter. src/api/coverage.test.ts anropar varje funktion i api och matchar anropen mot bff:s och de proxade tjänsternas openapi.json; varje rutt som inte anropas står i src/api/coverage.ts med skälet. 118 rader, varav 30 är P77-ytor som ännu saknas. Ingen ändring i det ops-ui anropar.)

Uppdaterad 2026-10-07 (P53 svarssidan, #488 — ops-ui:s test-server prövar varje mockat 2xx-svar mot tjänstens svarsschema och fäller testet som utlöste en avvikelse. Fyra mockar rättade: användarlistan och inbjudan bär tenant_id, feedbackraderna tenant_id/client_id/deleted_at, och en tjänst utan beskrivning får description: null som hos policy-engine. Assistant.description är därför nullbar, och guidens beskrivningsfält visar tom text för null.)

Uppdaterad 2026-10-07 (#581 — stacken.ai har undersidan /sa-fungerar-stacken, statisk HTML i public/landing/; marketing-site appVersion 1.4.5 → 1.5.0.)

Uppdaterad 2026-10-06 (#562, filvägen steg 5 — ops-ui:s dokumentredigerare läser in PDF, DOCX, XLSX och PPTX. Nytt dokument har fältet “Läs in från fil”; filen går som råa byte till BFF:s POST /api/rag/v1/documents/file (paths.knowledgeDocumentFile, createKnowledgeDocumentFromFile), blir ett utkast och redigeraren öppnar det. Dokumentvyn visar källan. Felkoderna på svenska i features/knowledge/fileErrors.ts. KnowledgeDocument har fyra nya nullbara källfält. Mocken har BFF:s ordning och felkoder och hör till kunskapsytan, som nu har 13 handlers. check_ops_ui_paths.py prövar bff:s egna rutter under ett proxat prefix mot bff:s openapi.json.)

Uppdaterad 2026-10-06 (P76, D-174 — ops-ui:s sista mockade ytor kan gå live. LIVE_SURFACES får keys, lifecycle och connectors. Nyckelfliken läser llm-gatewayens form (id, label, key_prefix, revoked_at) och visar hemligheten ur key; aktivera/inaktivera är borttaget eftersom gatewayen bara har mjuk återkallelse. Livscykeln följer lifecycle-api: dossierstatus active, lagrum consent, reaktorns uppgiftstyper, stängning kräver skäl, grinden är ActivationCheckResponse. Dossier, uppgift och grind låses mot openapi.json i contract.test.ts.)

Uppdaterad 2026-10-06 (#544, filvägen steg 4, D-172 — chat-ui bifogar PDF, DOCX, XLSX och PPTX. accept utökad; .txt/.md/.csv läses som förut i webbläsaren och går till uploads/scan, övriga skickas som råa byte till POST /api/chat/v1/uploads/file (paths.uploadFile, uploadFile i endpoints.ts, ny typ UploadFileResult). Filvägens typade fel visas på svenska (src/work/uploadErrors.ts). Mocken har samma ordning och felkoder som live-BFF:n och delar Presidio-attrappen med uploads/scan; work-ytan har nu åtta rutter. chat-ui appVersion 1.1.3 → 1.2.0.)

Uppdaterad 2026-10-06 (P75, D-173 — ops-ui anropar bara vägar tjänsterna har. Tillsynskön är borttagen (policy-engine saknar rutten, #323), och sökvägsvaktens spärrhake är tom. Feedbackfliken skickar since och assistant_id och läser feedback-apis egna namn. Tjänstetypen har policy-engines alla obligatoriska fält, låst av ett test mot openapi.json. check_ops_ui_paths.py prövar även bokstavliga frågeparametrar.)

Uppdaterad 2026-10-05 (#495 — ops-ui:s dossier följer lifecycle-apis värdemängd; schemaskyddets spärrhake KANDA_GLAPP är tom och borttagen.)

Uppdaterad 2026-10-04 (P50 del 2, #487, D-167 — docs-site har en API-referens genererad ur tjänsternas openapi.json och ett läcktest efter bygget; inga nya beroenden.)

Uppdaterad 2026-10-04 (P50, D-167 — docs-site renderar sex dokument ur docs/ genom urvalsgrinden tools/checks/check_docs_site.py; inga nya beroenden, ingen image eller chart.)

Uppdaterad 2026-10-03 (P44, #464 — marketing-site och docs-site på Astro 7 (5.18.2 → 7.3.5; sharp 0.35.5, esbuild 0.28.2 i hela workspacen). Renderad HTML är identisk med Astro 5-bygget efter tre anpassningar: Astro 7 skriver datastoren sorterad på id, så registren bär sin YAML-position som ordning och sidorna hämtar via register(); startsidans Astro.url.pathname är /index.html, så canonical, og:url och aktiv navlänk normaliseras till /; CSS:en minifieras i serverbygget, så vite.build.cssTarget sätts till webbläsarmål för att behålla prefix som iOS Safari behöver. marketing-site appVersion 1.4.4 → 1.4.5 och chat-ui 1.1.2 → 1.1.3 (samma lockfil i imagen; vite där får esbuild 0.28.2), bägge charts version orörda.)

Uppdaterad 2026-09-05 (landningssidan tillagd; D-121 — ops-ui:s MCP-vy talar samma språk som tjänsten för första gången. McpServer påstod status: 'connected'|'error'|'disabled', ett enabled: boolean och tools[].name; mcp-gatewayen svarar active|disabled, tenant_id, version och tools[].tool_name. Registreringen skickade auth_ref mot ett API som har auth_config, och setMcpServerEnabled PATCHade {enabled} mot ett schema utan det fältet — knappen låtsades fungera, eftersom pydantics extra="ignore" svalde kroppen och returnerade raden oförändrad. Allt rättat; formuläret väljer nu autentiseringstyp (none/bearer/basic) och skickar secret-REFERENSER, och en källa utan autentisering märks ut med ett eget chip i stället för att visas som ett tomt fält (vägval V2(c), #285). Msw-mocken är inte längre snällare än tjänsten, och det är mekaniskt vaktat: tools/checks/check_ui_contract.py kräver att mockens accepterade fältlista är identisk med RegisterServerRequest i den incheckade openapi.json. Bägge nginx-imagerna kör nu apk upgrade — samma åtgärd D-100 gjorde för de tio Debian-baserade tjänsteimagerna; basimagens libcrypto3 (CVE-2026-14456, HIGH, fix fanns) fällde supply-chain-grinden vid varje ombygge. chat-ui appVersion 1.1.1 → 1.1.2, marketing-site 1.3.1 → 1.3.2, bägge charts version orörda. Ops-ui har ingen chart och ingen image — dess ändringar rullas inte ut av Argo.)

Uppdaterad 2026-08-09 (D-86 — chat-uis modellfixturer bär katalogprefixet stckn-; chart version 0.2.0 → 0.2.1, appVersion 1.1.0 → 1.1.1).

Uppdaterad 2026-08-01 (D-82 — chat-uis mock-ytor uppdelade; me/assistants/models går att koppla live, work kräver dem fail-closed; paths.me()/paths.models() ompekade; chart version 0.1.0 → 0.2.0, appVersion 1.0.0 → 1.1.0).

Gemensam stack: React 19 + Vite 8 + TypeScript, oxlint, vitest + vitest-axe (a11y, båda teman) + MSW. Bygg tsc -b && vite build.

Deploy (D-66/D-79, rättad 2026-07-31). Samtliga frontends hör hemma i Stacken-klustret; pages-deploy.yml är RADERAD. Workflowen (PR #36) skulle deploya ops-ui/chat-ui/portal till Cloudflare Pages med per-app VITE_API_BASE_URL/VITE_LIVE_SURFACES gated på repo-variabler, men lyckades aldrig en enda gång — CLOUDFLARE_API_TOKEN fanns inte som repo-secret — så varje granskningsdeploy som gjorts är gjord manuellt. Inget D-beslut har någonsin valt Pages; raden i plattformens kickoff-dokument beskrev en vana, inte ett vägval (D-66).

RÄTTELSE (D-79): sektionen påstod fram till 2026-07-31 att ops-ui och chat-ui hade en deployväg där “Fabians bord är enbart att sätta variabeln, ingen kodlucka”, och beskrev OPS_UI_API_BASE_URL/CHAT_UI_API_BASE_URL som repo-variabler ett buildsteg läste. Det var osant sedan pages-deploy.yml raderades — buildstegen som läste variablerna försvann med den, och ingen ersättare fanns. Repo-variablerna styr ingenting i dag. Koden är sanningen; texten har rättats i stället för att skrivas om i efterhand.

Nuläget per frontend: marketing-site i drift (D-65). chat-ui har chart + image sedan D-79 men är inte driftsatt. ops-ui har chart + image-definition sedan P78 (charts/ops-ui, prefix /admin) men är inte driftsatt; image-jobbet i CI väntar på en klass 3-patch. portal är raderad (D-79, B2 avgjord). Överlämningen till plattformen står i docs/deploy-kontrakt-frontends.md.

Exponeringsmodell (D-66, ops-ui-raden ersatt av D-90): marknadssajt och chat-ui publika; ops-ui bakom BFF:ns inloggning på /admin under tenantens värd (P78), inte längre kubectl port-forward. chat-ui läser config.json ur ConfigMap med bevarad fail-safe (saknad ELLER trasig konfiguration ⇒ helmockat; okänt ytnamn ⇒ fail-closed, D-79). ops-ui behöver ingen sådan fil: produktionsbygget har inga mockar och talar same-origin, och VITE_LIVE_SURFACES gäller bara dev och granskningsbygget.

  • ops-ui — org-admin-gränssnittet. Features: overview, assistants, evals, usage, models, protection, audit, knowledge, users, connectors, legal. Mock-first (MSW); live-flipp per yta via VITE_LIVE_SURFACES (buildHandlers är fail-closed på okänt ytnamn); LIVE_SURFACES = ['evals','me','audit','usage','users','protection','knowledge','assistants','models','decide'] (knowledge ny, fas R3, D-41; assistants ny, fas S1, D-42; models ny, fas S2, D-43; decide ny, fas S2-ff, D-44 — de kunskaps-handlers (13 sedan filvägen steg 5) utbrutna ur huvudarrayen till knowledgeHandlers, samma mönster som usageHandlers/protectionHandlers; preview-handlern ingår i ytan, den är kunskapsflödets provtryckare). Kunskap-ytans kärnflöde (registrera kort → författa → klassa → verifiera → provfråga → publicera) är komplett live; assistants-ytan är nu live (assistants-CRUD via BFF→policy-engine, K3-formen, mock-reversering K12.4; vokabulär-align K12.5). Sedan fas S1 (D-42) speglar assistants-mocken de live-verkställda bind-tids-spärrarna: knowledgeBindingError 422:ar okända kort på publik yta (K12.4-reverseringen av R3:s tolerans, byte-exakt mot BFF; icke-publik yta hoppar grinden precis som BFF _evaluate_gate), och mockens POST+PATCH verkställer nu public-gaten (publicExposureViolations, AJ-5) mot den effektiva draften — så mocken aldrig är snällare än live. Bind-tids-spärrarna server-side ÄR byggda i S1 (policy-engine public-gate + protectionViolation; BFF knowledge-binding). Bundna-tjänster-räkningen på kunskapskorten läser assistenterna via samma assistants-yta som nu flippats live. paths.ts är single source of truth för både endpoints och mockar; contract-tester i mocks/*.test.ts låser mockformen mot live (inkl. 422/409-paritet — mocken aldrig snällare än live). Sedan P53 (#488) prövar test-servern varje muterande kropp mot tjänstens incheckade openapi.json (bff:s egna rutter först) och svarar 422 som tjänsten (mocks/schemaGuard.ts, låst av schemaGuard.test.ts). Sedan P53:s svarssida prövar den också varje mockat 2xx-svar ett test utlöser mot tjänstens svarsschema: obligatoriska fält ska finnas, och ett fält schemat saknar räknas som fel eftersom FastAPI filtrerar bort det live. test/setup.ts fäller testet. Bara i test; dev-läget i webbläsaren har inget skydd. Skyddet har inga undantag. Det sista kända glappet, dossierns värdemängder, är lagat (#495): ops-ui skickar lifecycle-apis archetype (gron|gul|rod) och public_record_status (public|confidential|mixed), och förifyllningen lämnar båda null. Spärrhaken KANDA_GLAPP är borttagen. Utflödespanelen på Skydd (OutputCheckPanel) är borttagen (#496): bff:s decide-proxy tar bara emot {assistant_id, requested_model} och sätter själv action="assistant_probe", så panelen fungerade bara mot mocken. Ett utflödesprov i live blir i så fall ett eget paket med spec. decide-ytan är nu live (fas S2-ff, D-44): DecideTest-panelen (“Testa beslutet” i AssistantDetailPage.tsx — INTE ProvtryckarenPage.tsx, som kör ren klientlogik och aldrig anropar decide) träffar BFF:s berikande decide-proxy POST /api/policy/v1/decide (se bff-sektionen). decide-mocken bröts ut till decideHandlers och exkluderas när ytan är live; svaret speglar DecideResponse exakt (substitute_model, evaluation_ms, pii_verdict, output_check_required, output_check_failures). Proben skickar bara {assistant_id, requested_model}; BFF injicerar action="assistant_probe" server-side och policy-engines gren hoppar medvetet över audience-/access-grinden (governance-only, samma ärliga mönster som _knowledge_preview) — mocken var redan governance-only (ignorerade roller), så den är aldrig snällare än live. Klient/typer/paths orörda (fryst kontrakt). Sedan fas S2 (D-43): Modeller-ytan är live — Model-typen växte 8→10 fält (residency: 'eu'|'us'|'on_prem', enabled: boolean, types.ts:333-346), modelsHandlers utbruten till surfaceHandlers['models'] (samma mönster som assistantsHandlers/knowledgeHandlers), paths.models() pekar om från den tidigare gatewaydirekta /api/llm/v1/models till BFF:ns nya GET /api/models, mock-till-live-paritet (dedup by id, display_name by construction, enabled=false-degradering — S1-empiri #6). RÄTTAD 2026-07-31 (D-79): raden sa tidigare att produktionsdeployen följde pages-deploy.yml när OPS_UI_API_BASE_URL var satt, och att “Fabians bord är enbart att sätta variabeln, ingen kodlucka”. Inget av det gäller: workflowen är raderad, repo-variabeln läses av ingenting, och ops-ui har varken Dockerfile eller chart — det ÄR en kodlucka, redovisad som D-79 Kvarstår 1. Känd uppföljning (fas R3, kosmetisk): ApiError-parsningen läser detail ?? error och visar därför mcp-gatewayens felKOD i stället för message-klartexten vid preview-fel — poleringsrad, inte blockerande.
  • chat-ui — chattappen, en kodbas med separata build-entries (work, public-chat, tillganglighet) och testvaktad bundle-isolering (check:entries). SSE via fetch-ReadableStream. pii_verdict-notisen (fas P2, D-36, TC-PII-06): useChatStream har ett explicit case 'pii_verdict'; en delad src/shared/pii/PiiNotice.tsx renderar en icke-terminal notis (arbetsläget: masked/blocked-chip; publika läget: maskerad-notis, D-14-mönstret för DegradedNotice) som kommunicerar att filtret är ett extra skydd, inte en garanti — publikt blocked går ALDRIG error-vägen, alltid via BFF:ns degraded-översättning (P1-kontraktet). Arbetsläget live sedan fas Q (D-37): egen LIVE_SURFACES, sedan D-82 ['public', 'work', 'me', 'assistants', 'models']; work flippar konversations- + uploads-handlers mot den riktiga BFF:n (feedback-ytan förblir mockad — feedback-api-sömmen är en egen, avgränsad rad). Ärliga mock-justeringar för paritet: sök är titel-enbart (envelope-kryptering gör innehållssök omöjligt server-side, D-37 §2.1), hård gallring speglas (utgången temporary-konversation ⇒ 404), attachment_ids konsumeras i mockens svarsbygge. Defensiv case 'error' tillagd i useChatStream (försvar i djupled — konversationsrutten emitterar aldrig error, men gamla /chat kan). Många rutter var PROPOSED i src/shared/api/ sedan fas C (D-14) och är nu byggda enligt kontraktet. verified_at date-only (fas R2, D-40): fixturerna (src/mocks/fixtures.ts) och typerna (src/shared/api/types.ts) justerade från datetime till date-only ('2026-05-01') — infriar D-39-vägvalet (ops-ui:s låsta form vann formkonflikten; chat-ui-sidan var den uppskjutna justeringen). Driftvägen byggd 2026-07-31 (D-79): Dockerfile + nginx.conf (bas nginxinc/nginx-unprivileged, uid 101) + charts/chat-ui (deployment, service, ingress, networkpolicy, pdb, configmap) + image-jobbet build-image-chat-ui i frontends.yml och trivy-jobbet image-chat-ui i security.yml. Runtime-konfiguration ersätter byggtidens import.meta.env: src/shared/config/runtime.ts hämtar config.json RELATIVT dokumentet före render (saknad ⇒ env-fallback för dev; trasig eller tom ⇒ helmockat; okänt ytnamn ⇒ fail-closed i buildHandlers). Inloggnings-redirect tillagd (_sessionExpiredRedirect, ops-ui-mönstret): 401 session_expired/unauthenticated på arbetsplanet ⇒ /auth/login?redirect_to=…; publika planet (credentials: 'omit') redirectas ALDRIG. Appen bor under ett prefix (/app) på BFF:ns host — delad host, eftersom BFF:n saknar CORSMiddleware och kör SameSite=strict; Vite base: './' gör bundlen prefix-oberoende och två absoluta /tillganglighet.html-länkar gjordes relativa. MSW:s worker registreras relativt (./mockServiceWorker.js) — MSW:s absoluta default hade träffat BFF:n och gett vit sida. Kvarvarande lucka, hittad 2026-07-31 vid överlämningen — STÄNGD 2026-08-01 (D-82). /api/me, assistent- och modellkatalogerna låg i commonHandlers, en grupp som saknade motsvarighet i surfaceHandlers och därför ALDRIG kunde bytas mot de riktiga tjänsterna. Det gjorde {work} obrukbart i drift: konversationerna gick live medan rullgardinen matades ur fixtures.ts, så första POST /api/chat/v1/conversations bar ett fixtur-assistant_id och fick 422 (issue #159, uppmätt på customer-prod). Gruppen är uppdelad i meHandlers/assistantHandlers/modelHandlers med egna ytnamn; LIVE_SURFACES är nu ['public','work','me','assistants','models'] och buildHandlers fäller work utan de tre andra (SURFACE_REQUIRES, samma fail-closed som okänt ytnamn). alwaysMockedHandlers är kvar men heter det den gör och innehåller bara feedback (gap 16). Två vägar rättade samtidigt: paths.me() /api/me → /me (BFF:ns me-router inkluderas utan prefix — /api/me träffade passthrough-catch-allen och gav 404 unknown_service) och paths.models() /api/llm/v1/models → /api/chat/v1/models (BFF:ns nya chattprojektion, se bff-sektionen). AiService.description är nullbar och human_oversight_required valfri, så typerna beskriver det policy-engine faktiskt svarar. LIVE, uppmätt 2026-08-13. Den här raden sa till 2026-08-13 “Inte driftsatt: ArgoCD-manifestet ligger som PR i digitalist-se/plattformen, ingen pod kör” — och det var falskt, vilket kostade ett felaktigt vägval i en planeringsrunda. Manifestet ligger på plattformens main som egen Application (clusters/customer-prod/apps/chat-ui.yaml, sync-wave 10, selfHeal), image pinnad 1.1.1, config.liveSurfaces = {work,me,assistants,models}, apiBaseUrl tom (same-origin under BFF:ns host). Uppmätt mot drift: GET https://api.stacken.eu/app/ → 200 text/html (“Stacken — Chatt”), /app/assets/work-*.js → 200, 101 kB, /auth/login → 302. Därmed är manifestets egen uttalade öppna fråga besvarad: de två Ingress-objekten på api.stacken.eu samexisterar — ingress-nginx slår ihop serverblocket, längsta prefix vinner, /app går till chatten och allt annat till BFF:n. Det var oprövat (“Stacken har ingen kubeconfig och har därför aldrig kunnat pröva sammanslagningen skarpt”); nu prövat med curl. public-ytan är fortfarande AV med avsikt (kräver rad i BFF:ns public_sites + accepterad inramningsrisk). Se docs/deploy-kontrakt-frontends.md och docs/runbooks/tenant-onboarding.md steg 5.
  • platform-ui (@platform/ui) — delat designsystem, konsumeras som källa utan build-steg (D-15): ui/markdown/feedback/theme/a11y/styles + token-contrast-test (WCAG AA, båda teman). Sedan D-79: Thumbs roterar nedtummen med en CSS-klass i stället för en inline-stil — React sätter style på SVG-element med setAttribute, vilket en CSP utan 'unsafe-inline' blockerar, och ikonen renderades uppåtvänd i båda knapparna. Uppmätt i chat-ui:s image under dess egen CSP.
  • portal — statisk landningssida. RADERAD 2026-07-31 (D-79, arbetspaket B2 avgjort). Tre länkar mot *.pages.dev-adresser som aldrig existerat, en sidfot som påstod “Deployas automatiskt från main”, och en Cloudflare-_headers-fil utan verkan i nginx. Marknadssajten är den publika ingången; en publik sida som pekar på en icke-publik adminyta var dessutom motsägelsefull. Ingen ersättning byggd.
  • docs-site — dokumentationssajten för docs.stacken.ai (D-136, D-167). Astro, statisk output, samma workspace och @platform/ui som marknadssajten. Fem handskrivna rutter (/, /koncept, /kom-igang, /sakerhet, /api) plus en rutt per fil i urval.json (/arkitektur, /atlas, /api-ytan, /control-surfaces, /capacity-model, /deploy-gitops), renderade ur docs/ av en content collection. src/lankar.mjs skriver om x.md#ankare till /x#ankare och fäller bygget på en relativ länk utanför urvalet. Vad som får stå i urvalet avgör pre-commit-vakten check_docs_site.py. tools/repo.py väljer frontend-CI när en publicerad fil ändras. API-referensen (/api plus /api/<tjänst>, en sida per services/*/openapi.json) läses av src/openapi.ts vid bygget: operationer med parametrar, kropp och svar, och scheman med fält. Inga anrop görs från sidan. Efter bygget kör repo.py frontend docs-site check_docs_site.py --dist: läcktestet fäller om ett filnamn ur superpowers/, strategi/, research/ eller meetings/ finns i det byggda, och API-referensen ska ha lika många operationer som kontrakten och api-ytan.md. Image och chart (#639): frontends/docs-site/Dockerfile bygger med repo-roten som kontext och en egen ignore-fil (Dockerfile.dockerignore) som släpper in docs/ och services/*/openapi.json. Ett mellansteg kör check_docs_site.py och --dist på den byggda dist, och slutsteget kopierar dist därifrån, så ett läckage ger ingen image. nginx som marketing-site, men CSP:n tillåter inga skript och bara stilattribut inline (kodblock och tabeller). charts/docs-site är marketing-sites chart med värden docs.stacken.ai. Imagen publiceras först när CI-patchen i PR:en är applicerad, och sajten är inte i drift förrän Plattformens Application finns (plattformen#384). decisions.md publiceras inte.
  • marketing-site — publika marknadssajten stacken.ai (ny 2026-07-26, D-64; deploymål ändrat av D-65; ersätter en Webflow-one-pager i ett svep). Avviker från gemensam stack: Astro (statisk output), inte React+Vite — innehållssajt med långläst text, granskbar som text i PR. Fem rutter (/, /kravstallning, /arkitektur, /efterlevnad, /kontakt), trailingSlash: 'never' + build.format: 'file' för länkstabilitet. Ingen backend, inga formulär, inga kakor, inget mörkt läge (D-64 §8 — a11y granskas därför i ett tema, till skillnad från produkt-UI:erna där båda är kontraktsyta). Ärver @platform/ui-tokens; marknadsskalan (--ms-*) bor lokalt i sajten, aldrig i det delade paketet. Innehållet ligger i tre YAML-register under src/content/ (formagor, fallgropar, arkitektur) som sidorna renderar — prosa i .astro skulle ligga utanför vakten. Registrens ordning är innehåll: sidorna hämtar via src/register.ts, som sorterar på positionen loadern stämplat (Astro 7 ger annars id-ordning). Grindar: tools/checks/check_capability_registry.py (pre-commit + pre-commit.yml) kräver status, mekanism och — för i-drift — både D-post och atlas-sektion, och förbjuder interna beteckningar i publik text (täcker alla tre register); frontends.yml-jobbet marketing-site kör typecheck/lint/build + kontroll av den exakta filmängden (rutter är citerbara URL:er och får inte glida); security.yml-jobbet image-marketing-site bygger imagen och trivy-scannar blockerande på HIGH/CRITICAL; a11y-marketing.yml kör a11y-grinden nattligt över åtta rutter inklusive 404 och apex-sidorna under /landing/ (browserbinär hämtas i ett explicit steg, eftersom npm ci --ignore-scripts medvetet inte kör install-skript).

LIVE sedan 2026-07-26 på ny.stacken.ai (customer-prod, enskild ArgoCD Application, Synced+Healthy, giltigt Let’s Encrypt-cert, två poddar Ready). Charten stämde rakt av — enda override var cert-utfärdaren; host, appVersion och replikantal gick på default. Säkerhetspositionen höll i drift: uid 101, readOnlyRootFilesystem, emptyDir på /tmp och /var/cache/nginx, netpol-selektorn matchade ingress-controllern, /healthz bar både readiness och liveness. Apex stacken.ai är INTE flyttad än — egen PR när Fabian granskat. Fynd vid deployen: GHCR-paketet är privat, och charten renderade då ingen imagePullSecrets, så namespacet krävde en pull-secret (löstes manuellt). Sedan PR #122 finns ett valfritt imagePullSecrets-värde i charten — tomt renderar ingenting, så beteendet är oförändrat där secreten redan är kopplad till standardkontot. Deploy (D-65, 2026-07-26): Hetzner, INTE Cloudflare Pages. Överlämningen till plattformen (som äger driften) står i docs/deploy-kontrakt-frontends.md (hette deploy-kontrakt-marketing-site.md till D-79) — inklusive att D-65 ändrar kickoff-dokumentets rad om att frontends körs på Pages och inte k8s. Image ghcr.io/digitalist-se/marketing-site byggd ur frontends/marketing-site/Dockerfile (byggkontext = repo-rot, som tjänsternas), bas nginxinc/nginx-unprivileged — vald för att den kör som uid 101 utan root-master, vilket gör runAsNonRoot + readOnlyRootFilesystem möjligt utan undantag. Chart charts/marketing-site: deployment, service, ingress, networkpolicy, pdb. Medvetet frånvarande: configmap, externalsecret, migrate-job, servicemonitor, HPA — statiska filer har ingen konfiguration, inga hemligheter, ingen databas och mättar inte en kärna. NetworkPolicy är isoleringslöftet: ingress endast från ingress-controllerns namespace, egress endast DNS — en analytics-pixel eller ett formulär skulle FÄLLA policyn, vilket gör en sådan ändring till ett beslut. /healthz (nginx, text/plain) bär både readiness och liveness. Skälet till bytet från Pages: Hetzners DNS stödjer ingen ALIAS och CNAME är förbjudet på apex, så Pages hade krävt att zonen — med api.stacken.ai i den — flyttades till Cloudflare. Argo-registrering och DNS-poster ligger i digitalist-se/plattformen respektive Hetzners DNS-konsol, inte här.

Landningssida, 2026-09-05: public/landing/ innehåller den godkända teasern “AI som går att förvalta”. Nginx väljer denna på stacken.ai och skickar www.stacken.ai till apex. Samma chart stöder alias i både regler och TLS. Plattformen publicerar en separat landing-site-instans med pinnad chart och image; den befintliga driftsättningen på ny.stacken.ai påverkas inte. Detta beskriver byggd kod. Drift och domänbyte verifieras i Plattformens release.

Ny form, 2026-09-08: landningssidan använder den självständiga profilen med JetBrains Mono, Public Sans, oliv/mineral/gult och mindre ordmärke. Tillgångarna är lokala, sidan är scriptfri och sökmotorernas canonical pekar på apex. Chart 0.6.6/appVersion 1.4.4 beskriver detta bygge; drift verifieras efter Plattformens separata image-bump.

Så fungerar Stacken, 2026-10-07 (#581): apex har en undersida, /sa-fungerar-stacken, ur public/landing/sa-fungerar-stacken.html med egen CSS-fil och startsidans sidhuvud och sidfot. Nginx ger filnamnet som reserv bara för den exakta vägen; övriga sökvägar ger 404 som förut och CSP:n är oförändrad. Startsidan länkar dit från navigationen och från “Fler AI-tjänster.”. a11y-grinden omfattar sidan. appVersion 1.4.5 → 1.5.0, chart version orörd. Publiceringen sker när Plattformen pinnar landing-site på merge-commiten.

Animationen Så fungerar Stacken, 2026-10-07 (#613): startsidan har sektionen #sa-fungerar direkt efter hero. Laddaren animation/stacken-animation.js hämtar scenen och three.js r170 (lokalt i animation/vendor/, MIT) först när sektionen närmar sig skärmen; utan JavaScript eller WebGL visas animation/poster.png. Filerna genereras ur riktningen och skrivs inte om här. Apex-CSP:n får script-src 'self' och connect-src 'self' (Fabians beslut 2026-10-07); sidan är alltså inte längre scriptfri, men kör inga externa skript, sätter inga kakor och gör inga anrop ut. appVersion 1.5.0 → 1.6.0, chart version orörd. Publiceringen sker när Plattformen pinnar landing-site på merge-commiten.

Illustrationer som följer startsidans text, 2026-10-08 (#649): startsidan har sju stillbilder under animation/illustrationer/: en ovanför var och en av de tre frågorna under “Demon gick bra”, en vid “Teknik och ansvar hör ihop”, en vid “Ta en pilot vidare” och nya bilder vid “Regler före modellens svar” och “Spår som går att följa”. Utsnitten animation/vinjetter/regler.png och logg.png är borttagna. Bilderna genereras ur riktningen och skrivs inte om här. Alla har alt-text och loading="lazy"; inga skript och ingen CSP-ändring. appVersion 1.6.0 → 1.6.1, chart version orörd. Publiceringen sker när Plattformen pinnar landing-site på merge-commiten.

Deploy — backend (Helm charts)

Verifierad/uppdaterad 2026-10-05 (D-172: ny backend-chart charts/extract-api, filvägens steg 1. Ingen instans ännu.)

Verifierad/uppdaterad 2026-09-30 (D-163: verkställaren digifactory-merger är enda förbigång på main; admin mergar inte längre direkt. Grenskyddsbulleten nedan omskriven.)

Stämpelhistorik — 8 tidigare verifieringar
  • Föregående 2026-09-29 (D-161: dolda tester ur digitalist-se/stacken-dolda krävs för klass 0 och 1. Grenskyddsbulleten nedan kompletterad.)
  • Föregående 2026-09-29 (D-160: röda basgrenstester i klass 2 och 3 kräver att en admin godkänt head. Grenskyddsbulleten nedan kompletterad.)
  • Föregående 2026-09-29 (D-159: verifierad kräver basgrenens vakter och, för klass 0 och 1, basgrenens tester med mutationsgrinden P37. Grenskyddsbulleten nedan kompletterad.)
  • Föregående 2026-09-28 (D-158: verifierad visar PR:ens klass ur .github/klasser.json och attesterar klassningen. Grenskyddsbulleten nedan kompletterad.)
  • Föregående 2026-09-28 (D-153/D-155 — grenskyddet på main är ersatt av två rulesets där bara verifierad från verifiera-appen räknas, och agenterna har en egen identitet. Grenskyddsbulleten nedan omskriven.)
  • Föregående 2026-08-24 (D-107 — supply-chain-grindens RÄCKVIDD ändrad: image-skanningen blockerar per tjänst i stället för repo-brett, och dispenserna i .trivyignore.yaml har fått slutdatum. CI-gate-bulleten nedan uppdaterad.)
  • Föregående 2026-08-13 (kartan mot terrängen — sektionen var stämplad 2026-07-30 men brödtexten citerade D-88/D-89 från 2026-08-10, och D-96 hade aldrig lämnat ett spår här alls: grenskyddet på main och plattformens SHA-pinning saknades helt, så sektionen läste som om main fortfarande var oskyddad. Båda inskrivna nu, se bulletarna ovan.)
  • Föregående 2026-07-30 (sektionen hade INGEN datumstämpel — därför kunde dess “EJ prod-deployad”-rad stå kvar osann ända från D-49/D-50 till nu. Stämpeln tillagd så att atlas-friskhetsvakten kan se sektionen alls.)

Tillagd fas Wave 1, D-49 (2026-07-20); CI-gate breddad till failande SAST/secrets/image-scanning + SBOM formaliserad, fas Wave 2, D-51 (2026-07-22); HPA på alla 11 charts + kapacitetsmodell (skalgrind #T6), fas Wave 2, D-53 (2026-07-23); NetworkPolicy-symmetrivakt tillkommen, D-69 (2026-07-27).

charts/ bär en fristående Helm-chart per deploybar backend-tjänst så plattformen kan driftsättas på Hetzner k8s. 11 backend-charts = 4 sedan tidigare (auth-api, eval-api, lifecycle-api, mcp-gateway) + 7 nya i D-49 (policy-engine, feedback-api, provisioning-api, pii-api, bff, llm-gateway, rag-api). Två FRONTEND-charts bor i samma katalog men beskrivs i frontends-sektionen, inte här: marketing-site (D-65) och chat-ui (D-79). De följer en annan mall — nginx-unprivileged på uid 101, ingen databas, inga hemligheter, ingen HPA — och räknas därför inte in i de elva. Sedan D-172 (2026-10-05) finns en tolfte backend-chart, extract-api: statelös, inga hemligheter, ingress bara från bff, egress bara auth-api och DNS, /tmp som emptyDir i minnet. memory-api (ej byggd, ingen kod i trädet) har ingen chart. charts/modules (library) + charts/controlplane/charts/tenant (umbrellas) är tomma .gitkeep-platshållare — library-konsolideringen är uppskjuten till Fas T. De ligger under charts/, inte under libs//services/, och rördes därför inte av paket 7:s städning.

  • checksum/config i pod-mallen (D-89, ALLA 12 charts med ConfigMap): en ändrad ConfigMap rör inte pod-mallen, så varken Helm eller ArgoCD har något att rulla ut — appen rapporteras Synced (ConfigMappen MATCHAR git) och Healthy (poddarna kör), medan poddarna läser konfigurationen de fick vid start. Uppmätt i drift 2026-08-10 (issue #176): bff körde 4h40m på ett gammalt Google-klient-ID. Fram till D-89 hade bara chat-ui annotationen. Vaktad av tools/checks/check_configmap_checksum.py, som också fäller en annotation placerad UTANFÖR pod-mallen (verkningslös). Täcker INTE hemligheter — en summa över externalsecret.yaml hashar mallen, inte värdet, och renderar dessutom ingenting under SOPS; en halv täckning som läser som hel är sämre än ingen.
  • Gemensamt template-set (kopierat ur auth-api): deployment/service/configmap/externalsecret/networkpolicy/pdb/_helpers. Säkerhetspostur ur values (icke-förhandlingsbar): runAsNonRoot, runAsUser 1001, readOnlyRootFilesystem, seccomp RuntimeDefault, drop ALL, emptyDir /tmp. Hemligheter: ExternalSecret (store platform-secret-store) → k8s Secret → env; aldrig i values/configmap. NetworkPolicy default-deny (Ingress+Egress) per chart — sedan D-69 mekaniserat: tools/checks/check_networkpolicy_symmetry.py (pre-commit + pre-commit.yml --all-files) kräver att varje egress-regel mot en annan chart i repot har ett motsvarande ingress-medgivande hos mottagaren på samma port; annars kan en egress-only-fix se hel ut men lämna vägen bruten (två dokumenterade fall före vakten, D-49 Task 0 och D-68). Sedan P61 väljs peers på app i alla charts, och tools/checks/check_netpol_peer_labels.py (i helm.sh) prövar varje peer mot de renderade poddetiketterna i målets chart. image.tag tom → Chart.AppVersion; pinnas per deploy, aldrig :latest.
  • Portar/sonder (ur Dockerfiles): policy-engine/provisioning-api/pii-api/rag-api 8080, feedback-api 8083, llm-gateway 8082, bff 8084. Sonder via namngiven port http. Probpath: de flesta /readyz+/healthz; pii-api /healthz-only; llm-gateway /health-only.
  • Per-tjänst-särdrag: policy-engine skrivbar emptyDir på /app/opa-data (OPA-residensslice vid boot, annars crashloop under read-only rootfs; models.yaml bakad i imagen); pii-api statelös, höjt minne (spaCy sv_core_news_md), HOME=/tmp, hemlighet SERVICE_TOKEN; provisioning-api dual-DB (platform-pg + tenants-pg); llm-gateway PLATFORM_PG_URL, 5 provider-nycklar, enda charten med egress till en OBEGRÄNSAD mängd externa destinationer (443, ipBlock.except för metadata 169.254.169.254/32 + RFC1918) — omformulerat i D-84 från “enda charten med extern-internet-egress”, som lästes bokstavligt och gjorde en nödvändig IdP-väg till ett skenbart regelbrott; rag-api egen tenant-lokal pgvector-DB (RAG_PG_URL); bff publik ingress; models.yaml bakad i imagen sedan D-88 (var en monterad platform-models-ConfigMap — katalogens tredje kopia, den enda ingen validerade; registret laddas fortsatt boot-eager). auth-api når policy-engine/llm-gateway/feedback-api/bff/rag-api för GDPR-erasure/export (egress tillagd D-49 Task 0). Ingen chart deklarerar IdP-egress (D-84): charts/{bff,auth-api} hade regler mot en app: keycloak-podd som inget chart reser, och de är borttagna — IdP-reachability bor vid plattformslagret, docs/runbooks/tenant-onboarding.md steg 4b.
  • CI-gate: .github/workflows/helm-lint.yml (charts/**-trigger) — helm lint + helm template | kubeconform -strict för alla charts, fallerande. Ersätter helm-delen av security.yml:s tidigare report-only-steg (#T7b). Sedan D-51 (2026-07-22): hela security.yml är failande — SAST (bandit -ll -ii/semgrep/pip-audit), secrets (gitleaks, allowlist i .gitleaks.toml) och trivy image-scanning (matris 8→11/11, dispenser i .trivyignore.yaml) blockerar nu byggen på HIGH/CRITICAL-fynd; trivy fs (dependency/config-drift) förblir varnande per det bevisade referensmönstret. Sedan D-107 (2026-08-24) blockerar image-skanningen PER TJÄNST: jobben behåller sina tretton checknamn men bygger och skannar bara de images PR:en kan ha ändrat (tools/checks/affected_images.py, härledd ur vad varje Dockerfile faktiskt kopierar till sitt runtime-steg). Egenskapen ingen PR inför en ny HIGH är oförändrad; det som försvann är att en orörd tjänsts CVE stoppade varje merge i repot. Alla tretton skannas i stället i sin helhet av .github/workflows/security-image-watch.yml — nattligt, vid push till main och på begäran — som blockerar inget och öppnar, uppdaterar eller stänger EN issue. Har ett fynd en rättad version får issuen till:stacken och fabriken-prov, med paketen och versionerna att höja, och fabrikens server rättar vid källan i en PR (#470). Det hoppas över medan en öppen PR stänger issuen. Dispenserna i .trivyignore.yaml bär statement + expired_at (max 90 dagar); utgången upprätthålls av trivy själv, formvakten tools/checks/check_trivyignore_expiry.py ser bara till att datumet finns. Den överflödiga charts-jobben i security.yml är borttagen. SBOM formaliserad över tre ytor (per-image-attestation, källträd, frontend npm) — se SECURITY.md.
  • Skydd på main (D-153, D-155, aktiverat 2026-09-28; ersätter D-96:s grenskydd). Två rulesets, inget klassiskt grenskydd. “main: vad som krävs” kräver PR och en enda obligatorisk check, verifierad från verifiera-appen digifactory-verifier (5105539), utan förbigång för någon. verifierad skrivs av tools/checks/verifiera.py från basgrenens workflow och kräver D-96:s 23 Actions-checkar gröna; en check med samma namn från en annan app räknas inte. Godkännande krävs först när Plattformen har en kund-tenant (D-155). “main: vem får flytta grenen” (update, deletion, non_fast_forward) släpper sedan D-163 bara verkställaren digifactory-merger (5126403) förbi, och bara via PR: den mergar det en admin godkänt och verifierad släppt igenom; admin har ingen förbigång utom nödutgången (Plattformens make break-glass). Övriga appar kan varken pusha eller merga. Agenter pushar som digifactory-proposer. Bevisat med negativa prov i stacken#386: direkt push GH013, appens merge 405, falsk verifierad “was not set by the expected GitHub app”. Sedan D-158 ger verifierad varje PR en klass (0–3) ur diffen och .github/klasser.json, och klassningen signeras med verifierarens nyckel. Klass 3 från föreslå-appen avvisas först när repovariabeln KLASSER_SKARPA=1 är satt; före det står “skulle avvisas”. Sedan D-159 prövas varje PR också av verifiera-bas.yml mot basgrenens vakter (“basgrenens vakter”, krävs för alla klasser) och mot basgrenens testträd med snabbprofilen och mutationsgrinden P37 (“basgrenens tester”, krävs för klass 0 och 1; röd i klass 2 och 3 kräver att en admin godkänt head, D-160). En PR kan alltså inte längre fria sig själv genom att ändra en vakt. Sedan D-161 kör verifiera-dolda.yml tester ur det privata repot digitalist-se/stacken-dolda, som föreslå-appen inte kan läsa, mot PR:ens kod i en container utan nät; checken “dolda tester” från verifiera-appen krävs för klass 0 och 1 när repovariabeln DOLDA_REPO är satt. Täcker INTE integrationstester med basgrenens testträd, eller skarp avvisning av klass 3 förrän KLASSER_SKARPA=1 är satt.
  • Plattformen pinnar oss på gemensam SHA (D-96). Deras ApplicationSet spårade targetRevision: main med automated sync och selfHeal — en merge här ändrade deras produktion utan en commit hos dem, vilket hände skarpt när D-94 rullade ut i customer-prod 08:28 2026-08-12 medan de skrev ärendet om saken (stacken#195). De pinnar nu en gemensam SHA för alla elva; appVersion bor i chartet, så pinnen låser både chart och image. Konsekvens för oss: en merge till main når inte längre kundklustret av sig själv — utrullning är deras beslut att flytta pinnen. Brytande ändring signaleras med major-bump på chartets appVersion + BREAKING CHANGE:-footer, per chart.
  • Autoscaling (#T6, D-53, 2026-07-23): alla 11 charts fick en HorizontalPodAutoscaler bakom autoscaling.enabled (default false; true i 7 prod-overlays) + en replicas-guard i deployment.yaml (annars slåss Helm och HPA på varje sync); min/max/target-CPU se docs/capacity-model.md. auth-api/eval-api/lifecycle-api/mcp-gateway saknar fortfarande en values-prod.yaml som slår på den — öppen lucka (kapacitetsmodellen). Kapacitetsmodell + DB-pool-risk (~810 anslutningar möjliga vid full autoskala mot otunad Postgres max_connections) dokumenterade i docs/capacity-model.md.
  • Schema-bootstrap (D-50): varje tillståndsbärande chart (9 st; pii-api statelös, mcp-gateway plattform-sida) har en migrate-job.yaml — alembic upgrade head som ArgoCD PreSync-hook, ordnad via sync-wave enligt e2e-depends_on-kedjan (provisioning=0 → policy-engine=1 [skapar public.audit_events] → auth/bff/lifecycle=2 → feedback=3 → eval=4; rag=0 egen DB). envFrom ConfigMap+Secret (env.py laddar för vissa full Settings(), för andra os.environ). INTE create_all (schemat är Alembic-versionerat). PreSync-prereq: ConfigMap+Secret måste finnas när hooken kör; DB-nivå-roller/extensioner (gateway_role, pgvector) = plattform/CNPG-sida.
  • Live-status: PROD: live sedan D-50 (customer-prod på Hetzner; api.stacken.eu/healthz → 200, sonderat 2026-07-30). Raden sa tidigare “EJ prod-deployad” och att nästa Wave 1-bit var prod-deploy-runbooken — båda osanna sedan D-49/D-50, och aldrig rättade. Kvar att verifiera: per-tjänst Synced/Healthy och NetworkPolicy-tillämpningen i drift (D-69 Kvarstår 6 — chartsanning, ej driftverifierad); ingen provisionerad tenant finns ännu (D-70 Kvarstår 3). Runbook-förutsättningar (ej skapade av någon chart): pod-etiketter app: platform-pg|rag-pg|tenants-pg|keycloak, namespace name: ingress-nginx, ClusterSecretStore platform-secret-store. ConfigMap platform-models är INTE längre en förutsättning (D-88) — bff bakar katalogen i imagen som policy-engine redan gjorde; ConfigMappen ska avvecklas på plattformssidan. Nästa Wave 1-bit = prod-deploy-runbooken (charts/<svc>/values-prod.yaml-skelett finns).