Subprocessor register API
The subprocessor register is a GLOBAL, operator-owned list of Myra's own sub-processors (LLM
providers etc.), published on the public Trust-Center page /trust for Art. 28 Abs. 6 DSGVO
(clause 6 of the AVV) transparency. It is not tenant-scoped and holds no end-user personal
data — the same data class as the product catalog or vouchers — so it carries no GDPR
erasure/export/tenant-purge wiring. /privacy §5.1 links to /trust.
Event-sourced model (one source of truth)
The register is stored as an append-style event log (subprocessor_event, migration 0301):
each row is a lifecycle event about one sub-processor, identified by a stable
subprocessor_key:
| kind | meaning |
|---|---|
added |
the sub-processor was (or, with a future date, will be) engaged |
changed |
a material change to an engaged sub-processor took effect (with an optional note) |
removed |
the sub-processor stopped being engaged |
The three sections the public page renders are all derived from this one log on read — nothing stores "current state" separately, so there is no second table to keep in sync:
- Current register — for each key, if the latest event on or before today leaves it engaged
(i.e. the walk ends on an
added/changed, not aremoved), the sub-processor is shown with its latest attributes and aneffective_since= the date the current engagement run began (a vendor removed then re-added reports the re-engagement date, never a span it was absent). - Upcoming changes — events with a future effective date.
- Change history — every effective event, newest first.
A changed/removed event for a key with no open engagement (e.g. before its first added) is a
data-entry mistake. It is handled on both sides (AGF-2641):
- Rejected at write —
POST(and aPATCHthat would produce the same inconsistency on the edited event) refuses it with400 { "code": "no_open_engagement" }, so a mistyped vendor key is caught immediately instead of silently vanishing. "No open engagement" means that, placed at its effective date in the key's event timeline, the vendor has not beenadded(or was alreadyremoved) — i.e. exactly the event the read derivation would suppress. Anaddedis never refused on this ground; the admin console surfaces the message next to the vendor-key field. - Suppressed on read — the derivation still drops any such event from all three sections
(defence-in-depth): the public page never republishes a removed vendor, and an edit/delete that
transitively orphans a different event (e.g. deleting an opening
added) is caught here even though the write guard scopes itself to the created/edited event. Ops corrects a mistake by editing/deleting the offending event.
Public read — GET /admin/auth/trust
Public, no authentication (served under the admin-auth dispatcher, like the login-branding and
signup readiness probes). No request parameters. Rate-limited per IP (a generous floor) and served
with Cache-Control: public, max-age=300 (the register is identical for every viewer). This is the
endpoint the /trust SPA page calls.
Response (200): three arrays, each possibly empty.
{
"register": [
{ "name": "Anthropic, PBC", "service_purpose": "AI Model", "location": "USA",
"data_processing": "Customer Content", "transfer_mechanism": "SCC", "effective_since": "2025-01-01" }
],
"upcoming": [
{ "kind": "added", "effective_date": "2026-11-01", "name": "Example Cloud Ltd.", "note": null }
],
"history": [
{ "kind": "changed", "effective_date": "2026-06-01", "name": "Vendor XYZ", "note": "processing location changed" },
{ "kind": "added", "effective_date": "2025-01-01", "name": "Anthropic, PBC", "note": null }
]
}
Dates are ISO YYYY-MM-DD calendar days (the SPA renders them locale-formatted). The payload is
display-only: the internal columns id, subprocessor_key, created_at, and created_by are
never emitted. Rejected: 429 when rate-limited; 500 (generic body) on a backend failure
— the driver error is logged, never returned.
Admin management — /admin/v1/subprocessors
All routes require the SUBPROCESSORS_MANAGE permission (gate class platform → platform
admin / super-admin only). Every field is validated fail-closed in storage; a rejection
carries a stable machine-readable code (the value a client branches on) alongside the prose
error (operator/log text — never parse it). The client is never the authorization boundary.
| Method & path | Purpose |
|---|---|
GET /admin/v1/subprocessors |
list every event (all columns, incl. created_by/created_at), ordered by effective date, newest first |
POST /admin/v1/subprocessors |
create one lifecycle event |
PATCH /admin/v1/subprocessors/{id} |
edit an existing event (fix a mis-entry) — partial |
DELETE /admin/v1/subprocessors/{id} |
delete a mis-entered event |
Accepted body (POST / PATCH)
{
"subprocessor_key": "anthropic",
"kind": "added",
"effective_date": "2025-01-01",
"name": "Anthropic, PBC",
"service_purpose": "AI Model",
"location": "USA",
"data_processing": "Customer Content",
"transfer_mechanism": "SCC",
"note": null
}
Field rules (each is validated as an explicit type — a non-string is rejected, never coerced; an absent required field and a malformed one are different answers):
subprocessor_key— required; slug^[a-z0-9][a-z0-9_-]*$, ≤ 64 chars. Groups all events about one vendor.kind— required; exactlyadded|changed|removed.effective_date— required; a real calendar dayYYYY-MM-DDwith the year in[2000, 2100].name— required (on every kind, so a removal line stands alone); ≤ 200 chars.service_purpose,location,data_processing,transfer_mechanism— required foradded/changed, ignored forremoved; ≤ 300 / 200 / 300 / 120 chars.note— optional operator detail for achangedevent (≤ 300 chars); a present-but-malformed value is rejected (it never silently degrades to "absent").
created_by is bound to the authenticated admin session, never taken from the body. Unknown
keys are ignored (never reflected). A PATCH merges the supplied raw values over the stored event
and re-validates the merged event — a supplied-but-malformed field is rejected (400), never
kept; an omitted key keeps the stored value.
Responses
201 { "id": "..." }— created.200 { "ok": true }— patched / deleted.200 [ ...events ]— the admin list.400 { "error": "...", "code": "<field>_required" | "<field>_invalid" | "kind_invalid" | "subprocessor_key_invalid" | "no_open_engagement" | "no_fields" }— validation rejection (no_open_engagement: achanged/removedevent for a vendor with no open engagement at its effective date — see Event-sourced model;no_fields: aPATCHsupplying no editable field).400 { "error": "invalid request body" }— a non-object / malformed body.401— no session;403— authenticated but not a platform admin.404 { "code": "not_found" }—PATCH/DELETEof an unknown id.500— infrastructure failure (generic body; the driver error is logged, not returned).
Subprocessor → provider mapping — /admin/v1/subprocessors/{key}/providers
When a tenant objects to a sub-processor (Art. 28 Abs. 6 DSGVO), the gateway must know which
LLM provider(s) that sub-processor backs, so it can deactivate exactly those for that tenant. That
link is an explicit operator-maintained mapping (never guessed): one subprocessor_key may map
to several provider_ids and one provider may back several sub-processors (many-to-many). It is
GLOBAL, operator-owned, holds no tenant/user data (same class as the register), and is consumed by
the per-tenant objection API.
All routes require SUBPROCESSORS_MANAGE (platform admin).
| Method & path | Purpose |
|---|---|
GET /admin/v1/subprocessors/{key}/providers |
list the provider ids mapped to this key |
POST /admin/v1/subprocessors/{key}/providers |
add a mapping — body { "provider_id": "anthropic" } |
DELETE /admin/v1/subprocessors/{key}/providers/{provider_id} |
remove a mapping |
Validation (fail-closed, principle 11):
{key}— must be a register slug (^[a-z0-9][a-z0-9_-]*$, ≤ 64) and, onPOST, an existing current or upcoming sub-processor in the derived register (a key that was added then removed is not current →404 subprocessor_key_unknown). A malformed slug →400 subprocessor_key_invalid.provider_id— canonicalized (trimmed, lower-cased,vllm→myra) and must be a known provider; a well-formed-but-unknown id (e.g.acme-ai) is rejected400 unknown_provider(a garbage id would otherwise store a dead mapping that deactivates nothing). Stored canonical.POSTis idempotent on the(key, provider_id)primary key (a re-add is a no-op201).DELETEis guarded: removing the last mapping for a key while a tenant objection still references it is refused409 objection_referenced(an objection must always deactivate ≥ 1 concrete provider). Removing a non-last mapping is allowed. An unknown(key, provider_id)→404.
created_by is bound to the admin session, never the body. A mapping change flushes the gateway
config cache of every tenant that currently objects to the key, so a widened/narrowed deny lands
immediately (bounded eventual consistency).
Change notification & 30-day objection window (AVV Ziffer 6)
Under Art. 28 Abs. 2/6 DSGVO and the Data Processing Agreement (AVV, clause 6), when the register gains a new or changed sub-processor with a future effective date, every affected customer (controller) must be informed in advance and given a 30-day window to object before the change takes effect. The gateway records the notice and the deadline so the window is enforceable and auditable; the objection enforcement itself is the per-tenant deactivation.
There is no customer-facing HTTP surface in this feature — it is an internal, timer-driven sweep
(internal/subprocessor_notify, worker 0, fleet-wide DB lease aig_subprocessor_notify, hourly). It
reads the register and the tenant/objection tables and drives the e-mail sender; it accepts no
untrusted input. The advance-notice e-mail (subprocessor-change, EN/DE) HTML-escapes every operator-
origin register value; recipients are resolved server-side (a tenant's live admins), never supplied.
The record — subprocessor_notification
One tenant-scoped row per (tenant, subprocessor_key, effective_date) — the unit of "we told
controller T about the upcoming change to sub-processor K effective E". A UNIQUE(tenant_id,
subprocessor_key, effective_date) makes the sweep idempotent across ticks and across the active-
active fleet (a change is never notified twice for the same controller). It stores no enforcement
state — live deactivation lives solely in tenant_subprocessor_objection (no second source of truth).
| column | meaning |
|---|---|
delivery_status |
pending → sent | no_recipient | failed |
notified_at |
unix seconds; the legal clock — set on the first successful delivery, NULL until then |
objection_deadline |
unix seconds = notified_at + 30 days; NULL until delivered |
recipient_count / attempt_count / last_attempt_at |
delivery bookkeeping |
closed_at / outcome |
window-close stamp + objected | accepted |
Two-phase sweep
Deliver. For every upcoming (future-effective) added/changed register change (a removed
never triggers a notice — nothing to object to) × every active tenant, ensure a pending row
exists, then e-mail the controller's admins. The first successful send starts the legal clock
(notified_at, objection_deadline = now + 30d). ABSENT and FAILED are distinct and neither
degrades to delivered (principle 11): no live admin address → no_recipient (retried; the notice
is never recorded as sent), all sends failed → failed (retried) — a legal notice is never silently
dropped, and notified_at stays NULL until a real delivery.
Close. When a delivered notice's 30-day window has elapsed, the sweep finalizes it: if the tenant
objected during the window (via 2631's immediate Ops API), it idempotently re-asserts 2631's
deactivation (guaranteeing enforcement is in place at the instant the change takes effect) and records
outcome = objected; otherwise silence = acceptance → outcome = accepted. A read failure on the
objection check defers the close (fail-closed — a window is never accepted on an error, which would
silently drop an objection).
Recipient policy & fallback
"Affected customers" = all active tenants (the conservative legal default — every controller is
informed). Recipients are the tenant's live admins (sys:admin / sys:tenant_admin,
deleted_at IS NULL); there is no dedicated DPO-contact field on tenant yet, so the tenant-admins
are the controller contact (the fallback). The 30-day period is a fixed module constant,
not tenant-config.
GDPR disposition
subprocessor_notification carries tenant_id with ON DELETE CASCADE, is enumerated in the
hard_delete_tenant Löschprotokoll, and is EXPORT_EXCLUDED (compliance/notice-audit metadata, not
Art. 20-portable content — same class as tenant_subprocessor_objection). It has no user-ref
column (rows are minted by the scheduled sweep, a system actor), so it is deliberately outside
hard_delete_user (Art. 17) scope.