Skip to content

feat(policy): CH-Gateway Policy-Kern — Routing nach Schutzbedarf statt nur nach Aufgabentyp - #23

Merged
Baldri merged 21 commits into
mainfrom
claude/ch-angebot-ki-orchestrierung-abfebf
Aug 27, 2026
Merged

feat(policy): CH-Gateway Policy-Kern — Routing nach Schutzbedarf statt nur nach Aufgabentyp#23
Baldri merged 21 commits into
mainfrom
claude/ch-angebot-ki-orchestrierung-abfebf

Conversation

@Baldri

@Baldri Baldri commented Aug 27, 2026

Copy link
Copy Markdown
Owner

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, openai und google kannte; alles andere fiel auf 0.5 zurü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

Datei Verantwortung
src/main/routing/provider-registry.ts Anbieter mit Herkunft (Land, Betreiber, Lizenz, Hosting-Art, AVV) und Fähigkeiten; erzwingt I2
src/main/policy/policy-types.ts Ordnungsrelation für Schutzbedarf, Regel- und Entscheidtypen
src/main/policy/sensitivity-classifier.ts PII-Befunde + Arbeitsbereichsklasse → Schutzbedarf, Trefferzahlen je Detektorschicht
src/main/policy/policy-engine.ts Regelsatz auswerten → zulässige Anbietermenge
src/main/policy/audit-writer.ts Routing-Entscheid ins activity_log, ohne Inhalte

Geändert

  • src/shared/provider-types.ts — Herkunftstypen (Residency, HostingMode, WeightsLicense, DpaStatus, ProviderOrigin, ProviderCapabilities)
  • src/main/routing/intelligent-router.tsPROVIDER_CAPABILITIES entfernt, Bewertung über die Registry, || 0.5-Fallback im Provider-Pfad ersatzlos weg
  • src/main/services/service-layer.tsrouteWithPolicy verdrahtet klassifizieren → Policy → routen
  • .env.exampleINFOMANIAK_PRODUCT_ID, INFOMANIAK_TOKEN

15 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 test1364 passed | 29 skipped (89 Dateien), Exit 0
npm run typecheck → Exit 0

39 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:

Guard Sabotage Ergebnis
I1 Reihenfolge in routeWithPolicy die ungefilterte Registry an den Router expected 'us' to be 'ch'
I2 Residenz entry.origin.residency = 'CH' über eine gehaltene Referenz frischer Lesezugriff liefert weiter unknown
Audit-Sicherheit driver.original in reason einbauen Test rot
'low'-Grenze Regel ab minSensitivity: 'low' einsetzen 2 von 8 Tests rot

Bekannter, 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 auf 9662895 (vor diesen Änderungen) und 1 in 3 auf 2201abc, isoliert 5/5 grün. Nicht angefasst, separat eingeplant.

Review-Punkte

1. ollama trägt residency: 'CH' — und wäre in der gepackten App der einzige.
Nichts im Repo lädt .env (kein dotenv, kein loadEnv). Eine per GUI gestartete App hat INFOMANIAK_PRODUCT_ID also nie, seedSwissProviders registriert nichts, und die Regel sensitive-stays-ch wäre allein durch das lokale Modell erfüllt — mit residency: '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 eigener Residency-Wert 'on-device' plus .env-Laden in Plan 2. Produktentscheid, bewusst offen gelassen.

2. Bei Schutzbedarf 'low' greift keine Regel — jeder Anbieter zulässig, auch residency: 'unknown'. Spec-konform (§4.2), aber eine Produktzusage. Ein Grenztest hält die Stelle fest; verschieben kostet eine Zeile in DEFAULT_POLICY.

3. Die Fähigkeitswerte sind übernommene Schätzungen, keine Messungen. Der Infomaniak-Eintrag steht bewusst auf dem Unmeasured-Default 0.5 und models: [] — 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. routeWithPolicy hat keinen Produktionsaufrufer — gebaut ist die Bibliothek, der Aufrufer kommt mit Plan 2. hybrid-orchestrator.ts:176 ruft 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

  • Plan: docs/superpowers/plans/2026-08-26-ch-gateway-policy-core.md
  • Spec: docs/superpowers/specs/2026-08-26-ch-gateway-design.md
  • .env.example dokumentiert die kontospezifische Basis-URL

H1 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ührt swiss-ai/Apertus-v1.5-70B unter 11 Modellen; die URL-Form /2/ai/{product_id}/openai/v1 ist gegen die API verifiziert (401 gegen 404 mit Negativkontrolle).

🤖 Generated with Claude Code

Baldri and others added 21 commits August 26, 2026 11:12
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>
@Baldri
Baldri merged commit 80dd86f into main Aug 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant