(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.
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:
docker exec -e PGPASSWORD="$CONTEXT_DB_PASSWORD" n8n-db-1 \
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:
docker exec -e PGPASSWORD="$CONTEXT_DB_PASSWORD" n8n-db-1 \
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.
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. 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 — no settings write needed, the incident-response path is never silently inert. Deleting a secret that settings still reference is a 409 listing the keys.
Per-tenant secret resolution (the tenant.allow_shared_secrets opt-in, default strict isolation) is documented in multi-tenancy.
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)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 n8n-ctx -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.
(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; manual is untouchable for the audit wave) and sensitivity_audited_at. 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.
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.