Skip to content

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 a removed), the sub-processor is shown with its latest attributes and an effective_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 a PATCH that would produce the same inconsistency on the edited event) refuses it with 400 { "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 been added (or was already removed) — i.e. exactly the event the read derivation would suppress. An added is 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; exactly added | changed | removed.
  • effective_date — required; a real calendar day YYYY-MM-DD with 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 for added/changed, ignored for removed; ≤ 300 / 200 / 300 / 120 chars.
  • note — optional operator detail for a changed event (≤ 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: a changed/removed event for a vendor with no open engagement at its effective date — see Event-sourced model; no_fields: a PATCH supplying 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/DELETE of 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, on POST, 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 rejected 400 unknown_provider (a garbage id would otherwise store a dead mapping that deactivates nothing). Stored canonical.
  • POST is idempotent on the (key, provider_id) primary key (a re-add is a no-op 201).
  • DELETE is guarded: removing the last mapping for a key while a tenant objection still references it is refused 409 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.