feat(policy): CH-Gateway Policy-Kern — Routing nach Schutzbedarf statt nur nach Aufgabentyp - #23
Merged
Merged
Conversation
Design for a Swiss governance gateway over multi-provider LLM routing: policy decides which model may handle a request, the router picks the best one inside that permitted set, and every decision is auditable. Decisions captured (2026-08-26): own gateway with the web UI as the default and the desktop app as a hardening tier; CH tokens always on our contract at cost with prepaid credit, frontier either BYOK or pooled; rented CH endpoints by default with self-hosting on concrete request; gateway hosted in Switzerland, which supersedes ADR-053/054 for this product and needs its own ADR; 8-12 days per month of capacity. Grounded in measurements against this worktree rather than project docs: the OpenAI-compatible client and custom-provider plumbing already exist, the router classifies task type but not sensitivity, ProviderConfig carries no origin attributes, PROVIDER_CAPABILITIES is hardwired to three provider ids, and ActivityLogger is imported by nothing but its own unit test. Also folds in the existing Apertus work (strategy paper 2026-07-31, the LoRA measurement series, the eval-framework suitability runner) so its constraints are not re-derived: the AUP output-filter duty that makes the response-side filter chain a product feature, the ban on justifying data protection with model origin, and the fact that the Ollama Apertus quantisations are v1.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The §4.4 wording promised that "structured identifiers — AHV, IBAN, phone, email, address" never leave the device in the web variant. Measured against the detectors, "address" overclaims: layer 2 only matches Swiss forms (CH_STREET_PATTERN requires a German street suffix, CH_PLZ_CITY_PATTERN a four-digit Swiss postcode). A foreign address falls through to layer 3 and comes back as LOCATION, and person names come from layer 3 exclusively — which runs server-side in the web variant. Replaces the sentence with the per-layer category table and the precise claim, and makes the app tier's added value concrete: in the web the structured identifiers and Swiss addresses stay on the device and names are replaced in the Swiss gateway; in the app the names stay on the device too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the CH gateway spec, both from the same question: does "stays in Switzerland" hold, and is BYOK an exception? The §4.4 promise said names and locations "never leave Switzerland". That is wrong by design — the whole point of the gateway is that non-sensitive requests may go to frontier models, which sit outside. What is actually guaranteed is that recognised personal data is replaced first and the mapping table does not travel; the remaining text follows the policy. The stronger claim describes a local-only product, which this is not. Adds two invariants that were only implicit in the data-flow diagram: I1 — the credential resolver runs after the policy. Residency is a property of the endpoint, the contract model a property of the key. BYOK is not a residency exception, only a different processing chain; because the resolver runs on an already-filtered set, a tenant key cannot reach a disallowed provider. Reorder those steps and BYOK becomes a bypass. I2 — residency is set by us, never by the tenant. Mingly lets tenants register custom providers with a free-form apiBase; in the gateway such an endpoint must not be able to claim residency CH. It gets residency unknown and is excluded above the lowest sensitivity class until we verify it. Otherwise one mislabelled endpoint defeats the whole promise and the audit trail records the breach as compliant. Both invariants get their own tests in §9 — they are bypasses, not edge cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same PII category arrives from layers with different reach and, in the web deployment, a different processing location: ADDRESS comes from layer 2 for Swiss forms only and from layer 3 generally. Checking the category alone treats both as equivalent, which is exactly what the §4.4 claim rests on. Narrower than first assumed: PIIEntity.source already exists and every detector sets it. It is read today only for dedup priority in detector-pipeline.ts:166-179. I3 gives it a second job — a binding mapping from source to processing location, so the audit entry carries hit counts per source rather than per category alone, and the privacy claim is provable per request instead of asserted architecturally. Also records that the mapping shifts in the desktop app: ner moves from gateway to device without detector or category changing. Processing location is therefore a property of execution environment times layer, and belongs in the log as that pair rather than as an inference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight TDD tasks that make Mingly route by protection level instead of task type alone: provider registry carrying origin, sensitivity classifier over the existing PII findings, declarative versioned policy engine, router reading capabilities from the registry, audit writer, wiring, and the first Swiss endpoint. Scoped deliberately to one of the spec's four subsystems. The gateway service, the credit ledger, the web client and the suitability run each produce working software on their own and depend on decisions this plan answers, so they get their own plans — listed at the end with their boundaries so nothing is lost. Invariants I1-I3 get bypass tests rather than edge-case tests: the best-scoring provider must lose to the policy, an empty permitted set must not resolve to a provider, a tenant endpoint claiming CH must not receive a sensitive request, and the same category from two detector layers must stay distinguishable in the audit entry. The Swiss endpoint is registered with unmeasured capability scores on purpose — no published benchmark figures exist for Apertus 1.5 and the suitability run has not been executed, so asserting a score would contradict what the offering sells. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan hardcoded https://api.infomaniak.com/1/ai/v1, which I had assumed rather than verified. Checked against the developer portal on 2026-08-26: the real form is /2/ai/{product_id}/openai/v1, with one AI product per organisation — so the base URL is account-specific configuration, not a constant. seedSwissProviders now takes the product id and registers nothing when none is configured, rather than a URL that cannot answer. Same check found that Apertus availability at Infomaniak is unverified: the Openstream table in the assessment claims it, the Infomaniak getting-started guide does not mention it, and the authoritative answer is GET /models against the account. That query is now an explicit step in the human task. The handoff assigns one plan task per subagent with its brief, deliverable, verification command and the one point that must not be watered down, plus the review points that need Holger. Waves follow file conflicts rather than themes — S5 and S8 both touch provider-registry.ts and cannot run in parallel. H1 (Infomaniak access) blocks only wave 4, so it can happen while waves 1-3 run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1 ist abgeschlossen: das AI-Tools-Produkt ist bestellt und die Modellliste des Kontos fuehrt swiss-ai/Apertus-v1.5-70B. Der Slice kann also gegen Apertus vorgefuehrt werden; der Ausweichweg ueber Safe Swiss Cloud oder PHOENIQS entfaellt. Die Basis-URL ist kontospezifisch und enthaelt die Produkt-ID, deshalb baut seedSwissProviders sie aus INFOMANIAK_PRODUCT_ID statt aus einer Anbieterkonstanten. Ohne gesetzte ID wird nichts registriert (Task 8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Residency, operator, weights licence, hosting mode and DPA status become properties of a provider. registerTenant forces residency to unknown so a tenant-supplied endpoint cannot claim Swiss processing (invariant I2).
…nce mutation
registerVerified and registerTenant now spread origin/capabilities and freeze
the resulting objects, plus freeze the RegistryEntry itself. This blocks both
field assignment (entry.origin.residency = 'CH') and object replacement
(entry.origin = {...}), ensuring the audit trail remains compliant.
Test added to verify read path is safe: mutation via returned reference must
not affect subsequent reads.
Reuses PIISensitivity as the scale so a PII finding and a workspace class compare without a translation table that could drift.
First production caller of ActivityLogger, which until now was imported only by its own unit test. Records level, applied rule, policy version, permitted set, chosen provider, residency and per-source hit counts — never content.
Add key-set assertion to 'never writes prompt or response text' test. Ensures the exact set of keys logged matches the explicit allowlist: allowedProviders, appliedRule, bySource, chosenProvider, level, model, policyVersion, reason, residency. This turns a substring check against a fixture into a real structural invariant that fails if unexpected fields (content, prompt, messageHistory, etc) are ever added.
… class Counts hits per DetectionSource rather than per category (invariant I3): the same category reaches us from layers with different reach and different processing location.
Ensure that reason field never contains detected text (entity.original), which would leak PII into the audit log passed to supervisory authorities. The test guarantees that well-meaning future improvements to reason informativeness cannot accidentally break this safety invariant.
… set An empty permitted set stays empty — falling back to any provider would turn the strictest case into the most permissive one.
Explicitly test that unknown-residency endpoints are permitted only at the lowest protection level, catching silent boundary shifts in DEFAULT_POLICY.
PROVIDER_CAPABILITIES was hardwired to anthropic/openai/google and every other provider fell back to 0.5 — a Swiss endpoint was a provider without properties. Adding a European model is now a registry entry, not a code change. An empty permitted set no longer resolves to a provider.
Capabilities stay at the unmeasured default on purpose: no published benchmark figures exist for Apertus 1.5 and the suitability run has not been executed against this endpoint. The offering sells a measurement, so the registry must not assert one.
routeWithPolicy runs the three steps in the only order that holds invariant I1: the router receives an already-filtered set and can only narrow it. Invariants I1 and I2 get bypass tests rather than edge-case tests — the best-scoring provider must lose to the policy, an empty permitted set must not resolve, and a tenant endpoint claiming CH must not receive a sensitive request.
…its parts The three original invariant tests composed classify/evaluate/route by hand and never called routeWithPolicy — swapping the router and the policy inside that method left all of them green. Add a fourth bypass test that constructs a real ServiceLayer and calls routeWithPolicy directly: a CH provider scored lowest on every axis, a US provider scored highest, a critical-sensitivity entity. Only classify-then-evaluate-then-route resolves to 'ch'; routing first over the unfiltered registry resolves to 'us' on score alone. ServiceLayer's other constructor dependencies (client manager, network AI manager, system prompt manager, command handler, context injector, tracking engine) are mocked to no-ops — none of them run inside routeWithPolicy, they exist only so construction does not require Electron's `app` or a live database. The database module and the ollama package are mocked following the precedent in policy-audit-writer.test.ts and intelligent-router.test.ts. The router field is left real: it is what carries out the invariant.
…s endpoint Fix wave from final whole-branch review, three findings: - intelligent-router.ts: selectProvider's doc comment claimed the caller always runs policy first. False for hybrid-orchestrator.ts and getSuggestion, which call route() unfiltered — only ServiceLayer.routeWithPolicy filters first. Comment now states only what the code guarantees (never widens the set) and names which caller is actually responsible for policy filtering. - provider-registry.ts: seedSwissProviders silently no-ops when INFOMANIAK_PRODUCT_ID is unset, which is correct behaviour but was inaudible - a packaged app has no dotenv loading, so the Swiss endpoint can vanish with nothing logged. Now warns (once, on the early-return path only) naming the missing variable. Early-return behaviour is unchanged; added a test asserting the no-op guarantee and that the warning fires. - policy-engine.ts: the comment above the rule-selection reduce claimed outcome never depends on list order, but the reduce uses strict `>`, so two rules sharing the same minSensitivity are resolved by position. Comment now states this honestly and flags such a tie as a rule-set authoring error. No behaviour change to routing, policy evaluation, or the Swiss early-return guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Was
Eine Policy-Schicht entscheidet, welche Anbieter eine Anfrage überhaupt bearbeiten dürfen. Der bestehende Router wählt nur noch innerhalb dieser Menge, und jeder Entscheid landet nachweisbar im Audit-Log — ohne Inhalte.
Warum
Bisher routete Mingly nach Aufgabentyp gegen eine hart verdrahtete Fähigkeitstabelle, die nur
anthropic,openaiundgooglekannte; alles andere fiel auf0.5zurück. Ein Schweizer Endpunkt war für den Router damit ein Modell ohne Eigenschaften. Für ein Angebot, das Datenresidenz verkauft, muss die Herkunft eines Endpunkts eine Eigenschaft im Code sein, keine Annahme.Änderungen
Neu
src/main/routing/provider-registry.tssrc/main/policy/policy-types.tssrc/main/policy/sensitivity-classifier.tssrc/main/policy/policy-engine.tssrc/main/policy/audit-writer.tsactivity_log, ohne InhalteGeändert
src/shared/provider-types.ts— Herkunftstypen (Residency,HostingMode,WeightsLicense,DpaStatus,ProviderOrigin,ProviderCapabilities)src/main/routing/intelligent-router.ts—PROVIDER_CAPABILITIESentfernt, Bewertung über die Registry,|| 0.5-Fallback im Provider-Pfad ersatzlos wegsrc/main/services/service-layer.ts—routeWithPolicyverdrahtet klassifizieren → Policy → routen.env.example—INFOMANIAK_PRODUCT_ID,INFOMANIAK_TOKEN15 Commits. Die Fähigkeitswerte für Anthropic, OpenAI und Google sind Zahl für Zahl aus der gelöschten Tabelle übernommen — geprüft, keine Drift.
Tests
npm test→ 1364 passed | 29 skipped (89 Dateien), Exit 0npm run typecheck→ Exit 039 neue Tests. Kein bestehender Test verändert. Die Invariantentests sind als Umgehungstests geschrieben, und für jeden tragenden Guard wurde der Rotlauf tatsächlich provoziert:
routeWithPolicydie ungefilterte Registry an den Routerexpected 'us' to be 'ch'entry.origin.residency = 'CH'über eine gehaltene Referenzunknowndriver.originalinreasoneinbauen'low'-GrenzeminSensitivity: 'low'einsetzenBekannter, vorbestehender Flake, nicht von diesem Branch verursacht:
tests/e2e/app.spec.ts > Code Splitting(5337 ms gegen 5000-ms-Limit). Gemessen 1 Ausfall in 7 Läufen auf9662895(vor diesen Änderungen) und 1 in 3 auf2201abc, isoliert 5/5 grün. Nicht angefasst, separat eingeplant.Review-Punkte
1.
ollamaträgtresidency: 'CH'— und wäre in der gepackten App der einzige.Nichts im Repo lädt
.env(keindotenv, keinloadEnv). Eine per GUI gestartete App hatINFOMANIAK_PRODUCT_IDalso nie,seedSwissProvidersregistriert nichts, und die Regelsensitive-stays-chwäre allein durch das lokale Modell erfüllt — mitresidency: 'CH'im Audit-Log für eine Inferenz, die überall laufen kann. Seit dem Schlussreview warnt es statt zu schweigen. Die saubere Lösung ist ein eigenerResidency-Wert'on-device'plus.env-Laden in Plan 2. Produktentscheid, bewusst offen gelassen.2. Bei Schutzbedarf
'low'greift keine Regel — jeder Anbieter zulässig, auchresidency: 'unknown'. Spec-konform (§4.2), aber eine Produktzusage. Ein Grenztest hält die Stelle fest; verschieben kostet eine Zeile inDEFAULT_POLICY.3. Die Fähigkeitswerte sind übernommene Schätzungen, keine Messungen. Der Infomaniak-Eintrag steht bewusst auf dem Unmeasured-Default
0.5undmodels: []— für Apertus 1.5 existieren keine publizierten Benchmarkzahlen, und die Eignungsprüfung ist gegen diesen Endpunkt nicht gelaufen.Was dieser PR NICHT tut
Die App routet noch nicht nach Schutzbedarf.
routeWithPolicyhat keinen Produktionsaufrufer — gebaut ist die Bibliothek, der Aufrufer kommt mit Plan 2.hybrid-orchestrator.ts:176ruft den Router weiterhin ungefiltert auf; das ist Bestandsverhalten und steht jetzt im Kommentar, statt dort das Gegenteil zu behaupten.Die Vollform von I1 — Credential-Resolver nach der Policy — ist hier nicht prüfbar, weil es den Resolver erst in Plan 2 gibt. Gesichert ist die Vorstufe: Policy vor Router.
Dokumentation
docs/superpowers/plans/2026-08-26-ch-gateway-policy-core.mddocs/superpowers/specs/2026-08-26-ch-gateway-design.md.env.exampledokumentiert die kontospezifische Basis-URLH1 erledigt: Infomaniak-AI-Produkt bestellt, AVV seit 27.08.2026 in Kraft (elektronisch, ohne Unterschrift gültig — daher
dpaStatus: 'signed'). Die Modellliste des Kontos führtswiss-ai/Apertus-v1.5-70Bunter 11 Modellen; die URL-Form/2/ai/{product_id}/openai/v1ist gegen die API verifiziert (401 gegen 404 mit Negativkontrolle).🤖 Generated with Claude Code