Skip to content

Latest commit

 

History

History
809 lines (578 loc) · 255 KB

api.md

File metadata and controls

809 lines (578 loc) · 255 KB

HTTP API

All endpoints under /api/*. Auth via X-Context-Key header or Authorization: Bearer token.

Request header limit: ctxd parses at most 100 header values per request and answers 431 Request Header Fields Too Large above that, before the handler runs (cmd/ctxd/main.go, maxHeaderValueCount). The cap counts values, not lines — comma-separated values inside one header line count once, the same header name sent as N lines counts N times. A browser behind a reverse proxy carries roughly 25, so only a client that fans a header out into dozens of lines can hit it. Not a settings key: changing it is a rebuild, deliberately.

Endpoints

Endpoint Description
POST /api/query 4-Way RRF + LLM synthesis (auto-backfills pending embeddings). Optional categories_exclude / types_exclude arrays filter slot-stealers (block_roles_exclude = documented legacy alias for types_exclude, both ⇒ union). Optional sensitivity classifies the query text for trust gating (default settings key pool.default_query_sensitivity). Optional include_content attaches a ≤1500-char snippet per source on the retrieval-only path (synthesize:false, default off so eval/sweep responses stay byte-identical — the chat harness's ctx_query tool sets it). On the synthesis path each source additionally carries citation_index — the <source id="N"> ordinal it had in the prompt, i.e. what a [N] in the answer points at; the prompt order is not the response order (low-confidence cap, lost-in-the-middle reorder, prompt budget), and the field is absent for a source that never entered the prompt and on the retrieval-only path. Whenever synthesis is on (synthesize != false), the response commits 200 up front and streams a whitespace keepalive every 25s so buffering proxies don't hit their read timeout; the body stays valid JSON (leading whitespace, RFC 8259) and a late synthesis failure reports success:false inside the 200 body. With the settings key query.semantic_floor armed (default 0 = off, see operations) an off-topic query is refused before the synthesis call — confidence:"no_relevant_blocks_found", empty sources, no eval_count, the same body the score filter already emits — unless a lexical-only hit (no cosine_sim, score ≥ query.confident_threshold) carries the result set. Three further request fields are admin, measurement only (B-W2 arm-weight sweep, see architecture): arm_ranks (bool) runs ctx_rrf + ctx_rrf_arms in one read-only transaction and adds an arm_ranks response block (rows, fusion_order, effective query/temporal, embed model, selector) — 403 without an admin key, 400 without synthesize:false; pinned_translation and pinned_temporal (strings) replace the translation resp. the temporal FTS expansion so a sweep run is reproducible — 400 unless arm_ranks:true. Access-log rows of such a request carry metadata.source = "armsweep". A fourth field of the same class is shadow_types (array of registry type names): it adds those types to the retrieval visibility allowlist of the two SQL statements only, so an offline A/B can measure a corpus that must not be live yet. Seven fail-closed gates — 403 without an admin key; 400 without arm_ranks:true, without synthesize:false, with include_content:true, or for a type that is not registered / not carrying retrieval.shadow_measurable / on the hard deny-list (checkpoint, system-meta, not configurable); the reranker is switched off for the request rather than assumed off, and every backend chain the request can still resolve must be locality=lan or it is refused. The post-fusion stages (cluster boost, graph expansion, aggregate fold) keep the unwidened allowlist, and every row of a shadow type is dropped before sources is built — such a block appears as a UUID plus numbers inside arm_ranks and nowhere else, not even as a title. MCP does not carry the field. Without arm_ranks the response is byte-identical to a request that never knew the fields.
POST /api/store Upsert (embedding async via scheduler). Optional sensitivity (credentials|personal|internal|public) classifies the block manually (sensitivity_source='manual'); absent ⇒ settings key pool.default_block_sensitivity (fail-closed credentials). On an upsert conflict an explicit value applies upgrade-only — downgrades go through manage update with confirm_sensitivity_downgrade. A credentials pattern in the content forces credentials upgrade-only regardless of the requested level (G40 detector, sensitivity_source='pattern'). Optional type validates against the registry and sets type_source='manual'; a type whose registry policy demands a structural parent (parent.mode=required) is refused unless this write names one (parent_required, see below). Optional parent_id hangs the written block under an existing block (context_blocks.parent_id, the one structural parent): a full block uuid in the SAME scope the write resolves to, absent ⇒ no parent and a byte-identical answer. An id that cannot carry the block — unknown, archived, in another scope, malformed, or naming the very block this write addresses — answers 422 {code:"unknown_parent"}, ONE class for all five cases so the field is no existence oracle for foreign block ids, and the refusal lands before the upsert so no half-written block is left behind. Identical content. A write whose content matches the live block of the same (category, title, scope) answers {success:true, action:"noop", reason:"identical_content", existing_id} and writes nothing only when the upsert would change nothing else either: the same tags (compared as arrays, order included), no new metadata key or value, no sensitivity upgrade the conflict clause would apply, no type the block does not already carry as type_source='manual'. Otherwise the write is an ordinary upsert and the answer is the upsert answer plus action:"updated", reason:"identical_content" and changed — the parts that differed, from ["tags","metadata","sensitivity","type"]. Every other upsert answers {success, block} without an action field. On identical content tags are replaced by the request's, as on every upsert, while the request's metadata is merged into the stored document: keys the request names take its values, keys it does not name stay — the guard's verdict, a sensitivity audit trail, a repair record, keys set through manage update — so a byte-identical re-save is a no-op and removing a metadata key goes through manage update. The stored guard_* keys win over a request's guard_* keys, guard_checked_at is never taken from a request, and the embedding is kept, so such a write neither re-embeds the block nor puts it back into the guard queue. A write that changes the content replaces metadata as before and drops the guard's verdict, so the next guard batch re-checks the new vector. A block that carries a derived provenance answers a write that would change it 403 provenance_protected instead of noop. Before this, the shortcut compared the content alone and answered noop while dropping the request's tags, metadata, sensitivity upgrade and type (issue #64). A write that names a parent never takes the identical-content shortcut: noop would report "nothing to do" while the parent it asked for went unwritten. The route runs the SAME write-gate chain as the MCP store tool (both arms) rather than a copy of it (handler/stage_gates.go): required fields, size caps, sensitivity resolution, the I7 claim gates, the G40 detector, the write-scope gate and the write rate limit are decided once, in one order, for REST, MCP-direct and MCP-staged — a gate added there reaches every call site of the chain (those three arms plus the confirm re-check and the chat stage runner), and for those seven gates the three arms reject with the identical code and the identical prose. What sits IN FRONT of the chain stays per transport: the MCP arms check the required fields in their own wording and answer an identical-content call before any gate runs — on the content alone, so the MCP store tool still drops the tags, metadata, sensitivity and type of such a call, and the HTTP status is carried by the REST answer alone (MCP names the class in structuredContent.code).
POST /api/search Lightweight search (no LLM). Results carry the type axes (type/lifecycle_state/type_source) plus untrusted on a foreign-text row (see Untrusted framing on read responses under Manage actions). Optional types / types_exclude arrays are server-side opt-in type filters (bind parameters; block_roles_exclude = legacy alias, both ⇒ union). Optional cluster is the topic facet — see Cluster facet. Visibility is scope plus block grants: a block granted to the caller's tenant is in the answer even when its scope is outside read_scopes — the same rule the MCP search/get/recent tools have followed since T40a. Of the three block read surfaces (search, get, recent) this route was the last one without grants; it passed an empty grant set until T03-9. The graph views (/api/graph/*) and the chat ctx_search/ctx_recent tools resolve no grants at all — separate surfaces, separate waves. An archived block stays invisible either way, and a grant set over the per-request bound is REFUSED with a 500 carrying the shared prose rather than answered scope-only.
POST /api/recent Newest visible blocks first (no LLM, no FTS) — the REST half of the recent surface the MCP tool and the chat ctx_recent tool already had. Request {limit, category, types, types_exclude, block_roles_exclude}, all optional; limit is clamped in the store (absent/0/negative ⇒ 10, >50 ⇒ 50), the alias unions with types_exclude exactly as elsewhere. Response {success, count, filters, results} — the rows are /api/search-shaped (same field names, incl. the type axes and untrusted) minus sensitivity, which the recent statement does not select; filters echoes the EFFECTIVE category/types/types_exclude and deliberately carries no limit (the clamp lives in the store, so an echo would name a number that did not run — count is the honest figure). Same read rate-limit bucket as /api/search: alternating the two routes does not buy a second budget. Block grants are resolved for the caller's tenant, so REST and MCP answer with the same block set for the same filters (pinned by recent_parity_integration_test.go).
GET /api/graph/ego Scope-filtered k-hop ego subgraph over dream links (read-only, no LLM — see Graph API).
GET /api/graph/all Flat load-all seed: every visible block up to the budget ceilings (newest-first, deterministic), induced edges of both data classes, degrees. Same envelope as ego with focus:""; no traversal params (see Load-all).
GET /api/graph/overview Scope-pure Louvain cluster supergraph ("landkarte"); reads precomputed scope-partitioned aggregates, gated on graph_overview.enabled (off → 404). Read-only, no LLM.
GET /api/graph/cluster Cluster ego: the members + neighbour topics of ONE topic handle (see Cluster ego). Double-gated on graph_overview.enabled and cluster.route_enabled (both default off → 404, indistinguishable from an absent route).
POST /api/synthesize/daily Manual trigger of the daily synthesis (Tagesbericht) for the caller's home_scope — the same GenerateDailyReport path the 03:00 scheduler iteration runs (digest role, constant internal). Concurrency cap 1 per principal (429 fail-fast), dispatch-interactive (429 + Retry-After on capacity rejection). Optional ?date=YYYY-MM-DD (M103) re-synthesizes the report titled that day over its historical window [date-1 03:00 UTC, date 03:00 UTC) — the backfill path; the (category, title, scope) upsert replaces the old report in place (same block id, embedding regenerates). A malformed date or a window that has not closed yet ⇒ 400. {ok, block_id, scope[, date][, reason:"no_activity"]}. The report language follows the hot dream.language setting, read per request: empty (default) keeps the German surface this row describes, any other tag localizes title and tag together (Daily Report <date> / daily-report) — so a change starts a new series instead of continuing the old one, and a backfill after the switch writes a second block for a day that already has a Tagesbericht. Deliberate and operator-triggered; see CTX_DREAM_LANGUAGE.
GET /api/whoami Calling key's identity: label, home_scope, read_scopes, the server-global admin tier flag, plus the Model-C tenant identity tenant_id + per-tenant role (owner/admin/member), and the feature-flag bag capabilities ({workflow: true} since v4.3.0 — switches the SPA's issues/board surface visible; the field is data, so a later per-tenant gate only changes the value, not the SPA). capabilities.model_api carries the gateway policy as data, read per request from the _global settings: {enabled, self_service, key_max_lifetime_s, rotation_grace_s, expiry_warn_s} = gateway.enabled, gateway.tenant_self_service, gateway.key_max_lifetime_app, gateway.key_rotation_grace, gateway.key_expiry_warn (durations in whole seconds; the peer lifetime is not carried). Every authenticated caller receives it — server policy, no secret; the SPA derives two capabilities from it, viewModelApi and mintModelKeys, for the gateway administration surface (multi-tenancy), while the manage actions enforce their own tiers (Model consumer administration). The SPA login gate probes it, derives its read-only degradation from admin, and tells server-admin from tenant-admin.
POST /api/manage CRUD, Guard API, stats, API-key management, block-type registry (see manage actions).
GET /api/types[/{name}] Block-type registry reads: effective type list (_global ∪ your tenant) and single-type policy config, member-gated (any valid key) (see Block-type registry).
PUT|DELETE /api/types/{name} Block-type registry writes (upsert/delete), admin-or-tenant-admin-gated: a tenant-admin writes its own tenant namespace, _global types are operator-only; DELETE 409s while referenced or on a builtin (see Block-type registry).
GET|POST /api/project + GET|PATCH|DELETE /api/project/{id} Project register: one project = one repo corpus bound to one tenant scope. Reads member-gated (scope-read), create/patch/delete tenant-admin (see Project register).
GET /api/project/{id}/issues + …/issues/{block_id} + …/issues/{block_id}/comments + …/board Project issue read surface (workflow W6), member-gated (scope-read): keyset issue list (filters state/labels/q, sort=updated|created, opaque after cursor), issue detail + inline comment thread, paginated comment thread, and the per-status board (columns + index-only counts). A foreign-scope project or issue id ⇒ 404 uniform (see Project issues).
POST /api/project/{id}/issues + PATCH …/issues/{block_id} + POST …/issues/{block_id}/comments Project issue write surface (workflow W7), write-scope-gated: create issue, patch fields/status (transitions validated against type-config policy data ⇒ 422), create comment. Project scope ∉ your writable scopes ⇒ 403; foreign/unknown project or issue ⇒ 404 uniform; per-key write rate limit ⇒ 429 (see Project issues (write surface)).
POST|GET /api/project/{id}/sync Project forge-sync trigger + status poll (workflow W11). POST starts a GitHub pull run — write-scope-gated (decision E6=a: whoever may write the project's issues may sync it); double-start of the same project ⇒ 409, a full concurrency semaphore ⇒ 409 + retry_after_s, the per-project rate limit ⇒ 429. GET is member scope-read (see Project sync (REST trigger)).
POST|DELETE /api/project/{id}/webhook-secret Webhook HMAC secret lifecycle (workflow W13), tenant-admin. POST creates/rotates a server-generated secret (crypto/rand, reveal-once) sealed in the PROJECT scope; DELETE deactivates the webhook. NOT via /api/secrets (its writeScope can't reach the project scope) and webhook_secret_ref is NOT PATCHable ⇒ 422 (see Webhooks (inbound)).
POST /webhooks/github/{project_id} Unauthenticated GitHub inbound (workflow W13) — HMAC-SHA256 over the raw body against the per-project sealed secret replaces the api key. Bad/absent signature or unknown project ⇒ uniform 401 with ZERO DB write; valid ⇒ 202 and one redelivery-idempotent inbox row; per-project rate limit ⇒ 429. The inbox is a debounced sync trigger, never an authority (see Webhooks (inbound)).
GET|PUT|DELETE /api/settings[/{key}] Runtime config overrides, admin-gated incl. reads (see Settings API).
GET|PUT|DELETE /api/secrets[/{name}] Write-only sealed credentials, admin-gated: PUT creates/rotates (value never returned), GET lists metadata + referenced_by, DELETE 409s while referenced — by a secret_ref setting or a backend pool row's api_key_ref (entries prefixed backend:, remediation text per reference type) (see security).
GET /api/status Admin dashboard aggregate from the process-wide status collector: health, backend pool (pool.Status() shape), dream queue + mode, 24h LLM telemetry (with an llm_24h_complete attribution flag), and the disable-profile registry line (profiles: slim {name, scope, label, active, member_count} rows, U01-W7 — replaced the retired gaming:{active} field; member names load on-demand via disable-profile-list). A server-admin additionally sees advisories ([{subject, state}], omitted when empty): named reasons for states the frame can only show as an absence — today exactly backend_pool: empty (fresh install, nobody seeded yet; deliberately NOT in the public /health body), and llmlog ({insert_failures, insert_dropped}, present even at 0): the process-wide count of context_llm_log rows that never landed — inserts the database refused or that timed out, and rows dropped by the bounded insert queue (operations), and foreign_load (present only while an enabled, not profile-parked _global backend row carries metadata.foreign_load_probe): one row per probed engine origin, {target, probe, backends, running, active, held, delta, suspected, since, suspected_hourly, hourly{hour, engine_requests, ctx_requests}, probe_error, probed_at} — the requests the engine holds (read from its own GET /metrics every status.foreign_load_interval and on each hour boundary, whether or not anyone reads the status) against the dispatcher's held leases, and hourly its finished responses against ctx's logged calls; evidence that ctx is the only load source of the GPU (operations). Never on the tenant view. The server-admin dispatch section carries per target held (ALL slot-holding leases, internal and external — the number the foreign-load detector compares) split into held_internal / held_external, inflight_internal, the external offer (internal_reserved, external_offered, external_capacity), the external class's wait view external and its refusals external_rejects {cap, not_offered}, the same two for the external bulk class (external_bulk, external_bulk_rejects {cap, not_offered}; held_external counts the leases of both external classes), and buckets with tenant_id and class (interactive | external | external_bulk; an external consumer's bucket key is _consumer:<id> in either external class). The tenant-admin dispatch_tenant view never shows external load: busy and depth read only the internal leases and queues, and no external bucket appears. Served from a cache (N pollers cost one collection). Carries hostnames, so it is admin-gated where /health stays anonymous. Opens to a tenant-admin with a reduced own-tenant view (see multi-tenancy).
POST /api/manage (eject-mode, alias gaming-mode) Canonical shim for the reserved disable-profile eject (092): read = legacy gaming shape, mutation = server-admin toggle with implicit blackout confirm. Since U01-W5 a profile-only write (the legacy gaming.active settings dual-write was retired); the eject profile is the single source of truth.
POST /api/manage (disable-profile-list/create/update/delete/toggle) Disable-profile registry (tenant-admin; tenant profiles may only contain own-scope backends, _global profiles are operator-owned). Toggle returns {profile, impact, as_of}; blacking out a whole role requires confirm_role_blackout (else 422; dry_run supported); the reserved eject profile is not deletable.
POST /api/manage (backend-create/backend-update, field disable_profiles) Profile membership as a backend property: list of profile NAMES (key absent = unchanged, [] = detach all); joins sync in the same transaction as the backend write. A tenant-admin may attach its OWN backend to _global profiles (self-disable) or its own tenant profiles; a scoped profile never contains foreign-scope backends. backend-list echoes disable_profiles including inactive membership.
GET|PUT|DELETE /api/graph/category-hues[/{category}] Per-category graph HUE overrides (093, AM-2): GET (any valid key — the graph is a member surface) returns the resolved sparse map {category: hue} for the caller's {_global, tenant} view, tenant beating _global per category; PUT {"hue":0–359}/DELETE (tenant-admin) write the caller's own scope (operator → _global), never a client-supplied one. Only the hue is overridden — sat/lum stay theme tokens, so every choice sits inside the G1a contrast sweep. Absent override = seeded hash colour. UI: the hue wheel at /settings/hues (U02-W6) — markers sit at perceptual OKLCH angles for distance judgement, the stored value is always the HSL degree.
GET /api/llmlog Admin LLM telemetry table (?limit=/pipeline=/errors_only=). NEVER returns the request_system/request_user/response_content body columns (the prompt shadow corpus); the error is normalized to a class + 256-char-capped detail. Tenant-admin sees only its own tenant's rows.
GET /api/audit Admin or tenant-admin history of the access-deciding tables (security): ?entity= (required) one of api_key, tenant_quota, gateway_limits, model_alias, model_consumer, model_key, model_grant, consumer_usage, instance; id= an exact entity_key (a key id, a quota scope); since= RFC 3339, rows strictly newer; limit= a positive integer, clamped to audit.max_limit (default 200, also the page size without limit; a configured 0 falls back to the default). Newest first (created_at, then id). 400 for a missing or unknown entity, for setting/secret/backend (their history stays on their own surfaces, e.g. GET /api/settings/{key}), for an id that is not valid UTF-8 or carries NUL, for a malformed since and a non-positive or non-numeric limit. Response {success, entity, rows:[{id, entity_key, scope, action, old_value, new_value, api_key_id, actor_label, metadata, created_at}]}. model_consumer and model_key rows are written by the triggers of migration 161, model_alias rows by those of migration 162 (a _global alias carries no tenant, so its rows are server-admin-only), instance rows by those of migration 163 (entity_key self; a rotation is a rotate row with the old and the new instance_id, never the secret); model_grant rows by the trigger of migration 164 (entity_key <consumer_id>:<alias_id>, actions grant/ungrant, scope the consumer's tenant_scope); gateway_limits and consumer_usage answer rows: [] until their writers ship. A server-admin reads every row unredacted. A tenant-admin reads only rows whose metadata.tenant_id is its own tenant (filtered in SQL): another tenant's id answers the same 200 with rows: [] as an id that does not exist, rows of a scope no tenant owns are server-admin-only, and so is instance (rows: [] for a tenant-admin). For a tenant-admin the actor is redacted unless it provably is a non-server-admin key of the reader's own tenant: actor_label becomes "server-admin", api_key_id null, metadata.actor_principal is dropped; metadata.actor_tenant_id/actor_server_admin (the inputs of that decision) and every other actor_* key (any letter case) are never sent to a tenant-admin, and neither are is_admin and principal_id inside the old_value/new_value of api_key rows (dropped on every key row — api-key-list shows neither). An api_key update whose only change lies in those two fields is left out of a tenant-admin's answer; the rows are filtered after limit, so such a page can hold fewer than limit rows while older rows exist — fewer rows than limit is not the end of the history.
GET /api/events Admin-only SSE live stream (text/event-stream) for the dashboard. The collector diffs its snapshot ONCE per tick and fans status/backends/llmcalls events to every connection; a new connection gets the full state first, then diffs. The pull path additionally carries guard_review_by_scope (S6): one four-tuple per scope the CALLER may read, out of the same per-tick generation (0 extra queries), per-credential and therefore never in the shared snapshot or the push frame — the /guard page polls and compares this vector (list and counter run on two different scope predicates: ReadScopes vs. home scope) and reloads when it moves; absent section renders '—', never '0'. The status frame carries the guard_review section (same generation as the pull paths, S2): present-with-zeros means a freshly measured empty queue, an ABSENT section means no fresh generation — and its built_at stamp is diff-neutral (zeroed in the diff key like as_of), so a walking stamp alone never fires a frame. One tick costs at most ONE frame per event kind: the telemetry rows of a tick ride a single llmcalls frame {rows, count}, and above events.llmcall_coalesce_threshold it degrades to the content-free {kind:"llmcalls-bulk", count, cursor} refetch signal (the client reloads over GET /api/llmlog) — the same coalescing doctrine project.events.coalesce_threshold runs on the domain-event hub. hb keepalive event (CTX_EVENTS_PING_INTERVAL) carrying {last_good_at, degraded, health} — a REAL named event, not an SSE comment, so the client can observe it: last_good_at is the newest tick whose DB-backed reads all answered, so a stream that keeps arriving while that stamp freezes means "transport fine, server blind" (as_of cannot say this — it advances on failed reads too). The SPA consumes it (S4): the /status SSE client arms a payload+transport watchdog (2.5 × ping interval) that flips its state to stale when the stamp stops advancing or frames stop entirely — stale ≠ open reactivates the page's poll fallback with no call-site change, and a ConnState live-region indicator (dot + word, degraded>0 → partial) renders it in the page head. The watchdog is opt-in per stream: /api/project/events keeps its comment keepalive and stays unwatched. A rolling 90s write deadline that outlives the absolute server WriteTimeout, the CTX_EVENTS_MAX_CONNECTIONS cap → 429 (client degrades to polling), and an in-stream re-auth every 12th tick that ends the stream on key revocation.
GET /api/project/events Member SSE domain-event stream (text/event-stream, workflow W9) for live board / issue-list updates. A key receives IDS-ONLY frames — project events {kind, project_id, op, block_ids} or the coalesced {kind:"issues-bulk", project_id, count}; NEVER title/content/body (the client refetches over the read API). Frames are scope-filtered at the fan-out: only the caller's readable project scopes, never another tenant's. Optional ?project_id= pins the stream to one project (foreign/unknown ⇒ 404 uniform). Per-tenant connection cap (project.events.max_connections) → 429; : ping keepalive; a 10k-import burst collapses to one issues-bulk frame per tick (project.events.flush_interval / coalesce_threshold). In-stream re-auth recomputes the caller's scope tags from the FRESH read set every ~12 flush ticks: a revoked key or a tenant change ends the stream, and a revoked cross-tenant scope grant (tenant/role unchanged) drops that scope's frames — separate from the server-admin telemetry /api/events.
POST /api/digest Topic map generation. Rebuilds the linear topic map and — when root_map.enabled is on — also renders the root map (root-map-<scope>, cluster-per-line, capped at root_map.budget_bytes). It never starts a Louvain rebuild: the map is rendered from the cluster state that already exists, so the route cannot become a lever on a job bounded only by graph_overview.rebuild_timeout. Carries its own write rate-limit bucket (digest) since the root-map wave. Response gains rootMapTitle / rootMapLength additively; title, blockCount, categoryCount and contentLength keep their meaning, and the two corpus counts behind them now run under root_map.count_timeout (0 = not measured this round) instead of unbounded.
POST /api/ingest Obsidian vault ingestion. Writes into the key's home scope; chunk mode (source_id + chunk_index) needs a context_sources row in that scope and never overwrites another scope's chunk — absent, foreign or foreign-held all fail the chunk as source not found. ctxd has no endpoint that creates context_sources rows; a chunk-mode client needs one created out of band with the scope of the ingesting key's home scope (since 5.29.1 a source in any other scope fails every chunk). Per-chunk failures do not change the status code: the request answers 200 with success: false, failed > 0 and results[i].status = "failed" plus results[i].error for each failed chunk, next to the ones that were inserted/updated/skipped — a client must read the body, not the status. Batch-wide refusals carry their own status (400/413 validation, 403 reserved namespace, 429 rate limit). Chunk content runs the credentials detector; each accepted request books the ingest bucket against query.rate_limit_write (429 past it). A block-mode write (no source_id) runs the type classifier on title and metadata like POST /api/store; a block whose type_source is 'manual' keeps its type. The registry snapshot is resolved once per request, so every block-mode chunk of a batch is classified against the same rules. Chunk-mode writes do not run the classifier; a new chunk row starts with the default type.
POST /api/blob/* Binary storage (store/fetch/search/manage). Write rejections additionally carry a machine-readable "code" (Gap-C6-c): the closed vocabulary is unauthorized, invalid_body, missing_fields, invalid_sensitivity, invalid_encoding, scope_denied, size_cap, unknown_type, reserved_type, parent_required, unknown_parent, reserved_category, reserved_metadata, provenance_protected, unknown_action, constraint, rate_limit, internal — one code per rejection class across /api/store, /api/blob/store and the MCP store tool (there as structuredContent.code, the Content[0].Text prose is unchanged; a STAGED answer is deliberately uncoded — IsError plus code means refused, IsError without code means staged). Answers without a code keep their exact pre-existing envelope (no empty code key). store runs on its OWN write budget: pool.blob_rate_limit_write (tenant-overridable, default 10) per api key and 60-second window, counted over the separate blob-write action in context_access_log — a key at its query.rate_limit_write block-write limit may still upload, and uploads never consume the block budget. 0/unset falls back to the VALUE of query.rate_limit_write (unlimited needs BOTH at 0). The budget is booked BEFORE the upsert, so a write that ends in a constraint violation still costs it; over budget ⇒ 429. Each stored blob leaves one audit row referencing it through context_access_log.blob_id, which is intentionally unconstrained — the row survives the blob's deletion. MCP transport (W02-8): blob_store / blob_fetch run this same write path — required fields, name caps, base64 decode, the writableBlockScopes gate and the blob-write budget are ONE shared core (handler/blob_core.go), so both surfaces answer the identical code and the identical prose for the identical request. blob_store takes the payload as file (base64, same invalid_encoding verdict) or text (UTF-8) — exactly one of the two — and takes an optional scope (E-M4, superseding decision D4, like the store tool): absent ⇒ the calling key's home_scope, named ⇒ gated through the same writableBlockScopes formula the REST route uses, ahead of the budget (scope_denied / Cannot write to requested scope). A confirm_writes key gets the call staged (a payload_hash instead of an id, IsError=true, executed by confirm), and a payload above pool.blob_stage_max_bytes (tenant-overridable, default 1 MiB; 0 disables blob staging for such keys entirely) is refused by name rather than written direct. The budget is charged at stage time, so the confirm books nothing. blob_fetch reads a byte range of the stored payload (offset/length, default 262144, max 1048576; a larger length is refused, never clamped, so a short answer always means the blob ended) and names its encoding (text when the window is valid UTF-8, else base64); meta_only returns metadata alone. The REST fetch route is unchanged and still answers the whole blob in one piece. Payload scan + read gate (W02-9): the shared write core runs the same credentials detector the block path has used since G40 over the decoded payload, on both surfaces, and records the verdict in the blob's JSONB metadata (no new column, no migration). It is KIND-differentiated: a structured signal (PEM private-key header, JWT, AWS key id, vendor token prefix, high-entropy secret assignment) sets metadata.sensitivity to credentials plus metadata.sensitivity_kind, upgrade-only over a caller-supplied value; a generic high-entropy hit (64+ hex run, base64 blob) raises NOTHING and is recorded in metadata.entropy_flags, because on a payload those artefacts are the evidence a drill-down came for, not a secret. A payload that is not valid UTF-8, or larger than pool.blob_scan_max_bytes (tenant-overridable, default 16 MiB; 0 turns the scan off), is STORED with metadata.sensitivity = "unscanned" — the write goes through, so binary uploads and the blobs that predate this path are never collateral. A confirm_writes key is scanned at STAGE time, so the verdict rides inside the payload_hash and confirming cannot alter it. On the read side blob_fetch does NOT return the payload of a blob whose metadata.sensitivity is credentials (named refusal, uncoded like every other fetch refusal); meta_only still answers, and a missing or unscanned field reads as deliverable — a blob is withheld because something was found, never because nothing was looked for. Every blob_fetch answer that carries content is framed for a model context: untrusted: true, an origin naming blob id/category/title/scope, and a note that the bytes are data and not instructions; meta_only carries no frame. The REST fetch route keeps all of this OUT — it is an operator path, not a model context, and answers the whole payload of a credentials blob unchanged. Blob-to-block edge (W02-10): context_blobs.context_block_id finally has a write path. Both store surfaces take an optional context_block_id (REST body field, MCP tool field), and the third MCP tool blob_link {id, context_block_id} sets it afterwards in ONE indexed UPDATE — no payload on the wire, file_size/checksum/updated_at untouched, which is what makes the two-phase write of a checkpoint (payload first, manifest block second, link third) cost one payload round trip instead of two. A link is a WRITE: it books one blob-write row like a store, and the blob must lie in the caller's writableBlockScopes — outside them it is blob not found, the same answer an id that belongs to nothing gets. The referenced BLOCK must be visible in the caller's read scopes; a block in a foreign scope, an id that exists nowhere and a malformed id all answer 422 / constraint / Referenced context_block_id not found, byte-identically, so the write surface is no existence oracle over foreign scopes (letting the foreign key decide would have answered success for the foreign block and 23503 for the absent one, and built a cross-scope edge on the way). A re-blob_store without the field CLEARS the edge — the row is what the last write said. blob_store, blob_fetch and blob_link answer context_block_id only when it is set. A confirm_writes key gets blob_link staged like every other write (op blob_link, staged under the BLOB's own scope, budget charged at stage time), and the confirm re-validates both the scope and the block's visibility on the un-consumed row. Deleting the block leaves the blob alive with a NULL edge (ON DELETE SET NULL).
POST /api/img/sign Camo image-proxy mint, auth-gated + per-key rate-limited. Body {urls:[…], origin:"assistant"} ⇒ {signatures:{url → "/api/img/<sig>?url=…&exp=…"}}; non-proxiable URLs (relative, data:, mailto:, a query string, userinfo, an escaped path over 512 bytes) are omitted. origin is assistant (also the default when absent); any other value is 400, including issue, which pre-upgrade bundles sent for issue markdown. One distinct URL per request; more is 400. The SPA renderer has no signing secret, so it asks the server to sign the one foreign image URL the viewer clicked (see Camo image proxy). Off (404) unless CTX_CAMO_ENABLED.
GET /api/img/{sig} Camo image-proxy fetch, unauthenticated — a browser <img> carries no api key, so the HMAC-SHA256 signature (over url+exp, keyed by a labeled subkey of CTX_SECRETS_KEY) IS the capability. Verifies the sig BEFORE any upstream request (forged ⇒ 403, expired ⇒ 410, ZERO fetch either way), then fetches under an SSRF-guarded / redirect-hop-checked / size-capped / content-type-allowlisted policy (SVG excluded — XSS) and streams the bytes from 'self' with X-Content-Type-Options: nosniff + immutable cache. Upstream failure ⇒ 502/415 + no-store, no substitute body. Re-hosts remote images so the SPA renders them under img-src 'self' WITHOUT widening the CSP, and the viewer's browser never talks to the foreign origin (see Camo image proxy).
POST /api/chat/stream + /api/chat/sessions[/{id}] Web-chat harness (see Web chat sessions).
GET /health DB + pool role reachability, aggregated to anonymous service classes (no backend names, no states — topology is admin-only via backend-list).
POST|GET|DELETE /mcp MCP Streamable HTTP (remote tool server).
GET /authorize + POST /token OAuth 2.1 authorization (PKCE) + token exchange. GET /authorize validates client_id and redirect_uri before it renders the consent screen (client name, client_id, redirect origin, warning for self-registered clients); the POST needs consent=yes besides api_key — without it no code is issued. /token enforces the client's registered grant_types: no refresh_token in the set → the code exchange returns the access token alone and the refresh grant is 400 unauthorized_client (DCR default: authorization_code + refresh_token).
GET / (unregistered paths) Embedded admin SPA (Svelte 5 + Vite, served from the binary). History-API fallback answers HTML navigations (Accept: text/html) only — mistyped API URLs stay 404 for JSON clients. Hashed /assets/* are immutable-cached and pre-compressed (.br/.gz); binaries built without the frontend serve a 503 placeholder while all APIs stay functional. See development.

Manage actions

POST /api/manage covers CRUD, Guard API, stats, key/MCP-client management, the block-type registry and the issue corpus.

  • Keys/clients: api-key-create requires home_scope; key + MCP-client management and mutating dream-mode require an admin key since migration 052 (see security). api-key-create/api-key-update accept optional write_scopes (078, E4b) — explicit per-key write scopes that must be ⊆ allowed_scopes ∪ {home_scope} (else 400); the effective block-write gate and its double enforcement are in multi-tenancy. Per-tenant scoping of the api-key-* actions and the tenant lifecycle (tenant-*, tenant-grant-*, scope-*, tenant-limit-set, tenant-usage-get, tenant-quota-*, block-grant-*) are in multi-tenancy.

  • Block-type registry: type-list/type-get are open reads; type-create/type-update/type-delete are the operator transport (server-admin, _global namespace) of the same store logic the REST PUT/DELETE /api/types/{name} surface uses — kept functional for MCP/CLI consumers, not superseded (see Block-type registry). manage update accepts a registry-validated type field; list-meta accepts the types/types_exclude filters.

  • Issue corpus: issue-create/issue-update/issue-get/issue-list/issue-comment-create/issue-link-create/issue-link-delete are the operator transport (all tierOpen; isolation is store-layer scope filtering, not an admin tier) over the same store issue logic the REST /api/project issue surface (W6/W7) uses — kept reachable for MCP/CLI operators, not the primary UI surface (see Issue corpus).

  • Backend pool: backend-list/create/update/delete/test — see Backend pool.

  • Model aliases: model-alias-list/create/update/delete — the /v1 model namespace, see Model aliases.

  • Model consumers: model-consumer-create/-list/-get/-update/-delete and model-key-rotate/-revoke/-history administer the external callers of the OpenAI-compatible gateway and their ctxm_ keys — see Model consumer administration.

  • Guard review: guard-list returns the flagged blocks of the caller's read scopes (similarity-sorted; each row carries type_name, guard_status, similarity and the matched partner matched_id/matched_title — cross-scope partners are [redacted]). Filters: status, category, types[] (bind parameters, WF-T10 line); limit clamps to 200. guard-stats is the aggregate pull. guard-resolve takes id + data.resolution ∈ {archive, keep} (unchanged single wire shape) or data.ids[] (batch, cap 500, one transaction): the batch touches only actually-flagged states (needs_review/near_duplicate/possible_duplicate) and accounts for every id — resolved[] plus skipped[] with a reason (invalid_id/not_found/already_archived/not_flagged; cross-scope ids collapse into not_found, no existence oracle). Write gate is writableBlockScopes. The push side of the same pipeline: GET /api/status carries a guard_review section (per-state counts + oldest_updated_at + built_at) on both paths — global for server-admin, home-scope-filtered for tenant-admin — and the daily report closes with a Guard-Review offen stand section while the queue is non-empty (live reports only, never backfills). Both paths read ONE generation per events.tick_interval (a single GROUP BY ROLLUP (scope) aggregate carrying the grand total and every scope's row), so N tenant polls inside a tick cost one query, not N. built_at is that generation's success stamp: a failed rebuild keeps the previous generation untouched, and once the stamp exceeds three tick intervals the section is omitted rather than served stale. A scope with no flagged blocks reads {0,0,0,null} (materialized from context_tenant_scopes); an unknown or empty scope yields no section — the grand total is reachable only through the server-admin path, never through a scope lookup. MCP transport: guard_list/guard_resolve mirror these actions (REST field parity, golden-pinned); guard_resolve refuses archive for confirm_writes keys (not stageable — fail-closed instead of bypassing the staging policy). SPA surface: /guard (tenant-admin-or-up nav, server-side authorization unchanged).

  • Dream review & link curation: dream-review is the read surface (stats, low_confidence candidates — pinned links are excluded from the candidate queue, supersedes_pairs, recent_checked); each link row carries the resolve identifiers source_id/target_id/relationship plus rationale (and pinned on supersedes pairs). dream-link-resolve (tierOpen, guard-resolve pattern) is the write side — one link per call, addressed by data.source_id + data.target_id + data.relationship (the relationship is matched as a guard: a link re-classified since the review reads as not found instead of being blindly resolved). data.resolution ∈ {confirm, delete}: confirm sets pinned=true (+ optional data.rationale, durable; confirm without rationale keeps an earlier one) — a pinned link survives the dream replace sweep (migration 119); delete removes the link and, for supersedes, reconciles the target against the supersedes links that remain — the same reconcile the dream write path runs: the target returns to knowledge (superseded_by → NULL, reported as supersedes_reverted) only when no superseder is left; if its pointer named this source and another superseder remains, superseded_by moves to it. Write gate is writableBlockScopes on the source block; foreign/invisible/absent links and malformed ids collapse into one Link not found (no existence oracle). Every resolve is audited in context_write_log (decision='dream_link_resolve'). Automatic dream writes leave rationale NULL — the benchmarked V5 eval prompt emits no reason field.

  • Back-off re-evaluation: dream-backoff-restamp (tierTenantAdmin) recomputes every existing re-dream cooldown stamp under the current dream.backoff_* policy: dream_cooldown_until = dream_checked_at + curve(dream_eval_count, dream_last_inert) — the immediacy half of a policy save, since the hot config only governs future cycles while stamps written under the old curve would persist for up to the old cap. The block's inert standing is preserved (dream_last_inert, migration 131), transient minutes-scale claim stamps are recognized by their sub-hour span and left untouched (a live worker claim must not reopen), and the mutation binds to the caller's own entitlement (home_scope + allowed_scopes, never grant-widened read scopes — a granted reader must not rewrite a foreign tenant's dream scheduling). Response: {restamped, skipped_transient}. The settings UI triggers this automatically after a Save of back-off keys.

  • Raw pass-through actions (no typed client). Seven actions have no CLI command, no SPA surface and no MCP tool — they are reached with ctx manage <action> [id] [data-json] or ctx api POST /api/manage '<json>', and that is deliberate: they are per-installation operator steps, so a typed command for each would be surface without callers. All seven are server-admin.

    • oauth-provider-create registers an external login IdP in the allowlist (data: slug, type — github or oidc —, display_name, issuer, client_id, client_secret, token_auth, redirect_base, scopes[], auth_url, token_url, userinfo_url, id_token_algs[], single_tenant_issuer, allowed_claim). The secret is sealed on the way in and never echoed back; without a configured master key a secret-bearing create is refused 503 rather than stored in the clear. oauth-provider-list returns the config rows with the secret's existence only as has_secret; oauth-provider-delete (data.slug) removes row and sealed secret in one transaction. This table is the trust anchor of external login — whoever writes it decides which issuers ctx believes.
    • oauth-identity-link binds an external identity to a principal (data: provider_slug, subject, principal_id, optional email, display_name) and is the only provisioning path: the login flow never creates a binding, so an unlinked login answers 403. Re-binding a subject to a different principal is a 409 — unlink first, on purpose (that shape is an account takeover). The issuer is resolved server-side from the provider row, so the operator names a slug and never a raw issuer string. oauth-identity-list lists bindings (optional data.principal_id filter); oauth-identity-unlink removes one and is idempotent.
    • overview-rebuild-start kicks the cluster-overview rebuild ahead of its interval. Asynchronous by design: it answers {success, armed, note} immediately, a second kick while one is pending coalesces (armed:false), and progress is read from graph_overview_run, not from this response. Without a wired overview controller it answers 503.

The derived layer is not client-claimable (I7). The block types of the derived layer — insight and catalog — plus the categories they write into (session-insights, catalog), the metadata key provenance, and any block that already carries such a provenance object are server-reserved on every write surface: POST /api/store, POST /api/ingest, manage update, manage delete, manage guard-resolve (archive), the whole issue domain (manage issue-create / issue-update / issue-comment-create, their /api/project/{id}/issues… REST twins and the MCP issue_create / issue_comment tools), the MCP store / update tools, the chat write and update tools, and the confirm of a staged card on any of them. The refusals:

  • naming such a type ⇒ 422 reserved_type (the type exists — it is simply not the client's to claim, which is why it is not unknown_type);
  • writing into, or moving a block into, a reserved category ⇒ 403 reserved_category;
  • sending provenance in your own metadata ⇒ 403 reserved_metadata — the key is written by the server, never accepted from a client and never silently dropped;
  • an upsert, update, archive or issue-PATCH that would replace or hide a block carrying provenance ⇒ 403 provenance_protected, and both its content and its metadata survive. Archiving is included on purpose: the (category, title, scope) uniqueness is partial on live rows, so an archived derivative would free its own identity. The one path through it is the SERVER path: an in-process writer that carries store.SensitivityWrite{Derived: true} may rewrite a derivative it owns and re-create one whose identity is held by an archived derivative it owns (regeneration is an upsert, and revival is a fresh upsert — never is_archived = false). Ownership is checked, not assumed: the badged write must carry a metadata.provenance of the current contract version, and its arm plus stratum must match the ones already on the row — a badged write with empty metadata, with a provenance of another version, or belonging to another arm or another level gets the same 403 as any client. Both halves matter: without the first, a badged write would replace the target's metadata wholesale and erase the very provenance this rule hangs on; without the second, one derived arm could take over another's block under the same identity. The badge itself is a Go struct field with no wire counterpart (json:"-", and no field on the staged, hash-bound payload), so no request body can spell it, and every client form of the same write still answers 403.

An ingest batch is refused whole for any of these, never partially — the same 403, with the offending chunk[i] named in the message. A staged card is re-checked at confirm time against the CURRENT rules, before the stage token is consumed: a card that predates a reservation is refused and stays re-stageable rather than executing under the old rules. manage issue-update and the project issue PATCH additionally answer 404 for any non-issue block — they are issue-domain verbs, not general block writers. On the issue domain the provenance refusal is enforced in the store primitives themselves (InsertIssueBlock, InsertCommentBlock, UpdateIssueBlock), so every issue transport inherits it; issue metadata is otherwise merged, not replaced, which is why a planted key there would have outlived the write that carried it.

A write without a type whose title happens to match a derived type's classify pattern is accepted and simply does not receive that type — the derivation level is assigned by the writer, never by a title. Ordinary writes are untouched: no type, no reserved category, no provenance key ⇒ byte-identical behaviour, including the staged payload_hash of a confirm_writes key.

The coverage of the layer is an operating figure (W01-6). GET /api/status carries a server-admin derived_coverage section: one row per derived type ({type, blocks, anchor_missed}), where blocks is the active population and anchor_missed the part of it that cannot be shown to still hang on what it was derived from. An empty corpus reads 0/0 per type rather than an absent section — a missing row and full coverage must not look alike. What counts as missed follows the anchor form: a cluster_topic derivative is covered while a live cluster node still carries its topic_id with its core_hash (one condition covering core drift, a retired topic and one that never existed), a root_session derivative anchors on a strictly monotone watermark and cannot drift, and anything whose anchor this build cannot read — no provenance, a foreign contract version, an unknown anchor kind — counts as missed rather than as covered. The figure exists because the precedent failed silently: the cluster-label arm had exhausted its retry counter on 42 % of live topics with zero errors in its call log, and the gap was discoverable only by querying four columns of another table. Server-admin only (per-type corpus sizes are the same global signal that keeps cluster_map off tenant surfaces), and measured on its own slow cadence rather than per status tick — it is the one cheap-path read whose cost grows with the corpus.

Explicit type on writes. POST /api/store, manage update and the MCP store tool all take an optional type: the name is validated against the caller's effective registry snapshot (unknown ⇒ rejected fail-closed, code unknown_type; a derived-layer name ⇒ reserved_type, see above) and the block is written with type_source='manual', which the title classifier never re-touches afterwards. Omitting type keeps the classifier's guess (type_source='auto'). On the MCP side the field is hash-bound through the stage-then-confirm dance (CanonicalWrite.type), so a confirm_writes key confirms exactly the type it staged; an absent type canonicalizes to the same bytes as before the field existed, leaving every already-issued payload_hash valid. The MCP update tool carries no type — re-typing an existing block stays on manage update.

A type that demands a parent is not claimable by a write that carries none. A registry row may declare "parent": {"mode": "required"} — the orphan-prevention half of the type vocabulary. Since 2026-09-05 the claim chain enforces it: naming such a type in the type field of POST /api/store, of the MCP store tool (both arms), of manage update or of a confirm card is refused 422 {success:false,code:"parent_required",error:"type: \"…\" requires a parent (parent.mode=required) — this write surface carries no parent, so a block of that type is created through its own domain path, not claimed here"}. Before that the mode was configuration without a mechanism: the registry accepted the promise, blocktype.Set.ParentMode resolved it and no write path read it, so {"type":"comment"} on POST /api/store bought a comment block with parent_id NULL — precisely the orphan the row forbids.

Since 2026-09-06 the refusal is conditional on POST /api/store and stays total on every other surface: that route is the only one whose request shape carries a parent (parent_id), and the message is the same on all of them because the machine class, not the wording, is the contract. Naming a valid parent turns the same write into a 200:

// 422 {"success":false,"code":"parent_required","error":"type: \"comment\" requires a parent …"}
POST /api/store  {"category":"test","title":"note","content":"…","type":"comment"}

// 200 {"success":true,"block":{…}}  — the row carries parent_id, the answer shape is unchanged
POST /api/store  {"category":"test","title":"note","content":"…","type":"comment",
                  "parent_id":"01a07624-2b77-7d2e-ad54-14bf0c466dc3"}

The parent must be a live block in the scope the write resolves to; anything else is 422 unknown_parent (one class for unknown, archived, foreign, malformed and self-referencing ids, see the route table). parent.mode=optional and parent.mode=none are untouched — a type that does not demand a parent is claimed exactly as before, may carry a parent_id all the same, and a write without a type is untouched on every path.

Blocks of a required-parent type are created through the type's own domain path, which takes the parent as a mandatory argument. For the one shipped required type, comment, that is manage issue-comment-create / POST /api/project/{id}/issues/{block_id}/comments / the MCP issue_comment tool; their store primitive refuses an empty parent_id itself with store: comment requires a parent issue, which stays that path's first refusal. That path runs neither the write-gate chain nor the claim gates and is byte-identically unchanged. parent_id on POST /api/store is not that path and does not replace it: it hangs a block under a parent, while the domain path additionally derives the title, the comment sequence number and the scope from the parent. A comment written generically is a block with a parent, not an issue comment. One deliberate consequence on manage update: a required-parent type cannot be re-asserted on a block that already has a parent either — the gate reads the claim, not the row. Nothing legitimate is lost by it, because no manage update payload can give a block the parent its type demands; the case is a no-op re-assertion.

Rollout (before upgrading an installation). The population this newly refuses is exactly the block types configured required — over all scopes, since every tenant may register its own types:

SELECT scope, name, builtin
  FROM context_block_types
 WHERE config->'parent'->>'mode' = 'required'
 ORDER BY scope, name;

Every row is a type whose generic writes stop being accepted unless they name a parent. A builtin row (comment) is the shipped case and needs no action. A tenant-registered row means that tenant's writers must add parent_id on POST /api/store, move to the type's domain path, or drop the type claim — worth telling them before the upgrade, not after the first 422. On the MCP store tool, manage update and the confirm there is no parent field, so only the last two remedies exist there.

Explicit scope on MCP writes (E-M4, supersedes D4). The MCP store and blob_store tools take an optional scope. Until 2026-08-25 they did not: an MCP writer always wrote its key's home_scope, while the same key over POST /api/store / POST /api/blob/store could name any scope its rights covered — one principal, two authorisation answers for one scope, and the narrower one attached to the transport agents actually use. The field adds no authority: it is resolved by the same function REST runs (resolveWriteScope → writableBlockScopes, the single eval point of the block write gate), so a scope outside that set is refused with code scope_denied and the identical prose Cannot write to requested scope on every surface, and an absent or empty value resolves to home_scope byte-identically to before. The gate stays ahead of the write budget on both paths, so spraying foreign scopes cannot drain a key's quota; on the block path it also runs ahead of the hash-NOOP check, so a scoped write is asked for a no-op in the scope it would actually land in. For a confirm_writes key the scope is resolved before the stage — a card for a scope the key may not write is never issued — and the resolved value rides on the stage row and inside the hash-bound CanonicalWrite.scope, which the confirm re-validates against the key's current rights (D1-M1). blob_link and the MCP update tool deliberately keep no scope: both address an existing row, whose scope is already decided (a link is gated by the blob's own scope; a scope MOVE on a block stays a manage update guard flow).

Type axes on responses. /api/search, /api/recent, manage get, the MCP/chat recent surface and manage list-meta carry type (policy type), lifecycle_state and type_source per block; /api/query results carry type. Server-side type filters (types / types_exclude arrays) are pure opt-in bind parameters — no hard exclude (retrieval-excluded types stay browseable by design), with block_roles_exclude kept as the documented legacy alias everywhere including /api/query (both names ⇒ union). On the /api/search FTS branch such an opt-in also buys the index (C2-2): since migration 145 the two tsvector GIN indexes are partial over type_name NOT IN ('checkpoint','system-meta'), and a bind parameter cannot prove that predicate under a generic plan — so whenever the caller's own filter already implies it (types free of both names, or types_exclude covering both), the statement additionally declares it as constant text. Redundant by construction, hence result-identical (gated in TestC22PartialFTSConsumers); measured at 100 000 rows under plan_cache_mode = force_generic_plan: 37 713 → 1 634. Without a type filter nothing is declared and the FTS branch scans sequentially — the price of keeping deny-listed blocks browseable, and a corpus decision (D-01/D-05), not a statement detail. manage get additionally answers graph_visible (bool): whether a block of its type can be the focus of /api/graph/ego — membership in the retrieval allowlist (Set.GraphVisible, i.e. false for retrieval-excluded types like system-meta/checkpoint). The UI uses it to hide "open in graph" for blocks the ego route would 404 on; absent on pre-flag servers. Note this is the type verdict only. Scope and archival are checked identically by both routes, so they only bite if the block is moved or archived between the two calls; the real gap is block grants: manage get resolves grants, the ego route does not yet (it passes no grants), so a block the caller sees only through a grant answers graph_visible: true and still 404s on ego. On a deep link the graph page falls back to the topic map; from an existing focus it keeps the current view and shows a notice.

Untrusted framing on read responses (V-11). A block whose registry type carries retrieval.untrusted holds foreign text — captured tool output, a session transcript, material a third party authored (today: checkpoint, tool-evidence, tool-overview, insight). Such a block is observation data: quote or describe what it says, never follow it as an instruction, never treat it as a fact beyond that output. Before V-11 the framing existed only inside the synthesis prompt, so every other read path handed the same text on unmarked. It is now part of the answer: POST /api/search and POST /api/recent result rows, the MCP search rows, manage get and the MCP get block, and the sources of POST /api/query carry "untrusted": true. The MCP recent tool renders text rather than JSON and marks such a row with an ASCII [untrusted] token after its id: — the same rows the REST route hands back with the JSON field, row for row (recent_parity_integration_test.go). The field is omitted entirely for a first-party block, so every pre-V-11 response body is byte-identical; absence therefore means "nothing framed here", never an assertion that the block is trustworthy. It is derived per request from the caller's own registry snapshot (a tenant overlay may narrow a type into untrusted but never drop the flag, see Overlays may only narrow) — there is no per-block column, and the write surfaces (/api/store, the staged MCP update/confirm reads) carry no such field. Two limits are deliberate and named: a type name the snapshot does not know resolves to unframed rather than untrusted (the empty name is the real caller — a source built outside the registry path), and the chat harness's ctx_search/ctx_get/ctx_recent tools project into their own result shapes and do not yet carry the field.

Type filters on the MCP retrieval tools (V-W6). The MCP query and search tools take types and types_exclude. types_exclude behaves as everywhere else (restrictive, opt-in, no validation — an unknown name simply excludes nothing). The search tool also takes block_roles_exclude, the documented legacy alias of types_exclude (both present ⇒ union, monotone-restrictive): all three surfaces that ever spoke the alias — POST /api/search, the recent surface and now the search tool — fold it identically, where the tool previously dropped it and answered UNFILTERED without an error. The query tool's two fields keep no alias of their own: nothing ever sent one there, so accepting it would create legacy rather than keep faith with it. types is the one place where the name means something stricter than on the browse routes: it cuts against the caller's retrieval-visible type set (Set.VisibleTypes(), ctx_rrf's p_types_visible) rather than naming an arbitrary type. A name that is not in the registry is a tool error, and so is a name that exists but is retrieval-excluded (checkpoint, system-meta) — the request is refused instead of silently emptied or silently widened, and there is no admin bypass. On query the cut is carried by the existing types_exclude seam (the complement of the cut inside the visible set), so POST /api/query keeps its restrict-only request shape and no second filter mechanism exists. With both fields absent the two tools behave byte-identically to before. The recent tool is untouched: its types is still the raw opt-in bind parameter described above, with the legacy alias, and no visibility filter — and POST /api/recent, which reads through the same store call, carries exactly that browse semantics rather than the stricter retrieval one.

Citations on the MCP query tool (V-W1b). The tool's Sources: list is numbered with each source's citation_index (the <source id="N"> prompt ordinal described under POST /api/query) and sorted by it, so a [N] in the answer names the source printed under [N]; a source that never entered the prompt is marked [n/a] instead of numbered, and a response carrying no citation_index at all (retrieval-only path, pre-V-W1 server) keeps the previous position numbering byte-identically.

Refusals on the MCP query tool. A delegated /api/query answer that refuses with an error_code — quota_exceeded from the tenant quota gate, no_eligible_backend — is an isError tool result whose text names the code (query refused: quota_exceeded) and whose structured content carries it as code; the other fields of the body (role, required sensitivity) are not passed on. This holds whether the refusal arrives as a real 429/503 (before the synthesis heartbeat) or inside the already committed 200 body. Any other answer with an HTTP error status and no error text is an isError result too (query failed (HTTP <status>)), never an empty answer.

Block-type registry

The block-type registry has its own REST namespace, the canonical surface going forward: reads (workflow W1) are member-gated — any valid key reads — and writes (workflow W2) are admin-or-tenant-admin-gated. Both gates live inside the mount (RequireMember for GET, RequireAdminOrTenantAdmin for PUT/DELETE), so a missing/invalid key is 401 and an under-privileged one is 403 before the handler runs — the gate cannot be forgotten without the route vanishing.

Endpoint Description
GET /api/types Effective type list: the shipped _global set ∪ your own tenant's overlay (the rows of your key's home scope), with a tenant-scoped type shadowing the _global row of the same name. Each entry carries a source badge (builtin = shipped _global namespace, tenant = your tenant's own).
GET /api/types/{name} One type incl. its policy config envelope + source. A name that is unknown or belongs to another tenant reads as 404 {success:false,error:"Type not found"} — the same body either way (no existence oracle: type names can leak project internals).
PUT /api/types/{name} Upsert (workflow W2). Body: display_name?, description?, is_default? (create only), config? (raw policy envelope, validated against the same authority the reload decoder uses). The scope is pinned by role — a tenant-admin writes its own tenant namespace, an operator writes _global — never taken from the body. The tenant namespace is the key's home scope: the scope tenant settings use, and the one the registry classifies and validates that key's writes against, so a type created here applies to the key's next POST /api/store (title rules, an explicit type). Keys of one tenant homed in different scopes see different tenant types. The background pipelines (dream, digest) resolve a tenant by its identity scope instead — _global for the default tenant, the tenant's alphabetically first scope otherwise — so there a tenant type applies only when that scope is the one it was created in: always for a tenant with a single scope, never for the default tenant. A tenant-admin targeting a _global type gets 403 (global types are operator-only; they are member-visible, so this is not an oracle). A tenant overlay whose config widens the _global base of the same name is refused 422 naming the offending field path (see Overlays may only narrow). Returns the frozen wire row + source.
DELETE /api/types/{name} Delete (workflow W2). Same role gate as PUT. A still-referenced type is refused 409 {success:false,error,blocks:{active,archived}} (the count spans archived rows too; for a tenant type it covers the scopes a block of that type can live in — the tenant's own scopes plus every scope a key homed in the type's scope may write, which reaches into another tenant's scope only where an operator-minted key bridges there — so outside such a bridge the answer never depends on another tenant's blocks, while a _global type counts every scope); a builtin is refused 409. Unknown/foreign name ⇒ 404 (same body as the read).

The response row is the frozen wire shape (K5): id, name, scope, display_name, description, builtin, is_default, config (the design 01-§3.3 policy envelope, carried verbatim), created_at, updated_at, updated_by?, source. The shape is pinned by a Go golden test (TestTypesGoldenShape) and its TypeScript mirror (web/src/lib/api/types.ts); the two must move in lockstep. The write responses reuse this exact row shape, so the freeze is unchanged.

One write logic, two transports. The REST PUT/DELETE handlers and the manage type-create/update/delete family call the identical store functions (store.Create/Update/DeleteBlockType), validation authority (blocktype.DecodePolicy) and error mapper — no mutation logic is duplicated. The _global write path carries the same authority (server-admin) on both transports, so there is no divergent gate. manage type-create is pinned to _global; type-update/type-delete resolve through the caller's visible namespaces, so a server-admin key reaches the tenant row of its home scope from the manage transport too — which is why the overlay rule below is enforced on both. The manage family stays functional for its MCP/CLI consumers (not removed).

Overlays may only narrow

A tenant row overlays the _global row of the same name: on read the tenant row wins wholesale. On write it may only make a type less visible and less involved in the autonomous pipelines, never more — on the ten axes named below, and only those. A widening write is refused 422 {success:false,error:"<field>: a tenant overlay may only narrow the '_global' base (base=…, overlay=…)"} on PUT /api/types/{name} (create and update) and on manage type-update. The rule covers retrieval.policy (excluded is always reachable, full-pass → damped is the one step between two visible kinds, aggregate-to-parent is off the axis in both directions), retrieval.damping_factor (a larger factor is a weaker damping), retrieval.intent_patterns (a pattern lifts the damping, so the overlay list must be a subset), retrieval.untrusted (may not be dropped), retrieval.shadow_measurable, guard.check, guard.candidate, dream.linkable, digest.include and overview.include. Everything else — guard.mode, guard.candidates, the guard thresholds, dream.link_classes, parent.*, workflow.*, structural_link_classes, classify.* — stays freely overlayable in both directions. checkpoint and system-meta carry an additional rule that does not depend on the base: in any tenant scope they stay retrieval.policy=excluded and never shadow_measurable (the _global namespace is exempt, see the last sentence of the next paragraph).

Three consequences worth knowing. A config is a complete policy, not a patch: an absent section takes its wide default, so a partial overlay body over a tight base is a widening and is refused — send the full envelope (ctx types get <name> shows the shape). DELETE of an overlay is always allowed: it returns the tenant to the base, which is the widest admissible position by definition. And the rule compares against the base at write time: a tenant type created while no _global row of that name existed is admitted (there is nothing to narrow), and if the name later becomes a _global builtin, that pre-existing row is not re-checked. Writes to _global itself are not covered by the ten axes either — that namespace is the base, and it stays the operator's. The one exception is write.internal_only, below.

write.internal_only — server write targets

A registry row may declare "write": {"internal_only": true}. Such a type is written by ctxd itself (through the store layer, past every handler) and is never claimable by a client: naming it in the type field of POST /api/store, of the MCP store tool (both arms), of manage update or of a confirm card is refused 422 {success:false,code:"reserved_type",error:"type: … is an internal write target (write.internal_only) …"}. The server's own write path is untouched, because it passes no handler gate. Default is false — every type without the key stays claimable, which is what the shipped types other than insight and catalog are.

The flag is also the one policy axis a _global write may not drop. A type whose compiled-in builtin carries it must keep it in every scope, so PUT /api/types/insight with the all-defaults body {"config":{"v":1}} is refused 422 rather than resetting the row to full-pass, guard-checked and dream-linkable. The base for that comparison is the compiled-in policy, not the current row — a row that had already lost the flag must not lower the bar for the next write. It is deliberately the only axis with that property: the other sections of a _global builtin row stay the operator's to set, and the visibility of these two types is expected to move as data (excluded → damped) once it has been measured. Inside a tenant scope the clause is reported after the ten narrowing axes, so an overlay that widens several things at once is still named on the axis it widens first.

CLI: ctx types / ctx types list (table on a TTY, JSON when piped), ctx types get <name>, ctx types set <name> (upsert) and ctx types rm <name> (delete).

Issue corpus

Issues and comments are ordinary context_blocks (type_name issue/comment, migration 084 seeds) — they inherit scope isolation, RRF, Guard and Dream for free (Modell C, one project scope = one repo corpus). The primary wire surface is the REST /api/project issue family (workflow W6/W7); the manage issue-* actions below are the operator transport over the identical store functions (store.InsertIssueBlock/UpdateIssueBlock/InsertCommentBlock/GetIssue/ListIssues, plus the structural-link store) — one logic, two transports (masterplan K2). All are tierOpen: isolation is enforced in the store layer via the caller's write scopes (writes) and read scopes + block grants (reads), never an admin tier.

Action Data Semantics
issue-create title (required), content?, scope? (default home_scope), tags?, metadata?, status? Insert-once issue. Title gets a per-scope #L<seq> prefix (so two issues may share a human title without a 23505). workflow_status = the type's initial (a supplied status must be a valid entry, else 422).
issue-update id; title?/content?/tags?/metadata?/status? By-id patch (never upsert). A status change is validated against the type's workflow policy data (invalid ⇒ 422); the #L<seq> prefix is preserved; metadata is JSONB-merged. A foreign/absent id ⇒ 404 (no oracle).
issue-get id The issue + its comment thread.
issue-list status?, labels?, limit? (≤100), cursor? Keyset board page. status set ⇒ one board column; absent ⇒ per-status merge. Scoped to the caller's read scopes (a foreign key sees an empty list).
issue-comment-create parent_id (required), content?, author?, metadata? Comment under an issue. The scope is always the parent's (never the request); a parent the caller cannot write ⇒ 404.
issue-link-create source_id, target_id, link_class Structural edge (context_structural_links). link_class must be in the source type's structural_link_classes allowlist (else 422); source and target must share one writable scope — a foreign/absent target ⇒ the same 404 as a nonexistent one (no existence oracle).
issue-link-delete source_id, target_id, link_class Remove a structural edge. Foreign/absent source ⇒ 404.

render:'untrusted'. Every body-bearing response (issue-create/issue-update/issue-get/issue-list/issue-comment-create) carries render: "untrusted" — issue/comment bodies are attacker-controlled markdown; the UI MUST take the sanitising render path (the field cannot be silently overlooked).

MCP transport (workflow W12). issue_create / issue_comment / issue_state are the third transport over the same store functions — the E5 issue-content write set (create + comment + state, no delete; type/config mutations stay off MCP, K12). Unlike REST they carry no project parameter: issue_create always writes the calling key's home_scope (unlike the store tool, which took an optional scope in E-M4 — the issue tools did not follow), and issue_comment/issue_state target an existing issue by id, gated by the identical writableBlockScopes block-write formula. State transitions are policy-validated (an out-of-policy target is a tool error, never a silent success); responses carry the same render:'untrusted' + field names as the REST freeze (issue/comment envelope, contract-golden-pinned). Agent-key-template contract (§5.5). The blast-radius guarantee — an agent touches only its own project — holds for a key minted from the project template (home_scope=project, allowed_scopes=[], write_scopes=[]), for which writableBlockScopes = [home]; a key that additionally holds shared in allowed+write can write shared by design (the sentence is about the template, not a universal MCP guarantee). Since E-M4 the MCP store/blob_store tools can NAME a foreign scope, which changes nothing for such a key: the name is checked against the very same writableBlockScopes set, and for the template that set is [home].

Project register

The project register (workflow W4, migration 079) binds a project identity to exactly one tenant scope — that scope is the project's repo corpus (issues/comments live there, so isolation/RRF/Guard/Dream come for free, Modell C). Reads are member-gated (scope-read); create/patch/delete are tenant-admin. Both gate groups live inside one MountProject, so a missing gate is a missing route (404), never fail-open.

Endpoint Gate Description
GET /api/project member Projects whose scope is in your read_scopes, newest first. ?identity=<id> narrows to the single project of that identity (the ctx project init existence probe).
POST /api/project tenant-admin Compound create: assign the project scope and insert the register row in ONE transaction (a failure after the scope assign rolls the scope back too). Body {identity, scope, display_name?, forge?}; scope is a NAME the server prefixes from the tenant slug (<slug>:<name>, ≤ 50 chars). A server-admin targets a foreign tenant via a tenant_id field (T22-analog; a tenant-admin passing tenant_id ⇒ 403). Scope quota is loaded fail-closed (a limits-lookup error is a 500, never silent unlimited) and enforced (over max_scopes ⇒ 429). Re-init of an identical identity ⇒ idempotent 200, no duplicate, no orphan scope.
GET /api/project/{id} member One project, scope-read. Unknown, malformed, or foreign-scope id all read as 404 {success:false,error:"Project not found"} — same body (no existence oracle).
PATCH /api/project/{id} tenant-admin display_name and/or forge only. scope/tenant_id (the tenant_id = tenant_of(scope) invariant) and webhook_secret_ref (server-managed, W13) ⇒ 422. forge.api_base is validated (SSRF): non-https, or an IP literal in the shared SSRF deny-list (private/loopback/link-local/CGNAT/special-purpose, e.g. RFC1918, 127/8, 169.254/16, 100.64/10, ::1, fc00::/7, fe80::/10 — see security) or localhost ⇒ 422. A foreign/absent project ⇒ 404 uniform.
DELETE /api/project/{id} tenant-admin Delete the register row (its context_project_sync_runs cascade). The project's blocks and its tenant scope survive — scope teardown is a tenant-lifecycle concern. A foreign/absent project ⇒ 404 uniform.

identity is one of github:owner/repo | git-root:<sha> | manual:<slug> (validated in Go, no DB CHECK on the open set). The register row is {id, tenant_id, scope, identity, display_name, forge, webhook_secret_ref, token_secret (never on the wire), sync_status, sync_enabled, push_enabled, last_sync_at, last_error, backoff_until, sync_cursor, created_at, created_by?, metadata}. The webhook_secret_ref is server-managed and always NULL until the W13 webhook surface lands; forge/sync_cursor are the Achse-02 sync contract (migration 080 adds token_secret/sync_enabled/push_enabled/last_error/backoff_until; the ETag/since fetch cursor lives inside sync_cursor JSONB — the K14 collapse of the design's separate context_forge_repos). A tenant prune drains context_projects (+ sync runs + the context_project_sync_map mapping, both by CASCADE) as a mandatory K14 gate.

Model consumer administration

A model consumer is an external caller of the models ctx serves (/v1, not mounted yet); a model key (ctxm_…, security) is one credential of exactly one consumer and carries no policy. Consumers are not members and not principals. Eight POST /api/manage actions (wave W01-5):

Action Tier data Answer
model-consumer-create self-service label, description?, tenant_id? (server-admin only), tenant_scope?, kind? (app default, ctx-peer), all_models?, models? (model alias ids to grant), prompt_sensitivity?, fair_weight?, admission_lane?, allowed_cidrs?, allow_plaintext?, expires_at? {consumer, api_key} — the consumer with its first key, and the key's plaintext once
model-consumer-list tenant-admin tenant_id? (server-admin only), status?, q?, cursor?, limit? {consumers, next_cursor}
model-consumer-get tenant-admin id {consumer, keys_truncated, audit}
model-consumer-update tenant-admin id + any of label, description, tenant_scope (null = _global backends only), all_models, models (the granted model alias ids — the whole target set; [] = none), prompt_sensitivity, fair_weight, admission_lane, allowed_cidrs (null = any source), allow_plaintext, active {consumer}
model-consumer-delete tenant-admin id {cancelled_inflight}
model-key-rotate self-service consumer_id, grace? (seconds), expires_at? {key, api_key} — the new key and its plaintext once
model-key-revoke tenant-admin key_id {cancelled_inflight}
model-key-history tenant-admin consumer_id, cursor?, limit? {keys, next_cursor}

Tiers. self-service is the stage tierSelfService of the dispatch table: a server-admin, or a tenant-admin of its own tenant only while gateway.enabled and gateway.tenant_self_service are on (both ship off: server-admin only). tenant-admin is a server-admin or a tenant-admin of the consumer's tenant; a member gets 403 on all eight. Every tier refusal is the shared 403 admin key required (security).

Field rule — revoking is always allowed, granting needs a release. On model-consumer-update each field is compared with the consumer as it stands (under its row lock, so a concurrent write is what the comparison sees): narrowing allowed_cidrs (a list whose every prefix lies inside a current one, or a list where there was none), tenant_scope → null, all_models → false, allow_plaintext → false, a higher prompt_sensitivity, a lower fair_weight, admission_lane urgent → bulk, deactivating (active false), label and description — always open to a tenant-admin. Removing an id from models is revoking too. Setting or changing tenant_scope, all_models true, adding an id to models, widening or removing allowed_cidrs and reactivating are granting: tenant-admin only with self-service. kind = ctx-peer (create), a lower prompt_sensitivity, allow_plaintext = true, a higher fair_weight and admission_lane = urgent stay server-admin even with self-service; on create they are measured against the defaults (app, credentials, 1, bulk, no plaintext). Peer row tier: every granting change of a ctx-peer consumer — rotation, a granting update, reactivation — is server-admin only. kind is fixed at create; tenant_id, the peer binding and any key expiry cannot be updated — an unknown field is a 400, expires_at included (a new expiry needs a new key: model-key-rotate).

Model grants (models, migration 164). all_models: true releases every model alias the consumer can see; otherwise only the aliases listed in models — by id, never by name: a deleted alias takes its grants with it, and an alias created later under the same name is granted to no one. models is a target set (duplicates and spellings are folded, the answer lists them sorted; on update null is a 400 and [] removes every grant; on create null, [] and an absent field all mean none); the server turns it into the grants to add and to remove, in the transaction of the update. Every id it adds must be one the consumer's own tenant_scope is offered — an enabled alias with a valid policy, _global or of that scope, and not a _global alias that a tenant alias of the same name (enabled or not) takes the place of; a consumer without tenant_scope is offered _global aliases only, and so is a ctx-peer consumer whatever its tenant_scope — a peer resolves model names against _global aliases only. The caller's own scope plays no part. Any other id — unknown, deleted, disabled, invalid, of another scope, shadowed — is 400 with "param": "models" and one text for all these reasons (a malformed id is a 400 at models as well); when the scope changes in the same update, the new scope decides. An id the consumer already holds is not judged again, so a grant stays when its alias is disabled later or a later tenant_scope change hides it — it then releases nothing (the request time checks visibility again). The list is judged against the server's in-memory alias catalog, which follows an alias write within moments; an alias created a moment ago may still be a 400. Without the catalog (a wiring fault) a models write that adds an id is 503; removing grants still works. Deleting an alias that an active consumer holds an explicit grant on is 409 with "code": "model_granted" and "count" — the number of such consumers, never who they are; all_models is no grant and does not count. Remove the alias from their models, or deactivate them, first; grants of inactive consumers go with the alias. A held grant that its consumer can no longer see — its alias was disabled, or a later tenant_scope change hid it — releases nothing but still counts for this 409 until it is removed from models (or the consumer is deactivated). The SPA's create dialog (/gateway, wave W05-06) is a client of exactly this rule: it offers the aliases of the chosen tenant_scope the way the server's offer does (enabled ones, a tenant alias in place of the _global one of the same name, shadowing by presence), sends the picked ids as models (or all_models: true), never sends kind, and shows a 400 at models in the dialog.

Tenant binding. tenant_id from anyone but a server-admin is 403 tenant_id is server-admin only, whatever the value; a tenant_scope the consumer's tenant does not own is 403 cannot create keys outside your tenant — for every caller, a server-admin included (also in its own tenant, unlike an api key's server-admin mint); a malformed or unknown tenant_id is 404 tenant not found. A consumer or key id of another tenant answers exactly like an unknown or malformed one: 404 model consumer not found / 404 model key not found, byte for byte. Keys are reached only through their consumer (the key table has no tenant column).

Expiry per kind. A new key (create, rotate) expires at now + gateway.key_max_lifetime_app (365 d) for app when the request names none, and an expires_at beyond that ceiling or in the past is a 400; gateway.key_max_lifetime_peer is the same rule for ctx-peer and is 0 by default — a peer key then never expires unless the request names an expiry. 0 means "no ceiling" for either kind. Changing a setting leaves existing keys as they are. Rotation retires every live key but the newest, shortens the newest to at most now + grace (grace defaults to gateway.key_rotation_grace, 24 h; at most 604800; 0 revokes it at once — the leak case) and mints the successor; never more than two keys are live.

Reveal-once. api_key — the plaintext — appears in the answer of model-consumer-create and model-key-rotate and nowhere else; no answer carries a key hash. cancelled_inflight is the number of requests on the revoked key (model-key-revoke) or on any key of the deactivated consumer (model-consumer-delete) that were running in this ctx process and are ended by the action (0: none ran here). Another ctx process on the same database ends its own through the database signal (migration 165) and is not counted. It is null only on a server without the model API's door (unknown — never a 0 that would claim no request ran). Only these two actions end running requests themselves and count them: model-key-rotate (the keys it revokes, and with grace: 0 its predecessor) and model-consumer-update with "active": false end the requests of what they revoke through the database signal after their commit — within milliseconds while the server's listener is connected, at the latest at the next gateway.inflight_sweep_interval sweep while it is away — and answer no cancelled_inflight.

Reading. A consumer reads as {id, tenant_id, tenant_slug, label, description, kind, tenant_scope, all_models, models, prompt_sensitivity, fair_weight, admission_lane, allowed_cidrs, allow_plaintext, active, status, last_used_at, created_at, created_by_label, keys, keys_total, peer}; a key as {id, hint, status, created_at, expires_at, revoked_at, rotated_from, last_used_at}. status of a consumer: inactive; no_key (no live key); rotating (two live keys); expiring (one live key expiring within gateway.key_expiry_warn, 14 d); active. Of a key: revoked ≻ expired ≻ superseded (live, but a newer live key exists — its grace runs) ≻ expiring ≻ active. The server computes both; a client never judges expiry by its own clock. peer ({instance_id, bound_at}) is null without a binding and for every reader below server-admin; created_by_label reads "server-admin" to a tenant-admin unless the creating key is a non-server-admin key of its own tenant, and is null once the creating key is deleted. last_used_at has 60 s resolution.

  • model-consumer-list lists newest first. status is a list of the five statuses or ["all"]; absent = the four statuses of an active consumer. q is a label prefix (case-insensitive, % and _ literal). limit defaults to and is capped at gateway.consumer_list_max_limit (100); next_cursor is the opaque token of the next page, null on the last; a malformed cursor is a 400 (it is opaque, not signed: a well-formed one only moves the keyset position within the caller's own reach). Every filter runs in SQL — a page of inactive consumers never hides the active ones behind it. The list carries the live keys only; keys_total counts the whole chain. The SPA's /gateway list (multi-tenancy) is a client of exactly this form: it sends the status set explicitly (["all"] for all five), sends no limit (its page size is the server's cap), hands next_cursor through untouched and renders status as received.
  • model-consumer-get adds the chain (newest first, at most gateway.consumer_chain_max = 50; keys_truncated says more exist) and the audit trail: the newest gateway.consumer_audit_trail_max (20) rows of model_consumer, model_key and gateway_limits whose entity is the consumer or a key of the returned chain (older keys' rows need GET /api/audit?entity=model_key&id=…), each with its entity_type, redacted for a tenant-admin exactly as GET /api/audit redacts (security) — the cap applies before the redaction leaves out invisible updates, so a trail may hold fewer rows than the cap.
  • model-key-history pages through all keys of a consumer, revoked and expired ones included, with the same cursor/limit/next_cursor as the list.

Error codes: 400 invalid payload, 403 tier or tenant binding, 404 consumer/key/tenant not found (no oracle), 409 label taken in the tenant / consumer not active (rotation of a deactivated consumer — reactivate first), 429 the tenant's max_model_consumers reached (create, reactivation; tenant-limit-set sets it, multi-tenancy).

Forge sync (manage actions, workflow I-F)

Under masterplan K14 there is no separate forge-repo registry — the project register above is the forge registration. So the forge family carries only the PULL sync surface, as manage actions (all tenant-admin; ownership is re-checked per project, a foreign project ⇒ uniform 404). GitHub PULL-only in I-F: conditional ETag requests (a 304 does 0 writes and does not count against the rate limit), a since window, strict Link-header pagination, pull_request-field PR filtering (PRs never become issues), and rate-limit respect (403 + Retry-After / x-ratelimit-remaining:0 ⇒ exponential backoff_until, cap 1h — never a conflict). Outbound dials pass a dial-time SSRF guard (net.Dialer.Control re-checks the resolved IP against the private/loopback/link-local deny-list, closing DNS rebinding). The 3-way direction logic + block/mapping writes are Welle I-G — I-F fetches, gates and records; it writes no blocks.

Action Data Description
forge-token-set {project_id, token} Seal a PAT for the project (write-only: context_secrets, AES-256-GCM, scope-bound AAD). The plaintext never survives the request and is never echoed — the response is {token_set:true}.
forge-sync-start {project_id, dry_run?} Start an on-demand pull run (own goroutine). 409 if a run of the same project is already in flight (single-flight). Fail-closed gates (0 wire calls when refused): a scope with no owning tenant ⇒ 422 + sync_enabled=false (never re-materialise blocks into an owner-less scope); a suspended tenant ⇒ 409; no resolvable issue type policy with digest.include=false ⇒ 422 with a clear message (the §6.4 digest-flood guard).
forge-sync-status {project_id} Run-state + last run + open conflict count. Exposes token_set as a bool only — never the ref name or the PAT.

Project sync (REST trigger, workflow W11)

POST /api/project/{id}/sync is the manual forge-sync trigger — a thin REST shell around the same forge.SyncManager the forge-sync-start manage action drives (one run engine, two transports; the deferred periodic scheduler loop stays deferred). It is write-scope-gated (decision E6=a — "whoever may write the project's issues may sync it"): a scope you cannot read ⇒ 404 uniform, a readable-but-not-writable scope ⇒ 403.

Method + path Gate Notes
POST /api/project/{id}/sync write-scope Start a pull run. Double-start of the SAME project ⇒ 409; a full process-global concurrency semaphore (project.sync.max_concurrent, default 3) ⇒ 409 + retry_after_s; the per-project rate limit (project.sync.rate_limit, default 6/h, counted over context_project_sync_runs — N agent keys of one repo SHARE the budget, so they cannot each storm one GitHub token) ⇒ 429 + retry_after_s. ?dry_run=true fetches without applying. Response {success, run}. Fail-closed gates (owner-less scope ⇒ 422 + sync_enabled=false, suspended tenant ⇒ 409, unresolvable issue policy ⇒ 422) match the manage transport.
GET /api/project/{id}/sync member (scope-read) Status poll: {success, project_id, sync_status, sync_enabled, push_enabled, token_set, last_sync_at, backoff_until, last_error, conflicts, run, last_run, recent_runs}. A foreign/absent project ⇒ 404 uniform.

Config keys: project.sync.rate_limit (per-project starts/h, tenant-overridable) and project.sync.max_concurrent (process-global slot count over the per-project run-state map, global-only + restart). On process restart the in-memory run-state does not survive, so a project left at sync_status='running' by a crash is normalised to error + last_error='interrupted' and its open context_project_sync_runs rows to interrupted.

Webhooks (inbound, workflow W13)

POST /webhooks/github/{project_id} is the first and only unauthenticated write surface — mounted OUTSIDE the /api Auth group because GitHub POSTs it with no api key. HMAC-SHA256 over the raw body against the per-project sealed secret (X-Hub-Signature-256: sha256=<hex>, constant-time compare) takes the place of the api key. The verbindliche Prüf-Reihenfolge (the order is part of the security model) is Body-Cap → Projekt-Lookup → HMAC-Verify → Rate-Limit → INSERT → 202:

  • Signature before any DB write: a forged/absent signature ⇒ 401 and ZERO context_webhook_events rows.
  • Rate-limit after verify: the per-project budget (webhook.rate_limit, default 120/min) counts ONLY signature-valid deliveries, so an unsigned flood can never push a legitimate delivery into 429 (Denial-of-Sync). Overflow ⇒ 429 + retry_after_s.
  • Unknown/unconfigured project ⇒ uniform 401 with structural constant-work (the lookup→resolve-secret→hmac chain runs against a process-constant random secret; no response oracle).
  • Redelivery idempotency: UNIQUE(project_id, delivery_id) + ON CONFLICT DO NOTHING collapses GitHub's own redeliveries (identical X-GitHub-Delivery GUID) to exactly one row. This is NOT replay-protection — the GUID is unsigned; the real mitigation is the translator (below).
  • Events are a TRIGGER, never authority: the scheduler inbox arm drains pending rows with FOR UPDATE SKIP LOCKED, debounces them to ONE forge sync per project, and the sync translator applies the 3-way content hash against the forge IST-state. A replay (new GUID, old payload) therefore changes NO block state — the payload is never upserted.

Secret lifecycle (POST|DELETE /api/project/{id}/webhook-secret, tenant-admin of the owning project): the secret is server-generated (crypto/rand, 32 bytes hex), sealed (AES-256-GCM, AAD binds name+scope) in the PROJECT scope under the server-fixed name webhook.github.<project_id>, and revealed once in the POST response (paste it into the GitHub webhook config). Rotation = another POST (old secret stops verifying). Deliberately NOT ctx secrets set / /api/secrets: that path maps a tenant-admin onto its HomeScope and could never reach the project scope. webhook_secret_ref is server-managed — a PATCH that names it ⇒ 422 (a free ref would turn the public endpoint into an HMAC-verification oracle over foreign secrets). A project delete AND a tenant prune drain the sealed secret (no orphaned credential).

Config keys: webhook.rate_limit (per-project deliveries/min, tenant-overridable) and webhook.retention (age past which the scheduler Janitor arm evicts PROCESSED inbox rows index-gestützt over idx_webhook_done, default 14d, global-only; 0 = keep forever).

Project issues (read surface)

The project issue read surface (workflow W6, MountProjectIssues) is the SPA's board + list + detail source. It resolves {id} to the project's single scope, requires that scope in the caller's read_scopes (else 404 uniform — no existence oracle), and bounds every read to that ONE scope (never read_scopes wholesale, and NO block grants — a project's issue view is deliberately scope-pure). All GETs are member-gated inside the mount (a missing gate is a missing route, never fail-open). Every body-bearing response carries render: "untrusted" (issue/comment bodies are attacker-controlled markdown) plus the workflow_status/type_name fields (wire-consistent with the manage issue-* transport).

Endpoint Description
GET /api/project/{id}/issues One keyset page. Filters: state (one workflow status), labels (repeated or comma-separated; AND), q (full-text). sort=updated (default) | created. after=<opaque token> resumes; limit ≤ 100. Response {success, render, issues:[{id,scope,type_name,title,workflow_status,updated_at}], cursor} — cursor is the opaque after token for the next page, or null at end of data.
GET /api/project/{id}/issues/{block_id} Full issue + the first page of the comment thread inline: {success, render, issue, comments, comments_cursor}. A foreign/absent/malformed id ⇒ 404 uniform.
GET /api/project/{id}/issues/{block_id}/comments The comment thread, ASC keyset on the immutable (created_at, id): {success, render, comments, cursor}. after/limit as above.
GET /api/project/{id}/board Per-config-status columns: {success, render, columns:[{status, count, issues:[…], cursor}]}. count is index-only; cursor is the per-column after token — load more of a column via …/issues?state=<col>&after=<token>.

Sort + pagination semantics. sort=updated (the board view) keys on the MUTABLE (updated_at, id): a row updated mid-pagination moves ahead of the consumed cursor and drops out of that traversal — accepted list semantics (a UI view, not an export contract), gated in TestW6CursorAndSemantics. For a lossless traversal (agents, export) use sort=created — keyset on the IMMUTABLE (created_at, id), which no update reorders. The opaque after token is a base64url-encoded keyset cursor; a malformed token ⇒ 400 (never 500).

q full-text search (masterplan K4 decision). q binds to the existing FTS tsvector GIN path (idx_context_ts_de / idx_context_ts_en over title || content, migrations 001/044) — matching plainto_tsquery('german', q) OR plainto_tsquery('english', q), scope/type/status-predicated. This is not a silent sequential scan (the EXPLAIN gate asserts the FTS GIN index is used, no Seq Scan). No new trigram index was needed: design/03 §6.1 specifies the FTS path, and idx_trgm_title already exists too (001). The freed W6 migration slot (086) instead carries idx_blocks_workflow_created for the sort=created traversal. Since migration 145 both GIN indexes are PARTIAL (WHERE type_name NOT IN ('checkpoint','system-meta') — see architecture, retrieval). The issue search is unaffected in RESULT (issue is neither name) and, under a custom plan, unaffected in PLAN: the planner folds the bound type_name = $2 into a constant and proves the implication itself, which is what the EXPLAIN gate above measures. That was not proof against a generic plan, where $2 stays a parameter, the implication is unprovable and the search falls back to idx_blocks_type_name with the FTS predicate demoted to a heap filter — measured at 100 000 rows: 774 against 14 126, a factor of 18. Since C2-2 both issue FTS statements carry the same static conjunct their RRF siblings carry (AND b.type_name NOT IN ('checkpoint','system-meta')), so the implication is clause-identical and plan-independent; the row set is untouched, because $2 is bound to issue at the only call site. Measured after the fix, same fixture and plan_cache_mode = force_generic_plan: 14 130 → 987 (sort=updated) and 8 074 → 1 169 (sort=created), both over a BitmapOr of the two FTS GIN indexes. The EXPLAIN gate gained a force_generic_plan variant (TestW6Explain_Integration/q_fts_gin_index_generic_plan), because the custom-plan gate above is structurally blind to this class.

Index story + scale (design/03 §6.1, W6 EXPLAIN gate). At the target scale (10k+ issues/repo, proven against a 10k-issue + ≥200k-non-issue backdrop):

  • state list rides the named idx_blocks_workflow_board (M077); sort=created rides idx_blocks_workflow_created (M086) status-unfiltered, and the board index when a state narrows it (both index-backed, no Seq Scan). At 215k rows the planner serves these as a Bitmap Index Scan on the named index + a bounded top-N heapsort — index-backed and keyset-correct; P95 < 100 ms (measured ~20–28 ms). The RED differential (index/bitmap scans disabled) Seq-Scans the 210k table an order of magnitude slower.
  • q rides the FTS GIN indexes (above).
  • Board count is an Index Only Scan over the board index (no heap) — degrades to a heap Filter only when a labels filter is present (documented edge).

Documented deviations from design/03 §6.1.

  • labels is a post-filter, not a dedicated GIN index. §6.1 sketched labels → Array + GIN; the shipped I-B primitive (M077 / store.WorkflowStatusListSQL, design/02 §3.3) treats labels as an AND post-filter over metadata->'labels' (?&) inside the board-index range scan — the board index already bounds the row set, so the post-filter stays cheap. W6 keeps that I-B semantics rather than adding a labels GIN index (Achse-02 schema hoheit); the default jsonb GIN index does not cover the metadata->'labels' sub-path, so a labels probe rides the board index with a Filter.
  • sort=created first page is O(scope-issues), not O(limit). With the +infinity first-page keyset sentinel the planner bitmaps the whole status-unfiltered issue set then top-N sorts (bounded by the scope) — <100 ms at the 10k target; a mid-page after cursor tightens the range. An ordered O(limit) range scan at 100k+ issues/repo would be a follow-up optimization.

Unmapped status. An issue whose workflow_status is not in the type config is not shown on the board and not returned by the default merge list (both are config-status-bound), but IS reachable via an explicit ?state=<unmapped> and via the lossless ?sort=created (which surfaces all statuses) — gated in TestProjectIssuesW6.

Project issues (write surface)

The project issue write surface (workflow W7, the write half of MountProjectIssues) shares the ONE RequireMember gate group with the W6 reads (a missing gate is a missing route, never fail-open). RequireMember admits; each write handler then runs the per-project write-scope gate and calls the SAME write core as the manage issue-* transport and the MCP issue tools (internal/handler/issues_core.go: registry guard, entry-transition validation, transaction, store.InsertIssueBlock/UpdateIssueBlock/InsertCommentBlock) — one write logic, three transports. Every body-bearing response carries render: "untrusted" plus workflow_status/type (wire-consistent with W6/I-D; the store.Block wire name for the type is type).

Write-scope gate + no-oracle (design/03 §4.6/§5.2). {id} resolves to the project's single scope. A caller who can not even read that scope (scope ∉ read_scopes), an unknown project id, and a foreign block id all read as a uniform 404 (no existence oracle). A caller who can read the scope but whose writable set does not contain it (writableBlockScopes(ar) = home_scope ∪ write_scopes ∩ readable ∪ shared-if-allowed, the SAME formula as /api/store and manage) gets 403 — returned only to someone who already knows the scope exists, so it leaks nothing. Every store write is bounded to the project's ONE scope (writableScopes = [scope], not the caller's whole writable set), so a block id belonging to another (even writable) project reads as not-found through this path — never silently mutated (scope purity, mirrors the W6 reads).

Route Behaviour
POST /api/project/{id}/issues Create an issue. Body {title (required), content?, tags?, metadata?, status?} — no scope field (the scope is the project's; an unknown key ⇒ 400). workflow_status = the type's initial; a supplied status must be a valid ENTRY transition ("" → status) or 422. Response {success, render, issue}.
PATCH /api/project/{id}/issues/{block_id} Update fields and/or status. Body {title?, content?, tags?, metadata?, status?} (all optional; none ⇒ 400). A status change is validated against the type-config transition graph (set.ValidateTransition) — an out-of-policy target ⇒ 422. The #L<seq> title prefix is preserved; metadata is JSONB-merged. A block outside this project's scope ⇒ 404 uniform. Response {success, render, issue}.
POST /api/project/{id}/issues/{block_id}/comments Create a comment under the path issue. Body {author?, content?, metadata?} — no parent_id (the parent is {block_id}) and no scope (the store forces the comment into the parent's scope, the comment-scope invariant). A parent outside this project's scope ⇒ 404 uniform. Response {success, render, comment}.

State validation is policy data, not Go. The initial status and every transition come from set.WorkflowInitial/set.ValidateTransition over the resolved type registry — swapping the registry status set changes the 422 verdict with no rebuild (gated in TestProjectIssuesW7: after dropping a status from the _global issue config + registry reload, a transition to it flips 200 → 422; a hardcoded list would keep returning 200).

Rate limit (design/03 §4.4). Issue writes ride the existing per-api_key_id write throttle — the same query.rate_limit_write config (tenant-overridable), 60-second window and context_access_log substrate as /api/store, so /api/store and the issue surface share ONE write budget. Over budget ⇒ 429. A limits-lookup error is fail-closed (500, never silent unlimited). The project-scoped sync/webhook limits of §4.4 (which need a project dimension the context_access_log substrate lacks) are W11/W13, not this per-key write path; no new limiter is introduced here.

The throttle and the access booking are REST-only — by decision, not by omission. The manage issue-* actions and the MCP issue tools run the same write core, but neither throttles nor books a context_access_log "write" row. Counted per surface (throttle call sites / booking call sites): REST 3/3 (one pair per write handler), manage 0/0, MCP 0/0. That split is the intent: REST is the human surface with a budget, manage and MCP are the operator and model surfaces without one. The core takes the metering identity as a parameter (issueWriteEnv.ApiKeyID) and calls no gate itself — a transport that wants to throttle or book wraps its own gate around the core call, which is exactly where REST's writeRateBlocked/logIssueWrite sit. Wiring them into the core would start throttling every MCP client that writes freely today: a change at a client boundary, not a refactor.

Body cap. 1 MB per request (the enclosing group's MaxBodySize); an over-cap body ⇒ 413. The store additionally caps an issue/comment body at 50 KB ⇒ 422.

Graph API

GET /api/graph/ego?block=<uuid> returns the k-hop ego subgraph of a focus block over the dream-link graph. Designed for 1M+ blocks: the server only ever ships budgeted subgraphs, never the full graph.

GET /api/graph/ego?block=<uuid>&hops=2&per_node_cap=25&limit=500
                  &min_confidence=0.5&link_class=topical,causal
                  &category=learnings&created_after=2026-01-01T00:00:00Z&edge_limit=4000
Param Default Range Meaning
block — (required) full UUID focus node (hop 0)
hops 1 1–3 BFS depth
per_node_cap 25 1–100 top-N edges per frontier node by raw_confidence — slots count only visible, filter-passing edges
limit 500 1–1500 total node budget (truncation: closer hop wins, then higher confidence, then id) — ceiling set by the G39 1M benchmark (p95 < 500ms)
min_confidence 0 0–1 gates dream edges only (traversal + display); structural facts are 1.0 by definition and always pass (GD4)
link_class all classes dream five + registry structural classes (e.g. references, duplicate-of) ONE parameter over both vocabularies (GB5): the CSV partitions by data class — link_class=references shows only that fact class, link_class=topical only that dream class; absent = everything. supersedes is display-only, never traversed; unknown class → 400 naming the full vocabulary
category all CSV filter on neighbor blocks (focus always included)
created_after / created_before open RFC3339 window on neighbor created_at
edge_limit 4000 1–20000 budget for edges within the node set, strongest first

Out-of-range values are a 400, never silently clamped. Response: nodes (id, title capped at 120 chars, category, scope, visible degree — capped at 201, rendered "200+"; since GB4 the degree counts visible link ROWS of both data classes, per direction per class, so it stays consistent with the client's multigraph badge math — and hop), edges as compact index tuples [srcIdx, dstIdx, relIdx, confidence], and stats. The payload never contains block content (load it lazily via manage get).

Since graph-structural GA8 the envelope additionally carries struct_rels + origins (response-local legends: sorted distinct sets of the DELIVERED classes/origins, built after truncation) and structural_edges (all-integer tuples [srcIdx, dstIdx, classIdx, originIdx] — no confidence slot, structural facts are 1.0 by definition and pass every min_confidence), plus stats.structural_edges (stats.edges stays dream-only). All three are arrays unconditionally; old clients ignore the fields. Since GB3 the traversal itself follows both data classes: per hop the frontier expands over dream links (strongest-first) AND structural links (newest-first, E5), merged into the shared node budget as a fair zipper (alternating classes — neither a degree-10⁴ dream hub nor a fact hub can starve the other class out of the budget). A block reachable ONLY via structural references — every forge-sync issue and daily report — now appears in its neighbors' ego graphs by default. Edges share ONE edge_limit under fact precedence with a dream floor of min(|dream|, edge_limit/2). The visibility triple gates every structural leg exactly like the dream legs (per-leg red-proven), grant-only nodes stay leaves class-agnostically, and an invisible focus stays an indistinguishable 404 regardless of its structural edges. On the SPA side (GC1) structural edges render as curved teal arrows (@sigma/edge-curve, one color for all classes) while dream edges stay straight lines — parallel structural facts on the same pair separate by curvature; the class itself is text (panel legend + window badge), not a color axis. The panel's structural class filter (GC2) is a BLOCKLIST (default: everything visible, unknown classes never hidden) and mirrors itself through the same unified link_class channel — whenever either side filters, the SPA derives BOTH sides into one CSV (a one-sided CSV would empty the other side per the partition semantics above). The link-class fieldset doubles as the edge legend (GC3): token-bound swatches mirror the canvas form language, structural checkboxes carry their semantics in the accessible name, and the stats line surfaces the structural count. The block detail window lists loaded structural links as a navigable section (direction glyph, class + origin badge, cap 50) that re-derives after every merge (GC4) — report chains are traversable textually, as the a11y equivalent of the canvas arrows.

Since the graph-budget wave the envelope also carries budget_report — the typed refinement of the single stats.truncated bool, which until now collapsed every cut (node budget, both induced-edge truncations) into one flag. Shape: {"limits":[…],"budgets":[…],"counts":{…},"source":"sql","cache_age_ms":0}. The two arrays are the two layers: limits are your OWN parameters running out (node_limit_reached = limit, edge_limit_reached = edge_limit) — ask for more and you get more; budgets are server-side guards (OOM/time), which no amount of asking lifts. counts gives how often each class tripped, source names the answering arm. Additive and unconditional (empty arrays when nothing tripped): stats.truncated keeps its exact old meaning, old clients ignore the field. Budget trips are a 200 with a report — only parameter violations are a 400. Deliberately absent: any class derived from pre-visibility-check candidate sets. Such a trip would reveal that raw edges exist beyond the cap — including foreign private ones — on a block you can see; those classes stay in server telemetry.

Since the Cluster-Topic-Map wave C2 the envelope additionally carries clusters + cluster_of + stats.clusters — where the delivered nodes sit in the Louvain cluster map. cluster_of[i] is positionally parallel to nodes[i] and indexes clusters[], or -1 for "no visible membership" (unclustered, visible only via a block grant, or created after the last rebuild). Each clusters[] entry is {cluster, topic?, label?, topics?, size, top_categories, scope_mix, in_response}: cluster is a RESPONSE-LOCAL ordinal (never a stable id — resolving one against another response is meaningless), size is the scope-pure size summed over the partitions of that cluster you may see, in_response how many of THIS response's nodes sit in it. Gated on cluster.ego_annotate (default off): off ⇒ both arrays empty and stats.clusters 0 — and the same empty shape answers whenever the annotation declines, so a client never branches on the reason. Above cluster.ego_annotate_max_nodes (default 500) the annotation declines with the cluster_annotate_capped budget trip rather than lowering the node ceiling of the route itself; /api/graph/all, whose limit defaults to the ceiling, therefore stays unannotated by default. The annotation costs one membership probe, reported as the cluster_annotate_probe budget posten — on the cache arm it is the only remaining database roundtrip, and its cost stays visible instead of hiding in the cache win. Deliberately absent: the internal cluster_id. It is the smallest member UUID of the community and block ids are uuidv7, so emitting it would reveal that an invisible block exists and roughly when it was created.

Since wave C5 each entry additionally carries the stable handle: topic (the v4 topic id from the identity table — the same value GET /api/graph/overview emits) and label, both optional. The ordinal stays what cluster_of indexes; the handle is what survives a rebuild, so it is the only value worth persisting on the client side or feeding into cluster:<handle> searches. A cluster the identity layer has not reached yet — pre-rebuild rows, and the mid-rollout window while the rebuild serves one tenant per tick — simply carries no topic/label, keeping the exact pre-C5 object; that is a normal state, not an error, and it never costs the entry its size. A handle is scope-bound by design ("one handle = one scope-pure topic"), while an entry is keyed by cluster: a cluster whose members live in two scopes you can both read therefore carries topic = the handle of the LARGER visible half plus topics = all of them, primary first. topics is omitted whenever there is only one (live: always) rather than repeating topic as a one-element array. Handles are never derived from a cluster or block id — that derivation is exactly the existence/time oracle the ordinal exists to avoid.

Security semantics. The visibility triple (not archived, block type on the registry visibility allowlist, scope readable by the key) is applied inside every hop AND inside the per-node cap legs — a node reachable only through a foreign private bridge is never delivered, and invisible edges never consume cap slots. degree counts only visible neighbors (scan budget 1000 raw edges/direction). "Does not exist" and "not visible" answer with an identical 404 (no existence oracle); only successful calls write an access-log row (action='graph', block_id=NULL).

Load-all (flat corpus seed)

GET /api/graph/all returns the flat "load all" seed behind the SPA button of the same name: every visible block (canonical visibility triple + the ego node filters), newest-first with an id tiebreak so truncation is deterministic, plus the induced edges of both data classes and the visible degrees. Nothing is traversed — the node set is predicate-defined, not reachability-defined, so blocks without any link (and disconnected components) appear too.

Params are the ego set MINUS block/hops/per_node_cap: limit (default 1500 = the ceiling — the button's whole point), edge_limit (default 20000 = the ceiling, matching the client's hard edge budget), min_confidence, link_class (same unified GB5 channel), category, created_after/created_before. Ceilings are still enforced, never clamped; the corpus growing past a ceiling reports as stats.truncated + budget_report limits (node_limit_reached/edge_limit_reached) — the endpoint stays a budgeted view at 1M+, never an unbounded dump. Response: the ego envelope verbatim with focus:"" and params.hops/params.per_node_cap echoed as 0 (not applicable). Same auth, same graph rate-limit bucket; there is no focus, hence no 404 arm.

Overview (cluster "landkarte")

GET /api/graph/overview returns the cluster supergraph: a few hundred meta-nodes (precomputed Louvain communities over the dream-link graph) with size, top_categories, a representative block, and aggregated inter-cluster meta-edges. The Louvain rebuild runs offline in the scheduler (internal/overview; clustering core selected by graph_overview.engine, default the own internal/louvain kernel since v4.31.0); the endpoint only reads precomputed tables. Since WF T6 the node set is policy-cut: a block becomes a Louvain node only if its type is on the registry visibility allowlist AND carries overview.include=true.

GET /api/graph/overview?min_cluster_size=1&min_inter_cluster_weight=0&node_limit=500&edge_limit=2000
Param Default Range Meaning
min_cluster_size 1 ≥1 hide meta-nodes whose visible size is below this
min_inter_cluster_weight 0 ≥0 hide meta-edges below this aggregated weight
node_limit 500 1–2000 max meta-nodes (largest first)
edge_limit 2000 1–20000 max meta-edges (strongest first)

Response: nodes (cluster ordinal, optional topic/label, size, top_categories, repr_id/repr_title, scope_mix), edges as compact tuples [srcOrdinal, dstOrdinal, link_count, weight], and stats (computed_at, null if never built). Gated on the hot setting graph_overview.enabled (default off → 404).

topic is the stable identity of a map node across rebuilds and label its name; both are omitted entirely until the server has run a rebuild with the identity layer, and omitted for the whole response while any readable partition is still without one (the server errs towards a complete map, not a complete identity). Render label ?? repr_title — the label accompanies the representative title rather than replacing it, because repr_id is the drill-down target and repr_title its caption. cluster stays the per-request ordinal the edge tuples point at. The internal cluster_id is still never emitted: it IS a block uuid, whereas topic is a v4 uuid with no block reference, no timestamp component and a scope partition, so two tenants can never observe a common handle.

Cluster ego (GET /api/graph/cluster)

GET /api/graph/cluster?cluster=<handle> opens ONE topic: its members, its neighbouring topics and its metadata. It is the drill-down the map lacks — clicking a cluster today opens the ego net of its representative, which in 41 of 59 live clusters is simply the oldest block with quality_score = 1, so the caller lands in an arbitrary neighbourhood instead of in the topic.

GET /api/graph/cluster?cluster=<handle>&limit=500&neighbor_limit=50

Read caps, this route's own: limit 1–1500 (default 500, member blocks) and neighbor_limit 1–200 (default 50, neighbour topics). Out of range is a 400, never a silent clamp; a malformed or missing handle is a 400 before any database roundtrip. Hitting the member cap sets stats.truncated.

Response: cluster (handle, label?, label_source / label_model?, size, top_categories, scope_mix, repr_id/repr_title, computed_at), nodes (member blocks: id, title capped at 120 chars, category, scope, created_at — no content, load it lazily), neighbors (handle, label?, size, link_count, weight) and stats. label_source (manual > llm > fallback > none) and label_model are the label provenance: they live here, on the detail route, and deliberately not on the overview wire, where per-node provenance would be noise on a dense list.

Deliberately absent: degree and hop on the member nodes. hop is meaningless without a focus (every member is equally far from "the topic"), and the per-node visible-degree query is the p95 driver that forced the ego route's node ceiling from 5000 down to 1500 — shipping it here would mean an unmeasured cost on a new route.

Partition-scharf. A handle names one scope-pure topic, so cluster.size and nodes[] describe the same set — including for a caller who can read both halves of a cluster that spans two scopes: the other half is a different handle, with its own label. Neighbours are TOPICS, and a meta-edge counts only when both endpoint scopes are visible; an endpoint whose handle cannot be resolved is dropped rather than delivered as "unknown". computed_at rides along unconditionally — unlike the ranking stage below, a read path has a viewer who can judge freshness, so the route serves and lets you decide.

Gates and 404 semantics. Double-gated on graph_overview.enabled AND cluster.route_enabled (both default off); a closed gate answers the same generic 404 as an absent route. 404 is identical for "handle unknown", "handle exists in a scope you cannot read" and "handle retired", and no access-log row is written on that path — otherwise telemetry itself would be bumpable by UUID probing. Only successful calls log (action='graph-cluster', block_id=NULL), and the route carries its own graph-cluster rate-limit bucket.

Cluster facet (cluster:<handle>)

POST /api/search takes an optional cluster field: a hard restriction to ONE topic of the cluster map, addressed by the same stable handle the graph surfaces emit (topic on GET /api/graph/overview and on the ego annotation). The CLI spells it ctx search cluster:<handle>, the MCP search tool as an optional cluster argument — a field on the known tool rather than an eighth tool, because a new tool raises the selection load of every MCP client.

The facet is partition-scharf: a handle names one scope-pure topic, so the answer is that topic's members — never the other half of a cluster that happens to span two scopes you can both read, whose half carries its own handle and possibly its own label. Resolution runs graph_cluster_topic → graph_cluster_node → graph_cluster_member, all under your read scopes, and the ordinary visibility predicate applies unchanged on top: the restriction can only ever narrow what you could already see.

No existence oracle, one rule. A well-formed handle always answers 200 with a possibly empty list — unknown, foreign, retired and member-less are one indistinguishable outcome. Only a malformed handle is a 400, decided before any database roundtrip. If unresolvable handles answered differently, the handle space would be enumerable, and because handles are stable per topic the NUMBER of foreign topics would be derivable.

Gated on cluster.facet_enabled (default off). While the gate is closed the field is ignored completely — not rejected, not echoed, not applied — so a request carrying it is byte-identical to the same request without it. When the facet applies, the response echoes it under filters.cluster. Cost at scale: the restriction stays a semi-join (never a materialised id list), so the planner keeps the choice between a hash semi-join and an index nested loop; measured against a worst-case fixture (a topic owning the oldest corpus decile), it drives from the membership side and touches 300 heap rows against a ceiling of 100 × the outer limit.

Cluster boost (categorical retrieval)

POST /api/query carries an optional categorical ranking stage beside the existing Dream-graph one: results sitting in the same Louvain cluster as the query's strongest hits get reinforced. Where the graph stage argues from EDGES ("these two blocks are linked"), this one argues from CATEGORIES ("these blocks live in the same community"). It runs BEFORE the graph stage, and that order is load-bearing rather than stylistic — Louvain builds its communities from the same context_dream_links table the graph stage traverses, so running it afterwards would pay a graph-reinforced neighbour twice for one piece of evidence.

Gated on cluster.enabled (default off): off ⇒ the response is byte-identical, and the whole stage is fail-open — any error keeps the pre-boost ranking, never a 500. The top cluster.seed_count results vote for their clusters with the RRF 1/(60+rank) weight; at most cluster.top_clusters clusters clearing cluster.min_share win, and their members are multiplied by 1 + cluster.boost_weight * share. Since share ≤ 1 by construction, the ceiling is 1 + boost_weight — 12 % at the default, below the Dream-graph boost and far below the reranker, which stays the final arbiter. cluster.size_damping (default on) normalises a cluster's vote by its share of the visible map, so "this cluster is relevant" stays distinguishable from "this cluster is large".

Freshness fail-safe. The stage refuses to boost from a landkarte it cannot vouch for. Past cluster.max_staleness (default 24 h) — or when even ONE of your read scopes has no successful rebuild on record, or when the freshness seam is unwired — it turns itself into a no-op and records the cluster_stale trip. Deliberately stricter than GET /api/graph/overview, which keeps serving a stale map with a 200 and lets you judge by computed_at: a ranking signal has no viewer to judge it. Aggregation over several scopes is the MINIMUM age, not the maximum — the maximum would hide a frozen partition behind a fresh one. The freshness source is graph_overview_meta.computed_at, never the scheduler's arm stamp: that stamp is set BEFORE a rebuild and survives every skip, so it reports "the arm ran", never "the map is fresh".

Because a correct fail-safe looks exactly like a broken rebuild from the outside, GET /api/status carries a server-admin cluster_map section: per scope computed_at / last_attempt_at / skip_reason / candidate_n / staleness_ms (-1 = never successfully built, which is not the same statement as "0 ms old"), plus consecutive_failures (rebuild attempts since the last success — skip_reason: "advisory-lock" among them is CONTENTION, not a frozen map: another instance built it) and cross_scope_clusters. The last one is the scope-boundedness monitor: cluster topics belong to exactly one scope, so this is expected to stay 0 — a non-zero value means a handle and its aggregate have begun describing different objects.

Three invariants: the stage never ADDS or removes a result (injection is its own separate, default-0 knob), it never PUNISHES one (a block with no visible membership row — unclustered, grant-only, or newer than the last rebuild — keeps its score bit for bit), and a grant-only result casts NO vote (its scope is outside your read scopes, so its membership row is already filtered — a shared block cannot drag the grantee's own blocks up over the grant bridge). A boosted result carries cluster_boost (the applied share) as provenance; the internal cluster id is deliberately absent, for the same oracle reason as on the overview.

Security semantics (the solved scope-count-leak). Aggregates are scope-partitioned — each precomputed row belongs to exactly one scope (nodes) or scope-pair (edges), and a request sums only rows whose scope(s) lie entirely within the caller's read_scopes (edges need both endpoint scopes visible). No global total is ever exposed, so a private member count cannot be recovered by difference. The internal cluster_id (smallest member UUID) is never emitted — clients see a per-request ordinal, so the identifier is not an existence oracle. Only successful calls write an access-log row (action='graph-overview', block_id=NULL).

Settings API

Runtime config editing over the context_settings override layer. Admin-gated including reads — the effective config (hosts, models, thresholds) is operational intelligence, and a non-admin key that can read it can also enumerate what to attack. (Tenant-admins reach a two-scope view — see multi-tenancy.)

GET    /api/settings           # every registry key: value, source, type, mutability, default, description
GET    /api/settings/{key}     # single key + last 10 audit rows (action, actor, via)
PUT    /api/settings/{key}     # body {"value": <scalar>} — validated BEFORE persist
DELETE /api/settings/{key}     # drop the override, revert to env/default
  • Key descriptions. Every registry key carries a one-line operator description (registry map config.keyDescriptions, completeness boot-enforced + test-pinned): what the value controls, its unit semantics and the practical effect of changing it. Rendered under the key in the settings UI and part of its fuzzy-search corpus.
  • Retired keys answer like typos. The 29 backend-tuple keys (chat/chat_fallback/embed/dream/dream_embed/rerank × host/protocol/model/api_key/num_ctx/think/timeout) left the registry in v5.0.0; their living value is a backend-pool row. On the wire they are simply unknown: GET, PUT and DELETE all answer 404, with no field, status or hint distinguishing them from a misspelled key — the move is documented in operations, deliberately not announced by the API. The superseded response field they used to carry is gone from every settings response, and so is the read-only legacy (superseded) card the settings UI collected them in.
  • Validation before persist. A PUT builds the candidate config through the same path the reload uses; a value the build would reject or ignore is a 422 and never reaches the table (no row, no audit entry). Unknown keys are 404; restart/coupled keys are 409 with the env var to set instead. String inputs are normalized to their registry type before persist ("0.7" → the number 0.7).
  • Hot effect. After commit the handler swaps the snapshot — the next request/cycle runs with the new value, no restart. Direct psql edits arrive through the NOTIFY listener with the same effect (audited as via='sql').
  • Masking rule. Any response position carrying the effective value of a sensitive key renders "(set via env)" when the value comes from env (incl. previous.value on PUT and the post-revert value on DELETE). DB-sourced sensitive values render the secret name (secret_ref), never resolved material.
  • secret_ref gate. A sensitive key accepts only the name of an existing sealed secret; a provider-key-shaped value is rejected with 422, so material can never reach context_settings or its append-only audit. Since the tuple retirement the class has one member left, server.db_password, and it is restart-only — the gate is a guard for future sensitive keys, not a path anyone walks today. Provider credentials live on the pool row instead (api_key_ref → sealed secret), where the same name-not-value rule holds.
  • Embed-cache coupling lives at the pool now. No settings key addresses an embedding endpoint any more, so no settings write can move the vector space and none flushes context_embed_cache. The flush moved with its subject: a base_url/protocol edit on a serving-eligible embed-role backend row triggers it, and a boot compares the pool against a persisted fingerprint for edits made while ctx was stopped (see operations).

Settings UI (SPA /settings). The generic editor renders one collapsible card per key prefix (collapsed by default, state persisted per browser; a card with unsaved edits stays open) with a jump-chip row and expand/collapse-all. The toolbar carries a Levenshtein fuzzy search (/ focuses it) over keys, descriptions, effective values, env vars and types — three tiers per token (substring, in-order subsequence, edit-distance-to-best-substring with a 1–2 typo budget by token length), multi-token queries AND across fields, hits are highlighted and ranked by score; sensitive values are excluded from the corpus. The dream card embeds the back-off curve editor: the six dream.backoff_* drafts render live as a smoothed active/inert cooldown curve (log-scale, saved policy ghosted behind, corpus maturity histogram from dream-stats underneath), and the curve is directly draggable — the curve body solves factor through the pointer, the min/cap guide lines set floor/ceiling, steppers adjust grace/inert_offset. Every interaction writes drafts only; the card's Save button activates them (the normal PUT flow), after which the UI automatically runs dream-backoff-restamp and refetches the histogram, so the saved policy governs the whole corpus immediately.

curl -s -X PUT "$CTX/api/settings/rerank.blend_weight" \
  -H "X-Context-Key: $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{"value":0.6}'
# → {"success":true,"key":"rerank.blend_weight","value":0.6,"source":"db",
#    "previous":{"value":0.5,"source":"env"},"warnings":[]}

Backend pool

Manage actions (all admin-gated, reads included — the list discloses egress topology; tenant-admin scoping in multi-tenancy):

POST /api/manage {"action":"backend-list"}                 # rows + live status (effective_state, cooldown, sanitized last_error)
POST /api/manage {"action":"backend-create","data":{…}}    # full validation, see below
POST /api/manage {"action":"backend-update","id":…,"data":{…}}   # single-field patch
POST /api/manage {"action":"backend-delete","id":…}        # hard delete (llmlog history stays readable)
POST /api/manage {"action":"backend-test","id":…,"data":{"probe":"chat"}}  # reachability dry-run
POST /api/manage {"action":"backend-test","data":{"probe":"embed-equivalence",…spec}}  # cosine proof, id optional
POST /api/manage {"action":"backend-reorder","data":{"order":[id…],"expected":{id:prio…}}}  # atomic priority ladder

Atomic reorder. backend-reorder rewrites the priority ladder in ONE transaction: order is the complete desired top-to-bottom sequence of the caller's writable backends (T37 — a tenant-admin's own rows only; visible _global rows keep their numbers and interleave), expected the id→priority snapshot the client based the move on. Priorities are reassigned in descending 10-steps (top = n·10), which eliminates ties structurally; unchanged rows are skipped so the audit trail records only actual moves. A concurrent edit answers 409 {error:"stale", current:{…}} instead of being overwritten; a foreign/unknown id or a partial order is a 422. This is what the admin table's drag-reorder and ▲▼ ride on — the per-row priority patch stays for direct numeric placement (the prio cell's click-to-edit).

Embed-equivalence probe. probe:"embed-equivalence" embeds a fixed canonical sample set (both asymmetric prefixes, Umlaute, code, symbol noise) on the local reference embed backend AND on the candidate, compares the DB-relevant vectors (1024-truncated, L2-normalized — the exact ingest path) per sample and verdicts verified against a threshold of 0.995 min-cosine (empirically anchored: same weights across a precision/quantization boundary measure ≥ 0.997, a different model lands far below 0.99). The candidate may be unsaved: send the full spec instead of an id — validation blocks persisting an external embed backend unverified, so test-before-create is the intended order (the backend dialog's cosine-test button does exactly this). On verified the response carries a ready-to-merge metadata_patch with embed_equivalence_verified:true plus an embed_equivalence_proof object (reference, models, min_cosine, checked_at); the dialog folds it into the save payload. Failures answer HTTP 200 with verified:false and a failure message — the verdict lives in the body, like every backend-test outcome.

Validation guards (create AND update, 422 with field errors): an extra_headers name starting with Ctx- or X-Ctx- (any case, every provider class) is rejected as a reserved federation header — ctx sets those on the wire itself (federation hop headers; a row written by psql with such a header loads, and the stamp removes the header from every model call; the backend-test reachability probe and the OpenRouter endpoint discovery (orwindow.go, the context-window lookup of an openrouter row without num_ctx) are not stamped yet and still send it); credential-carrier headers in extra_headers (Authorization, Cookie, *-key, *-token, …) and credential-semantic extra_body fields are rejected — provider keys go through api_key_ref (the name of a sealed secret, resolved in-memory only — and resolved in the row's scope: it must exist in the row's own scope, or in _global for a _global row — or for a row of an opted-in tenant (tenant.allow_shared_secrets) whose base_url has the same origin (scheme, host, port) as a _global row carrying that api_key_ref, so the opt-in never sends the operator's key to a host the tenant chose — else 422, with one message for an unknown name, a foreign name and an operator name on another host; backend-list omits api_key_ref on rows the caller cannot write (a tenant-admin's view of _global rows); see multi-tenancy); a base_url carrying userinfo (user:pass@host) is rejected for the same reason — the URL is echoed unredacted by every list response, log line and the append-only audit (rows written before this guard load fine and are rejected on their next write); a base_url containing ? or # is rejected — the wire paths are appended to base_url by concatenation, so a query or fragment marker would cut them off and send the request to whatever path the URL names; a tenant row (scope ≠ _global) must point at a publicly routable host — loopback, RFC 1918, link-local, unspecified and single-label (docker) hosts are 422 there and stay a server-admin choice on _global rows (see multi-tenancy); locality is cross-validated against base_url (a publicly routable host must be external); embed roles on external backends are blocked without metadata.embed_equivalence_verified=true (foreign quantization corrupts the shared vector space). metadata.score_domain (rerank dialect slot) must be one of auto | logit | probability — auto (the default for every row that does not set it) keeps the wire-container coupling: results ⇒ raw logits, data ⇒ calibrated [0,1] probability; explicit values override the coupling for dialect-mixing backends. Changing the effective score domain of a rerank-capable backend (create with a non-auto value included — the delete+create bypass is closed like the trust confirm's) requires confirm_score_domain_change:true; the 400 carries the full chain of change (effective immediately on all future rankings; the access-log score trail is only comparable up to the switch; no corpus reprocessing — no curation artifact persists rerank-derived data) and the confirmed write echoes it as a warning. Raising trust requires confirm_trust_elevation:true. Every mutation reloads the pool snapshot synchronously — backend-update {"enabled":false} is an instant brake, no restart; psql edits converge via the 053 NOTIFY trigger. The brake needs no validation: a payload that is exactly {"enabled":false} skips the validation guards above (and the api_key_ref and equivalence-flag checks), because it changes neither target nor key — so a row written before a guard existed, which every other write answers with 422, can still be taken out of service by its owner or a server-admin. Any further key in the payload, and re-enabling, take the fully gated path.

OpenRouter (first external backend, G29). provider_class: "openrouter" refines the openai wire: the request always carries provider.zdr:true + provider.data_collection:"deny" (non-streamed and streamed requests alike), independent of trust level — trust decides WHICH content may flow, the provider class decides whether the provider may store it. extra_body.provider entries merge but can only tighten (the force runs after the merge). The single escape is metadata.allow_data_collection:true, requiring confirm_data_collection:true. Responses feed telemetry — non-streamed answers and the web-chat stream (final usage chunk) alike: usage.cost → llmlog cost_usd (local NULL; the tenant cost budget sums this column), the top-level model (that actually answered) overwrites the row's model, the response id lands in metadata.provider_request_id. A "no providers" rejection (zdr/deny filter leaves no provider) classifies as configuration-permanent (1h cooldown, no retry storm). backend-test additionally reports credits_remaining/usage_usd (GET /v1/key) and zdr_endpoints (GET /v1/endpoints/zdr). base_url is the API root WITHOUT the version segment — the wire paths append /v1/... themselves; a base_url ending in /v1 double-segments to a 404, and since v4.25.4 create/update reject such a root with a 422 (all openai-, anthropic- and rerank-protocol backends, not just openrouter).

Anthropic (native Messages API, Migration 155). protocol: "anthropic" speaks POST {base_url}/v1/messages directly (base_url: "https://api.anthropic.com", the key from api_key_ref travels as x-api-key, anthropic-version: 2023-06-01 — deliberately, although 2026-10-21 exists: the wire sends none of what that version removes, and moving is a step along Anthropic's migration guide) instead of Anthropic's OpenAI-compatible layer — which rejects every sampling parameter on the Claude 5 models (the first production query failed on temperature), and, as observed on a production install, drops response_format silently and offers neither prompt caching nor effort control. The request carries no sampling parameters (temperature/top_p/top_k are 400s on these models; num_ctx stays ctx's own prompt budget), the system prompt as a top-level block with cache_control: ephemeral (cache hits need a ≥512-token prefix; verify in metadata.cache_read_input_tokens of the context_llm_log row), max_tokens = the pipeline's num_predict (16000 when a caller sets none) plus a thinking reserve that grows with the effort level (low 4096, medium 8192, high 16384, xhigh 32768, max 65536; capped at the 128000 output ceiling) — thinking tokens count against max_tokens, the Claude 5 models think adaptively and cannot be told not to (a model_map think:false is ignored on this wire), and max_tokens is a ceiling, not a charge; only the backend-test probe and the dream decide call send their one token exact. An answer that still stops at the limit without any text fails as output limit reached before any answer text (with max_tokens, effort and output tokens in the message — chain WARN line and the context_llm_log row's error) instead of passing as an empty answer; a role that shows it needs a larger model_map params.max_tokens or a lower effort, and output_config.effort: the role's model_map params.effort beats the row's extra_body.effort, which beats the explicit default medium (the model-side default of Claude Opus 5.5); both carriers accept only low|medium|high|xhigh|max (422 otherwise); the field is always sent, so the row needs a model that takes it (the Claude 5 family; Sonnet 4.5 / Haiku 4.5 reject it, and on Opus 4.7/4.8 an omitted thinking field means no thinking — neither is a supported row). Every other extra_body key merges top-level (the hatch for thinking.display, stop_sequences, metadata.user_id, …; the row wins over model_map params on collision, as on the openai wire), and an output_config object merges one level deep into the wire's own (the resolved effort stays). Keys the Messages API body cannot take are a 422 on create/update in both carriers: the sampling keys temperature/top_p/top_k (400 on the Claude 5 models), the OpenAI-compatible/Ollama dialect fields (chat_template_kwargs, response_format, reasoning, seed, frequency_penalty, presence_penalty, logit_bias, stop, format, …), the fields the wire builds itself (model, messages, system, stream, tools, tool_choice), an output_config that is not an object or carries effort. In model_map params the keys that land in dedicated option fields — temperature, top_p, top_k, min_p, repeat_penalty, presence_penalty, num_predict, max_tokens, think — stay allowed: this wire never sends them (max_tokens/num_predict set the visible budget). JSON mode is a no-op on this wire: output_config.format (structured outputs) needs a JSON schema with additionalProperties:false on every object, which ctx's schemaless json_object mode does not have, so ChatJSON sends the same body as Chat and the pipelines' local parsers stay the validator — run Dream with CTX_DREAM_JSON_MODE=off (see operations). Responses: text blocks are concatenated (thinking blocks skipped), prompt_tokens is the total prompt (input_tokens + cache_read_input_tokens + cache_creation_input_tokens — Anthropic's input_tokens is the uncached remainder only), the two cache counters land in metadata, the message id in metadata.provider_request_id, cost_usd stays NULL (ctx does not price Anthropic; read the console), and stop_reason is mapped onto the vocabulary the consumers read (end_turn/stop_sequence/pause_turn→stop, max_tokens/model_context_window_exceeded→length, tool_use→tool_calls, refusal→content_filter; unknown values pass through raw — a refusal after partial text still ends the turn with that text, marked content_filter in the done event). Errors classify like every other wire: 429 → rate-limit cooldown (Retry-After honoured), 529 overloaded_error → gateway (failover), 401 → auth, 400 → request-specific, 500 api_error → stop (the pool's HTTP-500 doctrine); the provider's error type prefixes the logged message, and the same types inside a mid-stream error event (HTTP 200) classify the same way. Row shape (422 on create and update): api_key_ref required, locality: external, num_ctx > 0 (no window discovery on this wire — synthesis would fail closed), provider_class: generic, no embed/dream-embed/rerank roles (the Messages API has no such endpoints). backend-test probes GET {base_url}/v1/models with the wire's headers (the row's extra_headers, then key and version), then the 1-token chat. The web-chat stream path (ChatStreamBackend) maps the Messages API SSE events (content_block_delta text/input_json_delta, message_delta stop reason + usage, error) onto the same event contract as the openai wire, tools included (tool_use/tool_result blocks, consecutive tool results folded into one user turn). Three repairs keep a session alive on this wire: the answer's thinking blocks, when the model emits any, ride along in memory to the next iteration of the same turn, which replays that assistant turn verbatim in the order the API produced its blocks — text, thinking and tool_use interleaved as they came, since Claude Opus 5.5 returns text between tool calls as thinking blocks (the reference asks that a turn whose tool_use still awaits its result keep its thinking block, and a reordered or altered block is rejected; live on 2026-10-11 a short tool turn carried no thinking block and the round trip was accepted — they are not persisted, later turns replay without them), a tool_use the history never answered (interrupted turn) gets a synthetic is_error tool_result, and tool calls that arrived without an id from an openai-wire row earlier in the session get synthetic ids. The system block's breakpoint covers the stable prefix only — the per-call promptguard nonce rule goes into a second, uncached block — and the stream path adds the top-level automatic cache_control, which reads the previous iteration's request from cache within one turn's tool loop; across turns the hit ends at the first difference (earlier turns replay without their thinking blocks, the system prompt carries the date, old turns are condensed past the history budget) and the 5-minute TTL usually lapses between human turns. Not available on this wire by construction: logprobs (the dream decide primitive falls back to its generating prompt and memoises the backend as decide-incapable), forced tool_choice (a 400 on Claude Opus 5.5; auto is what both wires send, plus none on the web chat's closing call on this wire).

Peer ctx instance (Migration 166). provider_class: "ctx" registers another ctx instance as a backend: its /v1 surface under a model key of that instance (the peer's admin creates a model consumer for this instance and hands over its key; it goes into a secret here). The row needs nothing from the peer beyond the key (A-E5) and is validated fail-closed (422): protocol must be openai (the peer serves chat, embeddings and rerank on that one surface); api_key_ref is mandatory (/v1 is authenticated); the roles embed and dream-embed are refused whatever the locality and whatever metadata.embed_equivalence_verified says — the peer's vector space is pinned by nothing yet, and the backend-test equivalence probe refuses a ctx candidate for the same reason; a background role (dream, dream-embed, embed, digest, classify) requires limits.slots, an integer from 1 to 2147483647, at most the concurrency the peer grants this instance's consumer — the admission layer counts a ctx row as a local target, so without slots its background calls would fill that concurrency (operations). A limits.slots above metadata.peer_limits.max_concurrency (written by a later peer-limits probe; absent until then) is a warning, not a refusal. A ctx row is sent the loop marker Ctx-Hop like a generic row until its federation tier lands (architecture); the status hint answers_as_ctx_instance never appears on it — it is the hint to register a row as ctx.

Plaintext model keys (every provider class, B24). A backend-create, or a backend-update that changes base_url or api_key_ref, whose base_url is plain http:// — loopback and localhost included, one rule — and whose api_key_ref resolves (in the row's scopes, like the pool) to a value starting with ctxm_ (a model key of a ctx instance, after any invisible characters in front) needs confirm_plaintext_credential:true: the key would travel unencrypted on every call. Without it the write is a 400 whose text names neither the secret nor any part of its value; with it the write succeeds with a warning and one WARN log line (backend and scope only). A re-save that changes neither field, re-enabling a row (enabled:true) and the brake need no confirm — so a row written past the check (psql, or before this release) is asked only when one of the two fields changes; a ref that resolves nowhere is no model key here (the existence check above stays the truth). The check runs on the backend write only — a later PUT /api/secrets/{name} that puts a model key behind an existing http:// row is not checked (security).

Failover classes. Every failed attempt is classified once (internal/backends.Classify) into a class that decides whether the chain goes on to the next backend and how long the failed row sorts last (the cooldown is an order hint — a row in cooldown stays reachable at the end of the chain). By status: 500 stops the chain (the server ran the request); 502/503/504 and any other 5xx are gateway (next, 30 s); 429 is rate_limit (next, Retry-After or 60 s, at most 5 min); 402 is billing (next, 1 h); 401/403 are auth (next, 15 min); 400/404/413 and any other status below 500 are bad_request (next, no cooldown); OpenRouter's "no allowed providers" is no_providers (next, 1 h); a transport failure goes next with 30 s; the attempt timeout stops the chain. An error event inside an HTTP-200 stream is classified by its code, on every provider class: the anthropic wire's error types and the codes of the gateway vocabulary each take the class their HTTP status gets — rate_limit_exceeded, server_busy, concurrency_limit_exceeded, insufficient_quota are rate_limit; upstream_unavailable, server_overloaded, internal_error, service_unavailable, model_unavailable, no_eligible_backend, upstream_timeout, the model-key door's proxy_misconfigured, auth_overloaded, auth_unavailable and the 508 federation codes are gateway; upstream_error stops the chain; invalid_api_key, permission_denied, peer_instance_mismatch are auth; the 400/404/405/413 codes are bad_request — and an unknown code still stops the chain. A row of provider class ctx (a peer ctx instance, writable since Migration 166) is read more closely: its code also comes from the JSON body of an error answer and wins over the status. There server_busy and concurrency_limit_exceeded are busy (a capacity refusal, not a health signal: no cooldown), upstream_timeout is timeout (stop, as on the peer itself), and insufficient_quota is quota_exhausted (cooldown = the full Retry-After up to 24 h, 1 h without one — the embed and rerank walks report no Retry-After yet, so there it is always 1 h); busy and quota_exhausted stop the chain for now rather than spill to the next, possibly paid, row within the request — a row in quota cooldown still sorts behind the others for the requests that follow. Back-off after three consecutive failures: the class cooldown doubles up to 10 minutes but never drops below the class cooldown itself — billing and no_providers keep their hour and auth its 15 minutes, where they previously fell to 10 minutes from the third failure on. backend-list shows the outcome per row as effective_state: "cooldown", cooldown_remaining_s and last_error (the class name).

Web chat sessions

(F6, migration 056) The persistence layer, server-side tool harness and streaming HTTP endpoint for web chat. context_chat_sessions is scope-owned: list and delete key on the creating tenant's home scope, so a key never sees a foreign tenant's chats. It snapshots the creating key's read_scopes and carries a monotone max_sensitivity high-water-mark. Reading or continuing a session requires session.read_scopes ⊆ caller.ReadScopes (else 404, no oracle), closing the shadow-corpus channel against future least-privilege keys. The HWM rises with every appended message (in the same short transaction that assigns the message seq), so a credentials-touched session stays full-trust-only for its whole life. context_chat_messages records per-message sensitivity (fail-closed credentials default), tool-call metadata, telemetry and a gapless seq (UNIQUE(session_id, seq)). A turn claims its session via a short busy_until CAS — a second concurrent turn gets 409 without blocking, a crashed turn self-heals on expiry. Retention is off by default (CTX_WEBCHAT_SESSION_RETENTION).

The harness (internal/chat) drives the model loop (model call → tool execution → next call), re-resolving the F3 chat chain each iteration on max(request, session HWM) sensitivity (an empty chain ends the turn — never a silent escalation). Each model call streams over the wire of the backend's protocol (ChatStreamBackend): the OpenAI-compatible SSE for openai and ollama rows, the native Messages API SSE for anthropic rows (tool calls as tool_use/tool_result blocks, see the Anthropic paragraph under Backend pool) — the harness's event contract (delta, tool_call_start, done) is the same on both. Four read-only tools run under the session's read_scopes snapshot: ctx_query (hybrid retrieval, delegated to /api/query with synthesize:false+include_content:true, attributed to the real key, homed in the session owner's scope — its embed/rerank chains, tenant quota and tenant config bind to the owner like the chat chain does; a refused query, e.g. quota_exceeded, is a failed tool call, not an empty hit list), ctx_search, ctx_get (full block, paged past the window via a resumable offset), ctx_recent. Each result is annotated with max(sensitivity) of the blocks it carried (a staged ctx_update result inherits the target block's sensitivity, because its card carries the target's category, title and scope); tools are offered only to a full-trust backend, and the closing call after the tool-budget cap runs no tool: on an openai/ollama row it carries no tools array, on an anthropic row the loop's tools array plus tool_choice: {"type":"none"} (a replayed thinking block is accepted only while the system prompt, the tools and the earlier messages are unchanged). An anthropic request without tool definitions (a row below full trust, tools switched off) gets the session's tool history rendered as text and no thinking blocks. Tool errors return to the model as {"error":…} and never abort the turn. Events flow through a narrow Sink interface, so a future headless agent runner can drive the same loop without HTTP.

Endpoints (auth required; CTX_WEBCHAT_ENABLED=false ⇒ 404):

Route What
POST /api/chat/stream Run one turn, response text/event-stream. Body {session_id?, message, sensitivity?, tools_enabled?, max_tokens?} — empty session_id creates a session. Pre-stream failures are JSON (404 unknown/foreign session or feature off, 409 busy, 429 scope semaphore); once the first event flows, later failures are error events. SSE events: session, backend, delta, tool_call_start, tool_call, tool_result, usage, done, error + a : hb keepalive every 15s. Errors are laundered to class code + backend NAME (the raw backend URL never reaches the client). Wrapped in the scheduler signal so dream yields the single llama.cpp slot during a turn.
GET /api/chat/sessions?limit=50 List the caller's home-scope sessions (metadata + message_count, newest first; no content).
GET /api/chat/sessions/{id}?after_seq=0&limit=0 One session + its messages (full tool-result contents); gated by read_scopes ⊆ caller → 404 on miss. Pagination additive.
DELETE /api/chat/sessions/{id} Hard-delete (messages cascade); home-scope-owned → 404 on miss. llmlog logs web-chat metadata-only (no conversation bodies in the un-scoped context_llm_log).

A per-home_scope semaphore (CTX_WEBCHAT_CONCURRENT_TURNS, default 1) bounds concurrent turns — multi-tenant fairness on the single slot (429 before stream start).

Camo image proxy

(design 07-camo) A signing image proxy that lets the SPA render foreign remote images WITHOUT widening the Content-Security-Policy and without the viewer's browser ever contacting the foreign origin (no tracking-pixel deanonymization). Off by default — fail-closed unless CTX_CAMO_ENABLED is truthy AND the sealbox master key CTX_SECRETS_KEY is present; when off both routes 404 and the renderer keeps its [external image blocked] placeholder.

Why a proxy, not an img-src widening. The markdown renderer runs entirely client-side and the CSP pins img-src 'self' data: (byte-pinned by TestCSPByteLiteralPin). A proxied image is re-hosted under /api/img/… = 'self', so it is already allowed — the CSP is never touched. The server fetches the bytes; the browser only ever talks to 'self'.

Two surfaces, opposite auth (design §4.2, D2b). The SPA has no signing secret, so it cannot mint proxy URLs itself; a browser <img> request carries no api key (at design time SPA auth lived in sessionStorage; since OAuth R4 a session cookie does ride same-origin subresource requests, but the HMAC signature stays the capability — the fetch route is deliberately credential-free), so the fetch endpoint cannot be api-key-authed. Hence:

Route Auth What
POST /api/img/sign api key + per-key rate limit Mints the proxy path for the foreign image URL the viewer clicked — exactly one distinct URL per request, origin:"assistant" (the default) only. The auth-gated mint bounds the signature oracle.
GET /api/img/{sig} none — the HMAC signature IS the capability Verifies the signature over url+exp (labeled-HMAC subkey of CTX_SECRETS_KEY, constant-time, prev-key rotation slot) BEFORE any upstream request. Forged ⇒ 403, expired ⇒ 410, ZERO fetch either way (no SSRF/timing oracle without a valid sig).

Fetch policy (design §4.4, analog to the forge W11 guard). Scheme allowlist (http/https); SSRF dial guard on the resolved IP of every hop including redirects (the classic "302 → internal IP" bypass is closed at the dial layer via the shared internal/ssrfguard); 3-hop redirect cap; dial/header/total timeouts 5s/5s/10s; size cap (CTX_CAMO_MAX_BYTES, default 10 MiB — LimitReader + Content-Length precheck, no unbounded buffering); and a two-layer content-type gate — the declared type must be in {image/png, image/jpeg, image/gif, image/webp, image/avif} AND the sniffed bytes must not be active content (HTML/XML/SVG/text/script). image/svg+xml is excluded outright (XSS vector). On success the bytes are served with the sniffed image content-type + X-Content-Type-Options: nosniff + Cache-Control: public, max-age=<TTL>, immutable; on failure a bodyless 502/415 + no-store (no substitute image, no retry storm).

SPA rendering (design §4.3). The shared markdown pipeline (renderMarkdown, the one sanitized sink for chat, issues and comments) leaves every foreign remote image as a visible [external image blocked] placeholder carrying the URL in data-camo-src. Every surface — chat assistant turns, issue bodies and comments — mounts the same camoClickToLoad Svelte action: nothing is signed on render or while streaming; each placeholder shows the target host and a shortened path (never the query string) with a Load image button, and one click signs exactly that URL via POST /api/img/sign (origin:"assistant") and swaps the placeholder for the proxied same-origin <img>. Approved URLs are remembered for the page session until their signature expires. It is additive and fail-safe: a disabled proxy, a sign failure, or a URL the server declines all leave the placeholder in place, and same-origin / data: images are never touched.

Env (all default-safe): CTX_CAMO_ENABLED (default false), CTX_CAMO_TTL (signature + cache lifetime, default 24h — bounds the auth-less fetch endpoint's relay window), CTX_CAMO_MAX_BYTES (default 10485760). No new secret and no DB migration — the signature is stateless and the signing key is derived from the existing CTX_SECRETS_KEY (rotation via CTX_SECRETS_KEY_PREV).

Federation refusals (Ctx-Hop, Ctx-Path)

Every authenticated route under /api and /mcp — and /v1, /v2 once mounted — checks the federation hop headers of a request right after authentication (security). A request without them is untouched. The refusals, with x-should-retry: false on every one:

Status code Ctx-Reason When
400 federation_header_invalid — Ctx-Hop, Ctx-Path or Ctx-Embed-Space-Require cannot be read (grammar, more than 16 entries, a path longer than the chain)
508 federation_loop loop the chain repeats an entry, carries one of this instance's hop tokens (then also Ctx-Loop-Token) or its instance id
508 federation_depth depth the chain already holds federation.max_depth entries
503 internal_error — a router without instance identity received a readable chain (test routers only)

On /api and /mcp the body is the house envelope ({"success":false,"error":…,"code":…}), on /v1 and /v2 the OpenAI envelope below with the same status and code (508 has type server_error). The text never names the chain, a token, an instance id, the depth or the limit.

OpenAI-compatible API (/v1)

External programs that hold a model key (ctxm_…, see model consumers) reach the models ctx serves through an OpenAI-compatible surface under /v1 (design 02). Mounted, but dark: the group answers only while the settings flag gateway.enabled is true, and it ships false — until it is switched on, every request under /v1 gets the same JSON 404. /v2 carries the Cohere-v2 form of rerank: a second group with the same stack as /v1 — the same model-key door (one failure lock, one database semaphore for both), the same flag, the same body cap and in-flight budget, the same JSON 404/405 for every other path — so with the flag off /v2/* answers the same JSON 404 as /v1/* and never reaches the web UI. The SPA reserves both prefixes (RESERVED_SERVER_PREFIXES), so a deep link cannot shadow them.

Endpoints today.

Method Path Answer
GET /v1/models {"object":"list","data":[…]} — the model aliases the key may use, sorted by id; nothing released ⇒ "data":[]
GET /v1/models/{id} one model object, or 404 model_not_found. Alias names may contain / (qwen/x); the path takes the name as is or percent-encoded (qwen%2Fx, what the SDKs send)
POST /v1/chat/completions a chat completion, non-streamed (chat completions); stream: true is a 400 until streaming lands
POST /v1/embeddings the embeddings of one text or a list of texts (embeddings)
POST /v1/rerank the documents ranked by relevance to a query, Jina's form (rerank)
POST /v2/rerank the same, Cohere v2's form

Which models a key sees. The aliases visible to the consumer's tenant scope (a tenant alias of the same name replaces the _global one; a consumer without a tenant scope sees _global only; a ctx-peer consumer always resolves _global aliases only), enabled and with a valid policy — and of those, with all_models every one, otherwise those whose id the consumer holds a grant on (model grants). A grant binds the alias id: an alias deleted and created again under the same name is not granted. The grant is read per request (one primary-key probe for /v1/models/{id}, one index range for the list), so a grant change applies to the next request.

Model object. The OpenAI fields plus a ctx extension, which both official SDKs keep as an extra field:

{"id":"chat","object":"model","created":1793707200,"owned_by":"ctx",
 "ctx":{"kind":"chat","context_length":262144,"max_output_tokens":8192}}
{"id":"embed","object":"model","created":1793707200,"owned_by":"ctx",
 "ctx":{"kind":"embedding","dimensions":4096,"matryoshka_min":32,"max_inputs":32}}

created is the alias's creation time (Unix seconds); ctx.kind is chat, embedding or rerank; the size fields come from the alias policy (context_length, max_output_tokens; embedding.native_dims, embedding.matryoshka_min, embedding.max_inputs) and are left out where the policy does not set them. No backend name, host or backend model name appears.

The stack. Every request under /v1 — a route, an unknown path, a wrong method, OPTIONS — passes, in this order: the group's access log (telemetry) → recovery (a panic is the 503 internal_error below, never a plain-text 500) → the flag → the model-key door → the federation loop guard → the federation ingress policy (PeerIngress) → the strict body cap gateway.max_request_bytes (after the door, so an anonymous caller learns no cap) → the process-wide in-flight budget → the handler, which checks once more that the request carries a valid model consumer with a tenant and no knowledge identity. An unknown path is the JSON 404 not_found, a wrong method 405 method_not_allowed with Allow — an HTML navigation under /v1 never gets the SPA.

  • The flag answers before authentication. With gateway.enabled=false every request under /v1 — any method, path, credential or body — gets the same 404 in the OpenAI envelope before any credential is looked at: a switched-off server does not reveal whether a key is valid, and costs no database query. The same bytes answer an unknown path while the gateway is on. The flag is hot. gateway.enabled cannot be turned on on a build without the mounted group (the settings write is a 409, the boot refuses).
  • Only model keys. The door reads Authorization: Bearer ctxm_… and nothing else: a knowledge key (as Bearer or X-Context-Key), an OAuth ctxt_ token and a session cookie are all the same 401, without a knowledge-auth query. There is no CORS: no /v1 answer carries Access-Control-* headers, so a browser page on another origin cannot read them.
  • Error envelope. Every gateway error has the OpenAI form with all four keys — {"error":{"message":…,"type":…,"param":…,"code":…}}, param/code null where they do not apply — Content-Type: application/json, and an explicit x-should-retry: true|false header that both official SDKs honour over their status-based default. Messages never carry key material, hostnames, backend or model names of the backends, upstream bodies or the requested model name. A key name the client sent appears in param and in the message cut to its first 64 bytes (at a character boundary, marked …); a shorter one appears as sent.
  • Statuses. 401 invalid_api_key (with WWW-Authenticate: Bearer realm="ctx"), 404 not_found, 404 model_not_found (one fixed body for a model that does not exist, belongs to another tenant, is not released to the key, is disabled or has a broken policy — no oracle: without all_models every name costs the same one grant probe, so the time does not tell the cases apart either), 405 method_not_allowed (with Allow), 413 request_too_large, 429 rate_limit_exceeded (the door's failure lock, Retry-After), 503 proxy_misconfigured (X-Forwarded-For without CTX_TRUSTED_PROXY, see operations), 503 auth_overloaded (Retry-After: 1) and 503 auth_unavailable (the door's database step), 503 server_overloaded with Retry-After: 1 and x-should-retry: true when the in-flight budget (gateway.inflight_budget, 3 × the body plus a response reserve per request, and for an alias below credentials the sensitivity scan's 23 × the body + 128 KiB + 104 bytes per nesting level (up to 1 040 104 bytes at the decoder's depth limit) on top; the JSON check's nesting state, up to 240 024 bytes at the decoder's depth limit, is booked before the check and takes the share's rest or grows it; the configuration refuses a budget that cannot hold one request at every cap) is exhausted — refused at once, never queued — and 503 internal_error with x-should-retry: false for every fault inside ctx: a recovered panic, a failed grant read (one that does not answer within the door's 1-s deadline included), a wiring defect. A fault inside ctx is a 503, not a 500: a peer ctx falls over to its next backend on a 503 but stops its chain on a 500. The only 500 the gateway will send is a relayed upstream 500 (upstream_error).
  • Federation guard. The group carries the federation loop guard and the ingress policy between the key door and the body cap (EnabledGate → ModelAuth → LoopGuard → PeerIngress → body cap), refusing in this envelope with the codes of federation refusals. gateway.enabled cannot be turned on while no model group with both stages is mounted: the settings write is a 409, the boot refuses.
  • Federation headers a client may send. Ctx-Sensitivity (public, internal, personal, credentials) raises the sensitivity the serving chain must admit above the consumer's own level and never lowers it — from any consumer; an unknown value counts as credentials. Ctx-Egress: local (or an unreadable value) keeps the request on this instance's own engines: no other ctx instance, no row that answered as one, no external row; absent or any, at most one further hop to a row known as a ctx instance (provider class ctx or a learned instance — a ctx instance registered as generic is not known as one, see security). A ctx-peer consumer that sends no Ctx-Path is always served as local. Ctx-Max-Wait-Ms can only shorten this instance's queue wait. Ctx-Class and Ctx-Pipeline take effect only from a ctx-peer consumer that also sends Ctx-Path; from an app they are ignored (no lane claim through a header). Prompts of a consumer below credentials are scanned for secrets over every string field of the body; a hit serves the request as credentials (security).
  • Code vocabulary. The full table of codes with their status and type is exported data (oai.Codes(), a copy): the statuses above plus invalid_json, missing_required_parameter, invalid_type, unsupported_parameter, unsupported_value, model_not_supported, context_length_exceeded, upstream_rejected (400), permission_denied (403), server_busy, concurrency_limit_exceeded (429 rate_limit_error), insufficient_quota (429 insufficient_quota), service_unavailable, model_unavailable, no_eligible_backend, upstream_unavailable (503), upstream_error (500), upstream_timeout (504) and the federation codes federation_header_invalid, federation_hop_required (400), peer_instance_mismatch (403), federation_loop, federation_depth (508). The three door codes proxy_misconfigured, auth_overloaded, auth_unavailable are 503 service_unavailable_error.
  • Conformance. The envelope and the model list are checked against the official OpenAI SDKs — Go openai-go/v3 in the regular test run, Python openai locally (go test -tags=pyconformance ./internal/oai/, needs uv) — and against the ErrorResponse, ListModelsResponse and Model schemas of the OpenAI spec (vendored excerpts under go/conformance/oai/testdata/).

Admission

Every inference route under /v1 admits its request through one planner before anything is written (handler.V1Admission, the contract oai.AdmissionPlanner). POST /v1/chat/completions runs it; the embedding and rerank routes follow.

  • Lane. A consumer's admission_lane is its ceiling. urgent requests run in the dispatcher's external class; bulk requests run in its external bulk class — the same external capacity, served after every waiting urgent request — except one that ctx's interactive work (under o2 also its background work) alone keeps out — and after ctx's own background work that may run now; background held back by interactive demand does not hold bulk back (lanes). Under sustained urgent load above the external capacity a bulk request is not served: it ends at the wait budget with 429 and a Retry-After. The lane is the minimum of the ceiling, the federation hint and service_tier: service_tier: "flex" lowers to bulk; a ctx-peer consumer's Ctx-Class counts only together with a Ctx-Path (interactive and external keep urgent, background and external_bulk lower to bulk), and a peer without such a hint is bulk; an app's Ctx-Class is ignored. Nothing raises a request above its ceiling.
  • Chain. The chain keeps only the rows whose target offers the external class (external offer); when none does, the answer is 503 model_unavailable.
  • One wait point. The first attempt waits in the dispatcher's queue at most dispatch.external_max_wait, shortened by Ctx-Max-Wait-Ms (0 = do not wait at all) and by the request's own deadline: a request is not admitted when its lease would most likely not end before that deadline. A failover attempt on a later row never waits again — it is admitted at once or the request is busy. Work that is split (embedding inputs, rerank documents) takes one lease per block of at most min(sub_batch, the target's external capacity) units; the blocks after the first are continuations.
  • No byte before the admission. Status and body are written only after the first acquire returned, so a refusal is always a real HTTP status — never an error frame after a 200.
  • Priority belongs to ctx. A body priority and the engine scheduling fields id_slot, bootstrap_host, bootstrap_port, bootstrap_room and kv_transfer_params never reach a backend.
  • No herald. A /v1 request does not count as interactive demand, so it never holds back ctx's own background work; the reserved internal partition protects ctx's share instead.
Situation Status, type code Headers
busy: the external queue cap, the wait budget ran out or cannot be met, the failover row is busy 429, rate_limit_error server_busy Retry-After (seconds), retry-after-ms, x-should-retry: true
not offered: no chain row offers the external class, the target stopped offering, a request heavier than the target's external capacity 503, service_unavailable_error model_unavailable Retry-After = the largest step of dispatch.external_retry_after_steps, x-should-retry: true
an internal fault (a planner or wiring defect) 503, service_unavailable_error internal_error x-should-retry: false

server_busy is distinct from rate_limit_exceeded (the door's failure lock, and the consumer's own limits) and concurrency_limit_exceeded: it says that the GPUs are busy, not that the key exceeded anything. Its Retry-After is the dispatcher's estimate for the refused request's own place in the queue (operations), rounded up to a step of dispatch.external_retry_after_steps, then multiplied by one random factor in [1, 1 + gateway.retry_after_jitter] that both headers share — Retry-After rounded up to whole seconds, retry-after-ms to milliseconds. The estimate is a minimum; the factor spreads retries that would otherwise arrive together. At the smallest steps the seconds header barely spreads — at the default jitter (0.5) an estimate of 1 s reads Retry-After: 2 for practically every draw and 2 s reads 3; with the jitter at 0 nothing spreads at all — so retry-after-ms carries the spread there; at any jitter, a client that reads only Retry-After never retries before the estimate. Bodies are generic: no target, number or row name. A client that hung up gets nothing written.

Chat completions (POST /v1/chat/completions)

The OpenAI chat completion, non-streamed or streamed (stream: true, see streaming). model is an alias name the key may use (see which models a key sees); an alias of another kind is 400 model_not_supported. The answer is an OpenAI chat.completion object: id is chatcmpl-<uuid> minted by ctx for this request (never the client's X-Request-ID, which is only recorded as metadata.gw.client_req_id), created is the admission time, model is the alias name, choices come from the backend with every key it sent (tool calls, refusal, logprobs; the reasoning traces reasoning_content/reasoning/reasoning_details are removed unless the alias's reasoning policy is passthrough), usage keeps the spec fields only (prompt_tokens, completion_tokens, total_tokens, *_details), service_tier is the lane (default; flex for the bulk lane). Every other field of the backend's answer — its model name, system_fingerprint, timings, provider, cost — is dropped, and no response header of the backend is relayed. The answer does not reach the client byte for byte: ctx decodes the backend's answer and encodes it anew — the keys of the answer, of every choice, of usage and of a message that lost a reasoning trace come in sorted order, whitespace between tokens is dropped, and <, >, &, U+2028 and U+2029 inside strings become \u003c, \u003e, \u0026, \u2028, \u2029, so a value keeps its JSON meaning, not its bytes; a choice without logprobs gets "logprobs": null (the spec requires the key, llama.cpp leaves it out), and a *_details value that is not an object (SGLang sends null) is dropped from usage.

Parameters. The body is checked key by key — top level, every message, every content part — against an allowlist; an allowed value reaches the backend with the bytes the client sent except the whitespace between its tokens: ctx writes the upstream body anew with the top-level keys in sorted order and every value compacted, nothing escaped — <, >, &, U+2028, U+2029 and the client's own escapes such as \u003c or \/ stay exactly as sent (the key order inside a nested object stays as the client sent it, except in a message ctx has to rewrite — a developer role translated to system, or a message with an ignored key removed — whose keys then come in sorted order). The upstream body is therefore at most the request body plus the keys ctx and the backend row add:

Class Keys Treatment
passed messages, temperature, top_p, stop, presence_penalty, frequency_penalty, logit_bias, logprobs, top_logprobs (0–20), seed, response_format (text, json_object, json_schema), tools, tool_choice, parallel_tool_calls, top_k, min_p, repetition_penalty type-checked (400 invalid_type) and size-capped by the alias policy (400 unsupported_value); null = the default, not sent
set by ctx model, stream, stream_options, max_tokens, max_completion_tokens, n, service_tier, reasoning, reasoning_effort read, then overwritten: the client's value never reaches the backend as sent
ignored priority, chat_template_kwargs, user, safety_identifier, prompt_cache_key, prompt_cache_retention, prompt_cache_options, metadata, store, verbosity, moderation, modalities (["text"] only), and the engine internals rid, request_id, id_slot, cache_prompt, lora, lora_path, return_hidden_states, custom_logit_processor, bootstrap_host, bootstrap_port, bootstrap_room, kv_transfer_params, vllm_xargs, cache_salt, session_params removed; their names are listed in the answer header X-Ctx-Ignored-Params (comma-separated, messages[].<key> for message keys)
refused everything else — e.g. functions, function_call, audio, prediction, web_search_options 400 unsupported_parameter, param = the key
  • Repeated keys. On every level whose keys are checked — the top level, a message, a content part, image_url, response_format and its json_schema object, tool_choice, a tool's {type, function} and logit_bias — a key that occurs twice (also when one is spelled with an escape, "\u0075rl") is 400 invalid_json with param = the key's path, and a key that differs from a known key of its level only in letter case (Type, Role) is 400 unsupported_parameter: what ctx checked is what the backend gets. The contents of a JSON schema and a tool's parameters are passed as sent. A repeat of a key the level names is always found; among the other keys of one object ctx compares the first 16 with every later one — so on a level that refuses unknown keys an object with more of them can get the unsupported_parameter of its first unknown key (in sort order) where a repeat further back used to be the invalid_json, and a repeat among more than 16 vendor keys of a tool_choice, response_format or tool hull — keys ctx does not check — passes. logit_bias is counted first: past logit_bias.max_entries it is the cap's 400 unsupported_value unless a repeat is among those 16; within the cap every repeat is found.
  • Messages. Roles system, developer (sent as system), user, assistant, tool; message keys role, content, name, tool_calls (assistant), tool_call_id, refusal; reasoning and reasoning_content of earlier turns are ignored. content is a string, an array of parts, or null for an assistant turn. Any other message key is 400 unsupported_parameter with param: messages[i].<key>.
  • Content parts. text, and image_url only for an alias with vision: true and only as a base64 data URI that starts exactly with data:image/png;base64,, data:image/jpeg;base64,, data:image/webp;base64, or data:image/gif;base64, (lowercase, no parameters), valid base64, decoded at most the alias's vision_max_bytes (default 4 MiB, at most 8 MiB and the request cap); image_url.detail is passed. A URL, a file path or any other form is 400 unsupported_value before any backend is contacted — ctx never lets an engine fetch an image (security). Other part types (input_audio, file, …) are 400 unsupported_value.
  • n. Only 1 (or absent): more choices per request are a later release.
  • max_tokens. max_completion_tokens is read as max_tokens; both with different values is a 400. The value sent is the client's (else the alias's default_max_tokens, else gateway.default_max_tokens), capped by the alias's max_output_tokens, by gateway.max_output_tokens_ceiling and, for a tenant alias, by gateway.tenant_alias_ceiling.max_output_tokens — the ceiling holds when neither the alias nor the backend sets a cap. A backend row lowers it further through limits.chat_max_tokens or model_map (max_tokens/num_predict); a max_tokens in a row's extra_body does not count.
  • Size caps (alias policy, default in brackets): tools.max_count [64], tools.max_bytes [256 KiB], response_format.max_bytes [64 KiB], logit_bias.max_entries [64], stop.max_entries [4]; for a tenant alias also gateway.tenant_alias_ceiling.tools_max_count, whether the alias sets the key or not.

Which backends serve it. The alias's role chain of the pool, filtered in this order: the rows the consumer's tenant may see whose trust admits the request's sensitivity — the highest of the consumer's prompt_sensitivity, a Ctx-Sensitivity header, the alias's sensitivity, the _global alias a tenant alias of the same name shadows, and the tenant's pool.default_query_sensitivity; a secret found in the body raises it to credentials — then the rows whose wire serves the OpenAI chat shape (openai, ollama), the federation filter, and for a tenant alias the export rule: operator rows (_global) only for the roles in gateway.exportable_roles (default none). Then the tenant's quota (daily_calls, cost budget with on_exceed = block), the per-consumer limits and the admission. An attempt that fails over moves to the next row as for ctx's own calls; a 400 of a row with a known context window moves only to a row with a larger or unknown window.

Errors of this route (besides the stack's and the admission's):

Situation Status code
malformed body, missing model/messages, wrong type, unknown key, value outside the policy 400 invalid_json, missing_required_parameter, invalid_type, unsupported_parameter, unsupported_value (with param)
the in-flight budget cannot hold the request's copies — the body read without a declared length (its chunks and their join, up to 2 × the body + min(the body + 512 bytes, 1 MiB): the body + min(the body + 512 bytes, 1 MiB) more than a body with a declared length, which the configuration's budget floor counts at the cap), a rewritten message array, an attempt's upstream body (every attempt's, a failover included), the JSON check's nesting state (booked before the check, up to 240 024 bytes) — once they pass the request's share (3 × the body) — and, for an alias below credentials, the sensitivity scan of the body (booked before it runs at 23 × the body + 128 KiB + 104 bytes per nesting level, what the scan can allocate; the booking lasts until the request ends) 503 server_overloaded, Retry-After: 1 — before the copy is made; on a failover attempt the chain ends there (a stream past its grace commit gets the error frame server_overloaded)
alias of another kind 400 model_not_supported
the backend rejected the prompt as too long 400 context_length_exceeded — the text names the alias's context_length (else the smallest known window of its rows), never the backend's words
any other request-specific backend 4xx 400 upstream_rejected — ctx's own text; with gateway.relay_upstream_4xx_messages=true the backend's text, cut to 512 bytes, with every row name, host, model name (and its prefixes of two or more segments) and provider of the pool replaced by the alias name and URLs, user@host authorities, IP addresses and paths removed
the tenant's quota is exhausted (its own internal use reached daily_calls, or its cost budget under block) 429 insufficient_quota, x-should-retry: false, Retry-After = the earliest relief: when the oldest call (or cost) the tenant's figure counts leaves its 24-hour (resp. 30-day) window — the moment the count first sinks; a tenant over its limit by k needs k + 1 rows to leave, so a retry at that moment can be refused again. When the relief is not known, until the next full hour
no row eligible (sensitivity, trust, export, federation filter, none configured), or no planner 503 no_eligible_backend, no Retry-After
the consumer's chat request or token budget per minute is used up, or the meter is starting 429 rate_limit_exceeded, x-should-retry: true, Retry-After and retry-after-ms — see limits; checked after the body, the model, the chain and the tenant's quota, so those refusals come first and book nothing
the consumer already has its limit of chat requests open at once (queued or running) 429 concurrency_limit_exceeded, x-should-retry: true, Retry-After: 1, retry-after-ms: 1000 — see limits; checked right after the request and token budgets, before the queue
every row failed (transport, 5xx other than 500, rate limit, the backend's own 401/403, a last 404) 503 upstream_unavailable — never a 401: ctx's own backend credentials are not the client's key; Retry-After = the smallest one a backend sent
the backend answered 500 500 upstream_error — the only 500 of the gateway
the backend did not answer in time 504 upstream_timeout
gateway.enabled switched off, or the alias disabled or removed while the request ran 404 not_found resp. model_not_found — the request's watcher ends it within one tick (5 s) plus the abort of the backend call, nothing of the answer is sent
the consumer's grant on the alias withdrawn (a consumer without all_models) while a non-streamed request ran 404 model_not_found — the grant is read again right before the answer is written, with the consumer as it entered (a withdrawn all_models or another tenant_scope applies from the next request); nothing of the answer is sent. A running stream reads the grant again only when the alias registry changes while it runs (its watcher's tick); otherwise the withdrawal applies from the next request
the model key revoked or expired, its consumer deactivated, its tenant suspended or gone, or its tenant_scope moved to another tenant while the request ran 401 invalid_api_key with WWW-Authenticate — the door's in-flight registry ends the request at once (the revoke action, the database signal of migration 165, the key's expiry timer, at the latest the next gateway.inflight_sweep_interval sweep); the backend call is aborted, nothing of the answer is sent
the check right before a non-streamed answer is written cannot read the consumer's grant within the door's 1-s deadline (a locked grants table, a slow database) 503 internal_error, x-should-retry: false — the finished answer is not sent; a stream makes no such check
the server shuts down while the request is queued or running 503 internal_error, x-should-retry: true — the backend call is aborted (no drain for a running chat request); a request that arrives, or is still being read, once the shutdown has begun gets the same answer before it reaches the admission or a backend

Deadlines. A request has gateway.request_deadline (540 s) from its arrival for everything — the queue wait and every attempt; one attempt is bounded by the alias's timeout_s, else the row's role timeout or gateway.request_timeout. The answer is written as one piece at the end, so a reverse proxy in front of /v1 needs a read timeout of at least dispatch.external_max_wait + gateway.request_deadline (20 s + 540 s by default); a shorter one cuts a long request off with nothing the client can retry on.

A served answer carries the consumer's x-ratelimit-* headers for chat, a stream with its status (limits).

Streaming (stream: true)

The answer is a server-sent-event stream of chat.completion.chunk objects, readable by the OpenAI SDKs as they are:

  • Bytes. Only data: <chunk> frames, the comment : keepalive (ignored by every SSE client) and one final data: [DONE] — never an event: line, which the SDKs would read as a chunk without choices. Headers: Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no and the consumer's x-ratelimit-* (limits); no response header of the backend is relayed.
  • Chunks. Every chunk carries the same id (chatcmpl-<uuid>), created (the admission time) and model (the alias name), object: chat.completion.chunk, service_tier (the lane) and only the keys id, object, created, model, choices, usage, service_tier. choices come from the backend as they are, except: a reasoning-off alias's deltas lose reasoning_content/reasoning/reasoning_details, and a chunk that carried nothing else is not sent at all (its role moves into the next chunk sent for that choice, so the stream still names the role once); a null the stream schema does not allow (SGLang's "role": null, "tool_calls": null) is dropped; a missing delta or index is added. A chunk without choices is never sent — except the usage chunk below.
  • Usage. ctx always asks the backend for usage. With stream_options: {"include_usage": true} the client gets the usage chunk (choices: [], the spec fields of usage) right before [DONE], and usage: null stays in the other chunks as the backend sent it; without it the usage chunk is swallowed and usage is removed from every chunk. include_usage is read only with stream: true (any value but a boolean or null is then a 400 invalid_type); without it, stream_options only has to be an object. A stream without a usage chunk (an abort, an engine without usage) is booked as usage: estimated: prompt tokens from llama.cpp's timings.prompt_n or estimated from the request, completion tokens estimated from the streamed text at 1.8 characters per token, at most the request's effective max_tokens (no backend generates more). The estimate is meant to err high but is no bound: text with fewer characters per token (CJK, emoji) and tokens an engine generated after ctx stopped reading are not in it. The per-consumer meter books the same: the usage chunk, else this estimate for a stream the backend began to answer, never more than the stream's reservation — a stream cut short is never given back in full and never charged above its reservation (what a request books).
  • No byte before the admission. While the request waits for a slot (at most dispatch.external_max_wait) nothing is written, so a refusal is a real 429 server_busy with Retry-After and retry-after-ms that the SDKs retry. The status 200 and the headers go out with the first chunk or, if the backend is still silent (prompt processing), at the latest at arrival + the effective commit grace — gateway.stream_commit_grace clamped to [dispatch.external_max_wait + 5 s, 50 s] (a WARN, throttled to one line a minute, names a clamped value) — never before the admission; then a : keepalive comment after every gateway.keepalive_interval of silence.
  • Errors after the commit. Once the status is out, an error is the last frame: data: {"error":{"message":…,"type":…,"param":…,"code":…}} with the code the same situation has as a status (table above; a backend failure as upstream_error/upstream_timeout/upstream_unavailable), no [DONE] after it, then the response ends — the Python SDK raises APIError, the Go SDK's stream.Err() is set. A backend error before the first chunk moves to the next row of the chain (the failover acquire never waits); after the grace commit a next row that is full ends the stream with the error frame server_busy, and one whose upstream body the in-flight budget cannot hold with the error frame server_overloaded. After the first chunk there is no failover. A panic after the commit, or a client that stopped reading, cuts the connection instead — the client reads an error, never a body that ends like a complete one.
  • Ends. The stream ends early with its error frame when gateway.enabled goes off (not_found), the alias is removed or disabled, the key stops authenticating — revoked, expired, its consumer deactivated, its tenant suspended or gone, its tenant_scope moved (invalid_api_key, at once through the door's in-flight registry) —, or the server shuts down (internal_error). A narrowed allowed_cidrs, allow_plaintext set to false, a withdrawn all_models or a changed tenant_scope of the same tenant does not end a running stream, nor does a withdrawn grant unless the alias registry changes while the stream runs (its watcher then reads the grant again: model_not_found); they apply from the next request. A client that does not read for gateway.client_write_window (30 s) is cut off; its backend call is aborted and its slot freed.
  • Slow clients. The backend's slot follows the backend, not the client: ctx reads the backend's stream to its end at the backend's speed and keeps what the client has not read yet in a buffer of gateway.stream_relay_buffer_bytes (2 MiB) per stream; the backend's slot is free when the backend's stream ends, and the client then reads the rest from the buffer. The consumer's concurrency slot and the meter's booking end only when the client has read the rest (or is cut): a stream that drains from the buffer still holds the buffer in the in-flight budget, a connection and its writer, so it counts against the consumer's concurrency limit until then. Every stream books its buffer in the in-flight budget for its whole life, next to two frames at gateway.max_stream_frame_bytes and its request share — 4 194 636 B per stream at the defaults — so the default gateway.inflight_budget (256 MiB) serves 63 streams at once; a buffer that grows only with what a client actually lags behind is a planned follow-up. A client that falls further behind than the buffer holds is cut: its stream ends with the error frame server_overloaded (what was still buffered for it is dropped), the backend call is aborted. A client that, while frames wait for it, drains slower than gateway.stream_min_drain_rate (1 KiB/s) over gateway.stream_min_drain_window (30 s) is cut off like one that stopped reading. A stream the client leaves, or that is cut, keeps the backend's slot for the target's abort_settle_ms.stream (0 by default) — the time the engine may still generate it.
  • Deadlines. A stream has no request deadline: it lasts as long as the attempt's timeout (the alias's timeout_s, else the row's role timeout or gateway.request_timeout); gateway.request_deadline applies to non-streamed requests only. A reverse proxy in front of /v1 needs an idle (read) timeout above the commit grace (≤ 50 s) and above gateway.keepalive_interval (15 s) — a typical 60 s is enough for streams.

Not yet: n > 1, Anthropic-protocol rows.

Limits, quotas and rate-limit headers

Every model consumer has its own meter per operation — chat, embed, rerank — with two buckets each: requests per minute and tokens per minute (gateway.<op>_rpm_default / gateway.<op>_tpm_default; these defaults hold for every consumer until per-consumer limits exist). A full bucket admits a burst of its whole limit at once and refills evenly over the minute (one request every 60 s / rpm). The meter counts per consumer, not per key: rotating, revoking or reactivating keys does not reset it. It runs after the body, the model, the chain and the tenant's quota — a request refused there (also a federation loop) books nothing — and before the queue; it refuses at once and never waits (only the queue waits, see admission).

  • What a request books. A chat request reserves the estimate of its prompt — the messages as they go upstream, one token per 1.8 characters rounded up, meant to err high (text with fewer characters per token, such as CJK or emoji, can count more) — plus its max_tokens/max_completion_tokens after the alias's and the gateway's ceilings, or gateway.tpm_reserve_default (capped the same way) when it names none. A reservation larger than the bucket is charged at the bucket's size, so a large prompt is admitted on a full bucket instead of being refused for good. When the answer arrives the reservation is replaced by the backend's real token count — for a stream its usage chunk, and for a stream the backend began to answer (its 200 arrived) that ended without one (the client gone, a connection reset, an error frame, the watcher, a re-check or the shutdown) the estimate of the streaming section, prompt plus streamed text, while it is below what the reservation holds — at or above it the stream keeps its reservation, so an estimate never books more than the reservation, however large the prompt (a cut stream whose prompt is larger than the bucket books the bucket's size). An overrun of a real count is debt the next request waits off, and unused tokens go back exactly as far as booking the real count when the request was admitted would have left the bucket — a reservation held while the bucket refilled cannot hand that refill to later requests, and one that ends early loses nothing. A request that no attempt — retries included — started to write to a backend connection (refused by the queue, ended by the shutdown, the client gone before the backend call, a connection refused, timed out or failed in its TLS handshake) gives back its reservation the same way, as if it had used nothing; it still counts as a request. Once an attempt has started to write the request to a backend connection, or ended — by its deadline, the client or the shutdown — while holding one, a request without a usage in the answer (a backend error, a connection lost after the request, an answer without usage, the client gone during the call, a failover refused after a backend had already answered — also after a stream's grace commit — or a panic after the request was written) keeps its whole reservation; a stream does so only while the backend's 200 had not arrived, after it the estimate above applies.
  • Lanes. A request on the bulk lane (a bulk consumer, a peer without an interactive hint, service_tier: "flex") fills at most the share 1 − gateway.xu_reserve_ratio (default 75 %) of each bucket; the urgent lane of the same consumer keeps the rest. A limit too small to split (1 rpm, say) leaves the bulk lane one unit and no reserve.
  • Concurrency. A consumer may have gateway.consumer_concurrency_default (default 2) requests per operation open at once — from the meter's admission until the request ends, waiting in the queue or running (a stream until its [DONE], its error frame or its client going away — the rest the client reads from the relay buffer after the backend's stream ended included); one more is refused at once, a stream before any byte of it. The bulk lane holds at most max(1, ⌊limit × (1 − gateway.xu_reserve_ratio)⌋) of them (one of the default two), so a consumer's urgent requests always find a free slot next to its bulk traffic and reach the queue; with a limit of 1, or with gateway.xu_reserve_ratio at 0, there is no such reserve. A refused request books nothing on the per-minute buckets. Lowering the limit while requests are open lets them run to their end; new ones are refused until fewer than the new limit are open. Every way a request ends — an answer, an error, a refusal by the queue, a crash of the handler, the client going away — frees its slot. The limit is checked after the request is read and parsed: a refused request has cost the server that work and held its body's share of the in-flight budget until the refusal. While dispatch.external_queue_per_consumer is above 0 the limit may not exceed it; a settings write that lowers that cap under a stored limit succeeds with a warning, and the limit falls back to its environment value or the default.
  • Embeddings. An embeddings request reserves the estimate of all its inputs (their characters summed, one token per 1.8 rounded up) and settles with the prompt tokens its sub-blocks reported, summed (a negative count counts 0, the sum saturates). The wire rule above holds for every try of every sub-block: no byte of the request written to a backend gives the reservation back; after that a request whose sub-blocks reported no usage keeps it whole; otherwise it settles with the usage they reported plus the estimate of every sub-block that reached a backend without reporting one — the client gone during it, a backend error, an answer without usage — counted over its inputs that no sub-block with a usage served by the end of the request, each input at most once. A failover books as at chat: inputs a later row served with a usage book that usage, the failed try on the row the request failed over from books nothing for them (the rows may cut the request into different sub-blocks — the inputs decide), and inputs no row served book their estimate. The estimate never books more than keeping the reservation would: a charge that reaches what the reservation holds — the estimate of all inputs, at most the bucket's size (x-ratelimit-limit-tokens of the admission) — keeps the reservation instead, while a reported usage above it is booked as reported. A sub-block that reached no backend books nothing, and none after a failed one reaches a backend.
  • Rerank. A rerank request reserves the estimate of all its pairs — for every document the query's characters plus the document's as they go upstream (after max_tokens_per_doc), one token per 1.8 rounded up — on the rerank bucket (gateway.rerank_rpm_default, gateway.rerank_tpm_default) and settles with the prompt tokens its sub-blocks reported, by the embeddings rule above with documents in place of inputs: no byte written to a backend gives the reservation back, no reported usage keeps it, a sub-block that reached a backend without reporting one books the estimate of its documents no sub-block with a usage served, never more than the reservation holds.
  • Operations are separate. An exhausted embedding budget does not touch chat, and the other way round.
  • Daily and monthly budgets. Besides the minute buckets every consumer has a token budget per operation and UTC day (gateway.<op>_daily_tokens_default: chat 2 000 000, embed 20 000 000, rerank 5 000 000) and optionally a token budget per UTC month and a request budget per UTC day (gateway.consumer_monthly_tokens_default, gateway.consumer_daily_requests_default, 0 = none). A request passes while the tokens the consumer used in the window, plus what its open requests reserve, plus its own reservation fit; a reservation larger than the budget is charged at the budget, so it passes an empty budget and its real usage counts afterwards. The windows are calendar UTC: the day budget resets at 00:00 UTC, the month budget on the 1st at 00:00 UTC — not 24 hours after the first request. The bulk lane fills at most 1 − gateway.xu_reserve_ratio of each budget. A request counts against the request budget once admitted, even if it ends before reaching a backend. A restart does not reset the budgets: at start ctxd counts every request of the running day and month again from the usage log — a stream cut short without a usage chunk with the estimate it booked (prompt from the request's characters, completion from the characters it relayed), and a request still running when ctxd stops is booked with that estimate before the process ends.
  • Which backends a consumer reaches. A consumer's chain keeps the local and LAN rows and the rows of other ctx instances (peers, whatever their address) always. Billable rows that report their cost (OpenRouter) stay only with gateway.consumer_allow_external_default (default off) and while the consumer's monthly cost budget (gateway.consumer_monthly_cost_usd_default, default 1.00 USD per operation, provider-reported cost) and its tenant's envelope of it (gateway.tenant_envelope_factor ×) last — after that the request is served by the remaining rows, never refused for the budget. Billable rows that report no cost (Anthropic protocol, other external /v1 servers) stay closed for consumers — and so do OpenRouter rows for embeddings and rerank, whose routes book no provider cost (the budget could never move there). A chain left empty is 503 no_eligible_backend and books nothing on the meter.
  • Envelopes. All consumers of one tenant share an envelope per operation (gateway.tenant_envelope_factor, default 5 × the consumer budget), all consumers of the instance another (gateway.instance_envelope_factor, default 10 ×); a consumer can be refused by an envelope while its own budget has room. The refusal names no value: "… quota of your tenant reached", resp. "Capacity quota for this deployment reached".
Situation Status, type code Headers
the request or token bucket of the operation is empty 429, rate_limit_error rate_limit_exceeded Retry-After (seconds, rounded up), retry-after-ms (rounded up to the millisecond), x-should-retry: true — the moment the same request would pass if nothing else arrives; no jitter
the consumer's requests of the operation open at once are at the limit 429, rate_limit_error concurrency_limit_exceeded Retry-After: 1, retry-after-ms: 1000, x-should-retry: true — the meter cannot know when a running request ends, so one second is the honest lower bound
the meter is starting (its boot read of the day and month budgets has not succeeded yet) 429, rate_limit_error rate_limit_exceeded Retry-After: 5, retry-after-ms: 5000, x-should-retry: true
a daily or monthly budget of the consumer, or an envelope of its tenant or of the instance, is used up 429, insufficient_quota insufficient_quota Retry-After / retry-after-ms until the window resets — the next 00:00 UTC, for a monthly budget the 1st of the next month at 00:00 UTC; x-should-retry: false (an SDK does not retry it)

The message names the caller's own limit and nothing else, e.g. Rate limit reached for tokens (chat): 60000/min. or Concurrency limit reached for requests (chat): 2 at a time. — on the bulk lane (chat, bulk lane) with its share.

Every successful answer carries x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens and x-ratelimit-reset-tokens for the buckets of the requested operation as the request's lane sees them (on the bulk lane the limit is its share): remaining after this request's reservation, reset the time until the bucket is full again as a Go duration string (30s, 1m0s, 1.042s). Refusals carry none of them. A stream sends them with its status — at its first chunk, at the grace commit, or with a stream of only [DONE] — showing the buckets as its admission left them, like a non-streamed answer. The status can come before the stream's outcome, so they stand on a stream that still ends with an error frame, and what the stream books after the commit is not in them: a failover refused after the grace commit (kept), a backend failure before the first chunk (kept, or the estimate once the backend's 200 had arrived), a panic after the commit (kept — once the backend's stream has ended, its booking at that end stands) and an end by the client, the watcher, a re-check or the shutdown (the usage or the estimate).

The meter lives in the memory of one ctx process: a restart starts with full buckets and no open requests, and two processes behind one load balancer count separately. Daily and monthly budgets follow in later releases.

Refusal ledger

A request refused before any wire call writes no LLM-log row (nothing is logged before the wire); one refused after a wire call — a failover row refused by the dispatcher, an answer the in-flight budget cannot hold — keeps the LLM-log row of its wire work and is counted as a refusal as well. Every refusal is counted: every refusal by a rule of the gateway — authentication, federation, size, budget, model, chain, meter or admission — lands once in an in-memory ledger, which ctxd writes every 10 seconds and at shutdown into context_llm_rejections_hourly (operations — Migration 167): per hour, consumer, operation, resolved alias, reason and detail, with the vain queue wait of the admission refusals. A refused request is counted exactly once and only when it ended as a refusal (status ≥ 400); a stream that already sent its 200 and ends with an error frame is not a refusal.

Refusal Status, code reason / detail
the model-key door refused the key 401 invalid_api_key auth / invalid_key — consumer_id NULL
the door's failure lock 429 rate_limit_exceeded auth / locked — consumer_id NULL
the door's concurrency share is full 503 auth_overloaded capacity / auth_overloaded — consumer_id NULL
a malformed hop header; a loop; the chain too deep 400/508 federation_* federation / header_invalid, loop, depth
the body is larger than gateway.max_request_bytes 413 request_too_large capacity / request_too_large
the in-flight byte budget cannot hold the request 503 server_overloaded capacity / inflight_budget
the model does not exist for the key 404 model_not_found model / not_found
the alias does not serve the operation 400 model_not_supported model / not_supported
the chain's rules (sensitivity, egress, federation, the tenant gate) left no row 503 no_eligible_backend capacity / egress_denied
no row of the chain serves the role now 503 no_eligible_backend capacity / not_offered
the tenant's daily calls or cost budget 429 insufficient_quota quota / tenant_daily_calls, tenant_cost
a daily or monthly budget of the consumer, its tenant's envelope, the instance's envelope 429 insufficient_quota quota / daily_tokens, monthly_tokens, daily_requests, tenant_envelope, instance_envelope
the request or token bucket of the operation 429 rate_limit_exceeded rate / rate_requests, rate_tokens
the consumer's concurrency 429 concurrency_limit_exceeded concurrency
the meter is starting 429 rate_limit_exceeded capacity / warming
busy: the external queue cap, the wait budget ran out or cannot be met 429 server_busy capacity / server_busy, wait_expired — with the queue wait
no chain row offers the external class 503 model_unavailable capacity / not_offered

Not counted: syntax and parameter errors of the body (400 invalid_json, missing_required_parameter, …), the switched-off flag's 404, an unknown path or method, internal faults and upstream errors (those are no refusal by a rule), and a request that ends while it runs — the shutdown, the watcher, a client gone, and a request whose key was revoked or expired while it ran: it was admitted, and no reason of the vocabulary describes its end. model_alias is the resolved alias name or _unresolved for every refusal before the resolution — never the client's string. dispatch_class and target are kept for capacity only: the external class the planner bound and the dispatcher target of the refused acquire (empty for a refusal of the plan itself, before any acquire — not_offered — and for the refusals before the dispatcher). A refusal whose wait budget was predicted out counts as wait_expired like one that ran out in the queue (the dispatcher's own counters keep that split).

The same counts feed the throttled log: a counted refusal reaches the gateway rejections line through the ledger with the ledger's reason and its detail (the details of the window, comma-separated); a refusal the ledger does not count (the list above) is counted by the access log itself under its error code or http_<status>, as before. Either way one refusal is one count, and a consumer gets at most one line per reason and minute.

Embeddings (POST /v1/embeddings)

The OpenAI embeddings call. model is an alias of kind embedding (another kind is 400 model_not_supported). The answer is {"object":"list","data":[{"object":"embedding","index":i,"embedding":…}, …],"model":<alias>,"usage":{"prompt_tokens":n,"total_tokens":n}}, one entry per input at its index, usage the sum the backends reported. The access-log line and the request's context_llm_log row carry the same reported sum; the meter can book more than it — that usage plus the estimate of the inputs a backend had without reporting a usage (limits, "Embeddings"), never more than the reservation holds.

  • input — a string, or a list of 1 … embedding.max_inputs strings (default 32; for a tenant alias also gateway.tenant_alias_ceiling.embedding_max_inputs). An empty string, an empty list or a token array ([1, 2], [[1, 2]]) is 400 unsupported_value (param: input resp. input[i]): token ids presume one tokenizer over every row of the chain. An input longer than embedding.max_input_runes characters (default: 9/5 of the smallest known context window of the alias's rows, else 3686) is 400 unsupported_value with param: input[i], before any backend is contacted. The number of elements is checked before any of them is read: a list past 256 (the code ceiling of max_inputs) is refused at the cost of one scan of the body. Invalid UTF-8 and lone surrogates in a text are replaced by U+FFFD, as on the chat route.
  • The texts are sent as they are — no instruct prefix, no cut, no normalization. A ctx instance that embeds through another one's gateway adds its own prefix and keeps its own 1024-d space; the vector is the backend's raw vector at its native dimension.
  • dimensions — absent: the native dimension. Set: it must lie in [embedding.matryoshka_min, embedding.native_dims]; the vector is cut to its first d components and renormalized to length 1. An alias without embedding.native_dims takes no dimensions (400), and one without matryoshka_min only the native value — a model that was not trained for shortened vectors is not cut.
  • encoding_format — float (the default of the API; float32 values) or base64 (the little-endian float32 bytes, standard base64 — what the Python SDK asks for when the caller names nothing; it decodes them itself). Both carry the same bits. user is ignored (named in X-Ctx-Ignored-Params); any other key is 400 unsupported_parameter; a repeated key is 400 invalid_json. A refusal names the client's key as every gateway error does — cut to its first 64 bytes, marked … — and of a body with more than five keys the first six are read: a repeat or an unknown key among them is the answer, one past them goes unnamed.
  • Sub-blocks. A request is split by the admission's weight plan: each upstream request carries at most min(embedding.sub_batch, E) inputs, where E is the external capacity of the target (limits slots minus internal_reserved_slots) — on a target with E = 1 every input is its own upstream request. Each sub-block runs under its own lease: the first waits in the queue like any request (a refusal there is the 429 before any GPU work); every further one is a continuation that waits at the head of its class, exempt from the queue caps and the wait budget, and is never refused for a full queue. Between two sub-blocks the unit goes back: one multi-block request lets one other waiting consumer through at each boundary (it keeps about half of E for itself, however many wait). All sub-blocks of a request run on one row: once the first sub-block has a result there is no failover, and a failure of a later sub-block is 503 upstream_unavailable with Retry-After (two backends' vectors are never mixed in one answer). Everything runs inside gateway.request_deadline. The number of sub-blocks is not limited at request time: the policy check ⌈max_inputs / sub_batch⌉ ≤ 8 at alias write time holds against sub_batch, but on a target whose E is smaller the weight clamp makes more leases (up to ⌈max_inputs / E⌉, 32 at the default max_inputs and E = 1).
  • What waits under load. A continuation waits at the head of its class, ahead of every other waiter. Two overlapping requests of several sub-blocks on one target hand the unit to each other at every boundary: every other external request for that target gets no unit until the first of the two has ended (measured: 0 % of E for the others while both run; on E = 1 — the live embedding target — two batches of 32 inputs lock the target for the length of the shorter one; three overlapping ones can outlast the default wait budget of 20 s, and the others are refused 429). A request that needs more units at once (a wider block on the same E) waits the same way until one of them has finished or its wait budget runs out (429 server_busy). Aging does not change this — a continuation goes before every aged head. What aging does change is the first sub-block (weight min(sub_batch, E)) of a request next to a busy consumer of the same tenant: it no longer starves on the tenant share, because the waiting request of a consumer that holds no unit ages — once it has waited one median lease duration of its class or its class has admitted E units to other consumers — and is then admitted when the leases it needs units from have ended. Aging does not lift the wait budget (the aging wait is not in the estimate at arrival, so such a request can still end 429 server_busy), and a first block of the full external capacity (sub_batch ≥ E) is served only once the target has drained — when its blocks are short against the leases of the others, the target runs below its capacity meanwhile; both are named limits of the dispatcher, see Fairness inside the external class under operations. A consumer on the bulk lane gets the next sub-block only when no urgent request waits for the target — under permanent urgent demand above E its continuation waits until gateway.request_deadline and the request ends 503 internal_error (x-should-retry: false); the sub-blocks done so far are logged and charged. These are named limits of this release, not goals.
  • Memory. The route holds on the heap only what its reservation covers, and it books every part before it allocates it, in this order: the body (read into one buffer of its declared length), the JSON check's nesting state (24 B per level), the decoded texts — their exact decoded length (an invalid UTF-8 byte becomes U+FFFD of three bytes, an escape one to four bytes) plus 24 B per text, sized before anything is decoded —, the scan (above), the upstream bodies — each written in one allocation of its exact encoded size (the texts as the JSON encoder writes them: as decoded for plain text, six bytes for a control character), with up to 8 KiB of allocation rounding, the transport's copy of the body onto the connection (32 KiB, or the body's length when shorter) and 14 KiB per backend call for each sub-block, and for the retry of each call (one more try on a fresh connection after a transport error before the answer) the copy again and 30 KiB — booked with the first call, whether the retry happens or not: the texts' part before the request queues, the rest of each sub-block's body at the attempt, again for every failover attempt — and, with body logging, the log row's text of the input (at most 64 KiB). The budget stage's request share (3 × the body) is spent first, the reservation grows past it and never shrinks. A budget that cannot hold a part is 503 server_overloaded before it is allocated: before the texts are decoded, before the request queues for the texts' upstream part, after the queue wait but before any backend call for the frames; the log row then goes without its text. Otherwise the answer is unchanged: status, body, headers, the upstream bodies and the stored log row are byte for byte what they were before the route booked these parts — except that a request below credentials which the earlier reservation refused, because it booked the scan on top of the whole request share, can be served when its parts fit the budget. A model longer than 768 raw bytes (128 characters, each written as an escape) is not decoded and is answered as an unknown model (404 model_not_found); an encoding_format longer than 36 raw bytes and a dimensions integer of more than 20 characters are 400 unsupported_value without being decoded — the answers they had. Named limit: at the default budget of 256 MiB a request at the default body cap of 8 MiB to an alias below credentials without embedding.native_dims books more than the budget — plain text ≈ 269 MiB, invalid bytes ≈ 301 MiB, control-character escapes ≈ 262 MiB; the reserves of an unknown dimension (≈ 52 MiB) and the scan (≈ 184 MiB) take most of it — and is 503 server_overloaded, as it was before the route booked these parts (before it queues, or after the queue wait when only the sub-blocks' per-call parts pass the budget); with native_dims (invalid bytes ≈ 249 MiB) or at credentials it is served. Budget dimensioning before the release.
  • LangChain and other batching clients: set the client's chunk size (chunk_size, embed_batch_size) to at most the alias's max_inputs.
  • Answer. Nothing is written before the last sub-block has its result, so every failure is a real status, never a broken 200. The answer is encoded item by item: the memory it holds is bounded by n × native_dims × 4 B (the float32 vectors) plus a 64 KiB buffer, which the request reserves from gateway.inflight_budget before it queues (with an unknown native dimension: gateway.embedding_max_floats × 4 B); a budget that cannot hold it is 503 server_overloaded at once. A request to an alias below credentials also books the sensitivity scan of its body before the scan runs — 23 × the body + 128 KiB + 104 B per nesting level, as at the chat route; at credentials nothing — and is 503 server_overloaded before the scan when the budget cannot hold it. Each sub-block's backend answer is read up to a cap derived from the block (inputs × dimension × 25 bytes per number, plus the JSON frame; at most 16 MiB) and its decode buffer (three times the cap) is reserved before the block's backend call: as far as it is certain at the entry, before the request queues — without embedding.native_dims all of it (every block is read up to the floats ceiling), with it the buffer of a one-input block — and the rest of the largest sub-block, whose size the row's capacity sets at the acquire, after the queue wait. A budget that cannot hold it is 503 server_overloaded — at once for the part known at the entry; for the rest after the queue wait, still before any backend call (a named limit: such a request can wait in the queue and then be refused). A dimension the first sub-block teaches (no native_dims) sets the cap of every later sub-block, so their decode buffer grows with it before the next one is asked for, like the answer reserve (503 server_overloaded after the first sub-block if the budget cannot hold it); a backend answer over the cap is a malformed answer (500 upstream_error, the chain stops). So is an answer with more entries than the sub-block has inputs (refused at the first entry past them, before it is decoded), an entry without index or with index: null, a null component, or a vector of another length than native_dims (without it: than the first vector) — each refused before its vector is allocated, so the decode stays within the decode buffer reserved for the block. Set embedding.native_dims on every embedding alias: without it the cap and the answer reserve take the ceiling gateway.embedding_max_floats, a request then reserves ≈ 52 MiB, and few fit the budget at once.

Errors besides the chat route's list (which applies the same way: quota, limiter, no eligible row, the shutdown, the watcher, the admission): a failure of the first sub-block maps like a chat attempt (upstream_unavailable, upstream_error, upstream_timeout, upstream_rejected) and fails over to the next row where a chat attempt would — not after a 500, a timeout or a malformed answer (upstream_error, upstream_timeout: the chain stops); a failure of a later sub-block is 503 upstream_unavailable; a sub-block's continuation that the admission refuses or that runs into the request deadline is answered by the admission (429 server_busy, resp. 503 internal_error with x-should-retry: false). Embeddings are never cached on this route.

Ollama rows on /v1 are sent without options.num_ctx (the gateway reads their window as unknown), so the rune limit is the fallback 3686 unless the alias sets embedding.max_input_runes, and Ollama's /api/embed may cut a longer input to the model's own window without an error — a vector of the shortened text with 200. A named limit of this release; set embedding.max_input_runes to the model's window × 9/5 for such aliases.

Rerank (POST /v1/rerank, POST /v2/rerank)

One handler, two dialects; the path decides the dialect, the dialect only the default of return_documents. model is an alias of kind rerank (another kind is 400 model_not_supported). The answer is {"id":"rerank-<request id>","model":<alias>,"results":[{"index":i,"relevance_score":x,"document":{"text":…}}, …],"usage":{"prompt_tokens":n,"total_tokens":n}} — a superset of Jina's and Cohere v2's answer (no Cohere meta); usage is the sum the backends reported.

POST /v1/rerank (Jina) POST /v2/rerank (Cohere v2)
return_documents absent true: every result carries document.text false: no document
request keys model, query, documents, top_n, return_documents, max_tokens_per_doc the same
SDK checked plain HTTP (httpx) cohere.ClientV2(base_url=…).rerank (cohere 7.3.1)
  • documents — a list of 1 … rerank.max_documents documents (default 128; for a tenant alias also gateway.tenant_alias_ceiling.rerank_max_documents), each a string or an object whose one key is text; both forms are scored alike. More documents than the code ceiling (1000) are refused at the cost of one scan of the body, before any of them is read. An object with another key is 400 unsupported_parameter (param: documents[i].<key>). Invalid UTF-8 and lone surrogates are replaced by U+FFFD.
  • query — a non-empty string.
  • top_n — a positive integer; the answer keeps the first top_n results (more than the documents: all). Absent or null: all.
  • max_tokens_per_doc — a positive integer: every document goes upstream cut to its first max_tokens_per_doc × 9/5 characters (the densest tokenization measured, so the cut keeps at most what the tokens can hold); the echo is the document as sent.
  • Every other key is 400 unsupported_parameter — also priority and relevant_spans, which the Cohere SDK sends only when the caller sets them; a repeated key is 400 invalid_json; a refusal names the client's key cut to its first 64 bytes.
  • Score. relevance_score = 1/(1+e^(−logit)) ∈ [0, 1] of the backend's logit (a backend whose row says score_domain: probability reports probabilities, which the wire maps back to the logit first). The results are sorted by logit, highest first — the order of the scores, kept where the sigmoid rounds two logits past about 36.7 to the same score 1.0 —; equal logits keep the order of the documents.
  • Saturation over a hop. An instance that reranks through another one's gateway sets score_domain: probability on that row; its wire maps the probability back to the logit with p clamped to [1e-7, 1 − 1e-7], so over one hop a logit is bounded to |logit| ≤ ln((1 − 1e-7)/1e-7) ≈ 16.12 — within that range the logits come back within 1e-6, beyond it saturated. The order holds as long as at most one document saturates. Echo over a ctx hop (named limit): the calling instance's rerank wire sends only model, query and documents, so the peer answers in its /v1 dialect (Jina) with return_documents at its default true — every document text comes back with the scores. The caller reads at most 1 MiB of that answer: a sub-block whose escaped document text passes about 1 MiB fails over the hop (500 upstream_error at the calling instance, measured with 128 documents of 9 KiB) although the same request is served when sent to the peer directly. This holds for every peer rerank row until the rerank wire sends return_documents: false (a hardening wave before the flip).
  • Sub-blocks. As at embeddings: each upstream request carries at most min(rerank.sub_batch, E) documents (default sub_batch 128) under its own lease, all sub-blocks of a request on one row (a failure of a later one is 503 upstream_unavailable with Retry-After; two backends' scores are never mixed in one ranking), without the continuation door a request of more than one sub-block is 400 unsupported_value (param: documents) — before it queues when it holds more documents than rerank.sub_batch, after the acquire (before any backend call, without a log row) when the row's capacity E cuts it into more than one. Each wire call is capped at the shorter of the row's timeout and 180 s (the rerank wire's own cap), all of it inside gateway.request_deadline. The live rerank row is off: until a rerank row is enabled the route answers 503 no_eligible_backend.
  • Memory. The route books every part before it allocates it, in this order: the body, the JSON check's nesting state, the decoded model, query and documents (their exact decoded length plus 40 B per document, sized before anything is decoded), the answer (64 B per document plus a 16 KiB encoder buffer — the echo is written from the decoded documents and not copied), the sensitivity scan below credentials (23 × the body + 128 KiB + 104 B per level), and the backend calls of every sub-block before the first one — their certain part (the documents and the query at the body factor below) before the request queues, the rest after the queue wait. A backend call is booked as measured: the request body the wire builds with Go's JSON encoder costs up to 7.25 × its length (the encoder's buffer grows by a quarter at a time and is copied out; <, >, & and control characters are six bytes in it), plus the transport's copy (32 KiB, or the body's length when shorter), 176 B per document for the answer, 52 KiB per call, and for the retry of each call the copy again and 30 KiB. A budget that cannot hold a part is 503 server_overloaded before it is allocated. Named limits: the backend's answer is read up to 1 MiB and decoded as a whole, so the decode allocates beyond what the request booked whenever the answer carries more or other entries than the documents — a malformed or hostile answer up to about 127 MB, and also the regular answer of a backend in the Jina dialect, which echoes every document (return_documents defaults to true there, a peer ctx's /v1/rerank included, and the wire does not send the field): about 1.1–1.2 × the booking, measured at 128 and 200 documents of 4 KiB — until the rerank wire sends return_documents: false and gets a strict decoder. The encoder's factor holds for a cold encoder pool: after an earlier, larger json.Marshal anywhere in the process a backend call can allocate up to that size up front (the next power of two above it), unbooked, until the wire writes its body without json.Marshal. And at the default budget a request at the body cap can be 503 server_overloaded in both classes: below credentials the scan (≈ 184 MiB at 8 MiB) and the encoder's factor are booked on top of each other; at credentials the factor alone passes the budget when the documents are mostly characters the wire writes as six bytes (<, >, &, control characters) — the factor applies to the escaped body (measured: the largest such body served was 5.87 MB). Budget dimensioning before the release.
  • Answer. Nothing is written before the last sub-block has its result. One context_llm_log row (gateway-rerank) with metadata.sub_batches/sub_batches_done; with body logging the row's text is the query and the documents as sent.

Errors besides the chat route's list map like the embeddings route's: a failure of the first sub-block like a chat attempt (fails over where a chat attempt would), a failure of a later one 503 upstream_unavailable, a refused continuation by the admission.

Telemetry & logging

The contract every gateway route writes against (llmlog.GatewayEntry / llmlog.RecordGateway, handler.GatewayAccessLog):

  • One LLM-log row per request. Chat, streamed chat, embeddings and rerank each write exactly one context_llm_log row, failover included: attempt is the number of physical attempts, metadata.chain lists them. Pipelines are gateway-chat, gateway-embeddings and gateway-rerank.
  • Attribution. The row names the consumer in consumer_id; api_key_id is always NULL (a row with both is refused by the database). metadata.gw (version 1) carries v, op (chat/embed/rerank), alias, stream, hop (0–16), claimed_chain (display only), peer_pipeline (ctx peers), usage (backend/estimated), ttft_ms (streams), client_req_id ([A-Za-z0-9._:-]{1,128}, else dropped), finish (stop, length, tool_calls, content_filter, error, aborted) and model_key_id — never an origin and never a second copy of the consumer. The hourly usage rollup counts such rows as source = 'gateway' with the alias.
  • Bodies. gateway.body_logging (global-only, hot): none (default) stores no prompt and no answer ('', shown as bodyless), errors stores the serialized request and the answer only on rows with an error, full on every row (a streamed answer is not stored yet — its row carries the request only); each body is capped at 64 KiB with a visible truncation marker. A credentials-class row stays sealed under every policy — a tenant's tenant.devmode does not open it — and a row with an empty or unknown sensitivity stores no body either.
  • Errors. The error column of a gateway row is <class> <status> <code> (for example bad_request 400 upstream_rejected) — never the upstream response body, which can echo the prompt.
  • Access log. One INFO line gateway request per successful request (consumer_id, model_key_id, tenant_id, op, alias, status, stream, token counts, usage, duration_ms, queue_wait_ms, class, hop, request_id), on while gateway.access_log is true (default). Refusals (status ≥ 400) are never logged per request: they are counted per consumer (empty before authentication) and reason — for a refusal the refusal ledger counts its reason, with the window's details in detail; otherwise the error code of the gateway or the model-key door, else http_<status> — and written as at most one WARN line gateway rejections with count and window="1m" per minute — the flag's 404 and the door's 401 included. The global http request line is not written for these requests: each gateway request is one line or one count.

The keys are listed under operations (CTX_GATEWAY_*).

Model aliases (model-alias-*)

A model alias is the model id an external program names on /v1 (migration 162, design 02 §3.1): it maps to a pool role (the chain is built from the backend rows carrying it), a sensitivity floor and a policy of request caps. Aliases are data — none are seeded; an empty table offers nothing. The registry reloads on every write (NOTIFY, plus a reload the action itself asks for after its commit). The four manage actions are tenant-admin tier; the field rules below decide what each caller may write.

Field Create Update Rule
name required immutable ^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$, unique per scope
kind required immutable chat, embedding or rerank
scope optional immutable server-admin: _global (default) or a tenant scope together with tenant_id; tenant-admin: its own home scope only (default)
tenant_id server-admin, with a tenant scope immutable scope must be one of that tenant's scopes, else 422 at scope; a tenant-admin sending it gets 403 tenant_id is server-admin only
role required yes a pool role (^[a-z0-9][a-z0-9:_-]{0,63}$); kind=embedding ⇒ every backend row visible to the alias's scope carrying the role speaks openai or ollama (else 422 at role); a role no visible row carries is accepted with a warnings entry
sensitivity default credentials yes raising is free; lowering (create: below credentials, update: below the current value) is server-admin only and needs "confirm_sensitivity_downgrade": true
policy default {} replaces the object the closed key set of design 02 §3.2, parsed fail-closed from the request bytes; see the ceilings below
description optional yes at most 500 characters of UTF-8, no NUL
enabled required (true or false) yes a new alias is never live by default

model-alias-update and model-alias-delete take the alias id as the top-level id; model-alias-list takes no parameters and returns {success, model_aliases, count}, each item with a writable flag (a tenant-admin reads _global and its own scope, writes only its own). Create, update and delete answer {success, model_alias, warnings?}. A delete of an alias that an active model consumer holds an explicit grant on is 409 model_granted with the number of such consumers (Model grants).

Scope. A tenant-admin writing a _global alias, a foreign tenant's alias or a scope that does not exist gets 404 {"success":false,"error":"model alias not found"} — byte-identical to an id that does not exist (no existence oracle). Disabling (enabled:false) and deleting its own alias are always allowed.

Shadowing floor (V-02-c). A tenant alias with the name of a _global alias shadows it for that tenant's consumers — but never below its sensitivity: a tenant alias under the _global alias's sensitivity is a 422 at sensitivity, for every caller and even with the confirm flag. The rule holds on the other write path too: a _global alias cannot be created or raised above a tenant alias that shadows it (422 naming the shadows; raise or delete them first). Both paths take one transaction-scoped lock on the name, so two concurrent writes cannot pass each other's check. A tenant alias also shadows by presence: a disabled one hides the _global alias for its tenant — delete it to fall back to the operator's alias.

Ceilings (B10). Beyond the static code ceilings of the policy keys, max_output_tokens and default_max_tokens are capped by gateway.max_output_tokens_ceiling, vision_max_bytes by gateway.max_request_bytes, embedding.max_inputs × embedding.native_dims by gateway.embedding_max_floats; every alias outside _global — a server-admin's included — is also capped by gateway.tenant_alias_ceiling.* (max_output_tokens and default_max_tokens, max_n, timeout ↔ timeout_s, tools_max_count, embedding_max_inputs, rerank_max_documents). The registry clamps a tenant alias that sits above the tenant ceiling — a psql edit, or a ceiling lowered later — to the ceiling at reload and logs a WARN; a _global settings write reloads the registry, so a moved ceiling applies at once. An update that leaves policy out is not re-judged against a lowered ceiling. Write check and clamp bound the values a policy states; a key a tenant alias leaves unset resolves to its default where the serving endpoint reads it, and that resolution has to be bounded by the same tenant ceiling (the endpoint's formula, not this action). The floats ceiling is reported at embedding.max_inputs when the policy states it, else at embedding.native_dims.

Errors.

Status Body When
400 {"success":false,"error":"…","code":"immutable_field","param":"name"} name, kind, scope or tenant_id in an update (even unchanged)
400 {"success":false,"error":"invalid data: …"} / "id required" / "nothing to update" malformed data, an unknown field (keys are exact — Name is unknown, not name), no id, no mutable field
403 admin key required a member key; a tenant-admin lowering a sensitivity
404 model alias not found see Scope
409 {"success":false,"code":"constraint","error":"a model alias of that name already exists in the scope"} duplicate (scope, name)
422 {"success":false,"error":"validation failed","fields":[{"field":…,"message":…}]} field errors; a policy error names policy (the document) or policy.<key> (e.g. policy.tools.max_count)
422 {"success":false,"error":"validation: sensitivity: lowering … — repeat with \"confirm_sensitivity_downgrade\": true"} a server-admin lowering without the confirm flag
503 model aliases not wired the policy ceilings are not wired (tests only)

A confirmed lowering answers with a warning and its audit row (GET /api/audit, entity model_alias) carries metadata.confirm_sensitivity_downgrade = true. Whether a tenant-admin may write aliases at all is not coupled to gateway.tenant_self_service (open question Q13 of the RC-B index; today: tenant-admin tier, the rules above).