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-devtenant 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-clientwraps 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 viaPOST /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_runsstate 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
| # | Gap | Plan task | Issue status | Blocking what |
|---|---|---|---|---|
| 1 | BFF /next/api/* authentication | 1.8 | #388 — filed 2026-08-12 | All real Mandanten data (0.11 go/no-go); D3/D4 lanes; plan 4.1, 5.1a/5.1b, 5.2; go-live 8.5 |
| 2 | BFF lane contract stability | 1.9 | #389 — filed 2026-08-12 | Safe consumption of chat/upload/ingest/events lanes from Phase 2 onward; 7.2 canary |
| 3 | KG entities+relationships HTTP write route | 1.11 | #390 — filed 2026-08-12 | Deterministic KG writer 3.6; edge projections for 3.7/3.8; reconcile job |
| 4 | ai-gateway structured output passthrough | 1.14 | #391 — filed 2026-08-12 | Nothing hard (interim: prompt-JSON + Zod per 2.5); quality/robustness of 4.3, 5.1a, 5.3, 5.5 |
| 5 | German PII / anonymization v2 | 1.10 | #392 — filed 2026-08-12 | Pilot 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.tsanywhere infrontend-next/(verified by filesystem search). The legacyfrontend/src/middleware.tsBasic-auth gate was never ported. - The Traefik router for the container attaches no auth middleware —
docker-compose.yml:3202–3212carries onlyPathPrefix(\/next`),priority=10,entrypoints=web`, and the port label. ADMIN_PASSWORD/PLAYGROUND_ENABLEDare passed as env atdocker-compose.yml:3136–3137with 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()readsprocess.env.PIPELINE_API_KEY, attached at:275) andfrontend-next/apps/web/app/api/upload/start/route.ts:31–32,68,115,149. - The tenant is attacker-chosen:
resolveTenant()infrontend-next/apps/web/src/lib/tenant.ts:35–43takes the inboundX-Tenant-IDheader from any caller, falling back toTENANT_IDenv, thendefault.
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 |
|---|---|---|
| 1 | POST /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. |
| 2 | Same, with no X-Tenant-ID header | HTTP 200 — silently used tenant default. |
| 3 | POST /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. |
| 4 | GET /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:
- Bearer check for machine callers: validate
Authorization: Bearer <token>against the union ofPIPELINE_API_KEY(comma-list) and theGATEWAY_CALLERSname:tokenmap — mirroringAPIKeyMiddleware+parse_callers()inpipeline-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 theGATEWAY_CALLERSattribution model for these lanes). - Browser/operator path preserved: HTML pages and same-origin browser fetches from the
operator console authenticate via the
ADMIN_PASSWORDHTTP Basic gate (port the legacyfrontend/src/middleware.tsbehavior) — 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. - Public paths: keep
/nexthealthcheck/static assets exempt, mirroring thePUBLIC_PATHSposture ofpipeline_common.middleware(/health,/ready, docs). - Dark by default, fail-closed when on: new flag
FRONTEND_NEXT_API_AUTH_ENABLED(defaultfalse→ behavior unchanged for one release; settruein.env.production.example). Whentruewith an empty key set, refuse to start unlessPIPELINE_ALLOW_NO_AUTH=1— the sameAuthMisconfigurationErrorposture aspipeline-common/src/pipeline_common/middleware.py:62–70. - SSE routes (
/api/chat/stream,/api/orchestrator/events/{jobId}) must reject before the stream opens (plain 401 JSON, not anerrorSSE 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.tsexists; every/next/api/*route returns 401 without credentials and 200/SSE withAuthorization: Bearer $GRAG_API_KEY. - Caller attribution: a
GATEWAY_CALLERS=knoll:<token>entry authenticates and the resolved caller name reaches the audit actor (no moreanonymousfor 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 + noPIPELINE_ALLOW_NO_AUTH→ hard startup failure. - Canary: a BFF-auth probe (unauthenticated
POST /next/api/chat/streammust 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=falsecommented line added to.env.example,.env.production.examplesets ittrue, and the flag tuple appended toCANONICAL_READERSinpipeline-common/tests/test_env_flag_coverage.pyin the same PR. -
docs/architecture.md§1 registry: no route/port/prefix change, so no row change — but runpytest pipeline-common/tests/test_architecture_table.pyanyway before pushing. - Deploy workflow passes the new flag through (
.github/workflows/deploy.ymlenv block). - Post-merge live verification per repo
CLAUDE.md: curl the live/next/api/chat/streamunauthenticated (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 TraefikPathPrefix(\/next`)router (docker-compose.yml:3209`). - Spec coverage is partial:
route.spec.tsfiles exist forchat/stream,upload/start, andchat/chunk/[docId]/[ordinal], but not foringest/start,orchestrator/events/[jobId], orpipeline/status/[jobId](verified by filesystem search underfrontend-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 listhistory → 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–3126states the app "can be lifted unchanged. The /api/* proxy routes were copied verbatim" — i.e. the/nextprefix may move, which is exactly the kind of change an external consumer must survive.
Proposed change
- Declare the contract: a short
docs/integrations/bff-external-contract.mdenumerating, for each of the six routes above: method, path, auth (per issue 1), request body fields with types/defaults (ChatRequestinapp/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. - Close the spec gap: add
route.spec.tsforingest/start,orchestrator/events/[jobId](SSE framing incl. terminalfailed), andpipeline/status/[jobId], in the style of the existing chat spec. - 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. - 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 checkpython scripts/check_docs_currency.pypasses. -
route.spec.tspresent 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 terminalfailedevent 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_READERSchange — confirm by runningpytest pipeline-common/tests/test_env_flag_coverage.pyunchanged.
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
DocumentIngestRequestisextra="forbid"and carries noentities[]/relationships[]fields — onlydocument_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:
IngestEntityItemandIngestRelationshipItem(from_entity_id,from_entity_type,to_entity_id,to_entity_type,relation_type,properties) inkg-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}(stageupsert_relationship); entity→entity edges have no relational copy andbackfill_agereplays 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/intentsrejectsCREATE/DELETE/SET/REMOVE/MERGEin 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):
- Fail loudly. Entities go through the existing relational-authoritative
EntityService.upsert_many; relationships go throughEntityService.upsert_relationship(AGEMERGE, idempotent) — but an AGE failure is not swallowed: the route returns HTTP 502 (or 207-style partial detail) instead of a silent success. Thekg_service_age_mirror_failures_totalbump stays for observability. - 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"). - Bounded: cap both lists (e.g.
max_length=1000, matchingEntityUpsertRequest,api/schemas/entities.py:24). - Dark by default, following the
KG_INGEST_ENABLED/KG_EMBEDDINGS_ENABLEDconvention: service settingKG_SERVICE_RELATIONSHIP_WRITES_ENABLED(defaultfalse→ route returns 503), compose maps the single operator varKG_RELATIONSHIP_WRITES_ENABLEDonto it. - 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 (usesafe_log.digest, per kg-serviceCLAUDE.md). - 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'sknoll-advisoryv1 deliberately omits them (plan 3.4/D7). Deterministic writes take free-formrelation_type, exactly like the envelope.
Acceptance criteria
- Route implemented reusing
IngestEntityItem/IngestRelationshipItem; OpenAPI reflects it. - Fail-loud test: a forced AGE failure on
upsert_relationshipreturns 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
mergedwithout 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 (extendtests/integration/test_rls_cross_tenant.pyposture to the new route). - Intent-executor read-only invariant untouched (no Cypher write templates added).
- Doc mirror pairs:
# KG_RELATIONSHIP_WRITES_ENABLED=falseline in.env.example(+ decision for.env.production.example), tuple appended toCANONICAL_READERSinpipeline-common/tests/test_env_flag_coverage.py, kg-serviceREADME.mdroute table andkg-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). Nodocs/architecture.md§1 change (no new service/port/prefix), but runtest_architecture_table.pybefore 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 LiteLLMSuggested 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/responsesshim maps onto the sameCompletionRequest(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 thelitellm.acompletion(**kwargs)call from it and forwards onlymodel/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.messageisdict[str, Any](completion.py:64–69), sotool_callsin a provider response would survive serialization untouched.
Proposed change
Small and additive — three optional fields threaded through the existing layers:
CompletionRequest: addresponse_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.CompletionConfig(providers/base.py): same three fields.LiteLLMProvider.complete(): add them tokwargsonly when not None, next to the existingtemperature/stopconditionals — absent fields keep the upstream request byte-identical, so existing traffic is unchanged and no dark flag is needed.- Apply the same passthrough in the
/api/v1/responsesshim 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/completionsand the/responsesshim; omitted fields produce a byte-identical upstream request (regression test on the builtkwargs). - Passthrough test:
response_format/tools/tool_choicereachlitellm.acompletion(**kwargs)verbatim. - Cache correctness: two requests differing only in
response_formatmust NOT share a cache entry. TODO-VERIFY: whether the content-keyed canonical body hash inai-gateway/src/ai_gateway/api/core/cache.pyalready 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.mdrequest-field table anddocs/API_REFERENCE.mdcompletion section updated in the same PR; no new env flag → no.env.example/CANONICAL_READERSchange (confirmtest_env_flag_coverage.pyunchanged). - Post-merge live verification: the curl above against
https://app.grag.aireturns 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 tenantsSuggested 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"]onAnonymizeRequest(anonymization/src/anonymization/api/schemas/requests.py:24–30— "non-en inputs are rejected at the API boundary") and onBatchAnonymizeRequest(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:60GATEWAY_FIREWALL_ENABLED=false(comment: flip after ~1 week ofGATEWAY_FIREWALL_SHADOW_MODE=trueobservation). - The multilingual capability partially exists already: the GLiNER2 stage uses
fastino/gliner2-privacy-filter-PII-multi— "42 PII labels across 7 languages" (repoCLAUDE.md§Anonymization) — and the compose stack builds withINSTALL_GLINER2=true(anonymization/CLAUDE.md). The defaultstageslist 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 exactSettingSpeckey names for firewall/anonymization forcing inpipeline-common/src/pipeline_common/tenant_settingsbefore wiring per-tenant enforcement.
Proposed change
- Accept
language="de": widen the Literal toLiteral["en", "de"]on all three request schemas. Forde, default the stage plan to GLiNER2-forward: eitherstages=["gliner2"]by default, or["presidio","gliner2"]with the Presidio NER backbone set to the GLiNER2 adapter (ANONYMIZATION_PRESIDIO_NLP_BACKEND=gliner2keeps 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 adespaCy pipeline in the image or the current tokenizer is acceptable for offset-correct span replacement. - German recognisers (rule stage): add German-specific Presidio recognisers where the rule
layer carries the load: USt-IdNr. (
DEVAT id), deutsche Steuernummer, Personalausweis-Nr., German phone formats. (IBAN is already country-agnostic.) - Quality bar before default-on, following the repo's own convention for model-gated flags
(
KG_RELATION_EXTRACTION_ENABLEDshipped 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 indocs/runbooks/rag-quality-profile.md. - Per-tenant firewall forcing: keep
GATEWAY_FIREWALL_ENABLED=falseglobally, and let a tenant opt in via the existing ADR 0030 admin-gated settings so tenantknollruns redact→restore on every/chat/completionswithout changing other tenants' posture. RunGATEWAY_FIREWALL_SHADOW_MODE=trueon German traffic first, per the.env.production.examplerollout note. - 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;languagevalues 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=1harness; results recorded indocs/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=placeholdersubstitution never corrupts umlauts/ß offsets. - Per-tenant firewall forcing verified: with the tenant setting on for
knollonly, a/ai-gateway/api/v1/chat/completionscall forknollshows a redact→restore auditfirewall.decisionrow while tenantdefaulttraffic 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.mdlanguage matrix, ADR 0003 amendment for v2 language scope,.env.examplelines for any new flag (e.g. adepreload/backend knob) +CANONICAL_READERSappend pertest_env_flag_coverage.pyif the flag is cross-cutting. - Post-merge live verification: the curl above against
https://app.grag.aireturns 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.