How the system defends itself is below. How to report a vulnerability — privately, through GitHub's private vulnerability reporting — is in SECURITY.md.
(BREAKING, migration 052.) Keys carry an is_admin flag (default false, no key is auto-promoted). These /api/manage actions require an admin key — BREAKING for previously-working non-admin keys: api-key-create, api-key-list, api-key-delete, mcp-client-create, mcp-client-list, mcp-client-delete, and dream-mode when mutating (reading the current mode stays open).
Rationale. Before this gate, ANY valid key of any home_scope could mint keys for arbitrary scopes — read access to foreign tenants — and the settings/secrets API must not inherit that model. The per-tenant admin tier (owner/admin/member) that layers on top is documented in multi-tenancy.
Self-service stage (tierSelfService, W01-5). The model consumer actions that grant a new credential — model-consumer-create and model-key-rotate — carry a fourth stage in the dispatch table: server-admin, and a tenant-admin of its own tenant only while gateway.enabled and gateway.tenant_self_service are on (both default off; a missing config snapshot counts as off). The switch is a setting, so it cannot be a payload-shaped tier function; as a stage of its own the lock sits in the pinned table, and the handlers check the same switch a second time. With the gateway off nobody below server-admin grants anything, whatever the self-service switch says (dark launch). The refusal is the shared 403 admin key required — no tier, switch or field oracle. Inside model-consumer-update the rule is revoking is always allowed, granting needs a release (api): narrowing a consumer stays open to tenant-admins; widening it needs self-service; kind = ctx-peer, a lower prompt_sensitivity (egress of foreign prompts to less trusted backends), allow_plaintext = true (credential transport without TLS), a higher fair_weight and admission_lane = urgent (the GPU is a host-global good) stay server-admin with self-service on. Peer row tier: any granting change of a ctx-peer consumer is server-admin only. The comparison runs against the row under its lock, so a concurrent server-admin write cannot be undone by a tenant-admin re-submitting the old value.
Admin bootstrap (one-time, host access required). Promote by id, never by label — label has no UNIQUE constraint and an UPDATE by label would escalate every same-named key, including inactive ones:
# 1. Inspect candidates:
PGPASSWORD="$CONTEXT_DB_PASSWORD" docker compose exec -e PGPASSWORD db \
psql -U "$CONTEXT_DB_USER" -d "$CONTEXT_DB" \
-c "SELECT id, label, active, home_scope, is_admin FROM context_api_keys;"
# 2. Promote EXACTLY one key by id:
PGPASSWORD="$CONTEXT_DB_PASSWORD" docker compose exec -e PGPASSWORD db \
psql -U "$CONTEXT_DB_USER" -d "$CONTEXT_DB" \
-c "UPDATE context_api_keys SET is_admin = true WHERE id = '<uuid>';"Admin-key hygiene. Since S3 (migration 099) the OAuth /token exchange hands out an opaque, key-bound, revocable ctxt_ token (SHA-256 at rest, 1h TTL) instead of the api key itself — what circulates through claude.ai/Cloudflare and external connector storage is no longer the long-lived key. The raw api key stays valid as a Bearer value (deliberate E2 legacy path for existing connectors), so a key pasted directly into a connector still circulates; the hygiene rule therefore stands: create a dedicated admin key that is never used as an MCP/OAuth credential; the claude.ai MCP key stays non-admin. Test/eval script keys stay non-admin too (least privilege). Revoking a key (or deactivating its principal) kills every token minted from it instantly — token resolution runs through the same ctx_auth_by_id gates as key auth.
Login CSRF. POST /auth/login accepts only Content-Type: application/json and only from this server's own origin: a present Origin (else Referer) must be the canonical issuer or the request's own host, and without either header Sec-Fetch-Site must not be cross-site/same-site; anything else is a value-free 403. This closes the cross-site <form enctype="text/plain"> that signed a victim into an attacker's account; CLI/script clients send none of the three headers and are unaffected. The SSO callback needs no such gate — it only completes with the browser's own httpOnly state cookie whose sealed state must match the URL (single-use).
SSO return_to. The post-login target is parsed and reduced to a cleaned, same-origin path (no scheme/host/userinfo, no leading // or /\ even after percent-decoding or path.Clean, no control byte anywhere — browsers strip TAB/CR/LF, so /%09/evil.example used to become //evil.example); anything else redirects to /. The callback re-applies the filter to the sealed value.
Deleting an OAuth client. mcp-client-delete (ctx mcp delete) deactivates the client and, in the same transaction, revokes every access and refresh token issued to it and drops its unredeemed codes. /token answers 401 invalid_client for an inactive or unknown client_id (it used to treat a deleted client as a secret-less public client), and token lookup and refresh rotation only accept rows whose client is still active, so tokens a pre-fix delete left live die on upgrade; web-login tokens carry no client and are unaffected.
Open client registration. With CTX_OAUTH_DCR_MODE=open anyone can register an OAuth client; the registration grants no data access, and the barrier before a user's key is the /authorize consent screen. The mode is read per request, so ctxd logs one WARN naming CTX_OAUTH_DCR_MODE at boot when it is open — the only place the setting shows up without a request. It names the client and the address the code goes to, and warns about a self-registered client whenever the redirect_uri of that request is not an exact member of the static allowlist (claude.ai, claude.com, ChatGPT) or CTX_OAUTH_REDIRECT_EXTRA. A code sent to such an operator-vetted callback cannot be received by whoever registered the client, so the warning stays off there instead of appearing on every genuine connector; a loopback callback or any other address keeps it, also for a client that registered a vetted and a foreign callback side by side. The client name is registrant-chosen text: Unicode format characters (bidi overrides and isolates, zero-width runes) and control characters are dropped at registration and again when the consent page renders, and the name sits in a <bdi> element, so it cannot reorder or hide the client ID and return address shown next to it.
Outbound SSRF guard. Forge sync, the camo image proxy, SSO discovery/token/userinfo calls, the forge.api_base validator and every egress path of a tenant-owned backend pool row (pool isolation) share one dial-time deny-list (internal/ssrfguard): every IANA special-purpose range that is not global unicast — RFC 1918, loopback, link-local, 0.0.0.0/8, CGNAT 100.64.0.0/10 (incl. 100.100.100.200), 192.0.0.0/24, documentation and benchmark ranges (198.18.0.0/15), multicast, 240.0.0.0/4/broadcast, fc00::/7, fec0::/10, NAT64 local-use 64:ff9b:1::/48, 6to4/Teredo — with IPv4-mapped IPv6 unmapped first. An address in the NAT64 well-known prefix 64:ff9b::/96 is judged by the IPv4 address it embeds (RFC 6052, the low 32 bits — the address the NAT64 gateway connects to): 64:ff9b::808:808 (8.8.8.8) passes, 64:ff9b::7f00:1 (127.0.0.1) and every other embedding of a refused IPv4 range is refused, so an IPv6-only host behind DNS64 reaches public IPv4-only hosts without opening the internal ranges. Up to 5.29.4 the whole /96 was refused. The SSO client dials directly and ignores HTTP(S)_PROXY: through a proxy the guard would only ever see the proxy's address. An IdP, forge or image host reachable only on such a range (e.g. over a Tailscale 100.x address) is refused.
Backend redirects. The model wires (chat, both streams, embed, rerank) and the foreign-load probe follow a redirect only while it stays on the origin of the configured base_url — same scheme, host and port, host names compared byte for byte apart from ASCII case, the scheme's default port filled in — or upgrades it from http to https on the same host (port 80 to 443, or the same explicit port). Every other Location (another host, a subdomain, another port, https to http) is not followed: the call fails with the redirect status itself, which the failover classifier treats as a request error (next backend, no cooldown), and ctxd logs one WARN redirect refused with both origins and the status, never the path or query. Without this rule net/http re-sends the POST body — the prompt — on a 307/308 and copies headers such as x-api-key and a row's extra_headers on every redirect, so the content would reach a host that never passed the trust × sensitivity check while the egress audit still names the configured row. The SSRF guard does not cover this case on its own: it judges the address a hop dials, and a foreign public host passes it. Chains are judged against the first request, and net/http's limit of 10 redirects still applies.
context_settings_audit is the append-only history of the tables that decide who may do what. Rows are written by AFTER ROW triggers inside the transaction of the change, so the API, the boot paths and a direct psql edit are recorded alike. Settings, secrets, backends, block types and disable profiles have been in it since their own migrations; since migration 160 it also records knowledge keys (context_api_keys; entity_type = 'api_key', entity_key = the key id, scope = its home scope) and tenant quotas (context_tenant_quota; entity_type = 'tenant_quota', entity_key and scope = the quota's scope), and since migration 161 model consumers (context_model_consumers; entity_type = 'model_consumer', entity_key = the consumer id) and model keys (context_model_keys; entity_type = 'model_key', entity_key = the key id) — the callers of the OpenAI-compatible model API and their ctxm_… keys; scope is the consumer's tenant_scope, _global when it has none — and since migration 162 model aliases (context_model_aliases; entity_type = 'model_alias', entity_key = the alias id, scope = the alias's scope), the model names that API offers — and since migration 163 the instance identity (context_instance, one row; entity_type = 'instance', entity_key = 'self', scope = '_global', no tenant_id: server-admins only, see Instance identity) — and since migration 164 model grants (context_model_consumer_grants; entity_type = 'model_grant', entity_key = <consumer_id>:<alias_id>, scope = the consumer's tenant_scope, _global when it has none), the single releases of a model alias to a consumer.
Actions. Keys: create, update, revoke (active true → false), reactivate (false → true), delete. api-key-delete is the soft revoke and records revoke; delete is the hard delete of a tenant's keys by tenant-delete, or a psql DELETE. Quotas: create, update, delete. Model consumers: create, deactivate (active true → false), reactivate, update, prune; model keys: create, revoke (revoked_at set), update, prune; model aliases: create, update, delete; model grants: grant, ungrant (a grant is only added or removed — its consumer_id and alias_id are immutable, SQLSTATE 23514, psql included — so there is no update; a grant that goes with its alias is an ungrant attributed to the alias's deleter, the tenant prune removes a tenant's grants as ungrant rows of the offboarding); the instance identity: rotate (instance_id changed), update (created_at/rotated_at only), delete (the row removed — ctxd then refuses to start), create (a row inserted by hand; the migration's own creation writes no row). Deactivating a consumer revokes every live key it has and refuses new ones (a key insert into an inactive consumer fails with SQLSTATE 23514; an insert and a deactivation running at the same time wait for each other on the consumer row, so under READ COMMITTED, which ctx uses for every write, the new key is either revoked or refused — a deactivation run by hand under REPEATABLE READ does not revoke a key committed after its snapshot), psql included, and records one revoke row per key; prune is the hard delete of a tenant's consumers and keys (offboarding) or a psql DELETE. snapshot rows are the state migration 160 found (one per key and quota row that had no history yet). An UPDATE that changes nothing audited records nothing — a re-set of the same quota, and above all the last_used_at write of every authenticated request: authentication never adds an audit row.
What a row carries. old_value/new_value hold a whitelist of columns, never the row as such. Keys: label, home_scope, allowed_scopes, write_scopes, active, is_admin, tenant_id, tenant_role, confirm_writes, principal_id. Quotas: scope, daily_cost_usd, monthly_cost_usd, daily_calls, on_exceed, enabled. Model consumers: tenant_id, label, description, kind, tenant_scope, all_models, prompt_sensitivity, fair_weight, admission_lane, allowed_cidrs, allow_plaintext, active, peer_instance_id, peer_bound_at (binding a peer and resetting the binding are identity events). Model keys: consumer_id, key_hint, expires_at, revoked_at, rotated_from. Model aliases: scope, name, kind, role, sensitivity, policy, description, enabled (the alias's id is the entity_key). Model grants: consumer_id, alias_id. Instance identity: instance_id, created_at, rotated_at. Never recorded: instance_secret (a write of it alone adds no row), key_hash of either key kind, last_used_at (the per-request write of knowledge-key authentication, the batched usage stamp of model keys), the created_at/updated_at bookkeeping, the actor columns (the quota's updated_by, the model tables' created_by_*/revoked_by_* — the actor travels in api_key_id, actor_label and metadata; their reset when the actor's key is deleted writes no row) and the quota's free-form metadata. A test pins every column of these tables as audited or excluded, and every table an audit trigger writes for as classified; a column a later migration adds stays red until someone classifies it, so a new secret cannot slip into the audit table unnoticed. A consumer's tenant_id and a key's consumer_id are immutable (a trigger refuses the change with SQLSTATE 23514, psql included): a key never moves to another consumer's policy, a consumer never takes its keys into another tenant. The same holds for an alias's id, name, kind and scope: a model id keeps its meaning, its history stays under one key, and an alias never carries one tenant's releases into another (renaming is delete + create, and releases bind the alias id). A TRUNCATE of the alias table bypasses the row triggers and so the trail — remove aliases with DELETE.
Who and how. api_key_id and actor_label name the acting key (the label as it was at write time); metadata adds actor_principal, actor_tenant_id and actor_server_admin (the reader needs them to hide server-admin actors from tenant-admins), request_id (the X-Request-ID the response carries — a correlation id, the caller's own when it sent a valid one), tenant_id (the tenant the key, quota, model consumer or model alias belongs to — a model key row takes it from its consumer, a model alias row from the tenant owning its scope, and a _global alias has none, so the operator catalog's history is server-admin-only; the filter a tenant-admin view applies), consumer_id on model consumer, model key and model grant rows, alias_id on model grant rows (a grant row takes tenant_id from its consumer), confirm_sensitivity_downgrade (true) on a model alias row whose writer confirmed lowering the alias's sensitivity, and via, the channel:
via |
Written by |
|---|---|
api |
a manage action (api-key-create/-update/-delete, tenant-create for the owner key, tenant-quota-set) |
bootstrap |
the first-key mint at boot (CTX_BOOTSTRAP_ADMIN_KEY on an empty key table); no actor |
provision |
project-provision: owner key, repo-agent key and quota seed |
offboarding |
tenant-delete: the hard delete of the tenant's knowledge keys (delete rows), model keys and model consumers (prune rows) |
migration |
the snapshot rows of migration 160 |
sql |
nothing stamped the transaction — a direct psql edit |
The values are constants in internal/store/audit_via.go; the store stamps them, together with the actor and the request id, as transaction-local settings (ctx.audit_via, ctx.api_key_id, ctx.request_id) before the write. A psql session can set the same settings, so the audit trail attributes faithfully what ctx writes and records an unstamped edit as sql; it is not proof against an operator with database access. A psql write that sets a quota's updated_by names that key as actor and still reads via = 'sql': on a via = 'sql' row the actor is the updated_by the row itself claims, not a stamped caller. Since migration 160 the settings and secrets trigger honours the stamped channel ctx.audit_via as well: when tenant-delete hard-deletes the tenant's keys, the reset of updated_by/created_by/rotated_by on settings and secrets those keys last wrote reads via = 'offboarding' with the deleting admin as actor; without a stamp its api/sql derivation stays.
Reading the trail. GET /api/audit (api) serves the key, quota, model-consumer, model-key and model-alias history — and the gateway-limit, model-grant and consumer-usage types once their writers exist — to server-admins and tenant-admins, the instance type to server-admins only; settings, secrets and backends keep their own surfaces. A tenant-admin sees only rows whose metadata.tenant_id is its own tenant, decided in SQL, so a foreign id reads as the same empty list as an unknown one — byte-identical, not time-identical: an id with a long history of another tenant answers measurably slower (3.7 ms against 0.3 ms at 5 000 rows), so timing can tell such an id from an unknown one; key, consumer and model-key ids are UUIDv7 and have to be known to be probed (quota scopes are guessable but have short histories); rows without a tenant (a quota on a scope no tenant owns, a hand-written row) are server-admin-only. Actor redaction: a tenant-admin must not learn who administers the server — the person behind a server-admin key, and which of its tenant's keys carry the server-admin bit (one known limit below). An actor stays visible to a tenant-admin only when the row proves it is a key of the reader's own tenant without the server-admin bit (metadata.actor_server_admin exactly false and metadata.actor_tenant_id = the reader's tenant). Every other actor — a server-admin wherever its key lives, a foreign tenant's key, a key whose identity fields are missing — is masked: actor_label reads "server-admin", api_key_id null, metadata.actor_principal is removed. actor_tenant_id, actor_server_admin and any other actor_* metadata key (any letter case) are never sent to a tenant-admin; an identity field a later writer adds must use that prefix to stay covered. The same holds for the state of a key: a tenant-admin's api_key rows carry no is_admin and no principal_id in old_value/new_value — on every key row, so a gap in the state cannot single out a server-admin key — and an update whose only change lay in those two fields (a psql promotion or demotion, a principal swap) is left out of a tenant-admin's answer altogether: an update with equal old and new state would say "a hidden field changed". model_consumer rows hide the peer binding (peer_instance_id, peer_bound_at) the same way — the consumer view shows it to server-admins only — and the audit trail of model-consumer-get goes through the same redaction. Known limit: a tenant-admin of the default tenant, where the bootstrap server-admin key lives, sees that key's rows like those of any key of its tenant — the key exists and api-key-list lists it too — including their channel via = 'bootstrap', which marks the key minted at first boot. The channel is audit data and is not rewritten for any reader. What stays hidden is the key's person and its server-admin bit. via, request_id and the rest of the whitelisted old_value/new_value pass unchanged. A server-admin reads every row unredacted.
Every ctx database holds one identity (context_instance, migration 163): instance_id, a random UUIDv4 — no timestamp, so a peer cannot read the instance's age off it — and instance_secret, 32 random bytes (gen_random_bytes), the HMAC key of the hop tokens with which ctx instances will recognise their own requests coming back through a chain of peers (loop protection of the federation layer, internal/fedhop). The identity is per database, not per process: every ctxd on one database is the same instance. ctxd reads it once at boot and refuses to start without it — no row, a secret of another length, or a database the migrations have not reached: federation: instance identity missing — run migrations (operations). Without an identity there is no loop protection, and a ctxd without loop protection must neither serve the model API nor call a peer.
The secret never leaves the table. No log line, no status field, no audit row and no error message carries it: the audit projection whitelists instance_id, created_at and rotated_at; the boot log names the instance id only; a failing write to the row reports the violated constraint or column but never the row (a guard trigger raises before PostgreSQL's own NOT NULL/CHECK error, whose DETAIL would print the failing row, the live secret included, to the client and into the server log); TRUNCATE of the table is refused, so the identity cannot disappear without an audit row; and in the process the secret sits in fedhop.Identity, whose every rendering — each fmt verb, %v of a struct that holds it in an unexported field, log/slog text and JSON, encoding/json — prints the id and nothing else (a test probes each of them for hex, base64 and Go's byte-slice spellings). Whoever learns the secret can recognise this instance's hop tokens, i.e. link its requests to each other, and can put such a token into a request of its own — which only makes that request look like a loop to this instance. The id is not secret but stays server-admin-only on the audit surface. What ctx cannot control is a statement an operator types: a secret written as a literal is part of that statement, and PostgreSQL's statement log, where enabled, keeps it — rotate with gen_random_bytes(32) instead. A database restored from another instance's backup carries that instance's identity; both instances then refuse each other's requests as loops (fail-closed, visible) until one of them rotates.
Provider credentials live AES-256-GCM-sealed in context_secrets (encrypted in Go — never via pgcrypto, the master key must not cross the SQL wire). The AAD binds each ciphertext to its name+scope row identity, so a ciphertext copied onto another row fails authentication; a stored nonce of the wrong length is an open error for that row, never a panic (the boot sweep and the reload run without recover). Writes go through the admin-gated, write-only /api/secrets (set/rotate/delete — values never appear in any response, list shows metadata + referenced_by only, no fingerprints); settings reference a secret by name (secret_ref), resolved to plaintext exclusively inside the in-memory snapshot. A rotation or revocation reloads the snapshot immediately — the settings snapshot AND the backend pool (whose rows resolve api_key_ref in-memory), via the same NOTIFY funnel that also covers direct psql edits; no settings write needed, the incident-response path is never silently inert. Deleting a secret that settings or backend pool rows (api_key_ref) still reference is a 409 listing the references (backend:-prefixed for pool rows) with a per-type remediation.
Per-tenant secret resolution (the tenant.allow_shared_secrets opt-in, default strict isolation) is documented in multi-tenancy; the backend pool's api_key_ref follows the same rule in the row's scope (pool isolation). Behind the opt-in a pool row reaches an operator (_global) secret only on the origin of a _global row that carries the same api_key_ref (scheme, host and port of base_url), so the opt-in never sends the operator's key to a host the tenant chose.
Master key setup (one-time):
# generate and append to .env:
echo "CTX_SECRETS_KEY=$(openssl rand -hex 32)" >> .envMandatory: copy CTX_SECRETS_KEY into your password manager when you set it. backup.sh archives only the pg_dumps — the ciphertexts are in every dump, the master key is in none (deliberate: the key stays spatially separated from the ciphertexts it opens, so disaster recovery needs both places). Key loss = total loss of all sealed secrets, by design. No recovery mechanism; re-enter the provider keys instead.
Master-key rotation. Generate a new key, move the old value to CTX_SECRETS_KEY_PREV, put the new one in CTX_SECRETS_KEY, restart ctx. The boot sweep re-seals every secret it can open with the previous key (key_version bump, log line per name, one transaction per row); it logs a completion line — re-encrypt sweep complete means remove CTX_SECRETS_KEY_PREV from .env, a finished with failures WARN means keep it set and investigate. Secrets that open with neither key are left untouched (WARN per name, no boot abort, no data loss). The value rotation of a single provider key is PUT /api/secrets/{name} (or ctx secrets rotate) — no restart, propagates immediately.
Break-glass extraction (host access; works even when the ctx container crash-loops — the decrypt mode reads ONLY env + stdin, no DB):
./break-glass.sh secret <name> [scope] # prints the plaintext
./break-glass.sh reset-settings [key] # factory-reset settings overrides (audited via DB trigger)Both docker targets resolve fail-closed: the DB container from CTX_DB_CONTAINER, else the running compose service db; the ctx image from CTX_IMAGE, else the image of the compose service ctx container (a crash-looping or exited one counts). When no stage yields a value the script stops with exit 2 instead of guessing a name — a guessed name would hand the DB password or the master key to whatever answers to it on that host. Both secrets travel by name (-e PGPASSWORD, -e CTX_SECRETS_KEY), never as a value on a docker command line, and the arguments reach SQL only as psql variables (:'name').
openssl enc cannot do AES-GCM, so extraction pipes the row through the ctxd binary itself: psql -At … | docker run --rm -i -e CTX_SECRETS_KEY -e CTX_SECRETS_KEY_PREV "$CTX_IMAGE" -secret-decrypt. PostgreSQL's encode(bytea,'base64') is MIME (RFC 2045) and wraps every 76 chars — the script strips the wraps SQL-side, and the decrypt mode additionally reads stdin to EOF and strips CR/LF, so every realistic provider-key length survives the pipe.
(internal/modelkey. The format lands first; minting, the admin API and the /v1 gate that accepts it follow in later releases.) External programs reach the model API with their own credential class — a model key, never a knowledge key:
ctxm_ │ body: 43 × Base62 (256 random bits) │ checksum: 6 × Base62
- 54 bytes, alphabet Base62
[0-9A-Za-z]after the prefix, no further separator.ctxm_joins thectxt_/ctxr_family of the opaque OAuth tokens; knowledge keys (64 hex characters) never carry a prefix, so the credential class is decidable from the first five bytes. - Body: 256 bits from the operating system's CSPRNG (
crypto/rand), every character drawn by rejection sampling — random bytes ≥ 248 are discarded, so each of the 62 digits is exactly equally likely. - Checksum: CRC32-IEEE over prefix and body, written as six big-endian Base62 digits padded with
0(the GitHub token pattern). It catches every single-character typo and every swap of two neighbouring body characters, so the model API can turn typos and garbage away without a database round-trip. It is no protection against forgery: the CRC is public, anyone can compute a well-formed key, and a well-formed key proves nothing — floods of well-formed guesses are the job of the/v1gate's failure limiter, probe bucket and database semaphore. - Strict parsing: only the canonical spelling is a key — no trimming, no case folding, and the checksum is compared as text, so no second spelling of the same CRC value passes. Every defect (length, prefix, character, checksum) yields the same error, so a prober never learns which part of a guess was right.
- Stored only as SHA-256 — lowercase hex over the whole key, the same procedure as for knowledge keys. The hash is computed in Go: unlike the knowledge-key path (
ctx_auth($1)), the plaintext never travels to PostgreSQL as a query parameter. 256 random bits need no password hashing (NIST SP 800-63B asks for it below 112 bits). - Hint
ctxm_…+ the last four characters, e.g.ctxm_…G44D(10 characters): all four are checksum characters — a fragment of a public function of the key, no body character. Lists and logs show the hint; the full key is shown once, when it is created. - In code, a key prints as its hint through
fmt's%v,%s,%qand%#v(%xgives the hint in hex) and throughslog, as a pointer address — in braces or as a%!verb(*string=0x…)marker — whereverfmtformats it by reflection instead (other verbs, an unexported struct field), and as{}throughencoding/json. The package's way to the plaintext is the explicitPlaintext()call; reflection-based dumpers (test-diff libraries,reflectitself) can still follow the pointer, so a key is never handed to them.
Refused on every knowledge path. A model key never authenticates a knowledge request. A value that starts with ctxm_ — also behind whitespace and the invisible characters a paste carries in front of it (zero-width spaces and joiners, a byte-order mark, bidi marks, soft hyphens, control characters) — is answered as an invalid credential before any database query on every knowledge entry point: X-Context-Key and Authorization: Bearer on /api/* and /mcp, the in-stream re-auth of both SSE streams, POST /auth/login, POST /authorize and POST /register in DCR mode admin. Without that refusal the knowledge-key path strips every non-hex character first and would hand a hex remnant of the model key to ctx_auth as a query parameter. The prefix is case-sensitive: CTXM_… is no model key and takes the ordinary key path. ctx init names a model key typed into its key prompt as such instead of counting its deviation from the 64-hex shape.
Secret scanning. Match \bctxm_[0-9A-Za-z]{49}\b and verify the checksum (CRC32-IEEE of the first 48 characters, six Base62 digits [0-9A-Za-z], big-endian, 0-padded) — a random 49-character match passes the check with a probability of 1 in 62⁶ ≈ 5.7·10¹⁰, so a verified match is almost certainly a real key. A scanner that verifies the checksum should drop the word-boundary anchors (ctxm_[0-9A-Za-z]{49}): \b misses a key glued to word characters — \nctxm_… in JSON-escaped text, TOKEN_ctxm_…, ctxm_…_old — and the checksum keeps the false positives at 1 in 62⁶ without them.
Plaintext transport from the calling side (B24, K-04-6). The door refuses a model key that arrives without TLS unless its consumer has allow_plaintext (gate 6). The instance that sends the key guards too: a backend write whose base_url is http:// and whose api_key_ref secret holds a model key (ctxm_, after invisible characters) needs confirm_plaintext_credential:true — on create, and on an update that changes base_url or api_key_ref (api); re-enabling a row does not ask again, so a row written past the check (psql, or before this release) is asked only when one of the two fields changes. Every http:// URL counts, loopback and localhost included: one rule without an exception list; the confirm costs one field. The refusal text and the log line carry no secret value, name or hint. Boundary: the check runs on the backend write. A later PUT /api/secrets/{name} that stores a model key under a name an existing http:// row already references is not checked — the row then sends that key in plaintext without anyone having confirmed it; check the rows that reference a secret (referenced_by in the secret list) before storing a model key in it.
(handler.ModelAuth. Mounted in front of every /v1 and /v2 route, behind gateway.enabled, which ships false; one door per process: /v2 is a second group with the same stack in front of the same door instance — one failure lock per (address prefix, key-hash prefix), one probe bucket and one database semaphore for both prefixes, so failures over /v1 lock the key on /v2 too and the other way round.) Every request to the model groups passes the same door, in this order, each stage failing closed; stages 0–6 never touch the database:
| # | Stage | Refusal |
|---|---|---|
| 0 | Mutual exclusion: the request already carries a knowledge AuthResult (the two doors are stacked) |
503 internal_error, x-should-retry: false, ERROR |
| 1 | Proxy consistency: X-Forwarded-For arrives while CTX_TRUSTED_PROXY is empty — the client address is unknowable |
503 proxy_misconfigured, x-should-retry: false, ERROR once per process |
| 2 | Origin: client address (the rightmost X-Forwarded-For entry, over all header lines, only from the trusted proxy — the direct peer compared with CTX_TRUSTED_PROXY as an address, so 2001:DB8::10, [2001:db8::10] and [2001:db8::10]:443 name one proxy; a value that is no address is logged and treated as unset) and TLS evidence: the connection itself, or the trusted proxy's rightmost X-Forwarded-Proto: https (likewise over all lines) — neither header counts from any other peer, and a client-sent line in front of the proxy's own never wins |
— |
| 3 | Credential: Authorization: Bearer only. X-Context-Key, the session cookie and the query string are never read |
401 invalid_api_key |
| 4 | Form and checksum (modelkey.Parse): a knowledge key, an OAuth token, a typo or garbage fails here |
401, identical |
| 5 | Failure lock per (client network, key): IPv4 /32 or IPv6 /64, and the first 8 bytes of the key's SHA-256 | 429 rate_limit_exceeded, Retry-After = rest of the window |
| 6 | Probe bucket: a key without a past success needs a token from one global bucket | 503 auth_overloaded, Retry-After: 1 |
| 7 | Database check under the door's semaphore: ctx_model_auth(hash, client address, TLS) — key alive, consumer active, tenant active, tenant scope still owned, network allowlist, transport |
refused: 401, identical; semaphore deadline, exhausted pool or database error: 503 auth_unavailable, never 500 |
| 8 | Accepted: the consumer identity goes into the request context, the request is registered in the door's in-flight registry (its context ends when the key stops authenticating — see "Revocation of running requests" below), last_used_at is stamped in a batch, and consumer_id, model_key_id and tenant_id go into the request's access record — the group's access log writes the request's one line (see below), the door writes none of its own |
— |
Every refusal carries x-should-retry: false: the same request against the same instance is not expected to fare better, and a 500 would make the OpenAI SDKs retry twice into the saturation (and stop a peer ctx's backend chain). All 401 answers are byte-identical — missing, malformed, unknown, revoked, expired, outside the allowlist, plaintext without allow_plaintext, or a knowledge credential — with WWW-Authenticate: Bearer realm="ctx" (no MCP resource_metadata); no answer echoes the key.
- Failure lock (
gateway.auth_failure_limit30 withingateway.auth_failure_window60 s): only a failed database check counts; form failures cost nothing and lock nothing. The lock key holds the key-hash prefix, so a stale key behind a shared address (NAT, a peer after its rotation grace) locks only itself, never the valid keys of the same address. An unknown pair is never locked, and a full map (8192 windows) never locks: it evicts the least recently touched window — a distributed flood with one-shot keys cannot turn the lock into an outage for every consumer without a bucket of its own. - Probe bucket (
gateway.auth_probe_rate50/s, burst 50): bounds the database probes of forged, well-formed keys — the checksum is public, so forging one costs nothing — independent of the number of source addresses. Keys whose last check succeeded (at most 4096, least recently used dropped) skip the token and keep their access during such a flood. This set is no authentication cache: every request still runs the database check, so revocation and expiry take effect on the next request; a key whose check fails leaves the set at once. - Database semaphore (
gateway.auth_db_concurrency4 of the 20 pool connections, below 20 by validation): the door never holds more connections than that, whatever the flood; the wait, the pool acquire and the query share one 1-second deadline. Measured at wave W01-6 with 1000 parallel forgeries against a DB-bound load of 12 workers on the same pool: the load's p99 connection-acquire time stays at the baseline (gate ≤ 1.1×); without the semaphore it rises by orders of magnitude. - Mutual exclusion: the knowledge door (
handler.Auth) refuses a request the model door already accepted with a500in the house format, and the model door refuses one carrying a knowledge identity with the503above, so stacking the doors in either order is a hard error instead of a request with two identities. Under/v1the knowledge identity is absent by construction. - Secret freedom: the door hashes the key in Go and sends only the hash to PostgreSQL; logs carry consumer and key ids, never the key, its hint or its hash. The consumer identity in the context holds the hash for the in-stream re-check only in an unexported closure, which prints as an address under every
fmtverb and is skipped byencoding/json. - Credential separation: knowledge credentials (64-hex keys, OAuth
ctxt_tokens, the session cookie) and model keys (ctxm_) never open the other side. Under/v1the door reads onlyAuthorization: Bearerand refuses every knowledge credential at stage 3 or 4 — before any database query, so not one knowledge-auth statement runs (pinned on the production router,TestV1RejectsKnowledgeCredentials); the handlers check once more that the request carries a model consumer with a tenant and no knowledge identity, and answer anything else as the503 internal_errorof a wiring defect. The credential is a header only — no cookie, no query string — so there is no ambient authority and no CSRF path, and the gateway sets no CORS headers: a browser page on another origin cannot read a/v1answer. - Door gate:
internal/handler/v1doorchecks a mount behaviourally over every route under/v1and/v2(chi.Walk, so a route mounted outside the group is found), an unregistered path, a wrong method andOPTIONS: flag off ⇒404everywhere, flag on and anonymous ⇒401, any knowledge credential ⇒401without one knowledge-auth query, a live model key ⇒ the handler runs with a consumer identity and without a knowledge identity. It runs over the production router (TestV1DoorCensus, with a probe behind every real/v1route).
(POST /v1/chat/completions, internal/oai — behind the model API door, dark while gateway.enabled is false.) A model key is an external caller: the route treats every byte of its request as hostile and every byte of a backend's answer as topology.
- Allowlist, not denylist. Every top-level key, every message key and every content-part key is classified — passed, set by ctx, ignored or refused — and anything unknown is a
400. Engine knobs that would steer the GPU past ctx (SGLangcustom_logit_processor,lora_path,return_hidden_states,rid; vLLMkv_transfer_params,vllm_xargs; llama.cppid_slot,cache_prompt; a clientpriority;chat_template_kwargs, which would switch the thinking mode of a backend that ctx keeps off) never reach a backend; a knob the next engine release adds is refused until it is listed (api). - Checked value = sent value. An unchanged message, part or passed value reaches the backend byte for byte, and JSON parsers disagree on a repeated key (Go and Python keep the last, others the first) and on letter case. So on every level whose keys the route checks, a repeated key is a
400— also through an escape ("\u0075rl"next to"url"), which would otherwise put anhttp://image URL behind a checkeddata:one — and a key that differs from a known key only in letter case ("Type"next to"type"inresponse_format) is a400; nothing is silently normalised. Free payloads (a JSON schema, a tool'sparameters) are not checked and keep their keys. - No server-side image fetch (vision SSRF). Engines load an image URL themselves — SGLang fetches
http(s)://, readsfile://and bare paths with an image extension; vLLM fetches media URLs — so an image URL from an external key would make the GPU host dial into the LAN or read its own files into the model context, past ctx's SSRF guard. The route accepts an image only as a base64data:URI ofpng,jpeg,webporgif, validates the base64 and its decoded size, for every role and every part, before any backend is contacted; every other form is a400. - Sensitivity. The chain admits only rows whose trust covers the highest of: the consumer's
prompt_sensitivity(the only lever belowcredentials, server-admin with a confirm), aCtx-Sensitivityheader (raises only, from any consumer), the alias's sensitivity, the_globalalias a tenant alias shadows (apsqledit that lowers the shadow does not lower the request), and the tenant'spool.default_query_sensitivity(a term, so lowering it lowers nothing). A secret found anywhere in the body raises the request tocredentials. No term lowers another; an empty or unknown term counts ascredentials. - Export. A tenant alias reaches the server's own (
_global) rows only for the roles ingateway.exportable_roles(default none): a tenant admin cannot route its tenant's prompts to an operator row (for example an OpenRouter row on the operator's key) the server admin did not export. - Tenant quota without a scope. The tenant gate takes the consumer's tenant id; an empty one is an error (
503), never an ungated chain. - No topology in answers. The answer is rebuilt from the spec fields — ctx's id, the alias name, the backend's choices and the spec usage — so no backend model name, row name, host, fingerprint, timing, provider or cost reaches the client, and no backend response header is relayed (a peer's
Ctx-Instance/Ctx-Loop-Tokenincluded). Backend4xxtexts are replaced by ctx's own; whengateway.relay_upstream_4xx_messagesis on (defaultfalse), the text is cut to 512 bytes, every identity string of the pool is replaced by the alias name — row names, hosts with and without scheme and port, model ids with their basename and every prefix of two or more segments (meta-llama,Llama-3.3-70Bofmeta-llama/Llama-3.3-70B-Instruct-Turbo), provider classes — and URLs,user[:password]@hostauthorities, IP addresses (IPv6 with a zone index and IPv4-mapped forms included) and paths are removed — whole URLs and authorities before any identity is replaced, an identity or IP address together with the port, path, query and fragment glued to it (gpu.internal:8000/private/x?key=…goes as a whole), and an absolute path behind any quote, bracket, backtick,|,;or>; the sanitizer errs towards replacing too much. It compares bytes after Unicode case folding and folds no confusables: an upstream that writes homoglyphs into its own error texts (a CyrillicЅinЅpark-GPU.internal.lan) is not covered, which is why the switch defaults tofalse. A backend401/403is a503, never a401: ctx's backend credentials are not the client's key. - Request identity. ctx mints the answer id and a fresh upstream request id per attempt; the client's
X-Request-IDis only recorded (metadata.gw.client_req_id), never sent to a backend — a client-chosen id would become the engine's request id (vLLM) or a correlation handle at a third party. - Kill switch and revocation while running. A watcher per request (streamed or not) ends a running request within 5 s when
gateway.enabledgoesfalseor the alias is disabled or removed; a non-streamed request checks the alias and the grant once more right before the answer is written. The lease is released with the aborted upstream call. - Revocation of running requests. Every request the door accepts runs on a context the door's in-flight registry can end (one registry per process, entries indexed by model key, consumer, tenant and tenant scope, gone when the request ends — a panic included). A key that stops authenticating — revoked, its expiry passed, its consumer deactivated, its tenant suspended, offboarded or pruned, its
tenant_scopemoved to another tenant or deleted (gates 1–4 ofctx_model_auth) — ends the requests running on it:401 invalid_api_keybefore anything is written, the error frameinvalid_api_keyin a committed stream; the upstream call is aborted, the lease and the consumer's concurrency slot are freed, and the key's hash leaves the door's known-good set (its next request pays the probe token). Four triggers:model-key-revokeandmodel-consumer-deleteend them right after their commit and answer how many requests of this process they ended (cancelled_inflight); every write that can end a key's validity — psql and other ctx processes on the same database included — firesNOTIFY ctx_model_access_write(migration 165), and the registry checks the keys it touches withctx_model_auth_live(one statement); a key's expiry ends its requests at that instant through one timer per key (a rotation's grace moves it through the same signal); and everygateway.inflight_sweep_interval(30 s) onectx_model_auth_livestatement over the keys of all running requests is the second line behind the signal — and the sweep right after the listener reconnects covers a signal lost while it was away. A failed sweep ends nothing (the next one, or the signal, does). Boundary: the registry checks liveness only (gates 1–4); the door's other checks run at a request's entry and are not repeated while it runs. A narrowed policy —allowed_cidrsnarrowed so the request's client address is outside (gate 5),allow_plaintextset tofalseunder a plaintext request (gate 6), a withdrawnall_models, a withdrawn alias grant (except as below), anothertenant_scopethat still belongs to the tenant, a lowerprompt_sensitivity, another lane or weight — applies from the next request; none of these writes ends a request that is already running, and none fires the database signal. A running request keeps what it was admitted with (its egress has already happened, an abort would not take it back). A withdrawn grant of a consumer withoutall_modelsis read again right before a non-streamed answer is written (404 model_not_found) and by a stream's watcher when the alias registry changes during the stream; otherwise it too applies from the next request. A key's expiry is the one the request was authenticated with, moved by the database signal; a rotation's grace that lands while the listener is away ends the predecessor's requests at the first sweep after the grace (at most onegateway.inflight_sweep_intervallate), and an expiry extended by hand in psql during such a gap still ends them at the old expiry (no admin action extends an expiry). The kill switch and a removed or disabled alias still end a running request through the watcher. Every grant read of a running request — and the one at its entry — has the door's 1-second deadline, and the kill switch and the alias registry are checked in memory before it: while the database hangs (an exhausted pool, a locked grants table) the kill switch and a removed or disabled alias still end a running request within one watcher tick (5 s) plus that second, and a new request whose grant read does not answer in time gets503 internal_error. With the database down, a revoked key's running requests end through the local action of the same process or at its expiry timer; the signal and the sweep need the database. - Telemetry. The request's one LLM-log row stores only
<class> <status> <code>as its error, never the backend's body (which can echo the prompt), and bodies only asgateway.body_loggingallows (see below).
What the gateway writes about a request, and what it never writes (design 06 §4.6):
- One body policy.
gateway.body_loggingis the only switch for prompt and answer bodies of gateway rows incontext_llm_log— global-only, so no tenant can raise it for itself.none(default) stores none;errorsstores the serialized request and the answer on rows with an error only;fullon every row, each capped at 64 KiB. Every row is then slimmed as for a tenant without developer mode: a credentials-class row stays sealed under every policy, whatever the tenant'stenant.devmodesays — and so does a row whose sensitivity is empty or unknown (fail-closed, logged as a wiring defect). Embedded NUL bytes and invalid UTF-8 are cleaned before the write, so a crafted body cannot make the row — and its attribution — vanish. - Error redaction. The
errorcolumn of a gateway row holds only class, status and ctx code. The upstream response body (which can echo prompt fragments, and which tenant-admins would read in the LLM log list) never reaches it; class and code must have the shape of vocabulary words (lower snake case, at most 64 characters), anything else is stored as[redacted]; the writer takes the code from the gateway's own code vocabulary, never from the upstream answer. - One identity. A gateway row is attributed to the consumer (
consumer_id); a knowledge key id that reached the row anyway is cleared with an ERROR line, and the database refuses any row carrying both. The model key id is kept inmetadata.gw.model_key_idfor key-leak forensics, never as a column. - Access log. A successful request is one
gateway requestline with ids, op, alias, status, token counts and timings (gateway.access_log, default on) — no consumer label, no content, never a key, its hint or a query string. Refusals are counted per (consumer, reason — a code of the gateway or door vocabulary, elsehttp_<status>) and logged as onegateway rejectionsWARN line per minute with the count, so a flood of401/429answers cannot rotate the error and security lines out of the 50 MB × 3 container log. The global request log stays silent for these requests.
Every authenticated route group with model egress — the knowledge API under /api (including ingest and the blob store), /mcp, and the model groups /v1 and /v2 — runs handler.LoopGuard directly after authentication and before the body cap, the body parse and any admission (design 04 §4.6, masterplan K14). After authentication, so an anonymous probe gets the ordinary 401 and never learns which hop token this instance recognises; before everything else, so a loop costs one authentication round trip and nothing more. The guard reads the hop headers of the request (Ctx-Hop, Ctx-Path, see federation hop headers), never the body, and touches no database:
| # | Check | Refusal |
|---|---|---|
| 1 | the hop headers parse (grammar, at most 16 entries, Ctx-Path not longer than Ctx-Hop) |
unreadable: 400 federation_header_invalid; a chain that repeats an entry: 508 federation_loop, Ctx-Reason: loop — garbage beats a duplicate |
| 2 | no own hop token in Ctx-Hop, no own instance id in Ctx-Path |
508 federation_loop, Ctx-Reason: loop; for an own token also Ctx-Loop-Token: <that token> — the echo the sending instance learns its self-loop from |
| 3 | the chain is shorter than federation.max_depth (default 3) |
508 federation_depth, Ctx-Reason: depth |
| 4 | — | the request goes on with the chain in its context, reduced to Ctx-Hop and Ctx-Path: the outgoing calls of the pipelines behind it continue the chain (inbound tokens plus a fresh own one), and nothing else a request sent — sensitivity, egress, class, wait budget, pipeline — reaches any stage |
Every refusal carries x-should-retry: false (the same request will loop again) and a fixed text: no answer names a chain entry, a token, an instance id, the depth or the limit. A forged chain gets its own request refused (the monotony principle) — with one exception for as long as the calling instance still classifies a 508 from a peer as a failure of that peer's row: on /api and /mcp the chain of an authenticated request is handed on to the instance's outgoing model calls, so a caller who sends federation.max_depth − 1 self-minted tokens makes a peer that runs this guard answer 508 federation_depth, and the calling instance's backend classifier currently takes federation_loop/federation_depth as a gateway failure (internal/backends/classify.go, class gateway): the peer's row goes into the 30 s cooldown (doubling after repeated failures) for every caller and tenant of that instance, once per such request. The window lasts until the classifier treats a 508 as a loop without cooldown (wave F-W10, or an interim class without cooldown); closing it is an item of the release checklist before the model API is switched on for peers (W02-15). Today nothing is reachable through it: no instance serves /v1 yet. A router without instance identity — only a test router can have one, ctxd refuses to boot without it — cannot tell its own token: a request with a readable Ctx-Hop or Ctx-Path is refused there with 503 internal_error and an ERROR line (an unreadable or repeating chain gets its 400/508 first, those need no identity), one without them passes.
No model API without its ingress protection. On /v1 and /v2 the guard and the ingress policy behind it are fixed inside the model group (mountModelGroup: EnabledGate → ModelAuth → LoopGuard → PeerIngress → body cap), so no mount can leave either out, and mounting a group registers the router's federation ingress with both stages — a group with the loop guard alone is not registered. gateway.enabled can only be on with that registration: the settings write that would turn it on (PUT true, or a DELETE of a false override that falls back to CTX_GATEWAY_ENABLED=true) is a 409 — "the model API cannot be enabled before its federation ingress is mounted" — before any row is written, and the boot refuses an effective true from env or override (operations, including the remedy and the upgrade note for an install that stored true before). A psql write of the key bypasses the 409; the boot assert catches it at the next start. The registration belongs to one router, never to the process. The camo fetch route GET /api/img/{sig} and the GitHub webhook POST /webhooks/github/{project_id} carry no guard: neither authenticates a caller nor calls a model. A test walks every route of the production router (chi.Walk) and sends each an authenticated request carrying the router's own token (expects 508) and an anonymous one (expects 401); a second walk sends every /v1//v2 route a pathless peer request with a body above every cap and expects the PeerIngress to count it before the cap answers.
On /v1 and /v2 the request goes from the loop guard to federation.PeerIngress (design 04 §4.6 steps 5, 6, 9, 10), which reads the model consumer the key door authenticated and decides which of the request's federation headers a later stage may see:
| Step | Case | Effect |
|---|---|---|
| 5 | no valid consumer behind the door | 503 internal_error, x-should-retry: false, an ERROR line — a wiring defect, never a pass |
| 6 | a ctx-peer consumer without Ctx-Path (a peer of an older version, or one that registered this instance as generic) |
served without hints: Ctx-Class and Ctx-Pipeline dropped, and as Ctx-Egress: local (no federation context was sent, so the sender's egress intent is unknown); counter federation_peer_without_path +1 per request; one WARN per consumer (a bounded set of 1024) |
| 9 | an app (kind ≠ ctx-peer) |
Ctx-Class and Ctx-Pipeline dropped: an app cannot claim an interactive lane |
| 10 | every request | the policy below and the cleaned inbound state go into the request context; a peer with Ctx-Path keeps its class and pipeline for the lane resolve |
The PeerIngress never shortens the request context: Ctx-Max-Wait-Ms caps this instance's queue wait only, after the admission the full attempt timeout applies (V1 — the sender's wire waits its own attempt timeout plus the budget plus a margin). Peer binding, Ctx-Instance and federation.require_peer_hop are later waves (F-W12).
Policy of a foreign prompt. Required = max(consumer prompt_sensitivity, Ctx-Sensitivity), EgressLocal = Ctx-Egress: local. The consumer's column is the floor (default credentials; only server-admin lowers it); an empty or unknown floor is credentials. Ctx-Sensitivity raises from every consumer — peers and apps alike — and never lowers; a missing header keeps the floor. A handler reached without the PeerIngress reads the fail-closed default credentials + egress local, never the zero value's public + any. No federation.inbound_* setting exists: there is no second floor next to the consumer's (a registry pin keeps it so). The chain build adds the alias and tenant-overlay terms and the scan (federation.ScanRaise): below credentials, sensitivity.Scan runs over every string leaf of the JSON request body — message content and parts, tool-call arguments, tool results, embedding inputs, rerank query and documents, and any other field — and one hit raises to credentials; a body that does not parse, or nests deeper than 10 000 containers, is scanned raw. At credentials there is nothing to raise and no scan runs. Each object member with a scalar value is also scanned as key: <first token of the value> — the whole token, as the scanner's assignment rule reads it, never cut by length (a cut prefix can fall below the rule's entropy threshold the whole token passes) — so a secret-named key such as client_secret is seen next to its value; a secret-named key over an array or object value is not paired with the strings inside it — a named limit: a short secret without a vendor prefix there stays at the lowered floor, as it would under a raw scan of the body. Measured cost at the request cap (8 MiB): about 2 s of CPU per body for a lowered consumer on a lightly loaded host, up to about 1.2 × that for adversarial body shapes and correspondingly more under load (the case-insensitive secret-assignment rule is the largest share); bounded by gateway.max_request_bytes and the consumer's concurrency limit, zero for a consumer at the default floor.
Egress over a hop. The chain of a /v1 request is filtered after Pool.Chain, in a fixed order: rows whose learned peer instance is already on Ctx-Path leave first; then, for Ctx-Egress: local, every row that would leave this instance's own engines — provider class ctx, a row whose answers revealed a ctx instance (answers_as_ctx_instance), and every row not local or lan; without local (transit) at most the first instance-hop row of the order stays, local and external rows stay. One instance hop per hop keeps a mesh's failover fan-out linear in the depth — for rows the filter knows as instance hops: ten instances in a full mesh of ctx rows, max_depth 3, every engine overloaded cost 18 wire calls per request with local and 45 in transit, against 819 without the filter (measured in-process). An empty chain is 503 no_eligible_backend without topology. Residual risk, named: the filter recognises an instance hop by provider class ctx (rows of that class exist since Migration 166, wave F-W5) or by a learned instance (learning needs a peer key at the other instance and the peer binding wave F-W12). A ctx instance this instance's admin registered as generic is neither: under local it counts as an own engine, in transit it does not take the one instance hop, so a mesh of such rows keeps the multiplicative failover fan-out of design 04 §5.1 L8, bounded only by federation.max_depth and the hop token (loops through it are still refused with 508). Such a row also receives the loop marker only, so the sender's Ctx-Egress and Ctx-Sensitivity never reach the instance behind it. This holds until F-W5/F-W12; register peers as ctx rows once the class exists.
(Backend pool, migration 055.) A backend carries a trust level and an egress locality; every block carries a sensitivity. A backend with trust T may receive content of sensitivity S iff rank(S) ≤ maxRank(T) — full-trust ≥ credentials, no-credentials ≥ personal, non-personal ≥ internal, public = public only. Empty/unknown sensitivity counts as credentials; an empty chain is an error, never a silent escalation across trust borders.
Block sensitivity. Every block carries sensitivity (default credentials — unclassified content never leaves full-trust backends; normal operation is untouched while all backends are full-trust) plus sensitivity_source (default/llm-audit/pattern/manual/derived; manual is untouchable for the audit wave) and sensitivity_audited_at. derived (migration 144) is reserved for the derived knowledge layer — a classification folded over a block's sources rather than decided by a human or a model. It is deliberately outside the G41 audit's pick set (a fold has provenance, it is not an unclassified row) but INSIDE the G40 pattern sweep, whose predicate excludes only manual: a folded block that later turns out to carry a key is still correctable. The write path landed with W01-5: store.SensitivityWrite{Derived: true} stamps the value, and on an upsert conflict it applies only on a STRICT elevation (>, like the pattern detector and unlike manual, whose >= re-stamps at an equal level) — so a regeneration that loses its most sensitive source lowers nothing, and an existing manual classification survives an equal fold. The folded value itself comes from store.ResolveSources, the one place the scope floor is applied, so the level in the block, the level in provenance.sensitivity_max and the level the LLM call demands are one value with one producer. No production caller sets it yet — the derived arm lands with a later wave. The query path batch-annotates all RRF candidates after graph expansion (a supersedes/graph straggler from beyond rank 50 still carries its level into the gate; a lookup miss acts as credentials), applies the scope floor pool.scope_sensitivity_floor (a JSON map scope → minimum level; it can only RAISE — blanket protection for friend-tenant scopes without block mutation), and gates each role with its real requirement: query-only roles (translate, temporal, query-embed) with the query sensitivity, rerank with max(query, judged docs), synthesis with max(query, final prompt set), inline backfill per block.
Downgrade guard (both directions of the same border). Lowering a block's sensitivity needs confirm_sensitivity_downgrade:true on manage update (audited to metadata.sensitivity_audit), exactly like raising a backend's trust needs confirm_trust_elevation; the settings defaults pool.default_block_sensitivity/pool.default_query_sensitivity/pool.llm_audit_min_sensitivity are guard-marked the same way.
Eject toggle (AM-7: manage action eject-mode, alias gaming-mode; CLI command ctx eject, legacy alias ctx gaming). ctx eject on flips the GPU-host backends (default herbert-chat + herbert-rerank) out of EVERY chain so the GPU is free — llama-cpu and any external backend stay in as failover. Since U01-W5 the toggle IS the reserved eject disable-profile (092): a profile write, admin-gated (an ungated toggle would let any tenant key flip the system's egress topology) and persistent — it SURVIVES a restart (the dream-mode break path, where a restart drops the GPU lock, is the anti-pattern it avoids) and takes effect on the next chain without one. In-flight requests finish normally. /health never carries the eject flag (it would be an "admin sits at the GPU host" presence oracle).
The legacy gaming.active/gaming.disabled_backends settings keys were retired in U01-W5: nothing reads them anymore. Any such rows left in context_settings are inert (dropped as unknown keys with a WARN), and a psql/break-glass edit to a gaming.active row is a no-op — the truth is now the eject profile row (context_disable_profiles, scope='_global', name='eject'). Toggle via ctx eject on|off (legacy alias ctx gaming), the disable-profiles card on the backends settings page (U01-W6: member chips + role impact shown before the click, role-blackout activations require an explicit confirm step), or flip context_disable_profiles.active directly. The status payload still exposes a gaming.active field for the frontend, but it is derived live from the eject profile's active state, not from any settings row.
OpenRouter ZDR. External backends of provider_class: "openrouter" always carry provider.zdr:true + provider.data_collection:"deny", independent of trust level — see api. Raising the backend to full-trust never silently drops the ZDR guarantee.
LLM audit (G41). ctx blocks audit start (manage action blocks-audit-start, admin) classifies every home-scope block still at sensitivity_source='default' out of the fail-closed credentials default: two SEPARATE yes/no questions per block over the classify role chain — one for schützenswerte credentials, one for personenbezogene Daten — answered as strict JSON booleans (deliberately NO confidence field: local-model self-reported confidence is uncalibrated). Verdict table: credentials-yes keeps credentials (the personal question is skipped), no+personal-yes → personal, no×2 → internal; public is never assigned by the audit (that stays manual). A parse failure is no verdict (the block keeps the credentials default and a 24h retry cooldown); a chain/backend failure aborts the run instead of cooling down blocks the model never judged. manual rows are untouchable by the SQL predicate itself, and the verdict write additionally re-checks md5(content) against the version that was picked: the classify calls happen outside any transaction and an ordinary content update does not reset sensitivity_source, so without that conjunct a verdict formed over v1 could land on a v2 nobody classified. A mismatch takes the same path as the manual race — verdict discarded, discarded counter up, block untouched and re-picked next run. The classify role is hard-local: backend-create/update rejects classify on locality='external' with 422 (no metadata escape hatch — audit prompts carry unclassified block content by definition, full-trust ZDR included); the chain executor additionally drops external rows at call time. Gate a bulk run with ctx blocks audit sample --n 30 (30 random pending blocks, no writes, reports would-be verdicts).
Structural veto on the verdict. The model's answer is not the last word. Before a verdict becomes a sample entry or a write, it passes a pure clamp with two independent, raise-only steps. First, the deterministic G40 detector runs over the full block content — a hit forces credentials no matter what the model said, and the secret-free match (kind/reason) rides the dry-run sample so an operator sees why. This is what makes the verdict body-blind: a block carrying an AWS key classifies identically whether or not the surrounding prose also tries to instruct the classifier, and it is the condition under which capping the classify prompt at 8 000 runes is admissible at all (a secret past the cap is invisible to the model and still visible to the detector). Second, the verdict is floored at pool.llm_audit_min_sensitivity (default internal, tenant-overridable, downgrade-guarded). At the default that floor closes no reachable path — the audit cannot produce public by construction — so it is defense in depth against a future verdict extension plus the lever for a tenant that wants its corpus floored at personal; being tenant-overridable, it is read from the iterated tenant's config generation, not the process-wide one. The clamp sits BEFORE the dry-run return, so blocks audit sample predicts what a live run would write.
Credentials pattern detector (G40). A deterministic, LLM-free scanner (internal/sensitivity) that only ever RAISES content to credentials — never downgrades. It runs at two points automatically: on POST /api/store (a content hit forces credentials with sensitivity_source='pattern', records the secret-free reason in metadata.sensitivity_detector) and on POST /api/query (a hit in the query text raises the operation's required sensitivity). Rule set (precision over recall — a false positive permanently blocks external failover for that block): AWS key ids, PEM private-key headers, JWTs, vendor token prefixes (sk-/ghp_/xox…/AIza…/glpat-), entropy- and placeholder-gated secret assignments, high-entropy base64 blobs (≥32 chars, >4.5 bits/char), long hex blobs (≥64). The bulk re-audit ctx blocks classify start (manage action blocks-classify-start, admin) keyset-walks every home-scope block not already credentials and not manual, raising hits — the deterministic veto against the G41 audit (a pattern row is outside the audit's pick set, so the LLM can never downgrade a pattern hit). Always dry-run first (ctx blocks classify dry-run) — it scans the real corpus WITHOUT writing and lists what would be raised, the empirical false-positive gate. Once the corpus is classified, pool.default_block_sensitivity can be lowered to personal via the guarded settings write.
The Camo image proxy (api, off unless CTX_CAMO_ENABLED) makes the server fetch a signed image URL, so every byte of that URL reaches the foreign host. Model output is attacker-steerable: injected text in a retrieved block can make the assistant emit , and signing that on render would exfiltrate context past the trust × sensitivity gate without anyone clicking (GHSA 2026-10-31 §7). Three layers close it. Images are click-to-load on every markdown surface — chat assistant turns, issue bodies and comments — the SPA signs nothing on render or while streaming, shows host + shortened path, and signs one URL per click. Issue and comment text gets no exemption: the MCP tools issue_create/issue_comment, ctx manage and the REST routes write it with the same api-key auth as the web UI, so an agent may have written it, and the stored block does not record which client did (security review 2026-10-31 §E). The mint rule (camo.ProxiableURL) refuses every URL with a query string or userinfo and caps the escaped path at 512 bytes for all origins, and GET /api/img/{sig} re-applies it, so a signature minted before the rule cannot be redeemed for such a URL. POST /api/img/sign accepts one URL per request; a batch is 400. Its origin field takes assistant (also the default when absent) and nothing else: the retired value issue, which pre-upgrade bundles sent when they signed issue images on render, is 400 like any unknown value. A browser tab still running such a bundle therefore fails closed — its issue and comment images stay placeholders until the tab reloads. The origin is declared by the client, so the one-per-request rule is defense in depth against a regressed frontend, not a proof of a click. What remains is a bounded channel the viewer sees before clicking: the host name and a path of at most 512 bytes.
Doctrine: foreign text enters an LLM prompt through exactly one function. internal/promptguard is that entry — pure functions, no DB access, no configuration: NewNonce (16-hex per prompt build), Neutralize (breaks control tokens — <|, double-newline turn markers, guard-tag delimiters — with U+034F so the readable text stays intact; idempotent, and deliberately narrow: |> is content), ClampLine (line breaks → U+23CE for line-based prompt positions), Wrap (nonce-carrying <untrusted_block> markers; every attribute position — name, value, tag, nonce — runs through a full-match allowlist clamp a caller cannot bypass), Rule (the nonce-bound system-prompt sentence that makes the block boundary verifiable instead of believed) and Canonicalize (zero-nonce substitution for deterministic prompt goldens). The guard is structural — it removes forgeable tokens and boundaries; it never claims model obedience. Call-site wiring lands wave by wave. Wired so far: dream-keywords and dream-temporal (fixed marker + Neutralize before the existing XML escape; the temporal cut is rune-safe now), dream-recurrence (ONE nonce binding both Wraped blocks and the Rule in the system prompt; block metadata rides a line-clamped header line above the marker, because a 36-char uuid and a spaced title cannot pass the marker-attribute clamp). Also wired: dream-daily-synthesis (the daily report prompt is line-based — every DB-sourced value runs ClampLine + Neutralize, so a newline in a title or an aggregate label can no longer forge an extra item line). Also wired: the chat tool return (web-chat: every foreign field of a tool result — title, category, tags, preview/content — is neutralised BEFORE mustJSON; JSON escaping alone only looked safe because encoding/json HTML-escapes < by default, an encoder default a routine SetEscapeHTML(false) would drop silently). Known open fifth path there: runUpdate serialises the staged-write card whose Category/Title come from the TARGET block — deliberately unguarded for now because the same struct feeds the SPA confirm card (Ops surface). Also wired: sensitivity-audit (the last raw-concat prompt: title and content now sit in ONE guarded block behind the code separator, so a forged ---/Titel: section inside a 50 KB content is data, not structure — and the previously unbounded content is capped at ClassifyContentLimit = 8 000 runes with a visible truncation suffix; the cap is admissible only because the deterministic detector reads the FULL content, which the H9 structural veto turns into a guarantee). Also wired: query-synthesize (all four foreign fields — query, title, category, content — run Neutralize before the existing XML escape; each source content rides inside a nonce-carrying Wrap marker whose ref= repeats the citation ordinal, ONE nonce per build bound by the Rule spliced into the existing <security> element; the <source id="N"> element stays because the system prompt's citation contract points at it). Also wired: query-rerank-judge (each of up to 15 docs rides a nonce-bound Wrap block with ref= carrying the array position — the judge answers a positional score array, so a forged header line is an array desync, not just an instruction surface; the query and the Doc n [category/title] header lines are line-clamped, and both rerank cuts are rune-safe now). Deliberately NOT guarded: the cross-encoder doc strings (RerankCrossEncoder) — a scoring API without a chat template has no role to switch, and any text change there silently shifts every score; a probe pins the doc bytes unchanged. Also wired: dream-eval (the highest-volume prompt pipeline: ONE nonce binds the source block and every candidate block plus the Rule appended to the dream system prompt; the category — a free string that used to reach BOTH prompt positions raw — is line-clamped and escaped on the attribute side (candidate) AND the line-based side (source), closing the attribute-breakout and the delimiter-forge; the shared truncate fallback is rune-safe now, which the keyword and recurrence builders inherit; the V5-calibrated <source>/<candidates> skeleton stays — the wave adds a verifiable boundary INSIDE each block, and filterValidCandidates remains the deterministic defence behind it). With that, every foreign-text prompt pipeline is wired; the call-site sync test (H11) turns the doctrine into a gate.
Prompt budget (H12). A guard that neutralises control tokens still lets a caller push a prompt past the model's context window — and the silent loser is whichever end the backend truncates, in practice the front, where the security rule sits. The budget therefore resolves BEFORE the prompt is built, over the resolved chain: min(pipeline ceiling, rune budget of every chain member's declared window) — a failover leg is walked with the prompt that was already built, so the weakest member bounds it. Runes convert at the measured minimum ratio (1.8 runes/token over context_llm_log, never the per-pipeline means of 2.5–3.2). A chain member with no declared window (context_backends.num_ctx IS NULL) refuses the prompt (promptguard.ErrUndeclaredWindow, decision E10 fail-closed) unless the operator declares a floor — per-backend num_ctx (preferred) or the tenant-overridable pool.external_num_ctx_fallback. A rate value would be wrong, not imprecise: for a multi-provider router the model-level context length is the maximum over providers, not the minimum. When the budget bites, promptguard.Assemble cuts from below (lowest priority, latest first — the first source survives), the security rule is never shortened (budget below the rule = error, no unguarded fallback), and the outcome lands in llmlog metadata.promptguard_dropped — set only when something was actually cut. The runtime wiring covers query-synthesize (the one pipeline whose item count is request-driven); the constant-capped pipelines are held by a static constants gate instead, which multiplies every item cap against its count at go test time and additionally pins the number of LIMIT-less daily-report sections (three today, vocabulary-bounded).
AUTO context windows (E10-W2). The H12 floor stands; what changes is that a provider_class=openrouter row with num_ctx IS NULL no longer has to be undeclared. Discovery (GET {base_url}/v1/models/{author}/{slug}/endpoints, cached per (host, model) under the tenant-overridable pool.openrouter_window_ttl, default 3600 s, 0 = off) reads the per-provider windows instead of the model-level maximum, and the member plans against the best of them minus a completion reserve. That is only safe because the input side is then constrained explicitly: OpenRouter's routing filters on max_tokens (output), never on prompt size, so ctx sends provider.only naming exactly the endpoints whose context_length holds inputTokens + maxOut and whose max_completion_tokens covers maxOut — max_tokens remains the second, output-side layer. Input tokens are estimated from the rendered prompt at the same measured-minimum ratio read backwards (runes ÷ 1.8 over-estimates the cost, so an admitted provider has head-room). An operator's own extra_body.provider fields survive: ignore is subtracted from the computed set, an operator only is intersected with it (two restrictions merge to their intersection, never a replacement), everything else passes through, and the forced zdr/data_collection: deny terms still run last. A member with no eligible provider is skipped for that one request — the chain fails over, because the backend is not down, it merely cannot serve this prompt; a chain that empties out is a no-eligible-backend error. Refresh failures are stale-while-error inside a hard 24 h age limit; with no data at all the row is exactly as undeclared as before, i.e. fallback or refusal.