13 — Platform Gaps: GitHub Issue Drafts for MR-Eder/document-processing-pipelines

Purpose. The Knoll Analyzer integration depends on five GRAG platform changes that do not exist today. This document contains one ready-to-file GitHub issue draft per gap, in Knoll priority order, so the platform operator can copy each section verbatim into MR-Eder/document-processing-pipelines issues. Every "Current behavior" claim is backed by a repo file reference; every "Proposed change" follows the repo's own conventions (pipeline_common middleware semantics, dark-by-default flags, doc/code mirror pairs from the repo CLAUDE.md).

Status / verified against: 2026-07-06, repo document-processing-pipelines @ db63a95. Plan reference: tasks/todo.md (rev. 2 after adversarial review). Sibling docs: 01-architecture.md, 05-verification-runbook.md, 09-chat-integration.md, 10-document-pipeline.md, 11-resilience-and-errors.md, 12-gdpr-compliance.md.


How the gaps interact with the plan phases

  • Gap 1 (plan 1.8, BLOCKER) gates everything that touches real Mandanten data: the D3/D4 decision to consume the BFF lanes (plan 0.3/0.4), document upload (plan 4.1), all three chat features (plan 5.1a/5.1b/5.2), and the GDPR go/no-go (plan 0.11). Until it lands, only the knoll-dev tenant with fixture data may flow through /next/api/*.
  • Gap 2 (plan 1.9) hardens the same surface Gap 1 secures. It should be filed together with Gap 1 and its contract tests should pin the new 401 behavior. Needed from Phase 2 (packages/grag-client wraps these lanes) onward.
  • Gap 3 (plan 1.11) gates the deterministic KG writer (plan 3.6) and therefore every KG projection of Fragebogen/Gutachten facts (BEWERTET, EMPFIEHLT, SETZT_UM, …). Without it, Phase 3 ships entities only (plan 3.5 works today via POST /kg-service/api/v1/entities/upsert).
  • Gap 4 (plan 1.14) is wanted before the Gutachten pipeline scales (plan 4.3, 5.1a, 5.3, 5.5), but Knoll has a documented interim: prompt-based JSON + Zod validation + bounded retries in the ai_runs state machine (plan 2.5). The gap is small and additive — file early, land when convenient.
  • Gap 5 (plan 1.10) must be decided before the pilot with real data (plan 0.11, 8.3), but the platform work (German anonymization v2) is only required if option (a) of plan 1.10 is chosen. Options (b) accepted risk + EU BYOK endpoints and (c) Knoll-side pseudonymization need no platform change. Longest lead time of the five (model eval + calibration) — decide early. See 12-gdpr-compliance.md.

Tracking table

#GapPlan taskIssue statusBlocking what
1BFF /next/api/* authentication1.8#388 — filed 2026-08-12All real Mandanten data (0.11 go/no-go); D3/D4 lanes; plan 4.1, 5.1a/5.1b, 5.2; go-live 8.5
2BFF lane contract stability1.9#389 — filed 2026-08-12Safe consumption of chat/upload/ingest/events lanes from Phase 2 onward; 7.2 canary
3KG entities+relationships HTTP write route1.11#390 — filed 2026-08-12Deterministic KG writer 3.6; edge projections for 3.7/3.8; reconcile job
4ai-gateway structured output passthrough1.14#391 — filed 2026-08-12Nothing hard (interim: prompt-JSON + Zod per 2.5); quality/robustness of 4.3, 5.1a, 5.3, 5.5
5German PII / anonymization v21.10#392 — filed 2026-08-12Pilot with real data (8.3) iff option (a) chosen; otherwise decision-only

Update this table when issues are filed (add the issue URL) and when they close.


Issue draft 1 — BFF authentication (plan 1.8) — file FIRST

Title: security: /next BFF API surface is publicly reachable with no authentication (injects PIPELINE_API_KEY, trusts inbound X-Tenant-ID)

Suggested labels: security, frontend-next, P0

Context

An external product (Knoll Analyzer) is being built on the /next/api/* BFF lanes as its chat and ingest contract (chat stream, upload/ingest submit, orchestrator SSE events). Today that surface is reachable from the public internet with zero credentials: any caller can chat against any tenant's documents, inject documents into any KB, and burn the platform's LLM budget simply by setting X-Tenant-ID. This must be fixed before any real tenant data exists behind these routes; for Knoll it is the single hard blocker in the GDPR go/no-go list.

Current behavior

  • There is no middleware.ts anywhere in frontend-next/ (verified by filesystem search). The legacy frontend/src/middleware.ts Basic-auth gate was never ported.
  • The Traefik router for the container attaches no auth middlewaredocker-compose.yml:3202–3212 carries only PathPrefix(\/next`), priority=10, entrypoints=web`, and the port label.
  • ADMIN_PASSWORD / PLAYGROUND_ENABLED are passed as env at docker-compose.yml:3136–3137 with a comment claiming "Reuse the same admin gating + kill switch as the legacy UI" — but no frontend-next code enforces either (the comment describes an intent, not the implementation).
  • Every BFF route injects the platform key server-side: frontend-next/apps/web/app/api/chat/stream/route.ts:107–108 (getPipelineApiKey() reads process.env.PIPELINE_API_KEY, attached at :275) and frontend-next/apps/web/app/api/upload/start/route.ts:31–32,68,115,149.
  • The tenant is attacker-chosen: resolveTenant() in frontend-next/apps/web/src/lib/tenant.ts:35–43 takes the inbound X-Tenant-ID header from any caller, falling back to TENANT_ID env, then default.

Reproduction (deliberately without Authorization — that is the bug; today this streams an answer, after the fix it must return 401):

curl -N -X POST "$GRAG_URL/next/api/chat/stream" \
  -H "Content-Type: application/json" \
  -H "X-Tenant-ID: knoll-dev" \
  -d '{"conversation_id":"conv-probe-1","kb_id":"kb-analyse-demo","user_message":"Welche Dokumente liegen vor?"}'

Confirmed live on 2026-08-12 against https://app.grag.ai

Four unauthenticated probes (no Authorization header on any of them) — all returned 200, all four /next/api/* lanes are open. The X-Tenant-ID header is blindly trusted and silently falls back to tenant default when absent.

#Probe (no auth)Result
1POST /next/api/chat/stream (X-Tenant-ID: knoll-dev, nonexistent kb)HTTP 200 + full SSE pipeline ran server-side on the platform's internal key: history(5ms) → persist_user(failed) → retrieve(404) → generate(429 "You have no credits remaining" on the platform OpenAI key). The 429 incidentally capped budget burn; the vulnerability is total.
2Same, with no X-Tenant-ID headerHTTP 200 — silently used tenant default.
3POST /next/api/upload/start (11-byte file, nonexistent kb)HTTP 200 — server accepted the bytes, created a docfold conversion job (docfoldJobId: bff70b8d-45d4-4605-9fe5-ca5803957229), only failed at the workspaces step because the kb doesn't exist. Document injection is possible against any kb_id the caller can discover.
4GET /next/api/orchestrator/events/{dummy} (no auth, no tenant header)HTTP 200 + subscribed frame. Payload shows "tenant":"default" — the silent fallback is observable on the wire.

Footprint: all probes used kb_id="kb-does-not-exist" — no real tenant data was touched. The 11-byte upload created one docfold job that will expire from Redis after its 30-day TTL; no authenticated DELETE is possible to clean it up. Impact: anyone on the internet can (a) read any tenant's documents via chat, (b) inject documents via the upload lane, (c) burn the platform's LLM budget. This is the hard GDPR gate before any real customer data can flow.

Proposed change

Add a frontend-next/apps/web/middleware.ts (Next.js edge middleware) that gates every /api/* route in the app (externally /next/api/*), reusing the pipeline_common auth semantics so operator configuration stays single-sourced:

  1. Bearer check for machine callers: validate Authorization: Bearer <token> against the union of PIPELINE_API_KEY (comma-list) and the GATEWAY_CALLERS name:token map — mirroring APIKeyMiddleware + parse_callers() in pipeline-common/src/pipeline_common/middleware.py:81–169. On match, resolve the caller name (e.g. knoll) and stamp it into a request header for the existing audit-actor plumbing, so BFF-lane traffic finally gets per-caller attribution (today it never presents a key at all, voiding the GATEWAY_CALLERS attribution model for these lanes).
  2. Browser/operator path preserved: HTML pages and same-origin browser fetches from the operator console authenticate via the ADMIN_PASSWORD HTTP Basic gate (port the legacy frontend/src/middleware.ts behavior) — a request is accepted if EITHER a valid Bearer OR a valid Basic credential is present. Without this, enforcing Bearer-only breaks the console UI, whose client-side fetches cannot hold the platform key.
  3. Public paths: keep /next healthcheck/static assets exempt, mirroring the PUBLIC_PATHS posture of pipeline_common.middleware (/health, /ready, docs).
  4. Dark by default, fail-closed when on: new flag FRONTEND_NEXT_API_AUTH_ENABLED (default false → behavior unchanged for one release; set true in .env.production.example). When true with an empty key set, refuse to start unless PIPELINE_ALLOW_NO_AUTH=1 — the same AuthMisconfigurationError posture as pipeline-common/src/pipeline_common/middleware.py:62–70.
  5. SSE routes (/api/chat/stream, /api/orchestrator/events/{jobId}) must reject before the stream opens (plain 401 JSON, not an error SSE frame).

Alternative (faster, coarser): a Traefik basicAuth/forwardAuth middleware on the frontend-next router in docker-compose.yml. This loses per-caller attribution and couples browser UX to machine auth — acceptable as an interim hotfix, not as the end state.

Acceptance criteria

  • frontend-next/apps/web/middleware.ts exists; every /next/api/* route returns 401 without credentials and 200/SSE with Authorization: Bearer $GRAG_API_KEY.
  • Caller attribution: a GATEWAY_CALLERS=knoll:<token> entry authenticates and the resolved caller name reaches the audit actor (no more anonymous for keyed BFF traffic).
  • Basic-auth (ADMIN_PASSWORD) path still serves the operator console end-to-end.
  • Auth cases added to the existing route specs (app/api/chat/stream/route.spec.ts, app/api/upload/start/route.spec.ts): 401 without header, 401 with wrong token, pass-through with valid token, SSE rejects pre-stream.
  • Misconfiguration test: FRONTEND_NEXT_API_AUTH_ENABLED=true + empty keys + no PIPELINE_ALLOW_NO_AUTH → hard startup failure.
  • Canary: a BFF-auth probe (unauthenticated POST /next/api/chat/stream must 401) so a regression pages the operator — consumers add the same probe (plan 7.2, and Knoll's 05-verification-runbook.md step 0.10a).
  • Doc mirror pairs (repo CLAUDE.md "Doc/code update reflex"): # FRONTEND_NEXT_API_AUTH_ENABLED=false commented line added to .env.example, .env.production.example sets it true, and the flag tuple appended to CANONICAL_READERS in pipeline-common/tests/test_env_flag_coverage.py in the same PR.
  • docs/architecture.md §1 registry: no route/port/prefix change, so no row change — but run pytest pipeline-common/tests/test_architecture_table.py anyway before pushing.
  • Deploy workflow passes the new flag through (.github/workflows/deploy.yml env block).
  • Post-merge live verification per repo CLAUDE.md: curl the live /next/api/chat/stream unauthenticated (expect 401) and authenticated (expect SSE).

Effort: M (one middleware file + spec updates + flag plumbing; the Basic/Bearer dual path is the only subtle part).

Risk notes: Breaking the operator console is the main regression risk (mitigated by the dual-credential design and the dark default). Rolling out true in prod invalidates any undocumented anonymous consumer — announce in the changelog. Knoll-side: until this lands, packages/grag-client BFF wrappers must refuse to run against non-dev tenants (see 11-resilience-and-errors.md).


Issue draft 2 — BFF lane contract stability (plan 1.9)

Title: contract: bless /next/api chat/upload/ingest/events lanes as stable external contracts (spec coverage + deprecation policy)

Suggested labels: frontend-next, contract, documentation

Context

An external consumer (Knoll Analyzer) builds its chat and document-ingest product paths on four BFF lanes: POST /next/api/chat/stream, POST /next/api/upload/start, POST /next/api/ingest/start, GET /next/api/orchestrator/events/{jobId} (plus the fallback GET /next/api/pipeline/status/{jobId} and the sources drawer's GET /next/api/chat/chunk/{docId}/{ordinal}). These routes live inside a UI container and have so far been treated as internal — an incompatible change would silently break the external product mid-stream. Knoll consumes them either way (plan D3/D4 decision), so the ask is to make the contract explicit and regression-tested, or to consciously extract it into a service.

Current behavior

  • The lanes are Next.js route handlers in frontend-next/apps/web/app/api/…, deployed behind the Traefik PathPrefix(\/next`) router (docker-compose.yml:3209`).
  • Spec coverage is partial: route.spec.ts files exist for chat/stream, upload/start, and chat/chunk/[docId]/[ordinal], but not for ingest/start, orchestrator/events/[jobId], or pipeline/status/[jobId] (verified by filesystem search under frontend-next/apps/web/app/api/).
  • The chat SSE event vocabulary is pinned by app/api/chat/stream/route.spec.ts (ADR 0031 documents the stage list history → persist_user → condense → retrieve → expand → generate → ground), but there is no consumer-facing contract document for the other lanes.
  • A root cutover is anticipated: the compose comment at docker-compose.yml:3125–3126 states the app "can be lifted unchanged. The /api/* proxy routes were copied verbatim" — i.e. the /next prefix may move, which is exactly the kind of change an external consumer must survive.

Proposed change

  1. Declare the contract: a short docs/integrations/bff-external-contract.md enumerating, for each of the six routes above: method, path, auth (per issue 1), request body fields with types/defaults (ChatRequest in app/api/chat/stream/route.ts:112–118: conversation_id, kb_id, user_message, system_prompt, model/top-k overrides), response / SSE frame vocabulary, and error taxonomy. State the compatibility promise: additive-only changes; breaking changes require a deprecation window + changelog entry.
  2. Close the spec gap: add route.spec.ts for ingest/start, orchestrator/events/[jobId] (SSE framing incl. terminal failed), and pipeline/status/[jobId], in the style of the existing chat spec.
  3. Prefix stability: commit that /next/api/* keeps answering after any root cutover (Traefik alias rule or permanent redirect), or announce the cutover as a breaking change with a migration window.
  4. Optionally longer-term: extract the lanes into a headless BFF service — out of scope for this issue, tracked separately if the operator prefers extraction over blessing.

Acceptance criteria

  • Contract doc exists and is linked from docs/integrations/README.md; the relative-link check python scripts/check_docs_currency.py passes.
  • route.spec.ts present for all six routes; specs pin the auth behavior from issue 1 (401 unauthenticated) once that lands.
  • SSE frame vocabulary for orchestrator/events/{jobId} documented, including the terminal failed event Knoll's checklist flow depends on (plan 4.2).
  • Changelog entry announcing the stability promise (surfaces in /playground/changelog).
  • No docs/architecture.md §1 change (no route/port/prefix change); no new env flags, so no .env.example / CANONICAL_READERS change — confirm by running pytest pipeline-common/tests/test_env_flag_coverage.py unchanged.

Effort: S–M (three spec files + one contract doc; no runtime behavior change).

Risk notes: None at runtime. Process risk: without this, every frontend-next refactor is a potential silent break for Knoll — the mitigation until it lands is Knoll's own contract tests inside packages/grag-client (plan 2.5) plus the knoll-flow canary probe (plan 7.2).


Issue draft 3 — kg-service HTTP write route for entities + relationships (plan 1.11)

Title: kg-service: authenticated HTTP upsert route for entities+relationships (fail-loud, per-edge result)

Suggested labels: kg-service, enhancement

Context

Knoll Analyzer projects deterministic advisory facts into the knowledge graph after questionnaire submit and expert-report approval: edges like (:Client)-[:SCORES {score, traffic_light, analysis_id}]->(:SuccessLever) and (:Recommendation)-[:ADDRESSES]->(:SuccessLever) (plan 3.6, schema in 07-kg-schema-knoll-advisory.md). Entities can be written today via POST /kg-service/api/v1/entities/upsert — but relationships have no HTTP write path at all; edges ride only the internal Redis ingest envelope, whose consumer swallows AGE failures by design. An external backend that owns its facts needs a write route that fails loudly and reports per-edge outcomes so it can verify and reconcile.

Current behavior

  • DocumentIngestRequest is extra="forbid" and carries no entities[] / relationships[] fields — only document_id, source_artifact_id, title, language, metadata, segments, spans, crossreferences (kg-service/src/kg_service/api/schemas/documents.py:42–52). Sending extra fields → 422.
  • The HTTP entity surface is upsert/read-only: POST /entities/upsert (api/routes/entities.py:64), GET /entities/{id} (:88), GET /entities/{id}/neighborhood (:100), POST /entities/search (:159). There is no relationship route and no entity delete route (hence the tombstoning convention in plan 3.5).
  • Relationship item models already exist for the Redis envelope: IngestEntityItem and IngestRelationshipItem (from_entity_id, from_entity_type, to_entity_id, to_entity_type, relation_type, properties) in kg-service/src/kg_service/api/schemas/ingest.py:28–51.
  • The worker's AGE writes are best-effort: failures are swallowed and only counted on kg_service_age_mirror_failures_total{tenant,stage} (stage upsert_relationship); entity→entity edges have no relational copy and backfill_age replays vertices only — a lost edge is unrecoverable except by re-ingest (kg-service/CLAUDE.md §"AGE mirror is best-effort"). That posture is right for pipeline ingest, wrong for a caller that owns the fact.
  • The intent executor is read-only by construction (POST /api/v1/intents rejects CREATE/DELETE/SET/REMOVE/MERGE in Cypher templates) — writes must be REST routes, never Cypher templates. This proposal keeps that invariant.

Proposed change

New route on kg-service (auth = existing pipeline_common APIKeyMiddleware; workspace scoping = existing WorkspaceMiddleware(required=True)):

POST /api/v1/graph/upsert          (external: POST $GRAG_URL/kg-service/api/v1/graph/upsert)

Request body reuses the existing envelope item models:

{
  "entities": [
    { "entity_id": "lever:market-position", "entity_type": "SuccessLever",
      "label": "MarketPosition", "description": "Competitive position of the client", "properties": {} }
  ],
  "relationships": [
    { "from_entity_id": "client:hartmann-maschinenbau", "from_entity_type": "Client",
      "to_entity_id": "lever:market-position", "to_entity_type": "SuccessLever",
      "relation_type": "SCORES",
      "properties": { "score": 1.5, "traffic_light": "Red", "analysis_id": "a-02", "source": "knoll-backend" } }
  ]
}
curl -X POST "$GRAG_URL/kg-service/api/v1/graph/upsert" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-abc123" \
  -H "Content-Type: application/json" \
  -d @graph-upsert.json

Semantics (the deliberate differences from the worker path):

  1. Fail loudly. Entities go through the existing relational-authoritative EntityService.upsert_many; relationships go through EntityService.upsert_relationship (AGE MERGE, idempotent) — but an AGE failure is not swallowed: the route returns HTTP 502 (or 207-style partial detail) instead of a silent success. The kg_service_age_mirror_failures_total bump stays for observability.
  2. Verifiable response. Per-item outcome so the caller can confirm every edge landed: {"entities": {"inserted": n, "updated": m}, "relationships": {"merged": k, "failed": [ {index, reason} ]}} (plan 1.11: "include edge writes in the response so the caller can verify").
  3. Bounded: cap both lists (e.g. max_length=1000, matching EntityUpsertRequest, api/schemas/entities.py:24).
  4. Dark by default, following the KG_INGEST_ENABLED / KG_EMBEDDINGS_ENABLED convention: service setting KG_SERVICE_RELATIONSHIP_WRITES_ENABLED (default false → route returns 503), compose maps the single operator var KG_RELATIONSHIP_WRITES_ENABLED onto it.
  5. Cost-ledger emit record_cost(units=1, kind="requests", usd=0.0) per call, same as /entities/upsert; no lineage artifact (this is a projection write, not content production); never log entity ids raw (use safe_log.digest, per kg-service CLAUDE.md).
  6. Schema interplay: the route must NOT require the workspace's active schema to declare relationships — declared relationships are the trigger for auto-extraction (KG_RELATION_EXTRACTION_ENABLED), and Knoll's knoll-advisory v1 deliberately omits them (plan 3.4/D7). Deterministic writes take free-form relation_type, exactly like the envelope.

Acceptance criteria

  • Route implemented reusing IngestEntityItem / IngestRelationshipItem; OpenAPI reflects it.
  • Fail-loud test: a forced AGE failure on upsert_relationship returns 5xx with the failed index — NOT a 200 (contrast test against the worker's swallow behavior).
  • Idempotency test: same payload twice → same graph state (AGE MERGE), second response reports merged without duplicates.
  • Dark-flag test: flag off → 503; flag on → route live (mirror the KG_EMBEDDINGS_ENABLED / entity-match 503 pattern).
  • Tenant/workspace isolation probes: missing X-Workspace-ID → 400 (WorkspaceMiddleware(required=True)); cross-tenant read-back cannot see the edges (extend tests/integration/test_rls_cross_tenant.py posture to the new route).
  • Intent-executor read-only invariant untouched (no Cypher write templates added).
  • Doc mirror pairs: # KG_RELATIONSHIP_WRITES_ENABLED=false line in .env.example (+ decision for .env.production.example), tuple appended to CANONICAL_READERS in pipeline-common/tests/test_env_flag_coverage.py, kg-service README.md route table and kg-service/CLAUDE.md §"edges are NOT replayable" updated to note that HTTP-written edges are replayable by the caller (Knoll re-upserts from its own Postgres — plan 3.6 reconcile job). No docs/architecture.md §1 change (no new service/port/prefix), but run test_architecture_table.py before pushing.
  • Cost-ledger emit verified in tests.

Effort: M (route + service wiring exist as internal calls; the work is the fail-loud error shape, the flag, and the test/doc mirror set).

Risk notes: Edges remain AGE-only and thus a lossy store even with this route — the route makes failures visible, not durable. Consumers must keep their own source of truth and an idempotent reconcile job (Knoll: plan 3.6, alert on kg_service_age_mirror_failures_total). Do not extend this into a delete route casually — tombstoning via properties.status="inactive" (plan 3.5) stays the removal convention until a real delete design exists.


Issue draft 4 — ai-gateway structured output passthrough (plan 1.14)

Title: ai-gateway: pass response_format / tools / tool_choice through CompletionRequest to LiteLLM

Suggested labels: ai-gateway, enhancement, good-first-issue

Context

Knoll Analyzer's Gutachten pipeline runs a state machine of single-shot LLM steps that must return machine-parseable JSON (checklist classification plan 4.3, lead scoring 5.1a, Hebel scoring 5.3 step 4, Projektsteckbrief 5.5 — see 08-gutachten-pipeline-spec.md). The gateway today offers no way to request provider-enforced JSON: no response_format, no tools, no JSON mode. The interim is prompt-based JSON + Zod validation + bounded retries — which works but burns retry tokens and degrades on long German outputs. LiteLLM already supports these params; the gateway just doesn't forward them.

Current behavior

  • CompletionRequest (ai-gateway/src/ai_gateway/api/schemas/completion.py:21–56) carries exactly: model, messages, temperature, max_tokens, top_p, stop, BYOK fields (api_key/api_base/api_version), cache, fallback, fallback_policy. Nothing else.
  • The /api/v1/responses shim maps onto the same CompletionRequest (ai-gateway/src/ai_gateway/api/routes/responses.py) and adds nothing.
  • CompletionConfig (ai-gateway/src/ai_gateway/providers/base.py:416–424) has the same field set; LiteLLMProvider.complete() builds the litellm.acompletion(**kwargs) call from it and forwards only model/messages/temperature/max_tokens/top_p/stop (ai-gateway/src/ai_gateway/providers/litellm_provider.py:241–259).
  • Response side is already shape-agnostic: CompletionChoiceResponse.message is dict[str, Any] (completion.py:64–69), so tool_calls in a provider response would survive serialization untouched.

Proposed change

Small and additive — three optional fields threaded through the existing layers:

  1. CompletionRequest: add response_format: dict[str, Any] | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: str | dict[str, Any] | None = None. Pass-through typing (LiteLLM/OpenAI wire shapes), no gateway-side schema validation.
  2. CompletionConfig (providers/base.py): same three fields.
  3. LiteLLMProvider.complete(): add them to kwargs only when not None, next to the existing temperature/stop conditionals — absent fields keep the upstream request byte-identical, so existing traffic is unchanged and no dark flag is needed.
  4. Apply the same passthrough in the /api/v1/responses shim mapping.

Example call after the change:

curl -X POST "$GRAG_URL/ai-gateway/api/v1/chat/completions" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "Bewerte den Erfolgshebel Marktposition. Antworte als JSON."}],
    "response_format": {"type": "json_schema", "json_schema": {"name": "hebel_score",
      "schema": {"type": "object", "properties": {"score": {"type": "number"}, "begruendung": {"type": "string"}},
                 "required": ["score", "begruendung"], "additionalProperties": false}}},
    "fallback": ["anthropic/claude-sonnet-4-5"]
  }'

Acceptance criteria

  • All three fields accepted on POST /api/v1/chat/completions and the /responses shim; omitted fields produce a byte-identical upstream request (regression test on the built kwargs).
  • Passthrough test: response_format / tools / tool_choice reach litellm.acompletion(**kwargs) verbatim.
  • Cache correctness: two requests differing only in response_format must NOT share a cache entry. TODO-VERIFY: whether the content-keyed canonical body hash in ai-gateway/src/ai_gateway/api/core/cache.py already covers the full request body (then this is a test, not a change) or whitelists fields (then extend the key).
  • Fallback interplay test: a fallback chain carries the structured-output fields to every attempted model; an unsupported-parameter provider error surfaces to the caller under fallback_policy="retriable_only" (4xx is not retriable) rather than being masked.
  • Anonymization-firewall interplay documented: redact/restore operates on message/response text; placeholders like <PERSON_1> inside JSON string values survive restore — add one test with the firewall enabled and a JSON response containing a placeholder.
  • Doc mirrors: ai-gateway README.md request-field table and docs/API_REFERENCE.md completion section updated in the same PR; no new env flag → no .env.example / CANONICAL_READERS change (confirm test_env_flag_coverage.py unchanged).
  • Post-merge live verification: the curl above against https://app.grag.ai returns valid schema-conforming JSON.

Effort: S (three fields, three layers, tests).

Risk notes: Provider support is uneven — json_schema strict mode is provider-specific, and a fallback model may not support the primary's response_format; the gateway should stay a dumb pipe and let the provider error surface (callers keep Zod validation as the last line — Knoll retains its plan-2.5 helper even after this lands). tools passthrough makes the gateway relay function-calling loops it does not orchestrate — that is fine (single-shot), just don't grow an agent loop here (agent-control owns multi-turn).


Issue draft 5 — German PII / anonymization v2 (plan 1.10)

Title: anonymization: German language support (v2) + per-tenant firewall forcing for German-document tenants

Suggested labels: anonymization, enhancement, gdpr

Context

Knoll Analyzer processes German Mandanten material end-to-end — Fragebogen answers, Jahresabschlüsse, NDAs, Gutachten drafts — and sends German text to external LLM providers via ai-gateway on every pipeline step. GDPR data-minimization strongly favors redacting PII before it crosses the provider boundary, but the anonymization service is English-only at the API boundary and the gateway firewall is off in the prod baseline. Knoll's plan gates the pilot with real data on a documented PII posture (plan 0.11); this issue is option (a) of plan 1.10 — the other options ((b) accepted risk + EU provider endpoints via BYOK, (c) Knoll-side pseudonymization) need no platform change and are documented in 12-gdpr-compliance.md.

Current behavior

  • The API rejects non-English input by type: language: Literal["en"] on AnonymizeRequest (anonymization/src/anonymization/api/schemas/requests.py:24–30 — "non-en inputs are rejected at the API boundary") and on BatchAnonymizeRequest (requests.py:106). This applies to /anonymize, /anonymize/batch, /anonymize/stream.
  • The anonymization firewall in front of ai-gateway is disabled in the prod baseline: .env.production.example:60 GATEWAY_FIREWALL_ENABLED=false (comment: flip after ~1 week of GATEWAY_FIREWALL_SHADOW_MODE=true observation).
  • The multilingual capability partially exists already: the GLiNER2 stage uses fastino/gliner2-privacy-filter-PII-multi — "42 PII labels across 7 languages" (repo CLAUDE.md §Anonymization) — and the compose stack builds with INSTALL_GLINER2=true (anonymization/CLAUDE.md). The default stages list is ["presidio"] (requests.py:31–41), i.e. the rule-based + spaCy-NER stage, which is the English-tuned part.
  • Per-tenant overrides for anonymization/firewall behavior are modeled in the ADR 0030 tenant settings store ("guardrail mode, firewall, anonymization mode" listed as security-sensitive, admin-gated keys — repo CLAUDE.md §Per-tenant settings store). TODO-VERIFY: the exact SettingSpec key names for firewall/anonymization forcing in pipeline-common/src/pipeline_common/tenant_settings before wiring per-tenant enforcement.

Proposed change

  1. Accept language="de": widen the Literal to Literal["en", "de"] on all three request schemas. For de, default the stage plan to GLiNER2-forward: either stages=["gliner2"] by default, or ["presidio","gliner2"] with the Presidio NER backbone set to the GLiNER2 adapter (ANONYMIZATION_PRESIDIO_NLP_BACKEND=gliner2 keeps spaCy only for tokenization, so language-agnostic rule recognisers — IBAN, credit cards — keep working; anonymization/CLAUDE.md §presidio-nlp-backend). TODO-VERIFY: whether spaCy tokenization for German text requires shipping a de spaCy pipeline in the image or the current tokenizer is acceptable for offset-correct span replacement.
  2. German recognisers (rule stage): add German-specific Presidio recognisers where the rule layer carries the load: USt-IdNr. (DE VAT id), deutsche Steuernummer, Personalausweis-Nr., German phone formats. (IBAN is already country-agnostic.)
  3. Quality bar before default-on, following the repo's own convention for model-gated flags (KG_RELATION_EXTRACTION_ENABLED shipped only after a labelled fixture bar, see .env.production.example:443–446): a labelled German PII fixture set with a stated precision/recall bar, tracked in docs/runbooks/rag-quality-profile.md.
  4. Per-tenant firewall forcing: keep GATEWAY_FIREWALL_ENABLED=false globally, and let a tenant opt in via the existing ADR 0030 admin-gated settings so tenant knoll runs redact→restore on every /chat/completions without changing other tenants' posture. Run GATEWAY_FIREWALL_SHADOW_MODE=true on German traffic first, per the .env.production.example rollout note.
  5. Vault semantics unchanged (per-tenant AES-256-GCM, reveal audit) — German placeholders use the same <LABEL_N> scheme; no new storage.

Example call after the change:

curl -X POST "$GRAG_URL/anonymization/api/v1/anonymize" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Herr Thomas Hartmann (Hartmann Maschinenbau GmbH, USt-IdNr. DE123456789) erreichte uns unter +49 171 2345678.",
    "language": "de",
    "stages": ["presidio", "gliner2"],
    "strategy": "placeholder"
  }'

Acceptance criteria

  • language: "de" accepted on /anonymize, /anonymize/batch, /anonymize/stream; language values outside the supported set still 422.
  • German eval fixture set committed with a stated precision/recall bar (mirroring the relation-extraction quality-bar pattern) and a RUN_MODEL_TESTS=1 harness; results recorded in docs/runbooks/rag-quality-profile.md.
  • German rule recognisers (USt-IdNr., Steuernummer, DE phone) covered by unit tests with character-offset assertions (spans are character units over the original text — anonymization/CLAUDE.md §Detector outputs).
  • Placeholder round-trip test on German text: anonymize → vault reveal → byte-identical original; strategy=placeholder substitution never corrupts umlauts/ß offsets.
  • Per-tenant firewall forcing verified: with the tenant setting on for knoll only, a /ai-gateway/api/v1/chat/completions call for knoll shows a redact→restore audit firewall.decision row while tenant default traffic is untouched.
  • No raw PII in any log line added by this change (repo-wide discipline; verdict/digest logging only).
  • Doc mirrors: anonymization/README.md + MODEL_CARD.md/CLAUDE.md language matrix, ADR 0003 amendment for v2 language scope, .env.example lines for any new flag (e.g. a de preload/backend knob) + CANONICAL_READERS append per test_env_flag_coverage.py if the flag is cross-cutting.
  • Post-merge live verification: the curl above against https://app.grag.ai returns German placeholders for person, org, VAT id, and phone.

Effort: L (model evaluation + recognisers + calibration + firewall rollout choreography; code surface itself is moderate).

Risk notes: German recall is unproven until the fixture harness says otherwise — do not flip any default before the bar passes (same discipline as KG_RELATION_EXTRACTION_ENABLED). Latency: adding the GLiNER2 stage to every firewall-intercepted chat call adds an inference per request (weights lazy-load; consider ANONYMIZATION_GLINER2_PRELOAD=true in prod). For Knoll: whichever option is chosen, record the decision + AVV/TOMs in 12-gdpr-compliance.md and gate the pilot (plan 8.3) on it; interim option (c) — Knoll-side pseudonymization of Fragebogen fields before LLM calls — remains available without any platform change.


Filing order & cross-links

File 1 and 2 together (same surface; 2's specs pin 1's 401s). File 3 when Phase 3 provisioning starts (it gates plan 3.6, not 3.5). File 4 anytime — it is the cheapest and unblocks removing the retry-based interim from the Gutachten pipeline. File 5 as a decision issue first (option a/b/c per plan 1.10); convert to the v2 implementation issue only if option (a) is chosen.

Operational fallout of each gap while open is covered in 11-resilience-and-errors.md (error taxonomy, retries) and 05-verification-runbook.md (live probes 0.10a/0.10b); budget consequences of the retry-based structured-output interim are in 14-cost-model.md.