Decisions
The decision log behind the gateway contract: each question, the decision taken, where the spec states it and the evidence that proves it. It is for maintainers; spec.md is normative for behavior, and the table format and the rule for contract changes are in platform decisions.
Read byRepository maintainer
Code cites these entries as "decision N"; numbers never change. A "Stated in" of — means the decision shapes the implementation without a dedicated spec section.
| # | Question | Decision | Stated in | Status | Evidence |
|---|---|---|---|---|---|
| 1 | Supported runtime versions | Built, tested and released on Bun 1.4.3; the contract says Bun ≥ 1.4.3. Bun 1.2.0 and 1.2.23 lack the early answer, body-producer cancellation, observable upstream handshake refusal and the caller upgrade with gateway headers. | §4.2 | resolved | internals.md, test/network/transport.test.ts |
| 2 | Upload and response commitment boundaries | Observable events are the end of the request stream, the final response headers and the first body byte; peer acceptance is not claimed. A failure after headers ends the body instead of replacing the answer. | — | resolved | test/network/transport.test.ts |
| 3 | Refusal of an upstream WebSocket handshake | Distinct from a network failure: the caller gets 502 bad_gateway without the upstream body, the status is logged and the slot released. | — | resolved | test/network/transport.test.ts |
| 4 | Connection establishment during an upload | No extra ceiling: the upload is bounded only by the idle bodyMs, and connecting counts toward responseMs. | — | resolved | test/network/transport.test.ts |
| 5 | The caller's Authorization header and the standard Forwarded header | forwarded is removed both ways; Authorization is forwarded unchanged, since identity travels in the signed gateway headers. | §7.1 | resolved | src/domains/forwarding/headers.test.ts |
| 6 | Header representation | Roles are comma-separated; a role with a comma or control character is dropped from the header but sent to Access. Identity values are cut to 512 characters and stripped of control characters. Cookie names are case-sensitive. | — | resolved | src/domains/forwarding/identity-signature.test.ts, src/domains/forwarding/headers.test.ts |
| 7 | Token purpose | The provider has no token-type claim, so the gateway checks signature (pinned algorithms), issuer, audience, expiry (five seconds' tolerance) and subject. Token type is decision 38. | — | resolved | test/integration/oidc.test.ts |
| 8 | Consuming the login transaction | Single-use four ways: the state cookie is deleted, the callback state must match it, verifier and nonce bind the exchange, and the provider's code is single-use. | — | resolved | src/domains/authentication/principal.test.ts |
| 9 | Decision-cache outage | Only explicit decisions are cached. Without the cache the gateway asks again and denies on failure. | — | resolved | src/domains/workspace-access/workspace-access.test.ts, test/integration/access.test.ts |
| 10 | Live WebSocket authorization | Checked at the handshake and every GATEWAY_WS_REVALIDATE_INTERVAL_MS (route, session, fresh access decision); any failure, or a check that cannot run, closes with 1008. | §10 | resolved | src/domains/websockets/bridge.test.ts, test/contract/websocket-revocation.test.ts |
| 11 | Provisioning the signature verifier | Upstreams got a derived key, versioned v3. Superseded by decisions 40 and 57. | §7.4 | superseded | src/domains/forwarding/identity-signature.test.ts |
| 12 | External host and response caching | No response caching and no added cache headers; upstream responses pass as is. | — | resolved | test/contract/endpoints.test.ts |
| 13 | Replica topology and shared-state TLS | Replicas share sessions, limits and decisions through a server verified with the deployment CA. Process caps are per process, so replicas multiply them. | — | resolved | operations.md, src/runtime/env/ |
| 14 | Drain policy | Readiness answers 503 draining, the listener stops, WebSockets close with 1001, and exchanges are cancelled at the deadline. | — | resolved | test/contract/lifecycle.test.ts |
| 15 | Request host | The effective host is a trusted proxy's x-forwarded-host (last value), else Host; untrusted or implausible values are ignored. It picks the route, is checked against the origin and is signed. | §7.3 | resolved | test/contract/forwarding.test.ts |
| 16 | Request metrics | Every answer is counted, refusals included, by route or unmatched. Labels are bounded (other; overflow beyond 500 routes). | §12.1 | resolved | test/contract/metrics.test.ts, src/observability/observability.test.ts |
| 17 | Limit scopes | Four: deployment, caller across routes, caller per route, subject per route. Address keys alone cannot bound a distributed flood or separate tenants behind one address. | §9 | resolved | src/domains/rate-limiting/rate-limiting.test.ts |
| 18 | In-memory limiter table | Bounded at 10 000 keys; a key leaves only when its window ends, never to make room. When full, overflow requests share one window. | — | resolved | src/domains/rate-limiting/rate-limiting.test.ts |
| 19 | Concurrent exchanges | Capped by GATEWAY_MAX_CONCURRENT_REQUESTS, because a body that never arrives holds a connection for the idle upload budget (five minutes). Overflow gets 503 gateway_overloaded. | §11.2 | resolved | src/domains/proxying/exchange.test.ts |
| 20 | Rate limits in production | Production refuses to start with every limit off unless GATEWAY_ALLOW_NO_RATE_LIMITS=true: the limiter is the only flood protection. | — | resolved | src/runtime/env/env.test.ts |
| 21 | Access revocation | Latency is the decision-cache TTL (GATEWAY_ACCESS_CACHE_TTL_MS, 5 s). WebSocket revalidation skips the cache. | — | resolved | src/domains/workspace-access/workspace-access.test.ts |
| 22 | Production state | Sessions and limits live in Redis or Postgres unless GATEWAY_ALLOW_MEMORY_STATE=true declares a single node. In-memory state logs callers out at random and multiplies limits by replicas. | §4.3 | resolved | src/runtime/env/env.test.ts |
| 23 | Per-route cross-site policy | Route property crossSite. Cross-site navigations are allowed by default (deep links); strict suits upstreams that change state on GET. GATEWAY_CSRF_STRICT is the default. | §8 | resolved | src/domains/configuration/configuration.test.ts, test/contract/policies.test.ts |
| 24 | The caller's Authorization header | Not forwarded by default: the credential of the caller stays in the trust domain of the gateway, and a route that needs it sets forwardAuthorization: true. | §7.1 | resolved | src/domains/forwarding/headers.test.ts, test/contract/policies.test.ts |
| 25 | Revocation latency | Route property accessCacheTtlMs; 0 makes revocation immediate. The cache key includes the route. | §6 | resolved | src/domains/workspace-access/workspace-access.test.ts, test/contract/policies.test.ts |
| 26 | WebSocket handshake header policy | The handshake follows the HTTP header policy: no gateway cookies upstream, gateway-written identity, caller headers not copied. It used to leak the session. | §7.1, §10 | resolved | src/domains/forwarding/headers.test.ts, test/contract/policies.test.ts |
| 27 | Upstream TLS | Upstream certificates are verified with the deployment CA; the deployment certificate is presented on request. Certificate without key, or the reverse, stops startup. WebSocket upstreams use plain http. | §11.1.2 | resolved | src/runtime/env/env.test.ts, test/network/tls.test.ts |
| 28 | Identity signature format | A JWS with a detached canonical JSON payload, replacing the hand-rolled v3 digest so upstreams verify with one library call. Superseded by decisions 40 and 57 (ES256). | §7.4 | superseded | src/domains/forwarding/identity-signature.test.ts |
| 29 | Cookie parsing | A standards library parses and renders cookies, replacing two inconsistent parsers (RFC 6265). For a duplicated name the first value wins. | — | resolved | src/domains/forwarding/cookies.test.ts |
| 30 | Limiter window | Sliding, in at most 60 buckets whose boundaries every replica computes alike; a fixed window lets twice the limit through at a boundary. | — | resolved | src/domains/rate-limiting/window.test.ts, test/integration/redis-limiter.test.ts |
| 31 | Shared-state client | The redis package (cluster and Sentinel support), offline queue off so commands fail fast, connection in the background so the gateway starts while Redis is down. | — | resolved | src/platform/redis/client.test.ts, test/integration/redis-sessions.test.ts |
| 32 | Shared-state backend | Redis or Postgres by URL scheme, under one contract and one test suite (test/support/shared-state-conformance.ts), benchmarked side by side (scripts/ops/benchmark.ts). | §4.1, §12.5, §12.6 | resolved | test/integration/postgres-conformance.test.ts, test/integration/redis-conformance.test.ts, internals.md |
| 33 | Postgres atomicity | The two Redis scripts are two Postgres functions; an advisory lock serializes one key, so the deployment counter is exact across processes. | — | resolved | src/platform/postgres/schema.ts, test/integration/postgres-replicas.test.ts |
| 34 | Postgres durability | GATEWAY_POSTGRES_SYNCHRONOUS_COMMIT=off by default: the state is a protection, not a ledger, and the limiter is on every request. | — | resolved | internals.md, src/platform/postgres/client.ts |
| 35 | Postgres row expiry | Expiry is a column; adapters sweep, and reads ignore expired rows. | — | resolved | test/integration/postgres-conformance.test.ts |
| 36 | Runtime version | The gateway refuses to start on Bun older than 1.4.3, checked before the environment, because on 1.2 it booted and then misbehaved. | §4.2 | resolved | src/runtime/version.ts, src/runtime/version.test.ts |
| 37 | Access outage | An explicit {"allow": false} is 403 workspace_forbidden (access_denied); no answer or an unreadable one is 503 access_unavailable with retry-after (gateway_access_unavailable_total), so outages are not mistaken for policy. | — | resolved | src/domains/workspace-access/decision.ts, src/app/dispatcher.ts, test/contract/endpoints.test.ts |
| 38 | Bearer token type | Bearer tokens must be access tokens: a token with a nonce (an id token of this client) is refused, unless disabled. A provider with a type claim names it in GATEWAY_OIDC_REQUIRED_TOKEN_CLAIMS. | — | resolved | src/domains/authentication/oidc.ts, test/integration/oidc.test.ts, test/contract/endpoints.test.ts |
| 39 | Request budgets | The three budgets are counted concurrently in one shared-state round trip, all counted even when one refuses, before authentication. The reported refusal is the first in spec order. | — | resolved | src/app/limits.ts, src/app/limits.test.ts, test/integration/redis-round-trips.test.ts |
| 40 | Identity signature key | Asymmetric in production: P-256 private key in the gateway, public key in upstreams, ES256, version v5. Production refuses to start without the key. | §7.4 | resolved | src/domains/forwarding/identity.ts, scripts/cli/identity-key.ts, test/contract/identity-signature.test.ts |
| 41 | In-flight Access calls | Concurrent requests for the same decision share one call, so cache expiry does not stampede the service. | — | resolved | src/domains/workspace-access/client.ts, src/domains/workspace-access/workspace-access.test.ts |
| 42 | Path rewrite | A route may declare its upstream path from placeholders and {rest}. Values keep the caller's encoding; the signature covers the rewritten path; routes without one are unchanged. | — | resolved | src/domains/routing/match.ts, src/domains/configuration/schema.ts, test/contract/rewrite.test.ts |
| 43 | Request trace | The gateway writes its own W3C traceparent as a child span, logs the trace ID beside the request ID, and measures gateway_upstream_duration_seconds separately. | — | resolved | src/domains/forwarding/trace.ts, src/domains/proxying/exchange.ts, test/contract/trace.test.ts |
| 44 | Route upstreams | A route may list upstreams, tried in order on connection failure for resendable requests only. The response budget covers the whole exchange; WebSocket handshakes use the first upstream. | — | resolved | src/domains/proxying/exchange.ts, src/domains/configuration/schema.ts, test/contract/upstreams.test.ts |
| 45 | Access protocol | Published in spec §6.1, with a conformance suite and a production-ready reference implementation the suite runs against. | §6.1 | resolved | docs/gateway/spec.md §6.1, test/support/access-conformance.ts, services/access-service.ts |
| 46 | Running policy | Each generation logs the configuration, each route, and a short list of warnings for permissive settings. | §12.2 | resolved | src/domains/configuration/describe.ts, src/runtime/policy-log.ts, src/runtime/policy-log.test.ts |
| 47 | Unresponsive route upstreams | With a successor available, an attempt is abandoned after upstreamAttemptMs (2 s), resendable requests only, so a silent upstream no longer consumes the whole budget. | §11.4 | resolved | src/domains/proxying/exchange.ts, test/contract/upstreams.test.ts |
| 48 | Replayable body | On multi-upstream routes, a body with an announced length within replayMaxBytes (64 KiB) is held so the request can fail over; others stream once. Only idempotent methods are resent. | §11.4 | resolved | src/domains/proxying/exchange.ts, src/domains/proxying/exchange.test.ts, test/contract/upstreams.test.ts |
| 49 | Route methods | A route may name methods; among same host and path, a method match beats the fallback. Matching on headers or query is excluded so ambiguity stays checkable at load. | §3.3 | resolved | src/domains/routing/match.ts, src/domains/routing/routing.test.ts, test/contract/methods.test.ts |
| 50 | Access outage grace | Opt-in accessStaleGraceMs (default 0) serves the last allow decision during an outage. Denials are never served stale, and fresh revalidations never use it. | §6.2 | resolved | src/domains/workspace-access/client.ts, src/domains/workspace-access/workspace-access.test.ts, test/contract/access.test.ts |
| 51 | Routes without a workspace | accessCheck asks about the route (subject, route, roles) with no workspace; it requires an authentication mode other than none. | §6.3 | resolved | src/app/dispatcher.ts, src/domains/workspace-access/client.ts, services/access-service.ts, test/support/access-conformance.ts, test/contract/access.test.ts |
| 52 | Body of a request never resent | A body is held only if it can actually be replayed (several upstreams, idempotent method, within the limit), so POST bodies stream and keep the early answer. | §11.4 | resolved | src/domains/proxying/exchange.ts, src/domains/proxying/exchange.test.ts |
| 53 | Release compatibility | Behaviour, access protocol, signature format, route-table format, variable and metric names are versioned promises (spec §14): MINOR extends, MAJOR changes with a migration. Code layout and log wording are not. | §14.1, §14 | resolved | docs/gateway/spec.md §14, src/runtime/composition.ts, src/domains/forwarding/identity.ts |
| 54 | Upstream health | A passive, per-process breaker moves recently failed upstreams to the end of the list, keeping file order within each group. It never probes, and state-changing requests are still sent once. | — | resolved | src/domains/proxying/breaker.ts, src/domains/proxying/breaker.test.ts, test/contract/upstream-breaker.test.ts |
| 55 | Revoking a caller's sessions | Sessions are indexed by subject (hashed in Redis), and a script, not an endpoint, revokes them. Both backends hold the index. | — | resolved | src/domains/authentication/adapters/redis-sessions.ts, src/domains/authentication/adapters/postgres-sessions.ts, scripts/cli/sessions.ts, test/support/shared-state-conformance.ts |
| 56 | Cookies the gateway reads | The parser reads only what the renderer would forward: no nameless pairs, no control characters in names, control characters stripped from values. Found by property tests. | — | resolved | src/domains/forwarding/cookies.ts, src/domains/forwarding/cookies.test.ts, test/fuzz/parsers.test.ts |
| 57 | Removing the symmetric identity format | v4, whose key every upstream shared, is removed in 1.0. Without GATEWAY_IDENTITY_SIGNING_KEY, non-production processes sign with a per-process key no upstream can verify. | §7.4 | resolved | docs/gateway/spec.md §7.4, src/domains/forwarding/identity.ts, src/runtime/composition.ts, src/runtime/env/ |
| 58 | Freezing the 1.0 contracts | The spec §14.1 contracts (§3, §6.1, §7.4, §4 names, §12.1 names, §14.2 answers) are frozen; the review changed only the signature (decision 57). | §14.1, §14.2, §14 | resolved | docs/gateway/spec.md §14, CHANGELOG.md |
| 59 | Requests waiting on a dependency | GATEWAY_MAX_PENDING_REQUESTS (1 000) bounds requests inside the gateway, including those waiting on Access; overflow gets 503 gateway_overloaded at once. Tuned separately from the exchange cap. | §2.4, §11.2 | resolved | src/app/admission.ts, test/contract/admission.test.ts |
| 60 | Access health | A per-process breaker stops calling after GATEWAY_ACCESS_BREAKER_FAILURES (5) failures for GATEWAY_ACCESS_BREAKER_COOLDOWN_MS (5 s). It only refuses; denials and 400 are not failures; grace still applies. | §6.4 | resolved | src/domains/workspace-access/breaker.ts, test/contract/access-breaker.test.ts |
| 61 | Denial reason in metrics | The protocol carries an optional reason code, counted when present; the deprecated free-text rule remains for services without one. | §6.5 | resolved | src/domains/workspace-access/decision.ts, test/contract/access-reason.test.ts |
| 62 | WebSocket revalidation schedule | Jittered: the first check is spread over the interval and periods vary within a 40 % band, so connections opened together do not hit the service together. | §10 | resolved | src/domains/websockets/bridge.ts, src/domains/websockets/bridge.test.ts |
| 63 | Unwritable caller identity | A subject that cannot be written into a header gets an explicit 500 with the route logged, instead of a thrown error; the identity is never forwarded. | — | resolved | src/app/dispatcher.ts |
| 64 | Bearer token lifetime | Tokens must carry exp, and iat while the bound is on; lifetime (issue to expiry) is capped by GATEWAY_OIDC_MAX_TOKEN_AGE_MS (one day). Browser sessions are bounded by their maximum age. | §5.4 | resolved | src/domains/authentication/oidc.ts, test/integration/oidc.test.ts |
| 65 | Limits while the shared state is unreachable | GATEWAY_RATE_LIMIT_FALLBACK: local (default, per-process windows), open (earlier behaviour) or closed. Fallback is counted in gateway_rate_limit_fallback_total and logged. | §9 | resolved | src/domains/rate-limiting/fallback.ts, test/contract/limits-fallback.test.ts |
| 66 | The caller in the log | Request and refusal lines carry the subject and workspace. The subject is an identifier, not a credential, so it is not redacted. | §12.2 | resolved | src/app/dispatcher.ts, test/contract/logs.test.ts |
| 67 | Postgres server version | Built and tested on PostgreSQL 18 but not enforced: the SQL works on 9.6 and later, and no spec boundary depends on the version. | §12.5 | resolved | docs/gateway/spec.md §12.5, src/platform/postgres/schema.ts, .github/workflows/ci.yml, scripts/dev/postgres-dev.sh |
| 68 | Deployment control plane | Administration is a separate process, gateway-control, on an internal socket with scoped tokens and an audit entry per write. The gateway has no admin endpoint, and only the control plane's shared-state role may write the route table. | §12.7 | resolved | docs/gateway/spec.md §12.7, src/domains/control/policy.ts, src/app/control.ts, test/contract/control-plane.test.ts, test/integration/control-runtime.test.ts |
| 69 | Route table in the shared state | GATEWAY_CONFIG_SOURCE=store: immutable, validated revisions plus an active pointer, compare-and-swap writes, polled at least once a second; the last valid table survives an unreachable store. Heartbeats list live replicas. | §3.4 | resolved | docs/gateway/spec.md §3.4, src/domains/configuration/revisions.ts, test/support/shared-state-conformance.ts, test/integration/route-table.test.ts |
| 70 | Route precedence | Host first, then method, so a catch-all naming GET can no longer shadow a host's own route. | §3.3 | resolved | src/domains/routing/routing.test.ts, test/contract/precedence.test.ts |
| 71 | Caller address | x-forwarded-for is read from the right, skipping GATEWAY_TRUSTED_PROXY_HOPS entries, so a caller cannot pick its own rate-limit key. | §7.3 | resolved | src/domains/forwarding/context.test.ts, src/domains/forwarding/headers.test.ts |
| 72 | Caller subject | Written as is; an empty, control-character or over-512 subject is refused with 500, because sanitizing could merge two callers. E-mail and workspace are still sanitized. | §7.1, §7.4 | resolved | src/domains/forwarding/identity-signature.test.ts |
| 73 | Trusted proxy ranges | Environment ranges replace the file's instead of adding to them; the sample trusts nothing. | §3.1, §7.3 | resolved | src/domains/configuration/configuration.test.ts |
| 74 | Redis shared-state users | The ACL file is one rule per line, with ~, %W~ and %R~ patterns and no passwords; passwords are set with ACL SETUSER after start. An earlier file with comments and >~ patterns broke startup and published passwords. | — | resolved | test/integration/least-privilege.test.ts, deploy/redis/gateway.acl |
| 75 | Session cookie and bearer token in one request | The token authenticates; cross-site rules still apply because the cookie was sent. | §5.1, §8 | resolved | src/domains/authentication/policy.ts, test/contract/endpoints.test.ts |
| 76 | Cross-site requests refused, navigations allowed | State-changing cross-site requests are refused; safe navigations pass unless GATEWAY_CSRF_STRICT; cross-site /auth/logout is always refused. | §8 | resolved | src/domains/forwarding/origins.ts, test/contract/policies.test.ts |
| 77 | Pipeline order | Gateway endpoints first; in the dispatcher, route matching precedes the limiters, which count unmatched requests before refusing them. | §2.3 | resolved | src/app/dispatcher.ts, test/contract/pipeline.test.ts |
| 78 | Upstream URL with a base path | The base path prefixes the request path, the request query replaces the upstream's, fragments drop; an upstream URL with a query or fragment fails at load. | §3.1, §11.1 | resolved | src/domains/forwarding/target.ts, test/contract/rewrite.test.ts |
| 79 | Failure while the body streams | The body ends early and the outcome is aborted; no JSON error replaces a started response. | §11.3 | resolved | src/domains/proxying/exchange.ts, test/contract/endpoints-failures.test.ts |
| 80 | Meaning of bodyMs | Idle budget, reset per chunk, governing the upload; responseMs and firstByteMs are totals. | §11.2 | resolved | src/domains/configuration/schema.ts, test/contract/limits.test.ts |
| 81 | Budget names | connectMs became responseMs and headersMs became firstByteMs; the old names are refused. | §11.2 | resolved | src/domains/configuration/schema.ts, src/domains/configuration/configuration.test.ts |
| 82 | Upstream answers before the upload ends | The upstream's answer wins; 413 only for a failure found before it. | §11.3 | resolved | src/domains/proxying/exchange.ts, test/contract/endpoints.test.ts |
| 83 | WebSocket handshake origin | With the session cookie, an allowed origin is required (missing is refused), regardless of GATEWAY_CSRF_STRICT, before the upstream is contacted. | §8, §10 | resolved | src/domains/forwarding/origins.ts, test/contract/websocket-revocation.test.ts |
| 84 | Browser cookie | Refreshed on every session request with Max-Age = the smaller of remaining idle lifetime and remaining maximum age. | §5.3 | resolved | src/domains/authentication/cookies.ts, test/contract/forwarding.test.ts |
| 85 | Request identifier | A valid incoming x-request-id is reused, otherwise a UUID is generated, and it is returned. | §2.1 | resolved | src/app/app.ts, test/contract/endpoints.test.ts |
| 86 | Maximum age of 0 | No cap. | §5.3 | resolved | src/domains/authentication/session.ts, test/contract/endpoints.test.ts |
| 87 | Response without a body | firstByteMs is not armed for HEAD or bodiless statuses. | §11.2 | resolved | src/domains/proxying/exchange.ts, test/contract/endpoints.test.ts |
| 88 | 103 and 101 | 103 is skipped for the final response; 101 on the HTTP path is refused. | §11.2 | resolved | src/domains/proxying/exchange.ts, test/contract/endpoints.test.ts |
| 89 | OIDC token validation | The browser flow requires nonce, single-use state, PKCE verifier and id token, with fixed cookie attributes. | §5.2, §5.4, §5.5 | resolved | src/domains/authentication/oidc.ts, test/integration/oidc.test.ts |
| 90 | Maximum session lifetime | Idle lifetime plus a maximum age from creation; the maximum age is the real bound. | §5.3 | resolved | src/domains/authentication/session.ts, test/contract/endpoints.test.ts |
| 91 | Readiness after a failed reload | Stays ready on the last valid configuration; the failure shows in /readyz, logs and metrics. | §12.3 | resolved | src/domains/configuration/revisions.ts, test/contract/lifecycle.test.ts |
| 92 | Code organization | Domains hold policy, use cases, ports and adapters; app, platform and runtime are explicit boundaries checked in CI (internals.md). | — | resolved | src/app/app.ts, test/architecture/imports.test.ts |