All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
-
traceparentis honoured and recorded, so gateway traffic joins back to your own APM. A caller instrumented with OpenTelemetry already sends the W3Ctraceparentheader on every outbound call. The gateway already continued that trace through its own spans; it now also records the caller's trace id ontraffic_event.trace_id, so a slow span in Datadog / Honeycomb / Jaeger leads to the gateway's row for the same call.The column is written only when an inbound
traceparentparsed. With no caller trace it stays NULL rather than receiving the trace id the gateway's own tracer derives from the request id — a column meaning "the caller's trace" must be empty when there is no caller trace to point at, or every row looks traced and none of them is joinable.Caller impact: none unless you send the header, and if you run OpenTelemetry you already do.
-
X-Request-Idis accepted as a compatibility alias forX-Nexus-Request-Id. They are two spellings of one id, not two ids. A stack that already stamps the industry-conventionalx-request-idon outbound calls is now understood without changing a line; the canonical name still wins when both arrive, and the gateway still mints one when neither does. The resolved value is echoed on the response and persisted totraffic_event.external_request_id. -
The SIEM export forwards every correlation id, not one. Rows now carry
requestId,endUserIdandsessionIdalongsidetraceId. Which id a security team correlates on is their decision; a bridge that forwarded only one was making it for them. Fields are omitted when the row carries no value, as before — nothing is sent asnull. -
Smart routing keeps a conversation on one model while its prompt cache is warm. A provider's prompt cache is keyed on the model plus the exact request prefix, so a conversation routed to a second model finds nothing cached and pays full price for the whole history again. Staging traffic showed 41% of consecutive chat requests from one caller switching model within 30 minutes, while only 3.5% exceeded the 5-minute cache lifetime — the switching, not expiry, was what threw the cache away.
A chat request that carries
X-Nexus-Session-Idis now routed to the model that tag last used, provided that model is still among the candidates the router chose from. Membership in the pool is the whole check, because the pool has already been filtered for the key's allowlist, the capabilities the request needs, the modalities it carries, and a context window that holds the prompt. A conversation that adds an image or outgrows a context window therefore routes normally rather than being pinned to a model that cannot serve it. Affinity only reorders inside the pool: it never adds a candidate and never relaxes a filter.Caller impact: none unless you send the header, and it is not new — it has always been an attribution tag. Sending the same value across one conversation's turns is what buys the cache hits. The entry lives five minutes, tracking the provider's own cache lifetime, and is scoped by virtual key, so one caller's tag can never reach another's traffic.
Operator impact: nothing to configure. The remembered model is held in process and, when Redis is configured, shared across gateway instances; the Redis leg carries a 50 ms deadline and the in-process tier is always read and written first, so a Redis that is slow, down, or hung costs cache hits and never a request.
-
traffic_event.trace_idnow holds the caller's W3C trace id; the cross-service correlation key isexternal_request_id. The two columns had drifted into names that described the other one's contents:trace_idheld the request id and was documented as "a grouping key", whileexternal_request_id— the caller-facing request id — carried no index and nothing joined on it.The request id is now the correlation key everywhere it was already the value being compared: the Control Plane's
requestIdfilter and traffic-drawer pivot, the identity enricher that copies a resolved identity onto the agent and compliance-proxy rows for the same request, and the diag ↔ traffic join. A new partial index coversexternal_request_id.thing_diag_event.trace_idis renamedexternal_request_idfor the same reason — it always held the request id — along with its composite index and theslogattribute key that feeds it (trace_id→external_request_id).Deploying this needs an order. The column rename must run as hand-written DDL before
prisma db push, which has no rename primitive and would otherwise ask to drop and re-add the column. Thediag_eventenvelope's field name changes on the same commit, so a data plane and the Hub that disagree about it lose the value silently in either direction: deploy schema → Hub → gateway / compliance proxy / agent, and do not leave the fleet split across the release. Agent binaries carry no external-user compatibility obligation, so no dual-read window is provided. Full procedure, including the rollback order, indocs/operators/ops/runbooks/prod-deploy-data-changes.md.Operator impact: none at the API surface — no request or response header changed, and the
requestIdquery parameter keeps its name and its meaning. Anything queryingthing_diag_event.trace_idortraffic_event.trace_iddirectly in SQL needs updating. -
A realtime session's rows group by the upgrade request's id. They used to borrow
trace_idfor a server-minted key of their own. Every row now carries the upgrade request'sexternal_request_id, so the ordinary request-id filter returns the whole session andtrace_idis free to mean what it says. -
Anthropic prompt caching now uses the provider's automatic caching, and the marker moved to the codec that owns the wire. The gateway had been placing explicit cache breakpoints itself: one on the last system text block, and optionally a second on the second-to-last user message. It now sets a single root
cache_controland lets Anthropic place and advance the breakpoint.Measured against every Anthropic model the gateway routes to, one arm per request so no arm could read what another wrote: the root marker cached the system prompt and the message turn while the system-block marker cached only the system prompt — 14597 vs 12489 tokens on Sonnet 4.6, 27529 vs 23573 on Opus 4.7, the same ratio on all ten. The old second breakpoint was worse than that: anchored one turn behind the request, staging traffic showed it writing 2.8 tokens of cache for every token it read.
cache_controlis a field of the Anthropic Messages wire, so the marker now lives in the Anthropic codec — on both of its doors, so a/v1/messagescaller and an OpenAI/v1/chat/completionscaller routed to Claude get the same treatment. This removed three separate lists of adapter names that each had to decide "is this an Anthropic-shaped body".Operator impact: none to configure. The Inject cache markers toggle keeps its meaning and its stored value.
Bedrock forwards uncached until someone can probe it. AWS documents its InvokeModel Claude integration answering 400 for a root
cache_control, and this repository has no Bedrock credentials to verify either that or a block-level fallback; the previous code applied markers to that wire on the theory that it shares the Anthropic body shape, with no cited measurement. -
The Claude Code nonce strip only worked on one of the two doors. Its rule declared a single body path,
system.#.text, whose gjson#requires an array. Anthropic'ssystemis an array of content blocks when a native/v1/messagesclient sends it, and a plain string when this gateway's own codec rebuilds the request on the cross-format leg — and the rewriter runs on the prepared body, so it sees whichever shape that leg produced. The rule therefore stripped the nonce for a/v1/messagescaller and did nothing at all for the same conversation arriving on/v1/chat/completions, on the upstream body and on the L1 cache key alike. Rules now declare a list of paths and the Anthropic rules carry both shapes.Why it matters: the nonce sits inside the system prompt, which is the first segment of the provider's own prompt-cache prefix. Measured on the live wire, turn 2 of a conversation with the nonce rotating reported
cache_creation_input_tokens=11792, cache_read_input_tokens=0; the same turn with it stripped reportedcreation=0, read=11774.Operator impact: none by default — both rules still ship disabled, and turning one on is an admin toggle. Enabling it now does what its description always claimed.
-
marker_boundary3_enabledkeeps its job and gets a better anchor. The knob has always added a SECOND cache breakpoint, and that second breakpoint is what keeps a long-turn conversation hitting at all: Anthropic finds the previous entry by walking back at most 20 content blocks, so a turn that appends more than that — an agent round with many tool calls — stops hitting entirely, with no error. What changed is WHERE the breakpoint goes, and that it now sits on top of the automatic breakpoint rather than beside a hand-placed system marker. Measured on the live wire with a 26-block turn, two arms with independent session identities:turn root marker only root + second breakpoint 2 (+26 blocks) creation 14242, read 0 creation 2477, read 11767 3 creation 14512, read 0 creation 2747, read 11767 The anchor moved from the second-to-last USER message to the end of the previous assistant turn. The old position moved every turn and cached a prefix one turn shorter than the breakpoint ahead of it already covered; staging traffic measured it writing 2.8 tokens of cache for every token it read. The new one is stable — finished content the next turn will not edit — and sits behind whatever the current turn appended, however much that was.
thinkingblocks are skipped; they cannot carry a marker.Operator impact: off by default, as before. The admin toggle keeps its name and its stored value.
-
The
X-Nexus-*namespace no longer reaches third-party providers. The compliance proxy and the agent minted a correlation id and set it on the intercepted request, so it travelled to OpenAI, Anthropic and everyone else — announcing Nexus to a party the caller never told about us, and handing them an id of ours.UpstreamTransport.ForwardRequestnow strips the whole prefix, matching what the AI Gateway has always done on its own forward path. Headers the client sent under that prefix are stripped too: a client speaking the Nexus vocabulary is addressing Nexus, and the header stops where it was addressed. The correlation id still exists on every hop's audit row; it simply does not travel on the wire.X-Request-Idis untouched — it is the caller's own header, and providers read and echo it. -
The seeded super-admin password is
nexus-demo. The README, the contributor guide, the examples index and the public getting-started page all quotedadmin123, which the seed has never set — a first-time reader was sent to the login screen with a credential that could not work. -
The stated Go prerequisite matches
go.work. The prerequisite tables said 1.25 while the workspace pinsgo 1.26.0; withGOTOOLCHAIN=locala 1.25 install cannot build the tree. The bootstrap scripts compared against the wrong boundary as well, so a 1.25 install passed the check and then failed the build. -
A dry-run normaliser rule silently swallowed prompt-cache markers, and the job that sets dry-run could not see its own remediation.
NormalizeUpstreamreportedDryRun=truewhenever every strip rule was in dry-run mode, but the marker injection that ran in the same call had still edited the body — and the caller discarded that body on the dry-run flag while the audit row kept reporting the markers as sent. Reachable in production: the Hub's cache-quality monitor setsdry_run_alwaysautomatically on an error spike.Marker injection no longer runs there at all, so the flag can no longer reach it. Separately, a dry-run rule now stamps ZERO strip counts on the audit row, because a dry-run rule measures and does not edit — the row describes what happened to that request. Recording the would-have-stripped figure there put a number nothing removed in front of four readers that all treat it as an edit: the traffic audit drawer shows it to an admin, cache ROI sums it into a savings figure, the 5m rollups aggregate it, and the Hub cache-quality monitor counts the row as "normaliser-modified" — the last of which is why that job, after flipping every rule to dry-run, kept measuring the same population and could not observe its own remediation.
Operator impact: none. No schema change.
-
Prompt-cache markers went silently off for providers created after the last cache-config push, and for a whole process on some cold starts. The per-provider marker settings were resolved once, when the
cacheshadow key was applied, against the provider list as it stood at that moment. A provider created later had no entry. Worse, the config loader applies shadow keys by ranging a Go map, so a start that reachedcachebeforeprovidersresolved against an empty provider list and left markers off for the process lifetime — while the admin UI still showed the toggle on.The gateway now holds the config blob and resolves per request, so there is no provider snapshot to go stale and key arrival order stops mattering.
-
A suspended account could still authenticate, on every login surface. Every gate enforced on
NexusUser.disabledAt, a column nothing in the tree has ever written; every surface that disables an account — the admin PUT, offboarding, agent-user suspend, SCIMactive:false— writesstatus = 'suspended'. The control was disconnected at both ends, so SCIM deprovisioning reported success while the departed employee kept signing in. Two further surfaces had no account check at all: the OIDC callback and the SAML ACS turned a federated identity straight into an auth code.The store now returns a resolved verdict instead of the raw columns, so a gate cannot enforce on the wrong one, and all five session-minting paths consult it. A status outside
activefails closed.Operator impact: accounts that were disabled but still working now stop working, which is the intended behaviour. No migration is required — the verdict is computed from columns that already exist.
-
admin:user.updatealone could set another user's local password, including the super-admin's, and then sign in as them. The grant ceiling now runs on the password field. The other five fields on that endpoint are deliberately ungated: withholding access is not conferring it, and gating them would take incident response away from any operator who does not out-rank the account they need to disable. -
Admin API-key regenerate and rotate bypassed the grant ceiling. Both mint a usable plaintext credential for the key's existing owner, so a caller holding only
admin:api-key.updatecould pick a super-admin-owned key and read the credential out of the response. All three minting paths now share one predicate, which also owns the skip conditions (owner unset, or owner is the caller). -
SCIM refused a group mutation and then performed it. The ownership guard signalled refusal by returning
c.JSON's result, which is nil on a successful write, so every caller'sif err != nilbranch was dead: the 403 went out and the rename / member replacement / delete landed anyway.DeleteGroupseparately had no ownership check at all, so a SCIM token could delete an admin-created IAM group with its policy attachments, or another IdP's group, and receive 204. Both guards now write their own refusal and report a boolean. -
Every OIDC login wrote its authorization code to the access log. The log recorded the raw query string, and the IdP callback carries
codeandstatethere; a 5xx logged it at ERROR, which the diag handler ships to the Hub as a persisteddiag_eventrow — and the 500 arm fires before the code exchange, so the stored value was an unredeemed code. The inbound log now shares the outbound redactor's parameter list, which gains the OAuth family. -
GET /api/admin/dsarreturned every listed subject's full Art.15 export. The list projecteddsar_request.outcome— the subject's user record, group memberships, traffic rows and inline prompt/response bodies — into every row, and the pages walk the table. The list no longer selects the column. Reading one named request's export (GET /api/admin/dsar/{id}) is unchanged. -
Host resolution in
shared/trafficwas internally inconsistent in three ways — regex was the only match type not folding case; the exact-host index was consulted BEFORE the priority-ordered scan, so an exact rule always beat a higher-priority glob and the priority field was decorative; and that index was keyed on the config's own capitalisation while the scan folds case. The index is now a build-time memo of the scan itself, so the two cannot disagree.Scope, stated plainly:
FindInstanceandResolveActionhave no production callers today, so none of the three was a live interception bypass. The per-connection path isshared/policy/domain, which is tracked separately and carries the mirror defect — a regex host pattern is compiled verbatim while the host is lowercased, so an admin who capitalises anything in a regex gets a rule that never fires. -
A scan on a closed rule-pack matcher reported that it had scanned to completion. The redaction path is contractually required to treat an incomplete scan as unsafe, because a dropped hit is unmasked PII — and the closed-matcher early return handed back "scanned to completion, zero matches" for a scan that never touched the database. The window is a rule-pack swap closing the old matcher while an in-flight redact hook still holds it.
Found while fixing it:
hs_selftest.goincluded<hs/hs.h>where its neighbour includes<hs.h>, and that one character made the entirevectorscan-tagged package fail to compile — so no test in it had ever run via the documentedgo test -tags vectorscanpath. The first run after the fix was red on a pre-existing assertion that required exactly one hit, which is an RE2 detail: withfirstOnly=falsea Vectorscan database reports every match END. -
Gemini redaction landed one slot off and sent the tail in plaintext. The extractor routes
thought=trueparts to reasoning and off the redactable segment list, while the rewriter wrote into every text part — so for[A, thought, B]it redacted A, wrote B's redaction into the THINKING part, ran out of segments and returned, leaving B on the wire in clear while the pipeline recorded the request as redacted. It also corrupted the thought text Gemini requires echoed back verbatim across turns. The response side had the same shape, returning unredacted assistant text to the client. Separately, the extractor read bothsystemInstructionand the protobufsystem_instructionspelling while the rewriter read only the first, so a snake_case request had its system prompt's redaction written into the first user message and its last message never rewritten at all. Both decisions are now single-sourced. -
Three per-device admin routes failed OPEN.
/agent-devices/:id/audit,/configand/timelinewere registered under the plain IAM middleware while their six siblings used the device-aware one. The plain middleware evaluates against the wildcard resource, so a policy statement scoped to a device group never matches the target — an administrator's group-scoped Deny is not merely outranked, it never enters the tally, and whatever unscoped Allow exists carries the request./agent-users/:id/deviceshad the same hole from the other direction: keyed on a user, it returned every one of that user's devices regardless of which groups the caller may see, and it now re-evaluates per row and fails closed. A tree-wide gate refuses any future device-scoped route registered without the device-aware middleware. -
A device could forge its own audit attribution. The
/things/auditHTTP fallback forwarded the device-supplied event map to the queue verbatim, so a device could self-assertentityId,orgId,identityand the producer-trust flags. An exact-string denylist would not have closed it: the downstream consumer decodes with a case-INSENSITIVE matcher, so a forgedEntityIdbinds anyway and a lowercasethingid— which marshals after the server-stamped canonicalthingId— wins on last-key-wins and overrides the stamp itself. The forwarder now keeps only the keys an agent legitimately produces, folded-case, before re-stamping.sourcestays settable by a Hub-internal service caller, which is the contract that path documents. -
A user's identity audit showed another subject's traffic after a device reassignment. The agent leg attributed traffic by a bare
thing_id IN (the user's devices)with no ownership window, so a device reassigned A → B surfaced B's events in A's audit view and vice versa. It is now scoped to eachDeviceAssignment's[assignedAt, releasedAt)window, matching the DSAR access and erase paths. -
The generated PAC file was not valid JavaScript. The bypass-domain template emitted one
ifper domain but only ever opened the condition once, so any install with two or more bypass domains produced a file every browser rejects wholesale — silently, and taking the whole proxy configuration with it. The template now emits a single parenthesised condition, and the fix is pinned by a test that runs the generated text through a real JavaScript parser at 0, 1, 2 and 63 domains. -
Five committed yaml files carried a database password, and the secrets gate could not see it.
check-no-yaml-secrets.mjsmatched on KEY NAMES, so a credential embedded in a connection URL under a key calledurlwas invisible to it. The gate now also inspects VALUES forscheme://user:password@host, the five URLs ship empty, and the one legitimate occurrence (a CI service container) carries an explicit waiver with its reason. -
npm run seed:prodre-enabled every job an operator had disabled.Jobwas a reference fixture, so a production re-seed upserted all 47 rows withenabled: trueover the live table — data retention included. The Hub's own store already refuses exactly this (UpsertJobomitsenabledso "a restart must not clobber an admin's disable action"); the seed was the one writer breaking the rule the rest of the system states. The seed no longer owns thejobtable at all: the Hub writes every one of those columns at boot from each job's own Go definition. -
SEED_DEMOwas read by two halves of the same system that disagreed. The TypeScript side tested!== 'false'— fail-OPEN on every near-miss spelling (0,False,no,off, a trailing space), while the container entrypoint tested= "true"— fail-CLOSED on the same input.SEED_DEMO=0therefore skipped the demo tier in a container and seeded it everywhere else, and that tier's credential plaintexts are derivable from ids committed to this repository. There is now ONE reader: both directions are accepted in the spellings anyone would write, unset still means yes so the dev quickstart is unchanged, and anything unreadable is refused with a non-zero exit rather than silently assigned a side. -
stream: truewas honoured on endpoints that have no stream. The rule forcing non-stream was a denylist naming image generation and TTS, so{"input":"…","stream":true}on/v1/embeddingsor/v1/reranksetstreamon the upstream request and took the SSE responder — for upstreams that answer with one JSON object. The client got a 200 withContent-Type: text/event-streamand no event frames. It is now an allowlist living beside the endpoint-kind constants, so a kind added later defaults to non-stream./v1/chat/completions,/v1/responses,/v1/messagesand Gemini:streamGenerateContentare unaffected.Client impact: a caller that sent
stream: trueto embeddings or rerank now receives the documentedapplication/jsonbody instead of an empty event stream. The published API reference already stated these endpoints do not stream. -
Sticky credential selection ignored
selectionWeightand reshuffled the whole fleet on any circuit change. It washash(virtualKeyId) % len(eligible), which picks an INDEX: every eligible credential took a 1/N share regardless of weight — including a half-open probe deliberately clamped to weight 1, which therefore load-tested a credential that had just been failing — and becauselen(eligible)is the divisor, one credential opening its circuit remapped essentially every virtual key, discarding the provider-side prompt cache fleet-wide during an incident. Replaced with weighted rendezvous hashing.Operator impact, one time, on first deploy: virtual keys pinned to multi-credential pools are re-assigned, so provider-side prompt caches refill once (single-credential providers are untouched).
selectionWeightbecomes load-bearing on the sticky path for the first time — an 8/1 pool that was splitting 50/50 will move to roughly 89/11 — so check that a heavy credential has the provider-side quota for its new share. Per-credential spend attribution shifts accordingly. -
The Redis rate limiter never recovered from a lost script cache.
AllowcalledEVALSHAwith a hash captured at construction and had noNOSCRIPTfallback, so a Redis restart, a failover to a replica that never loaded the script, or aSCRIPT FLUSHmade every later call error for the lifetime of the process. The caller's error path falls back to the per-process limiter, so the cluster-wide quota silently became per-instance — N replicas each enforcing the full limit — with nothing in the logs saying so.Operator impact: a deployment currently in that degraded state starts enforcing the configured limit again on deploy, so callers that had been passing may begin receiving 429s. That is the configured limit taking effect, not a new restriction.
-
A credential drained to
selectionWeight: 0kept serving traffic. Two lookups answered "which credentials may serve this provider?" differently: the list excluded weight 0, the single-credential fallback did not, and the resolver falls back from the first to the second exactly when the list is empty — which is what draining every credential produces. The same split existed in the store and in the cache layer that production actually runs; both are aligned, and an empty list is now an answer rather than a reason to reach for the credential the list excluded.Operator impact: a drained credential that was still serving now stops. A provider whose entire pool is drained returns HTTP 500
PROVIDER_TARGET_UNAVAILABLE; routing to a different provider still fails over normally. This is the drain doing what the console says it does — the field's own help text reads "Set to 0 to exclude from the pool without disabling."
-
Model.featuresgainsstructured_outputs, and GET /v1/models publishes it. The array is a shipped contract, so this is an additive contract change: existing values are untouched and a consumer reading only the older tags is unaffected. It answers a question no existing tag answered — will this model hold its answer to a caller-supplied JSON Schema — which is NOT whatjson_modesays.json_modedescribes the weakerresponse_format: {type: json_object}, and the two disagree on exactly the rows that matter: probed per model against each provider's own wire on 2026-08-19,gpt-4-turbocarriesjson_modeand answers 400 to a schema, while everyclaude-*, everycommand-*and the whole o-series carry nojson_modeand serve one correctly.The tag is an ELIGIBILITY constraint, not a preference:
autowill not choose a model whose row lacks it for a request carrying ajson_schema. One catalogued model is the reason —kimi-k2.5accepts the field and answers with HTTP 200,finish_reason: stop, and prose, so nothing downstream can turn it into an error and the caller's own parse is the first thing that notices. A model the caller NAMES is unaffected; their model's limits are theirs.A row that declares other features and not this one is excluded from structured-output routing. 44 rows on the six providers a key exists for are tagged from measurement; rows on the thirteen template-only adapters (
azure-openai,bedrock,vertex,fireworks,together,groq,mistral,xai,glm,minimax,perplexity,huggingface,replicate) are untagged and therefore not selected for such requests. Operators running those providers who want them eligible should probe the wire and tag the rows; inferring the answer from a direct-provider twin is what this tag exists to avoid. -
capability_matrixon GET /v1/models gainsreasoningandstructured_outputs. Both are hard routing filters, so a client could not observe from the public catalogue the two factsautoactually acts on. Additive: the five existing keys are unchanged. -
Media is one shape, with one grammar and one resolver. Every binary a request or response carries — image, audio, video, document — is now a single
MediaRefnaming what the bytes are, proving which bytes they were (sha256), and, when they are recoverable, saying where to find them. It replaces twoBinaryReffamilies, six divergent per-codec behaviours and a parallelimage_refsummary.The locator grammar has five containers, each resolvable from the stored bytes alone:
body,json:<path>,datauri:<path>,sse:<frame>:<path>,multipart:<part>. Grammar, predicates and resolver live in one package (transport/normalize/locator), because a locator is a promise, and a promise made in one place and kept in another drifts — the codecs and the admin artifact endpoint previously disagreed about what counted as decodable. -
GET /api/admin/traffic/{id}/artifactresolves any locator, on either direction, through the same package that built it:?locator=<locator>&direction=request|response. Request-side media — an uploaded image, transcribed audio, a submitted document — is reachable for the first time. Omittinglocatorkeeps the endpoint's original behaviour for image generations and TTS; those derive the locator the caller did not send and take the same path rather than being a second implementation.The served
Content-Typeis sniffed from the bytes, never taken from what the wire declared. Types outside a frozen inline-renderable set still serve; they download instead of rendering. Every response carriesnosniff, adefault-src 'none'; sandboxCSP,Cross-Origin-Resource-Policy: same-origin,Cache-Control: private, no-store, anETagover the bytes (If-None-Matchyields 304), and a filename built from the event id and the sniffed type. New statuses:400(unreadable locator),422(the bytes are there and are not decodable). -
Large binary request inputs are captured. STT audio and video input references were fingerprinted and released; the transcription was auditable and the thing transcribed was not. They are now captured by handing over the buffer the request already holds rather than copying it — measured at the 26 MiB STT ceiling, 3.3 ns and zero allocations against 1.36 ms and 27 MB across four allocations for the copy path. The size lands off-heap on the async audit side through the existing spill. Capture never changes what is forwarded upstream, and both paths pin that with a test that re-emits the forward after capture has taken its reference.
With payload capture off, the fingerprint alone is surfaced as a
fingerprintmedia element, so the file is still identifiable by digest. -
Published multi-architecture container images and a
docker composequickstart.nexus-hub,control-plane,ai-gateway,compliance-proxy,control-plane-ui, anddb-migratorare now built and published toghcr.io/alphabitcoreanddocker.io/alphabitcoreforlinux/amd64andlinux/arm64, plus an amd64-only-avx2tag variant for the four Go services.deploy/docker-compose.ymlbrings up a working instance from those images in two commands (./init-secrets.shthendocker compose up -d); seedocs/developers/architecture/cross-cutting/deployment/container-image-architecture.mdanddocs/operators/ops/container-deployment.md. -
Self-contained Linux tarballs.
scripts/release/build-tarball.shproducesnexus-gateway-<version>-linux-<arch>.tar.gzwith statically linked service binaries, the built UI, and systemd units, for operators who deploy without containers. -
Vendor-spend reconciliation:
traffic_eventnow records the smart-router LLM call's own cost and the provider that served it, closing the gap that under-reported OpenAI spend by 28.2% ($39.46 over eleven days of production traffic) while Anthropic reconciled to -0.07%. Three additivetraffic_eventcolumns —router_cost_usd,router_provider_id,embedding_provider_id— carried over new binwire field ids109-111, plus two new rollup series (vendor_spend_usd,vendor_spend_internal_usd) that attribute each cost component (customer traffic, router call, L2 embedding, AI-Guard classifier) to the provider that was actually charged for it — which is frequently not the provider that served the request. Onetraffic_eventrow can therefore contribute to several providers' vendor-spend totals at once. The vendor-bill reconciliation job's diff basis moves to this series via two additivevendor_bill_reconciliationcolumns,our_vendor_spend_usdandour_internal_ops_usd; the existingour_billed_usdcolumn and customer quota/billing are untouched. The reconciliation report's read and review-ack endpoints gain two additive response fields,ourVendorSpendUsdandourInternalOpsUsd, and ano_basiscoverage value for days the vendor billed but the gateway recorded no comparable vendor spend for. Rows written before this change are not comparable — router cost was never recorded in any form historically, so no backfill can reconstruct it — and the report marks them as such. Seedocs/superpowers/specs/2026-08-04-vendor-spend-reconciliation-design.mdfor the full analysis anddocs/developers/architecture/services/ai-gateway/cost-estimation-architecture.mdfor the cost-attribution detail. One side effect: the AI-Guard classifier's owntraffic_eventrow now carries arouted_providerdimension (it previously carried none), so an operator who enables AI-Guard will see that provider's per-provider request count and token totals step up in Analytics → By Provider with no other config change —billed_cost_usdis unaffected. Dormant today: production has zero ai-guard rows.Deploy order is mandatory: schema → Hub → producers. The new binwire field ids are FORWARD-INCOMPATIBLE — a producer (ai-gateway, compliance-proxy, agent) emitting field id 109, 110, or 111 to a Hub that does not yet know them has that field silently dropped, not queued or errored. Apply the schema migration first, deploy the Hub second, and only then deploy/restart the producers.
Operator impact — expect one
vendor.bill_sync_failedalert per reconciled provider on first deploy, and it is not a regression. The reconcile job's trailing window is[today-5, today-2](UTC); on its first run after this deploy, days in that window predate the vendor-spend series and getcoverage='no_basis'placeholder rows — an honest "we have no comparable basis for this day" signal, not a computed 100% drift. Once ano_basis(orfetch_failed) row stays unhealed past the ~25-hour staleness threshold it raisesvendor.bill_sync_failed, so expect exactly one such alert per covered provider shortly after deploy. It auto-resolves as those pre-cutover days age out of the bounded trailing window — no action needed. Seedocs/operators/ops/runbooks/vendor-bill-reconciliation.md(theno_basisrow in the coverage table) anddocs/operators/ops/runbooks/alerts.md(thevendor.bill_sync_failedsection) for the full alert semantics. -
The
audiomodel type is retired. It was minted by model discovery for any id containing "audio", and the models that received it —gpt-audio-*— are served by the provider on chat completions, so the routing guard rejected every one of their requests withMODEL_MODALITY_MISMATCH.typeanswers which endpoint serves this model; which modalities it handles is whatinputModalities/outputModalitiesare for, and one scalar cannot answer both. Discovery no longer mints it, the two affected rows are typedchat, andEndpointKindAcceptsModelTypeno longer accepts it for tts, stt or realtime.Migration: none for callers. Any catalogue row still carrying
type: "audio"should be retyped to the endpoint that actually serves it; a standing test fails if one appears. -
Catalogue modality data corrected. 94 models advertised
visionwhile declaring text-only input, 33 contradicted their own type (embeddings emitting text, transcribe models taking no audio), and 188 carried no modality arrays at all. Nothing broke only because the routing guard readstypeand ignores those fields — which made the drift latent rather than harmless. A standing check now asserts the three descriptions agree, so the next model sync cannot reintroduce it. -
Media custody has six states, each with a producer.
aged-outgained one: when the Control Plane serves a stored normalized sidecar after establishing that no body is recoverable, captured references in it are rewritten — locator cleared so no control is offered, digest kept so the file is still identifiable. Previously such a row offered a Download that resolved to 404.redactedwas removed; nothing produced it, and a custody value nothing can reach is a distinction the system claims to make and does not. -
Cohere catalogue completed.
command-a-vision-07-2025(verified against the live API) and thererank-v3.5/rerank-english-v3.0/rerank-multilingual-v3.0family are catalogued. Rerank rows record no per-token price — Cohere bills per search unit, and a zero would render as "free" where absent renders as "not priced". -
traffic_event.end_user_idis stamped fromX-Nexus-End-User-Idonly. The tag correlates traffic to the same end user across the Nexus product family, so it is something a caller declares to Nexus. The gateway no longer falls back to a provider's own end-user field — the OpenAI shape's top-leveluser/safety_identifierand the Anthropic shape'smetadata.user_ididentify the caller's end user to that provider and answer a different question, so filing traffic under them attributed rows to an identifier nobody chose for the purpose.Migration: callers relying on the fallback must send the header; otherwise
end_user_idis NULL for their rows from this release on and existing rows are untouched. Attribution now behaves identically on every ingress shape rather than depending on which protocol a caller speaks, and the request path no longer scans request bodies for the field — measured at 27 microseconds for a 64 KiB body and 105 microseconds at 256 KiB, against 32 nanoseconds when the header is present.
-
error.codeis a string on every error the gateway itself produces. A whole class of them sent the numeric HTTP status in that field —"code": 429next to"type": "rate_limit_error"— so a client branching onerror.codesaw a number from one route and an UPPER_SNAKE string from the next. Every gateway-originated error now goes through one builder:codeis an UPPER_SNAKE string or is absent, never the status restated, andtypeis derived from the status rather than fixed atapi_error. Migration: branch onerror.codeas a string, and onresponse.statusfor the numeric status. Provider errors are untouched — they keep the upstream's own words inside your dialect's envelope. -
One error envelope across the gateway's own routes. Five shapes were in service — the proxy's, the estimate endpoint's, the not-supported handler's, and two more — differing in whether
codewas present, whether a hint was inline, and whichtypethey claimed. They are one shape now. The estimate endpoint no longer has an envelope of its own. -
One 429, with the headers to act on it. Rate-limit refusals differed by route family and some carried nothing to back off with. Every 429 now carries
Retry-AfterandX-RateLimit-Limit, the compare endpoint (/v1/estimate) included — it has its own per-key ceiling and used to answer with neither. -
A path this gateway does not serve answers in JSON, and a wrong method answers 405. A miss outside
/v1returned a text/plain 404 from the mux; a wrong method on a served path returned 404 with noAllow. Both now return the dialect's JSON envelope, and a wrong method returns 405 withAllowlisting the methods that path does serve. -
The response carries one
x-request-id, not two. When a caller sent their own, the gateway appended its own value beside it and clients read whichever their HTTP library happened to surface first. The caller's value is echoed back unchanged when they send one, and the gateway's own is used when they do not. -
traffic_event.idis minted by the gateway and is no longer the same value astrace_id. They were equal for every row, so anything that had theX-Nexus-Request-Idresponse header could pass it to a row-keyed endpoint and it worked by coincidence.trace_idis the enclosing unit of work and is non-unique by design — retries, a realtime session's exchanges, an agent flow across services all share one;idis the per-row primary key and the idempotency key on the audit path. The header carries the TRACE id, which is the only handle a caller ever holds. Migration: to reach the rows behind a header value, filter the admin traffic list byrequestId(it matchestrace_id, and is what the drawer's trace pivot uses);GET /api/admin/traffic/{id}continues to take the row's own id. -
A Gemini-dialect wrong-method answers
status: INVALID_ARGUMENT. 405 is absent from Google's documented HTTP-to-gRPC table so it fell through toUNKNOWN, which is the one thing that does not tell a Gemini client the method was wrong. -
An image
nabove the resolved provider's ceiling is refused before dispatch, withSPEND_LIMIT_EXCEEDEDnaming the field, the bound and the provider. Routing can send one request to providers with different ceilings, and the request used to reach the upstream and bill for whatever it produced. -
A quota-exhausted provider is the provider's fault, not the caller's. Nine provider adapters classified an exhausted account budget as an invalid request, so
model: autoreturned the caller a 400 instead of failing over to a provider that could serve them. -
/v1/estimatehonours the parameters it accepts. Three of them were parsed and then ignored. -
The rerank document ceiling applies on the native Cohere target too, where the validation had been skipped, leaving the 1000-document guard inert on the one path that reaches Cohere directly. And the TTS
instructionsfield is scanned by the content policy — it is caller-supplied text and was not. -
external_request_idsurvives the agent's local audit queue, and an id the agent does not have reaches the column as SQL NULL rather than the empty string, matching what gateway-written rows have always stored. -
platform.BuildInfotakes two fields instead of three (Go API, internal).ServiceVersion/BuildSHA/BuildTimeare replaced byServiceandBuildVersion;CaptureStaticInforesolves all three itself. No migration window is offered because the type has no consumer outside this repository — it is not part of any published Go module surface, and every in-repo call site moved in the same commit. Callers passing the old fields will not compile, which is the intent: when callers computed the three fields themselves, three of the five services reported a hardcoded"<service>/0.1.0"and all five reported an emptybuildSha, so no node could be tied to a build at all. -
Model.featuresno longer carries two names for one capability. GET /v1/models publishes this array verbatim, so this is a contract change.thinkingis nowreasoning: the two described the same capability and the vendor sets were disjoint — Anthropic and Gemini rows said the first, thirteen other providers said the second, and no row said both — so a rule, a picker, or a query keyed on either one saw a partial answer with nothing to say so. The canonical layer had already chosenreasoningfor the response content type and the token counter.tool_useis removed: it sat on four rows, every one of which also carriedfunction_calling, and distinguished nothing. Consumers keying onthinkingortool_usemust switch toreasoningandfunction_calling. Stored rows are migrated bydedupe_model_feature_vocabularyintools/db-migrate/manual-scripts/; the admin picker keeps rendering an unmigrated value so such a row stays editable rather than losing it on the next save. -
requestedModelLiteralsmatches as globs, which is what the form has always said it does. The admin form offersgpt-4-*as its example for this field while the comparison was exact equality, so a rule written from that example matched nothing and left nothing to read. The rawmodelstring is where version suffixes live (gpt-4o-2024-11-20), so a pattern is the only way to write a rule that survives the next model release. A value with no*still compares exactly, soauto— which every smart rule is required to pin — is unaffected. A stored value containing a*starts matching after this ships; review any rule that carries one. -
A rule scoped to a provider no longer fires according to catalogue row order.
matchConditions.providerscompared the first candidate the catalogue returned for the named model code, and the query resolving a code states no order — so for a code two providers both serve, the same request could match on one day and not the next. It now compares against every provider serving that code, the same shapematchConditions.modelsalready used. The requested-side provider fields stay empty for an ambiguous code rather than naming one arbitrarily. -
A request a routing rule redirected is refused rather than served by the model the caller named. When every rule that matched resolved no target, the gateway fell through to the requested-model passthrough — so a compliance rule redirecting
gpt-4oelsewhere, whose targets were all unavailable, answered withgpt-4oand a 200. Such a request now returns 503ROUTING_RULES_RESOLVED_NOTHING, and the routing trace records why each rule yielded. A request no rule matched still passes through unchanged. -
Smart routing carries two more of its own candidates.
model: autoreturned a single target and discarded the pool the router had just chosen from, so one transient failure sent the request out of the rule entirely. Two other members of that pool now follow the pick — a provider not already in the plan first, then the cheaper model. This raises the maximum upstream calls one auto-routed request may make, since the call budget is derived from the plan's length. -
stickyOn/stickyTtlMsare removed from the loadbalance strategy. No gateway code ever read them, while the rule form described Redis-backed session affinity across replicas that did not exist. A payload still carrying the keys keeps validating; they are ignored, as they always were. -
The traffic row records the class of each failed attempt. The dispatch walk stamped the
retryOnbucket, which spells an unrecognised provider error and a genuine network fault identically, and the audit projection dropped even that. Attempts now carryerrorClassinrouting_trace, in the same vocabulary the neighbouringcodefield uses — three classes have names of their own (permission_denied,network,unknown_provider_code) because they draw distinctions a canonical code cannot. -
Routing rules now match on the endpoint and on the request, not on whatever model the caller happened to name. Two match conditions read facts that do not exist for a request that names no model, which made the rules carrying them silently inert for exactly the traffic they were written for. Both changes alter which rule serves existing traffic; neither is silent, and neither needs a data migration.
modelTypesis compared against the endpoint the request arrived on. Stored values are unchanged — an existingembeddingkeeps meaning the embeddings endpoint, and the audio sub-types keep their coarse-audiocompatibility, because the comparison goes through the same catalogue-type-to-endpoint translation the rest of the gateway uses. What changes: a rule with amodelTypescondition now matchesmodel: "auto"requests on that endpoint. Previously it could not match them at all, becauseautonames no model and the condition was reading the named model's catalogue row.providersis now INAPPLICABLE when the caller named no model, instead of failing to match. An admin who scopes a rule to one provider means "route within this provider", andautois the case where the gateway does the routing. A request that DID name a model is still compared, so a rule scoped to one provider still leaves another provider's model alone.Review any rule that carries a
modelTypesorproviderscondition and sits above a catch-all. Such a rule may now captureautotraffic that reaches a lower-priority rule today. -
A virtual key is refused a model it names but is not allowed, rather than being served whatever a routing rule would have redirected it to. A key restricted to one model, with a rule redirecting everything to that model, previously answered a client pinned to a different model with a 200 — the client's configuration silently overridden and every response attributed to a model the key cannot use. The refusal is a
403 MODEL_NOT_ALLOWEDnaming the model the caller sent.This applies only when the caller named exactly one catalogue model.
autois not a catalogue model and can never appear on an allow list, and a code that fans out to several providers has no single reference to match; requiring either would refuse every routed request from every restricted key. -
A routing rule that matches but produces nothing now says so on
traffic_event.routing_trace, naming the rule and which of the four reasons applied: its configuration does not parse, its strategy could not be evaluated, it resolved no target, or every target it resolved is outside the calling key's allowed models. Every trace entry also carries the rule that produced it, so a losing rule's reasoning is no longer read as the winner's. -
A routing rule's entries name a provider and a model; a nested strategy is refused. The gateway resolves an entry inside a strategy as a leaf and does not evaluate one strategy inside another. The admin API previously accepted nesting ten levels deep, and such a rule was persisted, broadcast fleet-wide, and then routed nothing while showing as enabled. It is now refused at the write boundary, where the admin can be told what a child must be.
The simulate walker agrees rather than descending. A stored nested entry is reported as unreachable with the reason, instead of contributing a probability to a distribution no live request can produce.
Migration, unconditional:
tools/db-migrate/manual-scripts/disable_nested_routing_configs_2026_08_08.sqldisables such rules and appends the reason to the description. Configurations are preserved, not flattened — which branch an admin meant to keep is not something a migration can know. -
A rule that backs up other rules is an ordinary rule at lower priority.
strategyType: "fallback"no longer marks a separate species of rule that was collected apart from the others and appended to every plan as recovery, whatever it matched. The walk now advances between rules on its own, so a rule an admin ranked below another IS the alternative for when that one cannot serve the request — and afallbackrule no longer backs up rules it has nothing to do with while its own match conditions go half-read.The
fallbackSTRATEGY is unchanged: a chain of provider+model entries inside one rule is a different thing from a rule that backs up other rules, and only the second is gone. A rule's own chain is still tried before any lower rule.Review every rule whose strategy type is
fallback. Such a rule previously could not win the primary slot whatever its priority, and backed up every other rule regardless of its own match conditions. It is now an ordinary rule: a high-priority one with broad match conditions will START serving traffic as the primary, and a narrowly-matching one will STOP backing up the rules it used to cover. Set its priority below the rules it is meant to back up, and check that its match conditions cover the traffic it should catch. -
The structured embeddings 400 is for requests the gateway routed. It lists what each candidate would have accepted — dimensions, batch size, encoding formats — which answers "you asked us to pick and none of ours fit". A caller who NAMED a model asked a different question: that model's limits are its own, and the upstream states them in its own terms. They now receive the provider's refusal instead of a catalogue of models they did not choose, which also stops publishing the shape of a deployment's model list to a caller who wanted one model.
-
Vendor-bill reconciliation's
diff_usd/diff_pctare now computed from the vendor amount and our recorded vendor spend rounded to cents, since the vendor's own cost APIs (e.g. Anthropic'scost_report) are only cent-denominated — comparing them against our unrounded 6-to-10-decimal-place estimate reported the vendor's own rounding as drift. A day where the two figures agree to the cent now reconciles to exactlydiff_usd = 0/diff_pct = 0instead of a spurious nonzero percentage. Stored amounts are unchanged:our_billed_usd,our_vendor_spend_usd,our_internal_ops_usdandvendor_reported_usdkeep their full unrounded precision in their existingDecimal(20,10)columns; only the two diff figures are quantised. Rows written before this change keep their old, unrounded-basis diff until the trailing[today-5, today-2]re-reconcile window recomputes them. Seedocs/operators/ops/runbooks/vendor-bill-reconciliation.md("Diff basis is rounded to the cent").
-
/v1/messagesusage counters now compose the way Anthropic's own do. The two conventions differ: canonical follows OpenAI, whereprompt_tokensis the total andcached_tokensis a subset inside it, while Anthropic's wire is additive —input_tokenscounts only what was neither read from nor written to the cache, and the two cache counters stand beside it. The egress wrote the canonical total intoinput_tokenswhile also emitting both cache counters, so the three overlapped and a client summing them as Anthropic documents over-counted by the whole cached prefix. Observed on a real cached request:input_tokens 12936besidecache_creation_input_tokens 12924, where the real uncached input was 12.This changes numbers an Anthropic-wire client may be reconciling against. A client that summed the three counters was over-counting and will now see the correct total; a client that read
input_tokensalone as "everything" will now see only the uncached part and should add the cache counters, which is what Anthropic's own API requires. Cache-creation tokens bill at a premium and cache-read at a discount, so the correction matters most on requests that used prompt caching. The Gemini egress is unaffected and unchanged — itspromptTokenCountis a total withcachedContentTokenCountas a subset, already the canonical convention. -
A caller's
cache_controlreaches the wire from every ingress, not only from/v1/messages. The gateway already promised to respect a caller-set marker — the marker injector stands down when it finds one — but on any other ingress the canonical→Anthropic projection rebuilt each content part and the marker was gone before the injector could look. Measured: the same marker cached on/v1/messagesand did nothing on/v1/chat/completions. Anthropic prompt caching is roughly a tenth of the input price, so the silent version cost the caller money on every repeated prefix. -
The gateway's internal namespace no longer reaches the caller. A response from an Anthropic-backed model arrived at an OpenAI-wire client carrying
"nexus":{"ext":{"anthropic":{"cache_creation_input_tokens":N}}}. The Anthropic, Gemini and Responses egress converters are projections and drop unknown keys as a side effect of rebuilding the body from named fields; the OpenAI-family egress is the identity, so whatever a codec left in the namespace was delivered verbatim.Four of the five response-side writers are deleted rather than filtered. Three had no reader anywhere (
nexus.ext.bedrock.model,nexus.ext.voyage.model,nexus.ext.cohere.returned_embedding_type), and the fourth wrote a number the canonical body already carried two lines above it — Anthropic's cache-creation count now has one home,usage.prompt_tokens_details.cache_creation_tokens, beside thecached_tokensOpenAI defines. An Anthropic-wire caller still receivescache_creation_input_tokensunchanged. Onlynexus.ext.openai.responses.*remains as a genuine carrier between a decode and the Responses egress encoder, so only that one is removed at egress. -
Audio sent to
/v1/responsesis served instead of refused. The Responses wire has no audio content part — its own rejection enumerates what it takes: text, image and file — while the same model accepts audio on/v1/chat/completions. A request carrying content that wire cannot express now takes the wire that can and comes back in the Responses shape the caller asked for. A request that ALSO uses a Responses-only feature (a built-in tool,previous_response_id,store, a non-disabledtruncation) still gets the existing refusal naming that feature, because it cannot be served either way. -
Smart routing no longer picks a model that cannot take the request's content. Its capability filter asked about images only, so a request carrying audio, video or a document was routed without anyone checking whether the selected model accepts it — and the router produced the upstream 400 itself. It now asks once per modality the request actually carries. A refusal the caller's own model choice caused is the model's; a refusal our pick caused is ours.
-
Every sampling rule now fails safe inside its vendor namespace. Each vendor's rule is an ACCEPTS list — a family the list does not name has its
temperature/top_pstripped rather than forwarded. Anthropic already read this way; OpenAI and Moonshot were denylists, and a denylist over a catalog that gains models without a code change goes stale in the direction that hurts.The reason it must be strip is routing. A rule or the smart router sends the request to a model the caller never named, so the parameters that arrive were chosen for a different one. If the unknown direction forwards, routing itself becomes the cause of a 400 — the caller asked for something that worked and got a failure the redirect created. Stripping costs sampling control on that request, reported on
X-Nexus-Coerced; forwarding costs the request.The namespaces are version-aware rather than string prefixes, so a generation nobody has probed yet lands inside the rule instead of outside it. Models below the rejecting generation —
gpt-4oand earlier,moonshot-v1-*— stay outside deliberately: they accept sampling, and stripping there would forfeit caller intent without averting anything. The DeepSeek thinking-mode structural fixes carry forward the same way. -
The catalog sync compared nine of the sixteen fields the template carries. The three modality lists and the three audio prices were never offered as corrections, so a provider row could drift on exactly the fields that decide whether a request is routable and what it costs. Fields are now classified at compile time, so one added to the template cannot reach the diff without a decision about it.
-
Adding a model with no required modality answered 500. The create path left
requiredModalitiesnil, which reaches Postgres as SQL NULL; an explicit NULL beats the column DEFAULT and the column is NOT NULL. Almost no catalog entry declares a modality floor, so this was nearly every manual model addition. -
Output ceilings take the minimum of a live probe and the vendor's documentation. A ceiling published on
/v1/modelsis one callers echo back, so a number the wire rejects turns the gateway's own advertisement into a 400. The o-series carried the probe's 100003 against a documented 100,000. -
A media card could offer a control that failed on click. Three separate causes, each closed: the frame walker behind
sse:locators split on a literal"\n\n"while the codec's own fold and the browser resolver both handled CRLF, so a captured reference pointed at a frame the resolver could not see; the aged-out sidecar case above; and adatauri:payload with leading whitespace or a format character that one side trimmed and the other did not. -
HEIC, HEIF and AVIF were served as
video/mp4. The admin endpoint and the browser held two sniffers that disagreed about the same bytes — an iPhone photo submitted as a video input reference downloaded as.mp4from one and read asimage/heicin the other. They now share a vector table that both assert against. -
A whole
data:URI could be stored inMediaRef.URL. A prefix test against a client-controlled string is not a guard: a leading space, a zero-width character, or an uppercaseDATA:scheme walked a 20 KB payload into a reference field. Normalisation now covers whitespace and the Unicode format category, the scheme test folds case per RFC 3986 §3.1, and a length bound catches whatever a future classifier misses. -
A base64 payload could reach the compliance scanner as prose. Media elements contribute nothing to the text projection hooks scan, and the text beside them still does; both halves are pinned.
-
Replicate array outputs. Captions kept their position relative to artifacts, each artifact addresses its own index, and a non-string element is named rather than silently dropped — the drop fused the text either side into one utterance that was never sent.
||||||| 8d850e575
-
Vendor-bill reconciliation no longer reports a day the rollup correction pass has not rebuilt yet. The reconciliation basis (
metric_rollup_1dvendor_spend_usd) is produced for historical days byrollup-correction, and both jobs run on the same 24-hour tick: the reconcile pass finished in ~3 seconds while the correction pass took ~100 seconds to write the very rows it reads. Every run therefore read the tier before it was rebuilt and stampedcoverage = 'no_basis'on days whose figures landed a minute later, whichvendor.bill_sync_failedthen escalated 25 hours after the fact. The correction job now publishes arollup-correctionwatermark naming the newest UTC day it rebuilt, and the reconcile job defers any day past it — writing nothing rather than a placeholder, and reconciling the day on a later run. The same gate stops a partially aggregated day (the live rollup covered only the hours after a deploy) from being compared at all, which would have firedvendor.bill_driftwith a fabricated under-record. Days already reconciled are untouched. -
A single rate-limited vendor cost-API response no longer costs a provider its whole reconcile window. OpenAI meters its admin API at 30 requests per minute across the entire organization, so the costs endpoint answers 429 for traffic the reconcile job never issued; any non-200 was terminal, so all four days of the window became
fetch_failedplaceholders. Rate-limited and 5xx responses (and transport failures) are now retried up to four attempts with exponential backoff — 0.5s, 1s, 2s — honouringRetry-Afterwhen the vendor sends it. Standing failures (400/401/403/404) are still reported on the first response, so a revoked or unscoped admin key surfaces as promptly as before. -
An expired virtual key can be brought back. Extending a key's
expiresAtleftvkStatusreadingexpired, and the renew endpoint matched onlyactivekeys, so it returned 404 on exactly the keys it exists to rescue. Because the gateway refuses admission on any non-activestatus as well as on a past date, the stale status alone kept a renewed key rejected.expiresAtis now the source of truth andvkStatus = 'expired'is derived from it: writing the column re-derives the status (a future or cleared date returns the key toactive, a past date marks itexpiredimmediately rather than at the next hourly job run), and renew accepts an expired key.revoked,rejectedandpendingkeys are unaffected by an expiry edit — those are administrative decisions, not clock positions.
-
POST /v1/audio/translationsis withdrawn. No catalogue model served it, so every call reached a routing failure; the route now answers as an endpoint this gateway does not serve. Migration: transcribe withPOST /v1/audio/transcriptionsand translate the text with a chat model. -
The
policystrategy type is gone from the admin API and the published schema. It never had an implementation. A rule carrying it was accepted, broadcast fleet-wide, and then held the primary slot at stage 1 while returning no targets and no error — locking out every lower-priority rule with nothing surfaced.POST/PATCHnow reject the value, at the top level and as a nested node.Migration, unconditional:
tools/db-migrate/manual-scripts/disable_policy_routing_rules_2026_08_08.sqldisables storedpolicyrules and appends the reason. The row and its config are kept and NOT rewritten to another strategy — apolicynode names no provider or model to convert, and silently reinterpreting an admin's configuration is what this release stops doing.The admin UI previously mapped an unrecognised stored strategy onto
singlewhen opening the edit form, so saving any field — even the name — persisted a single-shaped rule over the original configuration, while the detail page still displayedpolicy. The form now refuses to load such a rule and says why. -
traffic_event_normalizedis dropped. It held a second stored copy of a request's captured text; the normalized projection is recomputed at view time from the captured body instead. The erasure path is the reason — a second copy is a second thing a subject-erasure request can miss, and one surface is the stronger position.normalizedScrubbedstays in the DSAR response pinned at0, deprecated in the OpenAPI spec with a removal window rather than vanishing from a shipped contract. The deploy destroys the table and its rows;docs/operators/ops/runbooks/prod-deploy-data-changes.mdcarries the pre-drop dump, the ordering (binaries first, schema second) and what rollback can and cannot restore. -
The four per-package Dockerfiles (
packages/{nexus-hub,control-plane,ai-gateway,compliance-proxy}/Dockerfile) are deleted. They were dev-grade (no Vectorscan build tag, no version stamp) and unreferenced;docker/services/Dockerfilesupersedes them.
- A
storage.spillruntime-introspection source on the compliance proxy and the AI Gateway, reporting whether a spill backend exists, which one, where it stores, and whether that location is readable only from the one host — plusresidency: object count, total bytes and the oldest/newest object timestamps of the spill backend, measured when the introspection source is read rather than at boot. Bounded by the backend's own scan limit (localfs50 000 objects,s310 list pages) and by a 2 s deadline;truncated+scanLimitsay so when a bound bites, and a failed measurement omitsresidencyentirely rather than reporting zeros — "we could not look" and "the store is empty" must not render identically.
-
The audit trail no longer reports a compliance verdict for responses no hook examined. Six bumped-flow relay paths handed the audit emitter a fabricated
Approvewhen there was nothing to report — no response pipeline bound, an unreadable body, the non-AI fast path, a pipeline that failed to build and was fail-open relayed, an SSE stream with no response stage, and an upstream that failed before a response existed.response_hook_decisiontherefore readAPPROVEfor traffic nothing had looked at, indistinguishable from traffic a hook really approved. Confirmed live: with every hook disabled the column readAPPROVEwithresponse_hooks_pipelineNULL; with the same build and hooks enabled it readAPPROVEwith a populated pipeline. The column is now NULL when no response hook ran — matching what the request stage already did, and whatsse_audit.godocumented in a comment directly above the line that fabricated the value. The storage gate still sees an explicit approve action, so body capture is unchanged. Consumers must not read a null decision as approval; the publishedresponseHookDecisionschema already allowed null and now says what null means. -
The smoke's tool-coercion probes stopped false-reding on their own cache entry. Two arms of the same model differ only in a request parameter while sending identical message content, and the L1 cache-bypass nonce was scoped to the run — so the second arm was served the first arm's cached 200, which carries no
x-nexus-coercedheader because a cache hit never re-runs the coercion. The probe then reported "rule stale or report lost" against a gateway that had coerced correctly. Verified directly against the vendor: function tools with an absentreasoning_effortstill 400 ongpt-5.6-luna, so the rule is live, and a uniquely-nonced request returns 200 carrying the label. The nonce is now per-arm. -
An aborted ai-gateway smoke reports FAIL instead of PASS. The run's verdict came only from recorded results, and a fatal abort merely printed its failure — so a prod run that died in P0 preflight, having exercised no model at all, reported
Result: PASS — 0 failedand exited 0. The smoke is a mandatory pre-"done" gate; one that cannot fail certifies rather than checks. Guarded bycheck:smoke-harnessin CI. -
Access-token revocation actually persists now.
RevokedToken.targetJtiwas typed@db.Uuid, but an access token'sjtiis 16 random bytes in base64url — never a UUID — so every insert was rejected by Postgres with SQLSTATE 22P02. The revoke endpoint follows RFC 7009 and returns 200 regardless, logging the error, so a revoked access token stayed valid until it expired on every released version that had this column. The column is nowString?(TEXT) with an index on it. Migration:prisma db pushperformsALTER COLUMN "targetJti" TYPE text, which Postgres accepts without aUSINGclause and which cannot fail on existing rows (the column is empty wherever the bug applied). Operators should assume any access token they revoked before this release was NOT revoked and, if that matters, rotate the affected credentials or wait out the access-token TTL. -
SSE traffic was being relayed uninspected, and now is not. The compliance proxy's Type-B receiver for
streaming_complianceapplied the pushed (empty) payload instead of re-readingsystem_metadata, so boot installed the admin's configured mode and the first invalidation replaced it with the built-inpassthroughabout 70 ms later — after which no streamed response could be accumulated, redacted or blocked. The receiver now re-reads on an empty payload, and a DB error keeps the current policy rather than degrading it. Operators should treat streamed-response compliance evidence from before this release as unreliable for any node that received astreaming_complianceinvalidation. -
A
traffic_eventrow is now written for a body-carrying request that PASSES THROUGH uninspected (request_hook_decision = "PATH_PASSTHROUGH"). Previously such a request left no trace at all, which is the gap: an auditor could not distinguish "nothing was sent" from "something was sent and we chose not to look". Expect a step-change in row volume on hosts where passthrough rules cover chatty endpoints — the row is emitted only when the request could have carried content (a body-bearing method with a non-zero or chunked length), not for every GET. -
The compliance proxy's audit-overflow logs are throttled. Both the drop ERROR and the spool WARN fired once per event; with NATS unreachable at 1 000 rps that put ~1 000 ERROR lines per second onto the same disk the NDJSON spool needs, and the WARN flooded to report the healthy case where nothing is lost. Both now sample 1-in-2000 — matching the AI Gateway, so two services' logs sample alike — and each carries its running total; the Prometheus counters are unchanged and remain the authority on the rate.
-
The exemption store's shadow rebuild now takes the writer lock.
Rebuildpublished its snapshot without it while every other writer held it, so a revocation push landing insidepurgeExpired's read-modify-write window could be overwritten — resurrecting a revoked exemption and leaving the compliance pipeline bypassed for that source/host pair.-racecannot detect this class: the pointer swap is atomic, so it is a lost update rather than a data race. -
The
policy.matcherintrospection source no longer reports ascanBoundedfield. It claimed Vectorscan caps how much text a scan examines; it does not —hs_scanreads the whole segment, and the detection cap bounds a pattern's repeat. Since production runs the Vectorscan build, the field was a false claim exactly where it mattered, and it was false for the RE2 build too. What differs between the engines is passes, not coverage, whichsinglePassalready reports. -
The pprof boot line reports the endpoint's actual exposure. It said "(loopback profiling)" for whatever address was configured, while
.env.examplerecommends the wildcard:6060— an affirmative assurance that/debug/pprofwas unreachable off-box while advertising the opposite. It now printsexposure="loopback only"orexposure="REACHABLE OFF-HOST — bind 127.0.0.1 to restrict". -
The compliance proxy's undecryptable-cached-cert warning names the right key. Its remedy pointed at
CREDENTIAL_ENCRYPTION_KEY, a different subsystem; that path's key is derived from the CA private key or from the cert-cache DEK in Redis. It now names both, and the case where two proxies sharing a Redis hold different DEKs under the same CA. -
An agent upgraded in place no longer switches to a blocking audit-overflow mode.
auditLossModehad no entry in the agent'sapplyDefaults, so a config file that predates the key resolved through the sharedlossmode.Resolve("")to the no-loss default (spillblock) and overrode the queue writer's deliberatespill. A no-loss mode blocks the emitting goroutine until the record is durable, and on the agent that goroutine is on the host's own outbound packet path — the one thing the macOS network-extension rule forbids. All three shipped templates already saidspill, so only in-place upgrades were affected. No operator action required; an explicitly configured mode is still honoured. -
A request refused because its compliance pipeline could not be BUILT no longer persists its raw body. The empty-action rule (see the redact-gate change below) read
{Decision: RejectHard}with no action as "no redaction demand", so the one request class the product knows it could not scan was the one whose unredacted body reachedtraffic_event— while an ordinary scanned block persisted nothing.stageActionnow derives the action from the decision, which fixes every hand-built result literal at once rather than stamping each producer. -
The agent now cross-compiles for Windows, and the gate enforces it.
profiling's on-demand capture signal referencedsyscall.SIGUSR1unconditionally, which does not exist on Windows, soGOOS=windowswas warn-only incheck:agent-cross-build. The signal moved behind a build tag: Unix keeps SIGUSR1, Windows reports no capture signal and says so once at startup rather than pretending dumps are armed. Live profiling on Windows goes throughNEXUS_PPROF_ADDR, which is platform-neutral. The gate now fails on a broken Windows build instead of warning — a warn-only platform is one nobody notices breaking twice. -
The compliance proxy's Redis certificate cache is now scoped to the CA that minted each entry. The key was
nexus:proxy:cert:<hostname>, so a CA rotation left every hostname's entry in place holding a leaf signed by the previous CA and a key encrypted under the previous DEK: one wasted round-trip, one decrypt failure and one alarming WARN per hostname before the miss path re-minted. The key is nownexus:proxy:cert:<ca-fingerprint>:<hostname>, which makes a rotation an ordinary cache miss; orphaned entries expire on their own TTL. No migration is required — certificates are re-mintable, so the worst case is one extra mint per hostname on first use after upgrade. -
A cached certificate that cannot be decrypted no longer reports Redis as unavailable. Redis had answered correctly; the entry simply was not this process's. The handler logged "redis get failed" and set
redis_available = 0, so a routine CA rotation raised a false availability alarm pointing at the wrong system. It is now logged as a key-material condition with a remedy, and the gauge stays at 1. -
localfs.Statnow honours its context and bounds its walk. It previously ignoredctxand walked the whole spill root, so it could not safely be called from an operator-facing surface — which is whySpillStore.Stat()had no production callers at all. The context is checked per ENTRY rather than per directory, so a single flat day-directory cannot outrun cancellation, and a cancelled scan returns both its partial numbers andctx.Err(). -
s3.Statno longer reports a silent lower bound. It already stopped after 10 list pages and returned the partial counts unlabelled, so a bucket with more objects reported a total that was not one. It now setsTruncated.
-
PATCH /api/admin/rule-pack-installs/{installId}/overridesnow rejects an out-of-enumseverityOverridewith 400 instead of accepting it, and rule-packImportreturns 400 rather than 500 for a malformed pack. The previously shipped OpenAPI example usedseverityOverride: high, which is not one ofhard|soft|warn— a client that copied it, or automation that has been sending an out-of-enum value, will now getvalidation_failedwhere it got 200. Existingrule_overriderows holding out-of-enum values are left in place and remain non-enforcing; they are not migrated. -
shared/transport/streaming/policytakes an exported-signature break:OverrideFromColumnsloses its eighth parameter andPolicy/OverrideloseRawSpillEnabled. The package is compiled into the released agent binary, so an out-of-tree importer must drop the argument and the field — the same shape of change as theWithPreSpillNormalizeremoval noted below. -
The
raw_body_spill_enabledadmin switch is gone — from the streaming-compliance settings API (request and response), the admin UI, the per-host / per-provider override plumbing inshared/transport/streaming/policy, the Hub shadow projection, the agent shadow DTO and both services' SQL reads. It never did anything: no production code read the resolved field, and whether a body spills is decided solely by whether the node has a spill backend configured and by the inline-vs-spill threshold (spillstore/emit.go). Every value of the two per-row columns — in seed and in the live database — was NULL. (An earlier draft of this note said "every value in seed", which was wrong: the seeded globalstreaming_compliance.configblob shippedraw_body_spill_enabled: true, i.e. it explicitly enabled a switch nothing read. That key is removed from the fixture in this release.) Migration — this is a BREAKING change to the admin API and to the database schema. An old client that still sendsraw_body_spill_enabledonPUT /api/admin/settings/streaming-complianceis unaffected: unknown fields are ignored, as the field's value already was. But theGETresponse no longer returns it, and it was declaredrequiredon that response in 1.4 — a client generated from the 1.4 spec that validates required fields will fail to deserialize the response and must be regenerated. A node's spill posture is reported by thestorage.spillruntime-introspection source instead. The two database columns (interception_domain.raw_body_spill_enabled,Provider.raw_body_spill_enabled) are dropped;prisma db pushissues theALTER TABLE … DROP COLUMN. The drop is verified lossless — every row in seed and in the live database held NULL — but it is not reversible, so take a backup before applying and roll the schema change and the binaries together: a previous-release binary still running against the migrated database would fail itsSELECT, which lists the dropped column. A stored streaming-policy blob that still carries the key decodes normally (the key is ignored, not rejected; a test pins this), so no config rewrite is required.
- Rule-pack content scanning on the pure-Go (RE2) build now fans its per-pattern scans out across
cores. With this deployment's 423 seeded rules, a 400 KB request body cost 4.31 s in the matcher
and added ~3 s of latency to a live request through the compliance proxy; the scans are
independent, so parallelising them takes that to 0.73 s and ~0.4 s respectively with identical
results. Gated on measured thresholds (≥4 patterns and ≥2048 byte×pattern units) so small inputs
keep the sequential path, and neutral at CPU saturation by construction. A union-alternation
prefilter was benchmarked first and rejected — on Go's
regexpit is 13–44% slower than the per-pattern loop and 2.4× slower once anything matches. No configuration, no behaviour change: sequential and parallel scans produce the same hits in the same order, pinned by a differential test and by ascan-scaleregression arm that sends the same sensitive value in a tiny body and in a 200 KB one.
policy.matcherruntime-introspection source on the compliance proxy and the AI Gateway, plus a boot log line, reporting which content-scanning engine the binary compiled in (vectorscanvia build tag, or the pure-Go RE2 fallback) with itssinglePass/scanBoundedproperties and the operational consequence. The engine is a build-tag choice with an order-of-magnitude cost difference on large bodies and was previously answerable only by inspecting the build — a cross-compiled binary that loses its cgo engine keeps producing correct verdicts, slowly, with no runtime signal.
-
Prometheus counter
nexus_ai_gateway_generative_cap_shed_total{kind}renamed tonexus_admission_generative_cap_shed_total{kind}. The original carried the service in the metric name, whichprometheus-naming-architecture.md§1 forbids: the service belongs in the scrape config'sjoblabel, and one subsystem metric emitted by two services must be a single series name. The counter now builds its name fromNamespace: nexus+Subsystem: admission, the same subsystem asnexus_admission_shed_total(the pre-auth shed it mirrors). Semantics, labels and increment sites are unchanged. Migration: dashboards, alert rules or recording rules referencing the old series must be updated to the new name; there is no dual-emission window. Nothing in this repository referenced it outside documentation. To keep continuity across the rename in a Prometheus query, usenexus_admission_generative_cap_shed_total or nexus_ai_gateway_generative_cap_shed_totalfor one retention period. -
A cache lookup skipped because routing produced no target now reports
no_targets, notdisabled(traffic_event.gateway_cache_skip_reason, and theresultlabel onnexus_cache_lookups_total). Both conditions previously stampeddisabled, so a config posture ("no cache tier is on") and a routing outcome ("the tiers are on, but there was nothing to key an entry against") were indistinguishable, and pointed an operator at the wrong remedy. When both hold,disabledstill wins. Migration: additive — a new value in an existing text column and an existing metric label. Queries or dashboards that treatedgateway_cache_skip_reason = 'disabled'as "caching is off" become more accurate; any that counted it as "cache not consulted for any reason" should now match('disabled','no_targets'). -
An oversize audit body is now bounded when no spill backend is configured (
packages/shared/storage/spillstore, all three data-plane services). Previously, withspill.enabled: false— which is what every shipped*.config.yamlsets — a body at or abovepayloadCapture.maxInlineBodyByteswas stored whole inline ontraffic_event_payloadand published whole on the MQ message. A 10 MiB body was kept intact under a setting namedMaxInlineBodyBytes: 262144. It is now truncated to that threshold withtruncated = true, whilesizeBytescontinues to report the real pre-truncation size and the node logs a WARN naming the absent backend and the remedy. Migration: deployments that rely on whole oversize bodies must configure a spill backend (spill.enabled: true) — the setting whose absence this path is. On a multi-node deployment that meanss3, notlocalfs: a per-node localfs root is readable only by the process that wrote it, so following this advice withlocalfson each node leaves every spilled body permanently unreadable from the Control Plane (the read path reportsnot_found_host_local) — worse than the truncation it was meant to fix. Uselocalfsonly on a single node, or where every node mounts the same root. Rows written before this change are unaffected; only newly captured oversize bodies are truncated, and they say so viatruncated. No schema, column or wire-format change.
AuditEmitter.WithPreSpillNormalize(Go API,packages/shared/policy/pipeline). Removed with maintainer approval. The method opted an emitter into re-attaching up to 2 MiB of a spilled body in memory so a writer's flush-time normalize pass could read the content without a spill-store fetch. Nothing in the repository ever called it, and theapplyNormalizeits own doc named as the sole consumer does not exist — so the retention was memory cost with no reader. Recorded here because it removes an exported symbol frompackages/shared, which ships inside the released Agent binary: an out-of-tree importer that called it must simply drop the call. No behaviour, wire format, database column or persisted shape changes.InlineByteswas already excluded from a spill container's wire form (Body.MarshalJSONswitches onKind), so audit rows are byte-identical before and after. A spilled body is now unconditionally ref-only, which is what every caller already got. Reinstating the optimization requires the consuming normalizer to exist first.
Hardening release on top of the 1.3.0 multimodal launch: the jsonb shape-contract bug class is closed at every admin write boundary (a wrong shape now 400s instead of persisting and failing cryptically downstream), captured-traffic audit records survive mistyped scalars, the STT prompt field joins the request-stage compliance pipeline with redact-re-emit, and the Traffic UI gains the modality column/filter plus an inline artifact viewer.
- STT prompt field is now compliance-scanned at request time. The
promptform field of/v1/audio/transcriptionsand/v1/audio/translations— the one request-side text leaf of the multipart STT request — now runs the same request-stage hook pipeline as chat: a hard-blocking match returns 403, a redacting match rewrites the prompt in place so the sanitized value is what reaches the provider, and a clean scan stampscompliance_coverage = prompt-only(previously alwaysnone). Requests without a prompt are unchanged.
- Captured-traffic audit records survive one mistyped scalar. The
view-time normalize codecs used to discard the ENTIRE request/response on
any whole-struct decode failure — captured third-party traffic carrying a
single mistyped optional scalar (e.g.
"temperature":"0.7"as a string) produced a "partial" normalized record with zero message content, silently erasing the prompt text from the audit record. All codecs now decode leniently: a mistyped field is dropped, everything decodable (especially the messages) is preserved. - Routing rule
configvalidated against the gateway's full strategy shape. The write-time check previously validated only the top level of the strategy tree, so a NESTED element with a wrong-typed field (e.g. a weighted target with"weight":"5") passed the admin API, was broadcast fleet-wide, and then failed the resolver's parse on every request routed by that rule. The validator now mirrors the resolver's recursive node shape (including weighted/conditional/ab/latency sub-structures), rejects unknown nested node types, and bounds the tree to the depth the gateway actually evaluates. - Hook config blob shape validated at write time. A hook whose
configwas not a JSON object froze hook-config propagation fleet-wide (every reload kept the last-good snapshot) and, on the next AI Gateway / compliance-proxy restart, silently started the compliance pipeline with an EMPTY hook config — a fail-open bypass visible only as one warn log. The admin API now rejects a non-objectconfigwith400 validation_erroron create and update. - Routing rule
fallbackChainshape validated at write time. A chain written as bare model strings decoded to zero recovery targets at the gateway (best-effort decode), silently losing all failover coverage for that rule. The admin API now requires an array of{providerId, modelId}objects and rejects other shapes with400 fallback_chain_invalid. - Interception domain
adapterConfigshape validated at write time. A non-object value made the traffic snapshot skip the whole domain with only a warn log — traffic for its host pattern was silently no longer intercepted. The admin API now rejects a non-objectadapterConfigwith400 validation_erroron create and update. - Malformed IAM policy documents are no longer dropped silently. A policy row whose document fails to parse is still skipped from the effective set (keeping authz alive for the principal's other policies), but the drop is now logged at ERROR with the policy id/name/source so operators see the distortion instead of debugging phantom authz decisions.
- Virtual key
allowedModelsshape validated at write time. A virtual key whoseallowedModelswas set to anything other than an array of{providerId, modelId}objects (for example an array of bare model-code strings) was accepted by the admin/user API and then rejected by the AI Gateway on every request with an opaque decoder error — a 401 on a key that looked valid. The Control Plane now validates the shape on create and update and returns a clear400 validation_error, so a malformed allowlist can no longer be persisted. The OpenAPI spec and examples now document the{providerId, modelId}object shape (previously they showed bare strings).
- Modality-scoped routing hardening. Speech models are now typed precisely
in the model catalog (
tts/stt/realtimeinstead of the coarseaudio; Sora →video), closing a routing footgun:model: autoon a TTS endpoint could previously pick a non-TTS audio model. The modality guard already dual-accepts the coarse and precise types, so this is a smooth migration. The provider create/edit UI and model-discovery heuristic gain the precise sub-types; theModel.typeAPI enum widens (additive). - Multimodal normalized text in the Traffic drawer. New view-time codecs
render the image prompt + revised prompt, the TTS input, and the STT
transcript as messages the same way chat is shown. Image responses now
summarize the artifact by size/mime instead of inlining multi-MB base64, and
a TTS binary audio response is no longer misdetected as an OpenAI-chat
partialparse error. Behavior change for interception deployments: the same codecs feed compliance-proxy / agent hook scanning, so intercepted provider-direct image/TTS prompts that were previously unscanned can now match admin-configured content hooks — an intercepted image/TTS prompt matching a redact rule on a wire with no in-place span mapping hard-blocks (fail-closed), consistent with the existing multimodal redaction posture. STT and video submit response bodies are captured under the existing payload-capture toggle (the multipart audio/video request bytes remain fingerprint-only).
Multimodal release: the gateway extends beyond chat and embeddings to image generation, text-to-speech, speech-to-text, video (async), a standalone compliance-guardrail verdict endpoint, and its first WebSocket surface — realtime voice relay. Provider adapters move to the request-contract v3 model (the codec is always in the request path, absorbing per-model wire quirks on every ingress).
The gateway now relays the OpenAI Realtime API — its first WebSocket
surface. A server-side client opens a WebSocket to
GET /v1/realtime?model=<model> with a virtual-key bearer token; the gateway
runs its admission chain on the plain-HTTP upgrade, dials the resolved
provider (wss://…/v1/realtime, provider key injected, client credentials
never forwarded upstream), and relays both directions verbatim.
Dark launch. The realtime model is reachable only by a virtual key whose
allowedModels explicitly names it — an empty (unrestricted) list is NOT
entitled, because an unbounded voice session is the most expensive billable
surface. Entitle a dedicated realtime virtual key. Built-in bounds (not
admin knobs): a per-VK concurrent-session cap (default 2, env-overridable), a
per-WS-frame ceiling, a 65-minute session guard, and a 60-second by-hash VK
recheck that severs a revoked key mid-session. Metering emits one
traffic_event row per in-band response.done (priced across the six
text/audio/cached components) plus a $0 session row; per response the gateway
reconciles quota and severs on a crossed reject/downgrade cost cap. P1 does
no content scanning (compliance_coverage = none); transcript-level
compliance is a later phase. Per-minute-billed models, browser/ephemeral-token
clients, and Azure/Gemini realtime are out of P1 scope. See
docs/users/api/openapi/e88-s7-realtime.yaml.
The Model catalog gains three additive nullable pricing columns —
audioInputPricePerMillion, audioOutputPricePerMillion, and
cachedAudioInputReadPricePerMillion — so realtime models can be priced
per component: one realtime response bills text and audio tokens
simultaneously at different rates, and the existing single input/output pair
cannot express that. The base columns carry the text rates; the cached-audio
column follows the shipped cached-read contract (NULL = no discount, falls
back to audioInputPricePerMillion). The admin API (model create/update,
provider create with inline models), the Control Plane pricing drawer (a
six-field per-component layout for type=realtime), and the
sync-provider-pricing skill all carry the new rates end-to-end.
The model type vocabulary widens to
{chat, embedding, image, audio, rerank, video, realtime} across the admin
validation, OpenAPI specs, and CP-UI type options — this also fixes a live
drift where the UI offered rerank but the admin API rejected it with a 400.
The model-type validation, previously enforced only on model UPDATE, is now
also enforced on the CREATE paths (POST /api/admin/providers inline models,
POST /api/admin/providers/{id}/models): an out-of-vocabulary type is
rejected 400 at create instead of persisting silently. The retired
completion option is removed from the provider-creation wizard (it is not a
catalog model type; no seed model used it). Operators whose automation created
models with a non-standard type string must use one of the seven valid
values.
Additive contract: new nullable columns and new enum values only — no
migration needed beyond prisma db push; existing rows and API clients are
unaffected.
The gateway now serves async video generation — its first async endpoint
kind. POST /v1/videos submits a multipart/form-data job and returns a video
job object (not a completion); the client polls GET /v1/videos/{id},
downloads with GET /v1/videos/{id}/content, and cancels with
DELETE /v1/videos/{id}. These are parallel handlers (ServeVideo*), not
the small-JSON ServeProxy pipeline: the submit is a large multipart upload
and the follow-ups are governed passthroughs keyed by a new gateway-owned
correlation store (gateway_async_job — the gateway's first runtime-writable
table). The row binds the provider job id → virtual key → submit-time
credential, so every follow-up is authz'd on the row (unknown / foreign id →
404 non-disclosure, never forwarded upstream) and reaches the same provider
account that owns the job.
The submit is governed like image generation: VK auth, per-VK rate limit, a
per-VK non-terminal-jobs render cap (bounds concurrent paid renders, not
just in-flight HTTP requests), the request-side compliance pipeline over the
prompt (a content match hard-blocks 403 GENERATIVE_PROMPT_BLOCKED even
observe-only — the video output is uninspectable, so the prompt is the only
control point), and an advisory cost check. Cost is one row per job: the
submit row stamps the requested-seconds × per-second-price estimate
(estimate-as-floor); the poll that first observes completion reconciles live
quota with the same seconds × price value (never a provider-reported figure);
poll / content / delete rows stamp $0. Under an enforced cost quota an unpriced
routed model fails closed (503 QUOTA_MODEL_UNPRICED).
The artifact download streams through a sha256/size fingerprint tee with a
1 GiB ceiling (declared-oversize → 502; mid-stream overflow → connection abort,
never a silent short file), a Content-Type allowlist
(video/mp4/image/jpeg/image/png/image/webp), and nosniff +
attachment. No artifact bytes are stored (provider custody). The generated
video is not content-scanned (compliance_coverage = none) — the tee is the
named remediation mount point.
Cross-shape (Veo): when routing resolves a Google Gemini provider the
codec translates OpenAI /v1/videos ↔ Veo :predictLongRunning +
long-running operations — allow-list-only, lossy size → aspectRatio +
resolution (X-Nexus-Coerced), provider errors normalized to the OpenAI
envelope, the canonical job id veo_+base64url(operation name), and the
download dereferences the provider artifact URI under an SSRF + host-allow-list
guard (the one provider-URL-deref in the product). Per-leg differences (Veo:
video-variant only, best-effort local delete that does not stop the
still-billed render) are documented. A retention sweep (gateway-side,
hourly) marks stale rows expired (terminal > 30 d, non-terminal > 7 d), served
as 410 Gone. GET /v1/videos (list) and remix / edits / extensions /
characters are deliberately unserved with an explicit OpenAI-shaped 404
envelope.
Additive: new routes, new gateway_async_job table (db push),
BillableUnits.VideoSeconds + videoCostFormula, CostEstimate.EstimatedUsd
and ResolveHints.CredentialID additive fields, the video generative-caps row
raised to 16 MiB. No shipped contract changes. Veo catalog price rows are a
deploy dependency (an unpriced Veo model fail-closes under a cost quota).
The gateway now exposes its compliance pipeline as a standalone verdict API:
a caller submits text and receives an allow / block / redact verdict from
the SAME hook pipeline the inline path runs (rule-pack + PII redaction + the
AI-Guard judge) — WITHOUT relaying an LLM completion. This is the ApplyGuardrail
/ Content-Safety category, but backed by the deployment's already-configured
policy (same policy, two entry points, one audit trail), reached with a virtual
key like any other /v1/* endpoint. It is an in-deployment capability, NOT a
SaaS. Like STT it is a parallel handler (ServeGuardrail), not ServeProxy.
The endpoint always returns HTTP 200 with the verdict — a block/redact
disposition is data in action, not an HTTP error. The verdict carries a
coverage honesty signal (full/degraded/none — a judge that fails open
never masquerades as a clean scan), a per-policy assessments[] breakdown,
rule-pack/PII redactions[] (AI-Guard judge spans stay audit-only), and a
blocking block that exposes category/severity/labels but never pack/rule IDs.
The raw evaluated text is never persisted. v1 bounds judge-budget abuse with
per-VK concurrency + RPM + a 1 MiB body cap; a hard per-VK spend ceiling and
per-call cost in the response are a documented fast-follow.
Added
POST /v1/guardrailVK-authed endpoint (e90-s1);EndpointKindGuardrailtypology +endpoint_type=guardrailaudit vocabulary; aguardrailgenerative-caps concurrency row. Fully additive — no existing contract changes, no migration. OpenAPI:docs/users/api/openapi/e90-s1-guardrail.yaml.
The gateway now serves the OpenAI-shape speech-to-text routes through a
parallel streaming-proxy handler (ServeSTT), NOT the small-JSON
ServeProxy pipeline: an STT request is a large binary multipart stream —
one-shot, un-re-readable — that ServeProxy's byte-slice executor, response
cache, text-scanning hook pipeline, and canonical/codec bridge cannot serve
without polluting the hot core (e88-s5). v1a is competitor-parity passthrough:
the transcript forwards unredacted (compliance_coverage = none); transcript
redaction is the v1b differentiator.
Added
internal/ingress/proxy/stt_handler.go: theServeSTThandler — VK auth → per-VK RPM → per-VK generative-caps concurrency (sharedHandler.genConcurrencyinstance) → bounded multipart parse → single-target resolve → native multipart forward → meter → panic-safe audit tail. Reuses the shared cross-cutting subset (authenticate/checkRateLimit/ router + resolver / cost estimator / audit writer) and touches none ofServeProxy's internals;provcore.Request, the executor, andspec_adapterare unchanged.- Two routes registered:
POST /v1/audio/transcriptionsand/v1/audio/translations(both STT-kind, one wire shape; the ingress path is forwarded verbatim to the upstream so the two are distinguished). - Additive reuse seams:
forwardheader.Apply(request-side allowlist as a free function) and an exportedspecAdapter.ApplyAuth(optional interface, off theAdapterinterface so no test double grows it) so the STT forward single-sources provider auth + header filtering from the chat path.proxy.Deps.Resolverexposes the executor'sprovtarget.Resolverto the STT path.
Bounds & metering
http.MaxBytesReadercaps the upload mid-stream at the STT generative-caps ceiling (~26 MiB → 413 before full drain, defending chunked / lying- Content-Length uploads); part-count / single-file-part / per-field-size bounds reject multipart bombs; a duplicated governance field (model/response_format) is rejected 400 (R-5).- Only
json/verbose_json/textresponse formats are served;srt/vttreturn an explicit 400 (deferred), and a streamed transcription (stream=true, the transcribe models' separate SSE trigger) is likewise rejected 400 (v1a buffers the response). - Metering: provider usage tokens win, else
AudioSecondsfrom the responseduration(verbose_json); neither present prices $0 with a deduped WARN — the audio byte-count is never priced as seconds. Input audio is fingerprinted{sha256, sizeBytes, mime}(reference only — the bytes never enter the audit body pool, R-7). - Single resolved target, no failover in v1a (a deliberate simplification — the bounded-buffer body is re-readable, but a wedged-credential retry also wants the executor's circuit-breaker feedback; deferred, signed residual).
Expensive generative endpoints now carry built-in per-VK caps (e88 NFR-4) — no admin configuration — closing the billing-DoS surface where a single leaked or abusive virtual key could open unbounded concurrent per-call-priced requests.
Added
internal/policy/generativecaps: a registry of built-in per-endpoint-kind caps (image_generation4 concurrent / 256 KiB,tts8 / 256 KiB,video_generation2 / 256 KiB), env-overridable viaAI_GATEWAY_GENERATIVE_CAP_<KIND>_CONCURRENCY/_MAX_BYTES; and a lock-free per-(kind, VK) concurrency counter (the admission-gate atomic pattern).- Admission-stage enforcement: an over-cap generative request returns
429
GENERATIVE_CONCURRENCY_LIMITwithRetry-Afterin the caller's ingress error shape and an attributabletraffic_eventrow (post-auth, VK known); the per-kind body ceiling returns 413 (tighter than the global cap). New Prometheus counternexus_ai_gateway_generative_cap_shed_total{kind}. The slot release is defer-covered (finalizeAudit), so it returns on success, error, and panic alike. Non-generative traffic is never counted. - The realtime spike's P1 "built-in per-VK concurrent-session cap" is this same
registry with a future
realtimerow.
POST /v1/images/generations can now route to Gemini image models
(Nano Banana — gemini-2.5-flash-image etc., which have no dedicated
image endpoint): the gateway translates the OpenAI images canonical to
:generateContent + responseModalities:["IMAGE"] and reshapes the
response back (data[].b64_json). The literal OpenAI target keeps its
native passthrough (byte-unchanged); every other leg — including
wire-adjacent OpenAI-family siblings such as Azure — is independently
demand-gated and not opened in this slice.
Added
- Gemini image leg: target-side wire shape, Gemini codec image
encode/decode branches, canonical-bridge image methods
(
IngressImagesToCanonical/ImagesWireShapeForTarget/IngressImagesToWire), routing gate, prepare-stage + executor dispatch arms. - Per-parameter caller contract (documented in
ingress-api.md): closed allow-list on the Gemini leg —sizemaps toaspectRatioover the documented OpenAI sizes (lossy, marker recorded onX-Nexus-Coerced),quality/style/userdrop with value-free markers,response_format:urland absent both coerce tob64_jsonwith markers,nbounded 1–4 ridingcandidateCount; out-of-schema fields (tools,systemInstruction,safetySettings,nexus.*, gpt-image-1-only params) are rejected 400 and never reach the wire. - Provider-safety blocks surface as OpenAI-shaped content-policy 400s
that never retry or fail over; an image-less upstream reply is a 502 —
never a 200 with empty
data[].
Changed
- The adapter dispatcher now propagates a structured
*ProviderErrorreturned by a codec'sDecodeResponseverbatim (previously every decode error was flattened to a failover-eligible 502). No shipped codec returned one before this change — behavior-neutral for existing legs.
Operational note (pricing)
- Token-usage image models (Gemini Nano Banana,
gpt-image-1) must be priced per 1M tokens (usage tokens always win in the image cost formula); per-image rates are only for usage-less models (dall-e-*). A token-usage model configured per-image silently misprices — seecost-estimation-architecture.md.
The AI Gateway now serves two multimodal data-plane routes:
POST /v1/images/generations (OpenAI Images) and POST /v1/audio/speech
(OpenAI TTS), as OpenAI-shape native passthrough through the standard
ServeProxy pipeline (VK auth, per-VK rate limit, quota, kill-switch,
routing, alias → provider-model rewrite).
Added
- Route registrations + OpenAI-compat transport paths for the images /
audio-speech / audio-transcriptions wire shapes. The multipart siblings
(
/v1/images/edits|variations,/v1/audio/transcriptions|translations) are NOT yet registered — they need multipart model extraction + ingress-path preservation and ship with that work. - New
traffic_event.gateway_cache_skip_reasonvaluemodality_endpoint(additive enum): image / TTS / STT requests skip the response cache at pre-lookup, endpoint-driven likeembeddings_endpoint— generative variety is the product; no per-modality cache knob is added. - Multimodal prompts are now scanned by the hook pipeline: gateway-local
extraction feeds the image
prompt/ TTSinputtext (string or array-of-strings — no bare-string-check bypass) to the rule-pack engine as ordinary text blocks (the shared traffic adapters are untouched — they also run on interception paths, whose extension is gated on the NE fail-open review). Interim redaction posture is fail-closed: a redact hook firing on a multimodal prompt rejects the request (403) rather than forwarding it unredacted, because the adapter cannot yet reverse-encode a redacted prompt onto the images/speech wire; hooks configured to block behave exactly as on chat. - Multimodal routes are forced non-stream in this slice (a client
stream: trueis ignored, body still forwarded verbatim) so cost metering and the artifact fingerprint — both on the non-stream response path — always run instead of being silently skipped. compliance_coverageis honest:prompt-onlyis stamped ONLY when a content-scanning hook actually evaluated the prompt (a metadata-only pipeline of rate-limit / IP / size hooks, an unscannable prompt slot, or emergency hook-bypass all stampnone) — the badge never claims a scan that did not happen.- TTS character count for cost is read from the forwarded request body, not
the audit-capture copy, so TTS is priced correctly even when request-body
storage is disabled (the privacy-conscious default). A multimodal 2xx that
yields no billable units logs a deduped
underivable-unitsWARN instead of silently pricing at $0. Image artifact MIME is sniffed from the decoded bytes (png/jpeg/webp/gif), not hardcoded.
Two additive, non-PII traffic_event columns (versioned contract change;
prisma db push applies them):
Added
traffic_event.artifact_refs— JSON-encoded array of artifact references for multimodal responses:[{"sha256","sizeBytes","mime"}]for byte-bearing artifacts (inlineb64_jsonimages are fingerprinted over the DECODED artifact bytes; TTS audio over the response body),[{"url"}]for URL-return images (reference only — the gateway never dereferences the URL and no content hash exists in that mode). NULL for non-multimodal traffic.traffic_event.compliance_coverage— request-time record of what compliance scanning actually ran on a multimodal request (prompt-only/none); empty for chat/embeddings (no claim). Stamped at request time because a view-time recompute from current config would misreport history. Feeds the per-modality coverage badge.- Binwire field-ids 105 (
artifactRefs) / 106 (complianceCoverage) — append-only registry; same deploy-order note as 103/104 (schema → Hub → producers). - The multimodal cost formulas now receive real units at the cost site:
image count from the response
data[]length, TTS characters from the forwardedinput(rune count). Without this stamp the per-kind formulas would have priced every multimodal request at $0.
The cost estimator's formula registry now prices the three REST multimodal endpoint kinds instead of silently falling back to the chat token formula.
Added
estimator.BillableUnitsgainsImages,AudioSeconds,InputChars— each consumed by a newly registered per-kind cost formula (image_generation,tts,stt). Pricing semantic: a model'sInputUsdPerMis USD per million billable input units, where the unit is the modality's own — tokens for token-usage models, images / characters / audio-seconds for per-unit-priced models (e.g. dall-e-3 standard at $0.04/image →InputUsdPerM = 40000). Per-size / per-quality image tiers are represented as separate catalog model entries, not a pricing-schema extension.- Dispatch rule inside each modality formula: provider-reported usage tokens win when present (authoritative for token-priced models such as gpt-image-1); the modality unit is the fallback. Zero units → zero cost; the stamping site owns the underivable-units WARN.
Migration note — internal estimator registry/struct change; no DB or
wire contract is affected. Deployments that previously saw
image_generation / tts / stt traffic priced through the chat formula
(with the one-time WARN) will now see correct per-unit pricing once those
routes land; no operator action is required beyond configuring model prices
in the catalog with the per-unit semantic above.
The codec is now always in the request path (two entry points: cross-format
EncodeRequest and native-leg RewriteNative); passthrough skips only the
canonical round-trip, never the codec. Per-model wire quirks live in the codec
that talks to that wire, so a request coerces identically whether it arrives on
/v1/chat/completions, /v1/messages, /v1beta, or /v1/responses — the
transitional dispatch-level rewrite callback is deleted. Caller-visible via the
x-nexus-coerced response header.
Upstream 400s turned into a gateway coerce (all verified live on prod):
- Fixed-temperature Moonshot models (
kimi-k2.7-code,-highspeed) striptemperature/top_pon BOTH the native chat leg and a/v1/messagescross-format leg (the latter previously 400'dinvalid temperature). - DeepSeek thinking models (
deepseek-reasoner*,deepseek-v4-pro*) strip a forcedtool_choiceand back-fill a missingreasoning_contenton replayed tool-call histories (previously 400'dreasoning_content … must be passed back). - Newest-generation Claude models (Opus 4.7+,
claude-fable-5,claude-sonnet-5) strip the now-rejectedtemperature/top_p/top_kand clamp an over-ceilingmax_tokenson the native/v1/messagesleg too (owner-approved coerce-over-400; older families that still accept the params are untouched). - Assistant chain-of-thought survives the ingress→canonical→wire round-trip
(
reasoning_contentas the L2 universal field plus a per-block Anthropic signature carrier that is stripped before any non-Anthropic upstream).
Also fixed: a /v1/responses mixed-target-list failover posting the verbatim
Responses body to the chat URL; a codec's typed error surviving the cache-prep,
adapter, and failover stations instead of flattening to a generic 400; and the
dead EncodeResult.Headers channel removed.
Callers can now tag each request with THEIR user's identifier and THEIR
session/conversation identifier, and every gateway traffic row carries both,
so an external system can join Nexus traffic (cost, tokens, latency,
outcomes) to its own user table per end user — and group it per
conversation: cost per thread, replay of a misbehaving dialogue. Together
with X-Request-Id this completes the caller-side correlation hierarchy:
user → session → request.
The session tag is declared via the X-Nexus-Session-Id request header
(header-only — chat protocols carry no reliable native session field). The
end-user tag has three carriers, first match wins:
X-Nexus-End-User-Idrequest header — works on every ingress.- The OpenAI shape's top-level
userfield (or its successorsafety_identifier) — anyone already sending it gets attribution with no code change. - The Anthropic shape's
metadata.user_id— same, no code change.
The value is an opaque correlation tag scoped to the calling virtual key: the gateway never validates it, never resolves it against Nexus users, and never feeds it into quota, routing, or IAM. It is trimmed and capped at 256 bytes. Rows from the compliance proxy and agents carry NULL. It is stored verbatim and is NOT covered by body redaction — send opaque ids, not emails.
- Deploy order matters: schema, then Hub, then gateways. The gateway
starts emitting the new wire fields as soon as any caller's traffic
carries an OpenAI
userfield — that is existing traffic, not opt-in — and a Hub that predates the fields treats the whole frame as a poison record on its DB-writer path: logged, acknowledged, dropped permanently. NATS only buffers while the Hub is down; an old Hub actively consuming loses those rows for good, so never restart gateways onto a Hub that has not been upgraded first. New nullable columns + indexes ontraffic_event; on a large table, create the indexesCONCURRENTLYrather than via baredb push, which locks writes for the build. - Reading it back: query
traffic_eventdirectly — e.g.SELECT date_trunc('day', timestamp), sum(estimated_cost_usd) FROM traffic_event WHERE end_user_id = '<your-user>' GROUP BY 1;— both columns are indexed withtimestamp. The admin Traffic API/UI does not surface them yet.
The gateway composes its CORS request allowlist itself: the headers its own
read sites depend on (virtual-key carriers, correlation ids, the cache
opt-out) plus everything the forward-header allowlist relays to providers
(anthropic-beta, openai-organization, …). The yaml key now adds extra
names on top of that set — it can no longer shrink it.
Previously the yaml value replaced the built-in list wholesale, and every
shipped config had drifted below what the gateway needed: a browser client
sending x-api-key (the Anthropic SDK's carrier), x-goog-api-key,
api-key, or X-Nexus-No-Cache was rejected at preflight before it could
even authenticate.
- No action required. Existing lists keep working — their entries are merged in. Entries that duplicated the built-ins are now redundant and can be deleted from your yaml.
- Also fixed in the same pass: CORS responses now always carry
Vary: Origin(previously only allowed origins did, letting a shared cache mix per-origin copies), and a preflight from a disallowed origin no longer receives the allow-lists readout.
Changed — deprecation, migration window open: the admin API key header moved into the X-Nexus-* namespace
x-admin-key is now X-Nexus-Admin-Key. The Control Plane accepts both, with
the canonical name taking precedence when a caller sends both; the nexus CLI now
sends the canonical name. Nothing breaks on upgrade: an older CLI keeps
authenticating against a newer Control Plane.
- Action required for any script or integration that calls the admin API
directly: send
X-Nexus-Admin-Key. The old name is read for now and will be removed in a future release. - The one order that does not work is a newer CLI against an older Control Plane — that server has not learned the new name and answers 401. Upgrade the Control Plane first, which is the normal order anyway.
- If a WAF or edge proxy in front of the Control Plane inspects or strips the admin-key header, update its rule to cover both spellings — a rule keyed on the old name alone no longer sees every credential.
x-nexus-aigw-no-cache is now X-Nexus-No-Cache, matching every other
X-Nexus-* header — none of which carry a per-service segment. The caller
reference told clients to send the old name, so the gateway still reads it and
still bypasses the cache; both spellings work today.
- Action required before the old name is removed in a future release: send
X-Nexus-No-Cache. This deprecation cannot fail loudly — after removal, a caller left on the old name is served from cache while believing it opted out, with no error to notice — so it is worth migrating while both names work. - Browser callers need no preflight change: both names are in the CORS request allowlist for the duration of the window.
The header let a caller on an OpenAI-compat route declare that its body was
actually some other provider's shape. Nothing needs it: the route path decides
the ingress format, and every format already has a native route
(/v1/messages, /v1beta/…, /openai/deployments/…) that says the same thing
without a header. It had no documented callers, and it was the step that
unlocked the Gemini ?key= URL credential carrier from an OpenAI route in the
SEC-M3-02 kill chain — removing it forecloses that whole class of "flip the
ingress format to inherit another format's carrier" escalation.
- No action required unless you were sending it, in which case call the native route for the format you are actually sending.
-
Two metrics renamed to obey the naming rule, now enforced by a lint.
prometheus-naming-architecture.md§1 requiresnexus_<subsystem>_<name>and says the service belongs in the Prometheusjoblabel, never in the series name. Nothing enforced it, so two violations had accumulated:before after nexus_ai_gateway_admission_shed_totalnexus_admission_shed_totalnexus_hub_scheduler_leadernexus_scheduler_leadernexus_admission_shed_totalhas never had a non-zero value in production (the in-flight gate has never shed), so nothing can have been reading it.nexus_scheduler_leaderis live on the Hub — if you have a dashboard or query on it, update the name. No in-repo dashboard or alert rule referenced either.New
scripts/check-prometheus-naming.sh(npm run check:prometheus-naming, plus pre-commit on staged Go files) blocks a third one. The service list comes frompackages/shared/schemas/thingtype, so adding a service extends the check automatically.
OAuthClient.redirectUris was replaced wholesale by the fixture, which ships
only the localhost URLs a developer needs. Any deployment that had registered
its own console domain lost it on the next seed:prod run — and because the
authorize endpoint rejects an unregistered redirect_uri, every admin was
locked out of the console until someone re-added it by hand. The failure arrived
whenever anyone re-seeded for an unrelated reason, such as a model-price
correction.
redirectUris is now merged rather than replaced: the seed guarantees its own
URLs are present and removes nothing it did not ship. Removing a URL is done
through the admin API.
- Action required if a re-seed has already removed your console URL: the
symptom is
redirect_uri not registeredfrom/oauth/authorizeand a console login that cannot complete. Re-add the URL (admin API, orUPDATE "OAuthClient" SET "redirectUris" = array_append("redirectUris", '<your-console-url>/auth/callback') WHERE id = 'cp-ui';) and it will survive every seed from this release on. - No schema change, no migration. Deployments whose URLs are intact are unaffected; the merge is a no-op when the fixture's URLs are already the only ones present.
The gateway already normalised every provider failure onto one canonical cause
and then discarded it at the handler boundary, re-deriving what it needed from
the raw attempt list. That cost a rate limit its 429, left errors_total at
zero forever, and collapsed every upstream 4xx into one undifferentiated code.
Fixed
- A rate limit is now reported to the client as 429, not 502, when the retry that follows it cannot find a usable credential. The gateway decided 429-vs-502 by reading the last attempt's raw status, but a target abandoned before any call was made is also recorded as an attempt and carries no status — and the rate limit is what causes it, by opening the credential's circuit so the retry's re-resolve fails. The client was told the provider was down (false — it is throttling us) and therefore did not back off. Requires a provider credential pool of two or more; single-credential pools were never affected.
- The same failure keeps its credential attribution on the traffic row, so "which key got rate-limited?" is answerable.
X-Nexus-Attemptscounts calls that reached a provider, not targets abandoned before dispatch.- A provider reporting itself overloaded on a status other than 429 is now treated as the rate limit it is, on the same footing as the executor, which already classified it that way when it decided to retry.
Changed — traffic_event.error_code semantics
A terminal upstream 4xx now records the provider's canonical cause —
auth_failed, invalid_request, context_overflow, endpoint_unsupported,
not_implemented, no_compatible_provider — where it previously recorded the
blanket literal PROVIDER_ERROR.
- No schema change and no migration. Existing rows are untouched and the
?errorCode=filter still matches them exactly as before. PROVIDER_ERRORis retained in the code as the value for a terminal 4xx that carries no canonical cause, but the AI Gateway no longer emits it: the classifier only reaches that path via a branch that has already resolved aProviderError, so every new gateway row carries a cause. It is still written by the Compliance Proxy's own pipeline, which this change does not touch.- Action required if you have a saved query, dashboard or alert filtering
error_code = 'PROVIDER_ERROR'and expecting it to mean "any upstream 4xx". Against gateway traffic it now matches no new rows — it does not merely thin out, it goes to zero, while historical rows keep the old value. Widen it to the specific causes you care about, or filter onstatus_codeinstead. - Codes for failures the gateway decided (
PROVIDER_UNAVAILABLE,PROVIDER_RATE_LIMITED,QUOTA_EXCEEDED,CLIENT_CLOSED, …) are unchanged, as are all client-facing error envelopes.
Added
errors_total{provider, error_type}is incremented for the first time. It was registered, exported and documented as "incremented on every non-2xx path" while having no caller at all, so it always read zero.error_typeis the terminal attempt's canonical code. Client disconnects (499) and gateway-internal rejections are deliberately excluded — seedocs/developers/architecture/cross-cutting/safety/error-taxonomy-architecture.md§8.- Gateway upstream failures are logged under one stable message per cause, so the operator errors page groups them by cause and each can be silenced independently. They previously shared a single message and collapsed into one row covering every cause.
A service never configures another Nexus service's URL any more. Each server service reports its own base URLs to the Thing Registry, and peers resolve the reported value from the Hub at runtime — removing the config-drift class where a stale peer URL produced silent inter-service failures.
Added
- Every server service (nexus-hub, control-plane, ai-gateway, compliance-proxy)
now reports a second base URL,
staticInfo.privateUrl(internal service-to-service address), alongside the existingpublicUrl(external clients + the Agent). Config: optional yamlprivateURL/ env<SVC>_PRIVATE_URL(NEXUS_HUB_PRIVATE_URL,CONTROL_PLANE_PRIVATE_URL,AI_GATEWAY_PRIVATE_URL,COMPLIANCE_PROXY_PRIVATE_URL); default is auto-derived ashttp://<primary-outbound-IPv4>:<service-port>so nothing needs to be set in the common case. The compliance-proxy derives its port from the runtime-API listen address. - New Hub endpoint
GET /api/internal/things/service-url/:thing_type(service-token only; agents get 403 — the private URL never reaches end-user devices). Returns{thingType, privateUrl, publicUrl}for the most-recently-seen reporting Thing of the type (one base per service type; scaled fleets sit behind one LB base), or 404SERVICE_URL_NOT_REPORTEDduring the peer's boot window (callers retry). - New shared resolver
packages/shared/transport/peerurl: lazy first-use resolution, in-memory cache with 5-minute refresh (stale value served if a refresh fails), 5-second negative TTL,ErrNotReported— never a silent fallback; errors surface and the next use retries.
Changed
- The webhook-forward → AI-Guard trust anchor is now Hub-resolved instead of
locally configured: the internal
X-RS-Tokenis injected per request only when the hook endpoint path is/v1/ai-guard/compliance-webhookand its scheme+host match a trusted base (webhook.Options.TrustedAIGuardBases). The ai-gateway supplies its own public+private URLs; the compliance-proxy supplies the Hub-resolved ai-gateway URLs. While the peer is not yet resolved, the webhook posts without the token (fail-safe) and retries on the next request. - compliance-proxy
onboarding.cpUIBaseURLis now an optional override for the 407-page display link; when unset it defaults to the Hub-resolved Control Plane public URL.
Removed
- The four peer-URL config fields and their env vars:
compliance-proxy
compliance.aiGatewayUrl; control-planebff.aiGatewayUrl(envAI_GATEWAY_URL),bff.complianceProxyUrl(envCOMPLIANCE_PROXY_URL),bff.complianceProxyRuntimeUrl(envCOMPLIANCE_PROXY_RUNTIME_URL).
Migration notes
- Operators who set any of the removed fields/env vars can simply delete
them — the values are ignored. Split-horizon or non-default topologies are
expressed on the reporting side instead: set the target service's own
privateURL(yaml) or<SVC>_PRIVATE_URL(env) to the address its peers should dial. - The auto-derived private URL follows the service's BIND interface: a
service bound to a specific address (the single-box appliance binds
127.0.0.1behind nginx) advertises that address; a wildcard bind advertises the primary-outbound IPv4. - Verify compliance-webhook hook endpoints. The webhook X-RS-Token trust
anchor now matches the hook endpoint's scheme+host against the AI Gateway's
reported public/private URLs (plus the gateway's own loopback variants;
explicit default ports
:443/:80are normalized). If a webhook-forward hook posts to the AI-Guard compliance-webhook through a host that is neither of those (e.g. a vanity CNAME), the token silently stops riding and AI-Guard answers 401 — repoint the hook endpoint at the gateway's reported URL.
The cache shadow blob's Tier-1 global object is retired. Both switches it
carried duplicated capabilities that finer-grained mechanisms already own.
Migration notes
- Removed shadow-blob fields —
global.cache_master_kill_switchandglobal.normaliser_enabledare gone from thecacheconfig-key blob, which is now{adapters, providers}. Thecacheconfig key itself is unchanged and Tier-2 (adapter) / Tier-3 (provider) settings are untouched. The removal is tolerant in both directions during a rolling restart: a new gateway ignores the now-unknown fields on an old blob, and an old gateway reading a new blob defaults both tofalse(kill switch off = cache on; normaliser off) — the safe direction. - Retired admin endpoint —
GET /api/admin/cache/globalandPUT /api/admin/cache/globalno longer exist (404). Their only client was the Control Plane UI "Global Defaults" panel, deleted in the same change. Theprompt-cacheIAM resource is unchanged — the remaining cache endpoints still use it. - Response-shape change to a KEPT endpoint —
GET /api/admin/cache/effective(the per-provider effective config, which stays) no longer emits thenormaliser_enabledandcache_master_kill_switchkeys. Any consumer reading those two keys off the effective response must stop; every other key is unchanged. - Orphaned table, left in place — nothing reads or writes the
cache_global_configsingleton table any more. It is deliberately not dropped (no migration, no schema change, zero deploy risk); a later cleanup may drop it. - ⚠ Upgrade check for an ARMED kill switch — the new gateway ignores
cache_master_kill_switchentirely. If your deployment currently holds the kill switch ON (cache deliberately disabled fleet-wide) while the per-tierenabledflags are still true, your response caches will silently re-enable at upgrade. Before upgrading, disable the tiers explicitly instead: the/ai-gateway/cachestatus strip's "Disable all gateway cache fleet-wide" (sets per-tierenabled=false), or a time-boxed Emergency PassthroughbypassCache. Pre-deploy check:SELECT config FROM cache_global_config WHERE id='singleton'— ifcache_master_kill_switchistrue, flip the tiers off first.
Replacements — no capability was lost
- Emergency cache-off has two complementary surfaces. The cache stage now gates
purely on the two tiers' own flags (
cacheEnabled = l1Enabled || l2Enabled). (1) The status strip at the top of/ai-gateway/cachehas a one-click "Disable all gateway cache" fleet-wide action (confirm dialog, permission-gated) that sets both tiers'enabled=false— fast and durable, and more discoverable than the retired panel, which was buried two tabs deep. (2) Emergency PassthroughbypassCacheremains the auditable, time-boxed bypass — mandatory ≥20-char reason,enabledByrecorded, ≤8 h auto-revert, scopable per adapter/provider. Use the first when the cache itself is the fault and must stay off; use the second when the bypass must be governed, self-reverting, or narrower than the fleet. - The upstream wire-rewrite engine is now demand-driven. Instead of a global
gate, the engine derives a
hasWorkflag at reload time from "any adapter has an enabled strip rule" OR "any provider hascache_controlmarker injection on", and no-ops when there is nothing to do. Enabling a strip rule, or a provider's marker injection, is the demand — this also fixes the footgun where per-provider marker injection was silently swallowed because the global switch was off. The L0 cache-key normalisation (NormalizeKey) always ran and still always runs. - UI — the cache-config panels moved from
src/pages/compliance/cache/tosrc/pages/ai-gateway/cache/settings/so the source path matches the route. The/ai-gateway/cacheroute is unchanged; no deep links break.
- When a request hook rewrites the wire body (redaction), the L2 semantic cache now renormalizes the rewritten bytes once and feeds that canonical to the embedding input, the L2 write-back, and the freshness detector — the embedding provider and the vector store see the redacted content the upstream sees, never the pre-hook original. A renormalize failure skips the L2 lookup/write-back and freshness detection for that request (L1 exact-match, keyed on the rewritten bytes, is unaffected) instead of falling back to the stale canonical. Requests without a rewrite are unaffected.
- New canonical provider-error code
context_overflow: OpenAI (context_length_exceeded/ "maximum context length"), Anthropic ("prompt is too long"), and Gemini ("exceeds the maximum number of tokens") 400s are classified separately from terminal invalid_request. The executor never retries the overflowing target and fails over to the next target when one exists; multi-target routes (fallback chains) now advance on overflow where they previously stopped. On the last target the provider's own error is surfaced verbatim. - Smart routing arms a context-upgrade escape: alongside the router's
pick, the largest-window candidate from the same filtered pool rides as
a
ContextUpgradeOnlytarget used exactly on a context-overflow verdict — closing the loop the coarse size estimate cannot. - Smart candidate selection now also hard-filters by declared
capabilities: candidates declaring a feature list but lacking
vision(request carries images) orfunction_calling(request declares tools) are dropped before the router sees the catalog; undeclared feature lists pass and a dimension that would empty the pool is skipped (both fail-open).
- The smart routing strategy now hard-filters candidate models by the estimated
request size before the router LLM sees the catalog: candidates whose
declared
maxContextTokenscannot hold the estimated input (all roles, tool payloads, tool definitions) plus the output reserve (max_tokensor 1024) are dropped; when nothing fits, the largest-context candidates are kept and the routing trace records the overflow risk. Previously a large conversation could be routed to a 128k-context model while 1M-context candidates were available, producing an upstream context-overflow error. - The router-LLM call itself is now budget-bounded: the conversation sent to
the router is staged with recent-turns under
min(routerWindow − systemPrompt − 256, 4096)(router model's declared window; 8192 fallback), so an oversized turn is tail-truncated instead of being forwarded as-is and failing the router call. The router now also sees recent user+assistant turns (client system messages excluded) plus a request-metadata line (~tokens, images, tool definitions). Overflows are counted onnexus_smart_router_input_overflow_total. The router LLM must be a provider trusted with unredacted traffic — routing runs before request hooks; seesmart-routing-architecture.md.
ai_guard_config.input_strategynow defaults tofull_truncated(wassystem_plus_last_user): the judge sees every turn that fits its context window, so violations assembled across turns stay visible. The judge prompt template's size is now counted against the input budget, and the input is bounded by the sharedinputstagingbudget enforcement (oldest dropped first). Existing deployments keep their storedinput_strategyvalue; the new default applies to fresh installs and rows without an explicit value.
- The AI Gateway bounds concurrent in-flight proxy requests (default
1024 × GOMAXPROCS;AI_GATEWAY_MAX_INFLIGHToverrides,0disables). At arrival rates beyond the box's capacity, excess requests are rejected fast with 429 +Retry-After: 1in the caller's ingress error shape (OpenAI / Anthropic / Gemini envelopes) instead of queueing in-heap until the Go memory limit collapses throughput (measured pre-fix: 15.9s p99 at 1.5× capacity; the pre-GOMEMLIMIT failure mode was an OOM kill). 429 was already part of the data-plane contract (per-key rate limits and quota denials); SDK retry logic engages unchanged. Health, metrics, and admin endpoints are never gated. Shed requests are counted onnexus_ai_gateway_admission_shed_total.
- Hook configuration freshness is now push-driven with a background TTL-backstop ticker; the request path never loads configuration. Previously a TTL-stale check on the request path could fan out one full rule-pack database load per in-flight request while a slow load was running, collapsing the gateway at high request rates (measured: p99 120s at 16k req/s with content hooks on; fixed: p99 27ms at the same rate). Rule-pack install ordering also gained a deterministic tiebreaker so no-change config reloads can no longer churn the compiled matchers.
- Bodies-off deployments no longer allocate a fresh request-body buffer per request (the pooled buffer is returned at request end; previously measured at 52% of all gateway allocation under content-scan load).
- Redact-action rule packs skip re-localization entirely on benign traffic (zero matches on a complete scan).
- Config snapshot loads expose
nexus_configcache_load_failures_totalandnexus_configcache_last_success_timestamp_secondsfor alerting on a frozen config plane.
- Streamed responses now record response-hook timing, exactly once per hook.
The streaming response pipeline runs the response stage at every checkpoint, so
the live audit-only path previously recorded nothing (
response_hooks_msNULL) while the chunked_async path recorded the same hook once per checkpoint (N duplicate rows, an N×-inflated aggregate — observed as a "RESPONSE PIPELINE (63)" list of identical rows). The trace is now folded to one record per hook (summed latency, latest decision) across the ai-gateway live + Model A paths and the shared compliance-proxy/agent path. The audit drawer also collapses any residual duplicates (historical rows) into a single×Ncard.
- Per-hook latency is now measured in microseconds (
latencyUs) alongside the existing truncated-millisecondlatencyMs, with new aggregate columnsrequest_hooks_us/response_hooks_usbeside the unchanged_mscolumns. Hooks run at microsecond scale, so the millisecond aggregates floored a sub-millisecond hook to0; the µs fields carry the real value, surfaced precisely per hook in the control-plane audit drawer. The_mscolumns / wire ids / values are unchanged. The new binwire field ids are forward-incompatible, so the deploy order is schema → Hub → producers.
- Hook
onMatchcollapses to a singleaction(approve | redact | block). The orthogonalonMatch.inflightAction(approve / block-hard / block-soft / redact) ×onMatch.storageAction(keep / redact / drop-content) pair is replaced by oneactionfield across the AI Gateway, Compliance Proxy, and Agent.redactrewrites the payload (the same masked body is forwarded, returned, and stored);blockrejects and stores the policy attribution (matched rule, reason, compliance tags) — not a content body, since a blocked request never produces a masked wire copy;approveforwards and stores as-is. A redact whose adapter cannot reverse-encode the masked content onto the wire (ErrRewriteUnsupported) fails closed (the request/response is rejected, not forwarded unredacted). Soft-block (HTTP 246) is removed — block-soft folds into block (HTTP 403). The canonical normalized projection is no longer persisted for audit; the control plane recomputes it at view time from the (already-redacted) raw body, sorequest_normalized/response_normalizedandrequest_redaction_spans/response_redaction_spansare no longer emitted. Migration: the config reader maps the legacy keys for a deprecation window (one-shot warning); a one-off data migration (tools/db-migrate/manual-scripts/migrate_hook_onmatch_action_2026_06_22.sql) rewrites storedHookConfig.config.onMatchrows:block-hard|block-soft → block,redact → redact,approve + keep → approve,approve + redact|drop-content → redact. Runtime enforcement is unchanged by the mapping:block-softalready rejected the request — it returned an error response (previously with the non-standard status 246, now 403) and never forwarded the traffic, so this is a status-code change, not an allow→deny change. The only data-level behavior change isapprove + redact|drop-content → redact, which upgrades a storage-only redact to a full redact (the compliance-safe direction, never less masked than before) and occurs in no current row, so the live migration is lossless. Client note: any SDK that branched on the soft-block status 246 must now treat such a rule's response as a 403 reject. The Agent signals a block by dropping the connection (no rich error body); the proxies return an attributed 403 whose response-stage reason carries rule-ID labels only, never the upstream value.
- A redact hook co-firing with a soft-block hook now masks-and-delivers instead of
leaking or failing closed. When a redact hook (
Modify+ masked content/spans) and a soft-block hook fired on the same request or response, the pipeline aggregator promoted the reportedDecisiontoBlockSoft(the strictest) but DROPPED the redact hook's replacement content, leaving spans without content. Downstream this produced a no-op rewrite that, depending on the path, either failed closed (canonical response) or replayed/forwarded the ORIGINAL unredacted body — a PII leak on the shared buffer pipeline (compliance-proxy appliance included), the agent Model A wire, and both request stages.mergeResultsnow carries the redact'sModifiedContentunconditionally, and every redaction consumer gates on the newdecision.CompliancePipelineResult.CarriesRedaction()predicate (Modify OR a BlockSoft masking a co-firing redact) rather thanDecision==Modify, so the masked body is applied and delivered on all paths. The audit row stamps the dispositionaction=redacteven when the (soft-block)Decisionceiling isBlockSoft. No config or schema change; behavior is compliance-safe (a hardblock/RejectHardstill rejects; a standalone soft-block still delivers-with-warning). The no-redactor buffer degrade is now posture-aware (appliance fail-closed, agent fail-open).
- The Model A streaming-compliance algorithm is now a single shared engine driving
three ends. The prescan-gated real-time streaming path (bounded tail-hold +
union prescan + confirm + escalate-to-buffer redaction) for a redact-scope
chunked_asyncstream is extracted into a substrate-agnostic engine (shared/transport/streaming/modela). The AI Gateway drives it with a canonical substrate (fail-closed) and the transparent proxy used by the Agent + Compliance Proxy drives it with a raw-SSE-wire substrate (fail-open, NE host-packet safety) — so hooks/compliance behave identically across all three ends while each keeps its own ingress and delivery. The transparent-proxy live path becomes audit-only (real-time write-through, observe-only checkpoints, never blocks/rewrites): scope-derived routing sends ablockscope to buffer and aredactscope to Model A (or buffer), so only non-enforcing traffic reaches live. The adoption also closed two latent PII-leak paths in the shipped AI Gateway Model A (a redact masked behind a co-firing soft-block; a memory-pressure eviction of an incomplete content unit). No config or contract change; behavior is compliance-safe (a sub-window value is never delivered raw; storage never persists a raw prefix on an enforcing outcome).
- The normalized traffic projection is no longer written on the hot path; it is
recomputed at view time. Building on 1.1.0 (where the producers stopped
stamping it), this completes the move end-to-end: the Hub no longer
self-derives the projection from agent uploads, and the periodic
normalize-backfill job is retired. The Control Plane (and the Agent
dashboard) recompute the normalized request/response on demand — when an
operator opens a Traffic detail drawer — from the stored, already-redacted
body, so the rendered projection always reflects the current decoder version
with no scheduled job and no stored copy to drift.
traffic_event_normalizedandtraffic_event_normalize_skipare retained, write-frozen. No schema change and no migration is required. Thetraffic_event_normalizedsidecar still receives a row only when an older shipped agent uploads its own governed normalized copy — for a block/redact row whose raw body was dropped, that uploaded copy is the sole forensic record. Thetraffic_event_normalize_skipledger is now inert (the job that wrote it is gone). Dropping both tables is a planned deprecation-window follow-up, not part of this change.GET /api/admin/traffic/{id}/normalizednow returns the recompute and no longer includes redaction spans (the recompute reads an already-redacted body). It returns404when the projection is unavailable — no stored body to recompute from (payload capture was off, or a spilled body has aged out of retention) and no stored sidecar fallback.- Operators: the
nexus_normalize_backfill_*counters are no longer emitted. A missing/NULLtraffic_event_normalizedsidecar is now the normal state for current traffic, not a gap to heal.
- Streaming response compliance is scope-routed, and the real-time path is
audit-only. A response hook's enforcement scope decides how a streamed (SSE)
response is handled, overriding the admin streaming-mode default wherever that
default cannot enforce:
- A block scope buffers the full response before any byte is delivered (zero-leak hard block).
- A redact scope under
chunked_asyncstreams in real time behind a prescan gate that holds a bounded trailing window and escalates to buffered redaction on a confirmed match — best-effort on the wire: a complete sensitive value is never delivered, but a leading fragment of a value longer than the window may reach the client before redaction engages, while the persisted audit copy stays fully masked within that window. A redact scope underpassthroughfalls back to buffering rather than forwarding raw. - A non-enforcing pipeline streams in real time, audit-only: it scans and tags every checkpoint but never blocks or rewrites the wire.
- An unbuildable fail-closed response hook forces buffering, which fails closed with an in-band error frame — never a silent fail-open on the real-time path.
- The streamed
finish_reasonis preserved across the canonical re-encode instead of collapsing tostop. - The
streaming_compliance.configmode enum (passthrough/buffer_full_block/chunked_async) is unchanged; no migration. The Control Plane UI shows an always-visible per-mode disclosure of exactly what each mode enforces.
The first release after the 1.0 GA. It is a performance and audit-storage release: the captured-traffic pipeline was reworked to push far higher no-loss throughput on a single box, several shipped defaults flip toward that throughput, the Windows desktop agent reaches GA, and the AWS Marketplace AMI / single-instance appliance form factor is now a first-class deployment target.
Upgrade note. Two changes are breaking for direct database / config consumers and require a one-time migration on deployments that retain traffic history (see BREAKING (migration required), below). Fresh installs — the AMI appliance, or
prisma db pushagainst an empty database — need no manual step. The supported appliance upgrade path applies the schema change automatically, which is why this ships as a minor rather than a major; the data re-encode is the only manual action, and only when old rows must remain readable.
-
Captured body storage is now raw
BYTEA.traffic_event_payload.inline_request_body/inline_response_bodyhold the captured body's raw bytes (text verbatim, arbitrary binary, or a rawzstd/s2compressed frame), discriminated by theinline_request_encoding/inline_response_encodingcolumns (text|binary|zstd|s2, withbase64accepted as a read tag). Raw bytes let PostgreSQL store the body as-is — no per-insert parse / validate / tree-store, and no +33% base64 size inflation.- Direct
traffic_event_payloadconsumers: read theinline_*_bodycolumn together with itsinline_*_encodingdiscriminator and decompress accordingly, instead of parsing the old JSONB envelope. - Migration:
prisma db pushapplies theTEXT→BYTEAcolumn change. Rows captured before the upgrade whose encoding iszstd/s2were stored as base64 text; their bytes survive the type swap as base64 ASCII and must be decoded once to the raw frame, or they read as absent:UPDATE traffic_event_payload SET inline_request_body = decode(convert_from(inline_request_body,'UTF8'),'base64') WHERE inline_request_encoding IN ('zstd','s2');(and the same forinline_response_body/inline_response_encoding). Oldbase64-tagged rows decode transparently on the read path. The authoritative note lives intools/db-migrate/schema/traffic.prisma(modeltraffic_event_payload).
- Direct
-
Hook
onMatchcollapses to a singleaction(approve|redact|block). The orthogonalinflightAction×storageActionpair is replaced by one field across the AI Gateway, Compliance Proxy, and Agent:approveforwards and stores as-is;redactrewrites the payload (the same masked body is forwarded, returned, and stored);blockrejects and stores the masked copy. The soft-block path folds intoblock. The canonical normalized projection is not persisted for audit — the control plane recomputes it at view time from the (already-redacted) raw body — sorequest_normalized/response_normalizedand therequest_redaction_spans/response_redaction_spanscolumns are not emitted.- Migration: the config reader maps the legacy
inflightAction/storageActionkeys for a deprecation window (one-shot warning), and the one-off data migrationtools/db-migrate/manual-scripts/migrate_hook_onmatch_action_2026_06_22.sqlrewrites storedHookConfig.config.onMatchrows (block-hard|block-soft → block,approve + keep → approve,approve + redact|drop-content → redact). The proxies return an attributed403whose response-stage reason carries rule-ID labels only, never the upstream value; the Agent signals a block by dropping the connection.
- Migration: the config reader maps the legacy
These flip shipped behavior toward higher throughput; each is overridable by env or yaml and an upgrade silently inherits the new default. Operators relying on the prior strictness should set the opt-out shown.
-
Quota enforcement is soft by default (
NEXUS_QUOTA_WRITE_BEHINDON). Per- request quota cost is accumulated in-process and flushed to Redis on a 250ms interval behind a 1s read cache, instead of a synchronous per-request Redis round-trip. Overshoot per instance ≤ ~1.25s of spend; across an N-instance fleet the blind-spend window is that × N, and a hard kill loses un-flushed increments (graceful shutdown drains). Opt out:NEXUS_QUOTA_WRITE_BEHIND=0(strict synchronous per-request accounting). -
Credential-stats write-behind ON by default (
NEXUS_CREDSTATS_WRITE_BEHIND). Credential usage counters defer off the request path; circuit-breaker transitions stay synchronous. Opt out:NEXUS_CREDSTATS_WRITE_BEHIND=0. -
Audit overflow default
AI_GATEWAY_AUDIT_LOSS_MODE=spill. The request path no longer back-pressures on a full audit pipeline; overflow spills to a durable on-disk spool replayed to Postgres. No loss until the spill channel + disk saturate; sustained overload past that drops records, counted ondropped_total. Opt out for strict no-drop back-pressure:AI_GATEWAY_AUDIT_LOSS_MODE=block. -
NEXUS_EVENTSaudit stream is in-memory by default (NEXUS_EVENTS_STORAGE=memory,DiscardNew, capNEXUS_EVENTS_MAX_BYTES=auto= 15% RAM). Keeps the delay-tolerant burst buffer off the data disk. A NATS broker restart/crash drops published-but-undrained events (the overflow→disk no-loss path covers only the stream-full case). Opt out for a durable file-backed stream:NEXUS_EVENTS_STORAGE=file. -
GOMEMLIMITauto-set from the cgroup limit when unset. Each service, ifGOMEMLIMITis not provided, reads the cgroup memory limit at boot and sets the Go soft limit to ~70% of it (logging a WARN with the value), leaving it unset when no cgroup limit is detectable. Pin explicitly to override. -
Cache freshness protection defaults ON (
extract_cache_config.apply_freshness_rulesdefaultfalse → true). Freshness protection is intrinsic to caching: enabling a cache tier should not silently replay a stale time-sensitive answer (today's date, "latest" prices, live status). The freshness detector only runs when a cache tier is active, so a cache-off gateway still pays nothing and stays a lean passthrough. The flip applies to fresh installs and the no-row default; an existing deployment that already saved anextract_cache_configrow keeps its stored value, so no migration runs and no admin choice is overwritten. Operators who already enabled L1/L2 and want freshness should re-save the extract-cache config (or toggle the Freshness rules card) once; operators who want maximum hit-rate can leave it off explicitly. Each default below flips shipped behavior toward higher throughput. An upgrade silently inherits the new value; the opt-out to restore prior behavior is shown. -
One same-target retry by default (
maxAttemptsPerTarget1 → 2). A single transient upstream fault (network / timeout / 429 / 5xx) now retries once in place before failover, so flaky provider endpoints self-heal instead of surfacing a hard error. Bounded to one retry so a non-idempotent generation is re-sent at most once. Opt out: setmaxAttemptsPerTarget: 1on the routing rule / retry policy. -
Audit overflow defaults to
spillblock(zero-loss). The request path does not back-pressure on a full audit pipeline; overflow spills to a durable on-disk spool, and when the spool channel itself saturates the writer back-pressures rather than dropping. Opt out:AI_GATEWAY_AUDIT_LOSS_MODE=spill(drop on saturation) or=block(strict synchronous back-pressure on the request path). -
Quota enforcement is soft by default (
NEXUS_QUOTA_WRITE_BEHIND=1). Per-request quota cost accumulates in-process and flushes to Redis on a ~250ms interval behind a 1s read cache. Overshoot per instance ≤ ~1.25s of spend; a hard kill loses un-flushed increments (graceful shutdown drains). Opt out:NEXUS_QUOTA_WRITE_BEHIND=0. -
Credential-stats write-behind by default (
NEXUS_CREDSTATS_WRITE_BEHIND=1). Credential usage counters defer off the request path; circuit-breaker transitions stay synchronous. Opt out:NEXUS_CREDSTATS_WRITE_BEHIND=0. -
NEXUS_EVENTSaudit stream is in-memory by default (NEXUS_EVENTS_STORAGE=memory,DiscardNew, capNEXUS_EVENTS_MAX_BYTES=auto≈ 15% RAM). Keeps the delay-tolerant burst buffer off the data disk; a NATS restart/crash drops published-but-undrained events. Opt out for a durable file-backed stream:NEXUS_EVENTS_STORAGE=file. -
Response cache is opt-in per route, with substring freshness matching. Caching is enabled per route rather than globally; turn it on for the routes that benefit. The Control Plane UI surfaces the staleness risk tip.
-
GOMEMLIMITauto-set from the cgroup limit when unset. Each service reads the cgroup memory limit at boot and sets the Go soft limit to ~70% of it (WARN-logged), leaving it unset when no cgroup limit is detectable. Pin explicitly to override. -
Seed defaults: content hooks ship OFF, and the application virtual key carries a default $50k/month quota policy.
-
Inline-body audit codec defaults to
s2(AI_GATEWAY_AUDIT_CODEC,zstdavailable); the CGO matcher scan limit auto-sizes (NEXUS_CGO_SCAN_LIMIT=auto).
- gw→Hub audit wire defaults to a binary TLV frame
(
NEXUS_AUDIT_WIRE=binary). The Hub peeks the frame magic and dual-reads, so the legacy JSON wire still decodes;NEXUS_AUDIT_WIRE=jsonreverts. No persisted-contract or external API change.
- Windows desktop agent is now GA. Windows interception runs on a signed
NexusWFPkernel driver (Windows Filtering Platform, transparent TCP connect-redirect, with QUIC fallback and IPv6). macOS, Linux, and Windows desktop agents are all GA. - AWS Marketplace AMI / single-instance appliance.
nexus-ami/bakes the binaries, UI, Prisma, nginx, PostgreSQL, Valkey, and NATS into one AL2023 image via Packer, with Vectorscan compiled on-instance and the rig-validated audit-write defaults shipped in. Seenexus-ami/README.mdanddocs/developers/architecture/cross-cutting/deployment/ami-appliance-architecture.md. - Vectorscan-backed hook pattern matching with an edit-time pattern performance test in the Control Plane (governance) so admins see a rule's scan cost before saving.
- Semantic vector cache tiering — the L1 exact-match extract and L2 semantic lookup are now independent tiers.
- On-demand profiling — a
NEXUS_PPROF_ADDRpprof endpoint on all four services plus SIGUSR1 file dumps that include GoMemStats. - Typed error banner for non-200 rows in the audit drawer.
- COPY-based bulk insert for
traffic_event/traffic_event_payload, with a row-backing pool to cut per-batch allocations. - Adaptive memory/disk self-tuning of the audit pipeline: lossless spill-recovery, backlog-aware drain, batched spill with geometric growth, and a lazy-canonical default.
- Hook scan folds each hook's raw-body prefilters into one union scan, caps wide repeats in the detection database, and ships an AVX-512 build flag.
- Lower allocation on the audit/alert hot paths — lock-free precomputed alert dispatch, zero-copy pooled slim decodes, and typed identity/detail structs replacing map reflection.
- Dropped 7 rarely-read
traffic_eventindexes to cut ingest write-amplification.
- View-time normalization uses the ingress wire format rather than the upstream adapter format, so the audit drawer renders the request as the client sent it.
- Routing-strategy filter lists all canonical strategies with labels.
- Dashboard number formatting — token B/T tiers and cost separators.
- Governance pattern-performance endpoint returns
[]rather thannull.
- The in-tree load generator (
tools/loadtest) was extracted to the standalonenexus-loadtestrepository.
- Emergency cache master kill switch is now wired into the data plane.
cache_master_kill_switch(the Tier-1 global cache config) was parsed but never consulted by the AI Gateway, so flipping it did nothing. It now gates both gateway response cache tiers — L1 exact-match and L2 semantic — at the cache stage (cacheEnabled = (l1||l2) && !cache_master_kill_switch). It does not disable provider-side prompt caching (Anthropic markers / Gemini context cache), which only makes the upstream cache and never serves a stored gateway response. - L1 exact-match cache fills regardless of the
cache.brokerflag. Withcache.broker=false(the default) the broker registry was never constructed and the broker pump is the cache's sole writer, so an admin-enabled L1 tier silently never filled (0% hit rate). The registry is now always constructed;cache.brokercontrols only same-key in-flight dedup (coalesce concurrent same-key MISSes onto one upstream call vs. independent calls) — either way the cache fills. - L1 cache no longer serves cross-VK entries during the boot window or on
Sentinel/Cluster Redis. L1 folds the fleet
vary_byisolation scope into its cache key, but that scope arrives on the semantic-cache config push. Before the first push the scope was unset (fleet-wide), so an entry written in that window could be read by a different virtual key; and on Sentinel/Cluster Redis the semantic config was never delivered to the gateway at all. L1 now fails closed (no lookup/store) until the fleet config has loaded, and the config snapshot (includingvary_by) is delivered on every Redis topology — decoupled from the*redis.Client-only index lifecycle.
First general-availability release. All three intercept planes (AI Gateway, Compliance Proxy, Desktop Agent) and the full architecture — Hub Thing/shadow model, control plane + UI, compliance/audit pipeline, provider-adapter framework — are production-complete. macOS + Linux desktop agents are GA (Windows experimental).
- Desktop Agent AI-chat capture (macOS + Linux GA). End-to-end interception
and structured normalization of AI-chat traffic — codex (OpenAI Responses on
chatgpt.com), Cursor (app +
cursor-agentCLI via/agent.v1.AgentService/Run), and browser web-chat — into the audit /traffic_eventpipeline without breaking the tools. macOS uses theNETransparentProxyProvidersystem extension as the sole intercept path. - Cursor connect-RPC decoder: per-frame gzip-decompressed agent-service frames decode embedded OpenAI-compat / Lexical JSON into structured conversation + model + readable tool calls.
- AI vibe-coding documentation surface (
docs/developers/workflow/ai-workflow.md,docs/developers/workflow/ai-skill-catalog.md). - Two binding lints with HARD pre-commit + strict CI gates:
check-no-prod-todos.mjsandcheck-no-yaml-secrets.mjs; reverse-grep detection incheck-no-redis-pubsub.mjs. .github/ISSUE_TEMPLATE/and.github/CODEOWNERS.
useapi-querykeyandno-redis-pubsublints ratcheted from warn-only to HARD pre-commit + strict CI.- Streaming-policy three-service alignment: all three data planes load the
streaming-policy snapshot from the Hub-pushed
streaming_compliance.configshadow; an unreadable snapshot at boot resolves topassthrough(DefaultPolicy()) rather than a hard-coded YAML value. MQBatchWriter.Flush()coordinates with the writer loop so all pending events are drained, including those moved into the loop's private buffer.
traffic_eventrequested-vs-routed semantics. REQUESTED columns (model_id/provider_id/provider_name) mean what the client asked for and are NULL when the request did not pin a single catalog model; therouted_*columns carry what actually served, and all usage / cost / analytics attribute by the routed side. Direct consumers readingprovider_id/model_idas "what served" should switch torouted_*.- Connect-RPC envelope flags (
0x01per-message gzip vs0x02end-of-stream) are decoded distinctly, fixing Cursor/agent.v1.AgentService/Runcapture. - Cursor host interception is chat-only —
*.cursor.shpasses through by default and captures only chat-bearing paths. docker-compose.ymlPostgres credentials honor${POSTGRES_*}overrides.
Shipped work accumulates under Unreleased; at each release cut the section is
renamed to [X.Y.Z] — YYYY-MM-DD and a fresh Unreleased opens above it. Each
release mirrors the structure above
(Added / Changed / Performance / Fixed / Removed / Deprecated /
Security).
Versioning policy:
- Major — a breaking change to a shipped contract (public/admin API,
routing-rule schema,
traffic_event_*tables, agent↔Hub wire) with no in-place migration path: a re-architecture an existing deployment cannot follow without rework. - Minor — new features, performance work, and schema changes that ship with an automated migration, even when direct database consumers must adapt — those adaptations are called out per entry under "BREAKING (migration required)".
- Patch — bug fixes, docs, and lint changes.