Skip to content

Account (my) API

The account API exposes the per-user /me/* endpoints. Every endpoint is scoped to the calling user and requires an authenticated admin session.

While impersonating ("View as User"), the personal /me/* endpoints are refused with 403 impersonation_forbidden — PII keywords, the data export, personal tokens, device tokens, and DELETE /me / /me/erase are not readable or actionable act-as. GET /me/roles and GET /me/groups remain available (role/access context for support). GET /admin/auth/me masks the target's personal fields (email masked, name/instructions omitted). See Admin impersonation.


Listing personal PII keywords

GET /admin/v1/me/pii-keywords

Returns the calling user's personal PII keyword blacklist as an array (empty when none are set). Each row carries the keyword and its match flags. Returns 401 when unauthenticated, 500 on a load failure.


Adding a personal PII keyword

POST /admin/v1/me/pii-keywords

Field Type Required Default
keyword string yes —
case_sensitive boolean no false
whole_word boolean no false

The keyword is trimmed of surrounding ASCII whitespace; it must not be empty, must be at most 200 bytes, must be valid UTF-8, and must not contain control characters or invisible / zero-width / bidirectional / format / non-ASCII-space codepoints — for example a zero-width space (U+200B), soft hyphen (U+00AD), byte-order mark (U+FEFF), right-to-left override (U+202E), line/paragraph separator (U+2028/U+2029), no-break space (U+00A0), or a variation selector. Such a codepoint is invisible in the input yet makes the keyword a dead match needle that never fires against real text, so it is rejected with 400 (nothing is stored) rather than silently accepted as a keyword that never masks anything.

This is a deliberately looser contract than the bulk endpoint below: a single keyword may contain an internal ASCII space (e.g. Project X) and arbitrary ASCII punctuation, and visible letters/symbols of any script — none of which the strict bulk validator allows. The two endpoints are consistent only on the invisible/format class (both reject it); they differ, by design, on ASCII spaces and punctuation. Zero-width joiners (U+200C/U+200D) and emoji variation selectors (U+FE0F) are rejected on both paths, so a keyword that depends on an intra-word joiner or a VS16 emoji sequence is not accepted here.

case_sensitive and whole_word also accept the integer 1 for true. The response is 201 Created with the stored row (case_sensitive and whole_word echoed back as 0 or 1). Returns 400 for an invalid keyword (with a machine code such as unicode_forbidden or bad_utf8), 429 when the per-user limit (200 keywords) is reached, 401 when unauthenticated, 500 on a storage failure.


Adding a list of personal PII keywords (bulk)

POST /admin/v1/me/pii-keywords/batch

Adds several keywords at once from a pasted list (one word per line) to the caller's own blacklist. Tenant-admin only: restricted server-side to the admin and tenant_admin roles — a member, viewer, or ki_manager receives 403, an unauthenticated caller 401. This is a product restriction on the bulk affordance, not a privacy boundary: the single-word POST above remains available to every user. This paste endpoint takes text only (no file), so it carries no malware byte-vector and is not antivirus-scanned; the sibling file-upload endpoint below accepts a .txt file and is scanned when malware scanning is enabled (see below).

Field Type Required Default
text string yes —
case_sensitive boolean no false
whole_word boolean no false

text is split into lines (\n, \r\n, or \r); blank lines are ignored; each remaining line is trimmed of surrounding ASCII whitespace and validated as a strict bare word. The case_sensitive / whole_word flags apply to every word in the batch.

Accepted — a bare word of Unicode letters/digits of any script (e.g. Müller, 北京, José) plus the punctuation @ . - _. Examples: ProjectAtlas, jane.doe@acme.com, Anne-Marie, user_name.

Rejected — the whole batch is refused and nothing is stored when:

  • the request body is not an object, or text is missing/not a string (400, code invalid_body);
  • text exceeds the size ceiling (400, code too_large);
  • text is not valid UTF-8 (400, code bad_utf8);
  • there are no non-blank lines (400, code empty);
  • there are more than 20 lines (400, code too_many);
  • any line is not a bare word (400, code invalid_words, with an invalid array of { line, reason }). A line is rejected when it is too long (> 200 bytes, reason too_long), contains an ASCII space or any regex/shell metacharacter or control character (reason metachar), contains Unicode whitespace, a zero-width/bidi/format character, or other non-letter symbol (reason unicode_forbidden), or contains no letter or digit at all (reason no_word_char, e.g. ... / @);
  • adding the new words would exceed the per-user limit of 200 keywords (429, code cap).

On success the response is 201 Created with { "added": [ …stored rows… ], "count": N }. Words already on the caller's list (or duplicated within the paste) are skipped — they do not create duplicates and their existing match flags are left unchanged; a batch in which every word already exists returns 200 OK with { "added": [], "count": 0 }. The insert is atomic: a storage fault (500) rolls the whole batch back rather than leaving a partial list.


Uploading a list of personal PII keywords (file)

POST /admin/v1/me/pii-keywords/upload

The file counterpart of the bulk paste above: upload a plain-text (.txt) list of keywords, one per line. Same tenant-admin-only gate (403/401) and the same per-line bare-word validation, dedup, 200-nothing-added / 201-created / 429-cap / 500-rollback semantics as the paste endpoint (a single shared pipeline — the two paths can never drift). The difference is the input: an uploaded file carries a malware byte-vector a paste cannot, so the raw bytes are type-checked always and antivirus-scanned when malware scanning is enabled before they are parsed.

Field Type Required Default
data string yes — (the file bytes, base64-encoded)
filename string no list.txt
case_sensitive boolean no false
whole_word boolean no false

The uploaded bytes are untrusted and validated at the trust boundary, in this order — each step fails closed, nothing is stored until all pass:

  1. Shape/size — data must be present and valid base64 (400, code invalid_body / bad_base64); an empty file is 400 (empty); a file over the small list ceiling is 400 (too_large).
  2. Malware scan (only when enabled) — malware scanning is opt-in per gateway and off by default (a gateway enables it with av_scan.enabled in its config). A personal keyword list has no single gateway, so it is scanned when your tenant has malware scanning enabled on any of its gateways; otherwise this step is skipped and the list is parsed normally. When it applies, the raw bytes are streamed to a self-hosted ClamAV daemon (clamd). An infected verdict is rejected with 422, code malware_detected ("This file was blocked by malware scanning."; the signature is logged operator-side only, never returned). If clamd is unreachable/errors, the upload is rejected, not accepted unscanned: 503, code scan_unavailable, with a short Retry-After. The virus scanner itself is configured for your deployment by Myra and is consulted only when a gateway/tenant has opted in. The enablement read is fail-closed too: if it cannot be determined whether scanning applies (your tenant's gateway list is unreadable, or one of its gateway configs will not decode) the upload is refused with 503, code scan_unavailable (+ Retry-After), never accepted unscanned. An account with no tenant at all (a platform admin's normal shape, or a tenantless user — the rule is role-independent) owns no gateway, so no gateway can have opted in — that caller's list is knowably not scanned and uploads normally; a tenant id that is present but empty is a malformed account and refuses (503).
  3. Type allow-list — the file's real type is verified from its magic bytes; it must be text/plain. A file whose bytes are a PDF/image/Office document (even if named .txt) is rejected 415, code not_text.
  4. Bulk validation — the text is then split, validated, deduped, capped and inserted exactly as the paste endpoint above (same 400/429/201/200/500 outcomes and codes).

Deleting a personal PII keyword

DELETE /admin/v1/me/pii-keywords/<ID>

Deletes the keyword by id, scoped to the caller's own rows. The response is 204 No Content. Returns 401 when unauthenticated, 500 on a storage failure.


Listing personal tokens

GET /admin/v1/me/tokens

Returns the personal authentication tokens of the calling user. The token value is never returned by the list endpoint; only metadata.


Creating a personal token

POST /admin/v1/me/tokens

Field Type Required Default
gateway_id string yes —
label string no —
scopes array no ["inference"]
expires_at unix seconds no unset (token never expires)
budget_usd number no unset (no cap)
rate_limit { requests, window_sec } no unset — validated like every other token route: only those two keys, each a whole number ≥ 1; anything else is HTTP 400 (see Users & Tokens)

Who may mint. Only chat-capable roles — member, ki_manager, tenant_admin, and admin — can create a personal token. A viewer (read-only) or the shared demouser identity is refused with HTTP 403 at the endpoint itself, not merely hidden in the UI; the check is enforced server-side before any token is generated. gateway_id is also scoped to a gateway the caller may access (a cross-tenant gateway_id returns 403/404). An unauthenticated request returns HTTP 401.

expires_at must be a whole unix timestamp in seconds that is in the future and within 100 years. A value that is non-numeric (for example an ISO-8601 date string), fractional, in the past, or implausibly far in the future (such as a milliseconds timestamp) is rejected with HTTP 400 — never applied. Leaving it unset makes the token non-expiring (the recommended default — a token that never lapses cannot die silently). When an expiry is set, the owner is warned in-app (expiring_tokens on GET /admin/auth/me) and by email 7 days before it lapses; a gateway service token (no user owner) warns the tenant-admins instead — see Agents → Proactive token pre-expiry warning.

The response is { "id": "<TOKEN_ID>", "token": "myra_<HEX64>", "gateway_id": "<GATEWAY_ID>" }. The token value is shown once; the gateway never returns it again.


Revoking a personal token

DELETE /admin/v1/me/tokens/<ID>

Returns 403 forbidden when the token does not belong to the caller. Revocation invalidates the cached auth lookup and deletes the row immediately.


Connect-OX handshake (mint a token for the Open-Xchange plugin)

POST /admin/v1/me/connect/ox

The zero-config token handout behind the Open-Xchange "Connect" button. The plugin opens the authenticated SPA page /connect/ox?origin=<exact OX origin>&nonce=<opaque> in a popup; the signed-in user picks a gateway + model and confirms, and this endpoint mints an inference-scoped token for the plugin to call the gateway with. It is a thin wrapper over Creating a personal token that removes every dangerous knob — the client can only supply a gateway, a model hint, and the origin.

Field Type Required Accepted / Rejected
origin string yes The exact browser Origin of the calling OX site. Must be a canonical https origin (https://host[:port], lowercase scheme+host, no :443, no path/query/fragment/userinfo, ASCII host ≤ 200 chars) AND an exact member of the platform allow-list connect_ox_allowed_origins (see Settings). Absent, malformed, or not-listed → HTTP 403 origin_not_allowed. Membership is exact set-equality — a substring or prefix of an allowed origin is refused.
gateway_id string yes A gateway the caller may access. Absent/non-string → HTTP 400; a cross-tenant or unknown gateway → 403/404 (same gate as /me/tokens).
model string yes A model-name hint echoed back to the plugin (slug shape ^[A-Za-z0-9._-]+$). Absent/non-string/bad-shape → HTTP 400. It is not an authorization boundary — the minted token is gateway-scoped inference and any model the gateway serves is callable regardless of this value.

The endpoint ignores any other body field. In particular a smuggled scopes (e.g. [], which elsewhere means unrestricted) is never read — the scope is forced to ["inference"]. The token is also minted with a forced rate-limit (60 requests / 60 s, a leak cap) and no expiry. A caller is capped at 20 live Connect tokens (labelled OX Connect · <origin> · <date>, self-revocable in Profile → Tokens); the 21st request is refused with HTTP 409 too_many_connect_tokens — existing tokens are never auto-revoked.

Who may mint. Same as /me/tokens: member, ki_manager, tenant_admin, admin. A viewer or demouser → HTTP 403; unauthenticated → HTTP 401; minting while impersonating is blocked. Each successful mint writes an audit_log row (connect.ox.mint, actor, gateway, origin — never the token).

Response 201 { "tenant": "<slug>", "gateway": "<slug>", "model": "<hint>", "token": "myra_<HEX64>" }. The token is shown once, hashed at rest, and never logged. The SPA hands it to the opener via postMessage to the exact origin only (never "*"); see Authentication → Connect-OX popup.


Registering a device push token

POST /admin/v1/me/device-token

Field Type Required Default
token string yes —
platform string no ios

The endpoint is upsert: posting again with the same token updates the row.


Unregistering a device push token

DELETE /admin/v1/me/device-token

Field Type Required
token string yes

The endpoint deletes a single device-token row matched by the token value and scoped to the authenticated caller — a user can only delete their own device token, never one registered by another user.


Updating your profile

PATCH /admin/auth/me

Updates the calling user's own profile. Every field is optional; only the keys present in the body are written, and the endpoint is scoped to the authenticated session user (a user can never patch another account). Each field is validated at the trust boundary — a value of the wrong type is rejected with 400 and nothing is written.

Field Accepted Rejected → 400
name non-empty string, trimmed, up to 255 characters (the account's identity name — distinct from preferred_name below) non-string, or trimmed-empty, or over 255 characters — unlike every other field on this endpoint, name has no clear semantics: it cannot be set to null/empty via this route
locale "en" | "de" | null any other string
preferred_name string (trimmed; empty → cleared) | null over the documented max → 400
work_category one of the fixed work categories | null any value not in the set → 400
model_instructions string up to the documented max | null over the documented max → 400
default_model string up to 128 characters (the user's soft default chat model) | "" or null (both clear it → SQL NULL) any non-string (number, boolean, object, array) or over 128 characters → 400 (nothing written)
currency exactly "USD" or "EUR" (the display-currency preference) | null (clears it → SQL NULL, resolving to the EUR default) any other string, or any non-string (number, boolean, object, array) → 400 (nothing written)
composer_enter_sends JSON boolean (true = Enter sends, false = Enter inserts a newline) any non-boolean (string, number, null)
onboarding_completed JSON boolean (true marks the first-run walkthrough done for this user) any non-boolean (string, number, null)
personalisation_tooltip_seen / style_conflict_note_dismissed true (one-shot UI flags) —
budget_alert_dismissed_sig a 32-character lowercase-hex string (the budget_alerts_sig value from GET /admin/auth/me) — dismisses the budget-alert banner for the current alert set any other shape (wrong length, non-hex, non-string) is ignored — no write, no error
favorite_models a JSON array of strings (each a "<provider> <model>" favorite key, at most 200 characters), at most 500 entries — replaces the caller's favorite-model set wholesale. An empty array [] or null clears it not an array (object, string, number), any non-string element, a key over 200 characters, or more than 500 entries → 400 (nothing written)

The budget-alert dismissal (budget_alert_dismissed_sig) records which alert set the user acknowledged, so the banner re-appears automatically when the set changes (a budget crosses into a higher tier, a different entity breaches, or a new budget period begins). It is a UI preference and, like the two one-shot flags, is not recorded in the Art. 16 rectification audit. A blocked (at/over 100 %) budget alert cannot be dismissed.

composer_enter_sends controls the chat composer's Enter key for this user only: true (the default for every account) sends on Enter with Shift+Enter for a newline; false inverts it (Enter inserts a newline, Cmd/Ctrl+Enter sends). It is stored as a 0|1 column and returned by GET /admin/auth/me. As a UI preference it is not recorded in the Art. 16 rectification audit (see below).

onboarding_completed records whether the caller has finished (or skipped) the one-time first-run onboarding walkthrough. It is stored as a 0|1 column (existing accounts were backfilled to 1; a genuinely-new account starts at 0) and returned by GET /admin/auth/me. The SPA sets it to true when the user finishes or skips the walkthrough. Like the other UI flags it is scoped strictly to the session user — the identity comes from the session, never a body-supplied user id — and is not recorded in the Art. 16 rectification audit.

currency is the user's display-currency preference — "USD" or "EUR", returned by GET /admin/auth/me. It controls only how monetary figures (gateway/tenant/token budgets, spend, cost analytics) are displayed across the app; amounts are always stored and enforced in USD, and the SPA converts to the selected currency at the display boundary using the daily reference rate. When unset (NULL) the app defaults to EUR (Myra is EU-focused). It is fully independent of locale (the UI language) — a separate column and a separate selector under Settings › Preferences. As a UI preference it is scoped strictly to the session user and is not recorded in the Art. 16 rectification audit.

favorite_models is the user's set of favorite models — each key is a "<provider> <model>" pair (a model served by more than one provider is favorited per provider). The model selector pins favorites to the top of the list under a Favorites heading. The client sends the entire desired set on every change (it is not an incremental add/remove); the server validates the array shape, per-key length, and count at the trust boundary and stores it verbatim as a JSON array (empty or null clears it). GET /admin/auth/me always returns favorite_models as an array ([] when none; a stored value that is malformed or predates this field decodes to []). Like the other UI preferences it is not recorded in the Art. 16 rectification audit.

permissions is the caller's effective permission set — an array of permission-key strings (e.g. "USERS_MANAGE", "GATEWAYS_MANAGE"), sorted, [] for a role with none. It is the union of the permissions the caller's platform role confers and any tenant-defined custom roles assigned to them (dynamic RBAC). It is advisory only — the SPA uses it to show/hide nav entries and actions; it is never the authorization boundary. Every protected request is re-checked server-side against the same effective set, so a client that forges or replays a permission it does not hold is still rejected (403). Read-only; not settable via PATCH.

Response semantics. On success the endpoint returns 200 with the same full account payload as GET /admin/auth/me. Three non-success cases matter to clients: a transient database fault before the account check passed returns 503 without saved — nothing was stored, retry; a transient database fault after the write committed returns 503 with { "error": …, "saved": true } — the profile change (and its Art. 16 audit entry) is already stored, so the client should tell the user to reload rather than re-submit (a re-submit writes a duplicate audit row); and the account is re-read from the live row before anything is written: a session whose account has been deleted or disabled mid-session returns 401, clears the session cookie and writes nothing, mirroring GET /admin/auth/me, and an account whose live role is demouser, or that has no system role at all, gets 403. The session cookie is never cleared on a 503.

name is the account's identity name shown throughout the app (nav, the account popover, "My Account"), distinct from preferred_name (a personalization-only field the model's system prompt uses — see above). Unlike every other field on this endpoint, submitting name always records a user.self_rectified audit entry, even when the value is unchanged — this endpoint does not diff against the prior value (see the Rectification logging section below). It is stored and returned via GET/PATCH /admin/auth/me's name key; an admin can also rename a different user via PATCH /admin/v1/users/{id} (a distinct, permission-gated endpoint — not covered here).

My access (roles & group membership)

GET /admin/v1/me/roles

Returns the calling user's own tenant custom-role grants ONLY (origin='user', scoped to their own tenant) — perms_store.list_user_custom_roles for the caller's own id. The caller's base system role (origin='system') is deliberately excluded: it is already reported by GET /admin/auth/me (role) and drives the "My access" page's platform-role card, so echoing it here made a user with no custom roles appear to hold their base role as a custom grant. Every returned row therefore has origin='user'. Session-authenticated only; no elevated permission required (self-access to your own grants needs none), and the route accepts no uid/user_id parameter — there is nothing for a client to point at another user's data. 200 with a (possibly empty) JSON array of { id, name, origin, description }. A database fault returns 500.

GET /admin/v1/me/groups

Returns the calling user's own group/project memberships ({ group_id, name, source, joined_at }), same self-scoping and no-uid-param guarantee as above. 200 with a (possibly empty) JSON array. A database fault returns 500.

Both endpoints back the My access page (a role/permission explainer for the caller's own account) — they never expose another user's grants or memberships, by construction (the identifier is read from the authenticated session, never the request).

Exporting your data (DSGVO Art. 15)

GET /admin/v1/me/export

Returns a machine-readable JSON copy of the calling user's own data under the right of access (GDPR Art. 15). Every section is scoped to the caller and rebuilt from a fixed allowlist, so no secret, no other user's data, and no internal field can appear. The response is schema_version: 2 and contains:

Section Contents
user The caller's profile (email, name, role, locale, preferences) — no password/hash.
signup_intents The caller's own registration record(s) captured at self-serve signup, keyed by their email: name, the optional company / phone / job title Sales fields, the accepted terms version and timestamp, locale, plan and creation time. Never the signup one-time-code hash, the Stripe checkout-session/price ids, the voucher code, or the capture IP. Empty ([]) for accounts not created via self-serve signup.
conversations The caller's conversations, each with its messages (the message bodies).
memories, pii_keywords, agents, projects The caller's own memories, personal PII keywords, agents, and owned projects.
statistics Aggregate usage attributable to the caller: request count, token/cost totals, blocked count, first/last request time, and a per-model breakdown (by_model).
request_logs Recent request metadata (timestamp, provider, model, status, tokens, cost, latency, blocked flag and the fixed-category blocked_by). No free-form fields. Capped at the most recent 5000 rows; request_logs_truncated is true when older rows exist.
agent_schedules The caller's agent schedules (no scheduler lease/claim internals).
knowledge_files The caller's uploaded knowledge files, including the extracted text (extracted_text).
client_errors The caller's own browser crash reports: the error message, the (query-scrubbed) route, the captured IP, the browser diagnostic (client_context), and the timestamp. Anonymous reports (no session) have no user and are not included.

Empty sections are encoded as [] (never {}). Any storage failure fails the whole export with 500 — a partial copy is never returned.

Deliberately excluded (documented on the manifest's omitted field): the request bodies (prompt/response) of non-chat traffic (raw API, playground, agent runs) — because request_log.user_id records the token creator, and a shared token can attribute other people's rows to one user, so dumping those bodies would leak foreign content; your own chat message bodies are included under conversations. Also excluded: the original binary blobs of uploaded files (available via the per-file download) and stores with no per-user dimension, which cannot be attributed to you: gateway playground traces (removed by the trace retention sweep) and the residual rows of the disabled semantic cache (no sweep).

Responses:

  • 200 OK — the JSON manifest.
  • 403 forbidden — the shared demo account is not an individual data subject and cannot export.
  • 401 unauthenticated.

Deleting the calling account

DELETE /admin/v1/me

Performs a soft delete on the calling user (sets deleted_at). Every session on every device ends immediately and permanently, and the account's /v1 API tokens are revoked, by stamping the per-user revocation floor (sessions_valid_after; see Authentication). The account can be restored only by an administrator via POST /admin/v1/users/<ID>/restore — and a restore does not revive the old sessions or tokens: the user signs in afresh and mints new tokens. Protected accounts (such as App Store reviewer accounts) silently no-op.

Erasing the calling account (DSGVO Art. 17)

DELETE /admin/v1/me/erase

Performs an irreversible hard delete of the calling user under the right to erasure (GDPR Art. 17). Unlike the soft DELETE /admin/v1/me, this cannot be restored: the user row and all personal data are physically removed in a single transaction, and a tamper-evident deletion record (Löschprotokoll) is written to the audit log (action = "user.hard_deleted", counts only, no erased content).

What is erased: conversations and messages (incl. attachments), memories, personal PII keywords, presets, slash commands, feedback, OAuth links, sessions, device tokens, MCP connector credentials, conversation embeddings/summaries, the user's agents/schedules/connectors, uploaded knowledge documents, and — for a self-serve account — the pre-payment registration record (signup_intent, keyed by email: name, company, phone, job title, capture IP, consent snapshot), deleted by the same email key as the email one-time-code challenge. What is anonymized instead of deleted (retained under the billing/usage-integrity legal basis, GDPR Art. 17(3)): request/usage log rows — the user_id and all free-text columns are nulled, numeric usage aggregates are kept; the user's audit-log IP addresses are removed; the user's browser crash reports (client_error_log) have their user_id, IP, tenant linkage, page URL, and browser-context (client_context) nulled; only the bounded error message and stack remain, removed by the 30-day retention sweep. Workflow run outputs (workflow_run.run_state) are retained as tenant operational work-product under a distinct legal basis (GDPR Art. 6(1)(f)) — the run row is kept and the erased user's uuid and email are scrubbed to a tombstone in place, while the run's own submitter input (trigger_input) is nulled on the subject's runs; the storage-limitation ceiling for these retained records is enforced by the workflow-run retention ceiling (an age-based purge of old terminal runs, default 365 days). Stores with no per-user dimension cannot be targeted per user; each is named in the deletion record together with what actually bounds it: gateway playground traces — removed by the trace retention sweep (playground-source traces are retained); the residual rows of the semantic cache (a feature disabled) — no sweep. Errata: deletion records written before an earlier release list the client error log among these untargetable stores; it since gained a per-user dimension and is now anonymized in place (above). Historical records are immutable and keep the old wording.

Responses:

  • 200 OK — { "ok": true, "erased": { ...per-table counts... } }.
  • 403 forbidden — the shared demo account and protected seed/review accounts cannot be erased.
  • 409 conflict — the caller is the last admin of a tenant that still has other users; promote another admin first, then retry.
  • 503 service unavailable — the erasure transaction kept conflicting with concurrent database writes after bounded automatic retries (statement-level re-read + full-transaction re-runs). The transaction was fully rolled back — nothing was deleted and the account is intact; re-issuing the same request is safe and converges once the contention subsides. A single transient conflict never surfaces: it is absorbed by the automatic retries.
  • 401 unauthenticated.

Shared resources owned by the leaver are not destroyed: a project that has other members is reassigned to a surviving admin (or, failing that, the oldest remaining member) so the workspace keeps working; the leaver's own uploaded knowledge documents in it are still erased.

Erasing another account (admin)

DELETE /admin/v1/users/<ID>/erase

Tenant-admin-only equivalent of the self-erase above, for fulfilling a data subject's erasure request. Requires tenant-admin authorization; a target in another tenant returns 403 (a 404 is returned only when no user with that id exists at all). Same 403/409 semantics (protected accounts; last-admin guard) and the same Löschprotokoll record (with actor_type = "admin").

Rectification logging (DSGVO Art. 16)

Every correction of a user's own profile (name, locale, preferred name, work category, model instructions) made through the personal profile endpoint is recorded in the audit log as a user.self_rectified event. The admin equivalent (PATCH /admin/v1/users/{id} changing email, name, role, or tenant) records a user.rectified event. In both cases the event stores only the names of the changed fields plus the actor and timestamp — never the old or new values — so that a subsequent Art. 17 erasure cannot leak personal data through the retained audit trail. One-shot UI flags (e.g. tooltip-seen markers) are not personal-data rectifications and are not logged.

The self path (PATCH /admin/auth/me) logs a field name whenever the client submits a valid value for it — it does not diff against the prior stored value first (unlike the admin path, PATCH /admin/v1/users/{id}, which does diff and only logs fields that actually changed). Re-submitting your own unchanged name therefore still records a user.self_rectified entry: exercising the right to rectification is itself the event being logged, not the value's delta.