Repository navigation
Configuration
One file: app.config.ts at the repo root. There is no per-environment config directory, no config/production.ts, no .env.local cascade. Environment differences are env vars, validated once at boot.
As of 2026-08-22. Stable API — semver from here (Upgrading). Field names are covered by semver: renaming or removing one needs a major, and the entry in Upgrading names the edit. There is no codemod — x upgrade is a PLANNED_COMMANDS entry (packages/cli/src/cmd-planned.ts), so every migration below is a hand edit.
import { defineConfig, defineEnv } from '@ultimat3/core';
// Module scope, in `app.config.ts` itself: the file is imported at boot, so the env gate runs
// before any listener binds. There is no separate `env.ts` — one config file means one.
export const env = defineEnv({
APP_URL: { type: 'url' },
DATABASE_URL: { type: 'url' },
SESSION_SECRET: { type: 'string', secret: true },
});
export const config = defineConfig({
name: 'postly',
// No connection string and no pool size: both are env (`DATABASE_URL`, `DATABASE_POOL_MAX`),
// read where the client is built, so the same image deploys to every environment.
database: { ssl: true },
// The tiers ARE the selection: naming `redis` is what builds the shared rung, and it reads
// `REDIS_URL` — there is no second `driver` field to agree with, and no env key to name.
cache: { tiers: ['request-memo', 'lru', 'redis'] },
jobs: { queues: ['postly-default'], concurrency: 8 },
pwa: { enabled: true, offline: { fallback: '/offline' } },
});This example compiles. It did not until 2026-08-22 — it passed database: { urlEnv, poolSize }, two keys deleted from DatabaseConfig, so the first thing an agent copied off this page was TS2353. Every table below is now derived from the interfaces in packages/core/src/config.ts rather than curated beside them.
Everything derivable from code is not in this file — routes, actions, policies, jobs, tags all live in the generated x.manifest.json. Inspect the resolved config with x config show --json.
AppConfigInput (packages/core/src/config.ts) carries exactly these keys As of 2026-10-06: name, theme, auth, pwa, roles, database, cache, jobs, realtime, notify, ai, drain, health, site, seo, navigation, islands, mail. That type is the contract, and every table below names only its members — a block with no key here (http, seo, budgets, mail, storage, otel) is not an app.config.ts field and its section says where the real knob is instead.
The shape is closed, As of 25.0.0: a key defineConfig does not declare — at any depth, in app.config.ts or any config/*.ts overlay — is refused at boot with X_CONFIG_INVALID, naming the full path and the nearest real key (drian is not an app.config.ts key — did you mean drain?, fix rename drian to drain in app.config.ts). Before 25.0.0 section() copied an unknown key through and nothing read it, so a typo or a JS config that skipped the compiler was a switch with no wire. A key an earlier major deleted gets its own refusal first, naming the major and the replacement (REMOVED_CONFIG_KEYS in packages/core/src/config-removed.ts). The known keys are the defaults' own plus SECTION_KEYS in packages/core/src/config-keys.ts — the members with no default (pwa.id, pwa.shortcuts[], pwa.colors, mail.retainMime, navigation), each row checked against its interface by tsc. The only positions whose keys an app chooses are OPEN_CONFIG_PATHS: the locale-keyed manifest texts (pwa.description, a shortcut's name/shortName/description, a screenshot's src/label). A key written as undefined is a layer not saying, and is never refused.
| field | type | default | notes |
|---|---|---|---|
name |
string |
required |
^[a-z][a-z0-9-]{1,63}$. Names the dev DB, the image, the queue prefix |
localesdefaultLocale |
— | — |
Deleted in 25.0.0 — a second declaration of the app's locales. The one source is defineCatalogs({ default: 'en', locales: { en, es } }) from @ultimat3/i18n: the keys are the locales, default the fallback. A config still writing either is refused at boot, X_CONFIG_INVALID naming the key and the replacement |
defaultTimeZone |
— | — |
Deleted in 25.0.0 — read by nothing but its own validator, and an ambient zone is what the framework forbids. Pass the zone at the call: formatDate(at, { locale, zone }), task({ tz }), the actor's tz (userActor({ id, locale, tz })). Refused at boot like the two above |
defaultCurrency |
— | — |
Deleted in 25.0.0 — read by nothing; every Money carries its own currency. An app that wants a default declares its own constant. Refused at boot like the three above |
theme.defaultMode |
'light' | 'dark' | 'system' |
'system' |
what a visitor with no stored choice gets. The boot inlines the no-flash theme script into every document with this as its fallback and admits it to the CSP — As of 20.2.0; before that the key was read by nothing. theme.tokensexport const brand = defineTheme(…) from @ultimat3/ui in apps/web/shared/theme.ts) and is refused at boot |
roles |
Role[] |
every ROLE
|
which runtime roles this app runs. Empty is X_CONFIG_INVALID
|
There is no url field. The canonical origin is an env key the app reads at its point of use (APP_URL), so the same image deploys to every environment.
DatabaseConfig is two fields, deliberately — the connection itself is env, not config.
| field | type | default | notes |
|---|---|---|---|
database.driver |
'postgres' |
'postgres' |
one driver; postgresDriver() from @ultimat3/entity is its only implementation |
database.ssl |
boolean |
false |
true on managed Postgres |
database.urlEnv |
— | — |
Deleted 2026-08. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). @ultimat3/db's client.ts reads process.env['DATABASE_URL'] as a hardcoded literal, so a different key here could never be honoured. Migration: delete the key; the connection string is DATABASE_URL
|
database.poolSize |
— | — |
Deleted 2026-08. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). A second, non-functioning spelling of DATABASE_POOL_MAX, which baseClient() layers over the role profile and which works. Migration: delete the key; set DATABASE_POOL_MAX. The sizing rule is unchanged — the pool is per PROCESS, so keep replicas × poolMax under Postgres max_connections
|
database.schema |
— | — |
Deleted 2026-08. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). Nothing emits SET search_path. Migration: delete the key; there is no replacement, and entity() tables live in public
|
Wiring the three instead would have needed a tier-0 → tier-1 read the tier table forbids. Deleting is axiom 3 applied to configuration: a value that produces neither a build error nor a runtime effect is worse than no field, because an SRE sets poolSize: 3, redeploys, and nothing changes.
As of 2026-08-24. Opt in twice, and the second opt-in is the safety argument rather than an ergonomic one.
| Step | What it is | Without it |
|---|---|---|
| 1. the pool |
DATABASE_REPLICA_URL names a read-only standby. defaultClient() reads it once and builds a primary + replica client; the replica inherits the role's pool profile and DATABASE_POOL_MAX, so a fleet sized against max_connections sizes against two servers |
one pool, statement for statement what it always was |
| 2. the scope |
withReplicaReads(fn) from @ultimat3/db — inside it, a statement that is provably a plain read may be answered by the standby |
nothing routes, whatever is configured |
import { db, sql, withReplicaReads } from '@ultimat3/db';
declare const id: string;
await withReplicaReads(async () => {
await db().query(sql`select id from posts limit 20`); // -> replica
await db().execute(sql`insert into posts (id) values (${id})`);
await db().query(sql`select id from posts limit 20`); // -> primary, for the rest of the scope
});| Rule | Detail |
|---|---|
| read-your-writes | one write anywhere in the scope, at any depth, across any await, and every later read in it is the primary's for the rest of the scope. The flag is a mutable value on the async context, not a per-statement computation, which is what lets a write ten frames down be seen by the read after it |
| a transaction | always the primary. reserve() is delegated there, so BEGIN, the body and COMMIT are one connection on one server — a BEGIN that landed on a standby is not a transaction, it is 25006 on the first write inside it. A readOnly: true transaction leaves the scope unmarked |
| what is eligible |
select / table / values / a read-only with, minus locking reads, select … into and the functions a standby answers instead of refusing. Everything the classifier cannot vouch for is the primary's, including begin and set
|
| a replica that fails | the statement is re-run on the primary — exactly-once, because only plain reads are sent there and a 25006 refusal never executed. Three consecutive failures park it for 10 seconds, so an outage costs 3 doubled reads rather than every read |
| observability |
client.stats (replica, primary, fallbacks, parked) and a db.replica_fallback warning per fallback |
The URL must name a read-only standby. The server's own 25006 is the safety net under a classifier that cannot be complete; pointed at a writable node, a misroute becomes a write on the wrong server, silently.
Nothing opens the scope for you yet. withReplicaReads ships first and wrapping a request in it is the app's call, so until that lands no production traffic is routed.
AuthConfig on app.config.ts is one field. Authorization is not here — it is Policies and authz, and the authentication policy is defineAuth(), below.
| field | type | default | notes |
|---|---|---|---|
auth.signInPath |
string | null |
null |
where a browser that failed auth: 'required' is sent. null keeps the redirect off, and that is the default on purpose: the framework may not invent one of its app's routes, and guessing /login would send every unauthenticated visitor to a 404. Null means the visitor gets the problem document — right for an agent, and what a browser got in production until this existed |
auth.afterSignInPath |
— | — |
Deleted in 8.0.0. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). Declared, defaulted and merged, and read by no file — an app that set /dashboard got whatever its own sign-in route already did. Migration: delete the key, and send the visitor where you mean from the sign-in route itself, which is the only code that can honour it |
The rest of authentication is defineAuth(), not app.config.ts (packages/auth/src/auth.ts). It takes an adapter and the policies, because each of them needs a value — a session store, a limiter, a clock — that a serialisable config field cannot carry:
defineAuth key |
shape | notes |
|---|---|---|
adapter |
AuthAdapter |
required; where users and sessions are read and written |
session |
Partial<SessionPolicy> |
absoluteTtlMs 30d, idleTtlMs 7d, cookieName '__Host-x_session', rotateOnPrivilegeChange true. The __Host- prefix is a browser-enforced contract — Secure, Path=/, no Domain — so a subdomain (or an XSS on one) cannot overwrite it |
password |
Partial<PasswordPolicy> |
|
rateLimit |
Partial<AuthRateLimitPolicy> |
scope: 'shared' must be matched by a limiter that says the same, or defineAuth refuses at boot rather than at 3am on the first spray |
limiter / orgLimiter
|
AuthLimiter |
omitted means one process' worth of state, i.e. maxAttempts × N for N replicas. An attempt is reserved before the KDF (reserve / refund), never checked and recorded after it |
mfa |
Partial<AuthMfaPolicy> |
required is typed false and cannot be set true: both credential paths branch on user.mfaSecret, so a user who never enrolled would be locked out for good |
totpReplay |
TotpReplayGuard |
which TOTP steps are spent, read by completeMfa. Omitted means memoryTotpReplayGuard(): in-process, so a code is single-use per replica |
providers |
OAuthProviderId[] |
defaults to [], As of 2026-08-23 — an empty list is "no OAuth", and every /auth/oauth/<id> answers X_OAUTH_PROVIDER_UNKNOWN. Never the live registry: that would let any dependency that calls registerOAuthProvider turn on a login route this app never enabled |
link |
OAuthLinkPolicy |
defaults to 'verified-email'
|
There is no auth.passkeys and no auth.trustedOrigins in either place As of 2026-08-22.
| field | type | default | notes |
|---|---|---|---|
jobs.driver |
— | — |
Deleted in 5.0.0. It accepted 'postgres' | 'redis' | 'nats' and was read by nothing: boot always built the Postgres driver, so jobs: { driver: 'redis' } did not throw, did not warn, and silently gave you Postgres. Which driver runs is setJobDriver(driver) and only that — setJobDriver(postgresJobDriver({ executor })), or setJobDriver(memoryJobDriver()) in a test (Jobs and workflows). Since 25.0.0 a config still writing it is refused at boot, X_CONFIG_INVALID naming that call |
jobs.queues |
string[] |
['<name>-default'] |
derived from name, not the literal ['default']. A worker serves these and every queue a registered job() is enqueued on (the union, so a job naming no queue is never stranded on default). A Deployment that sets WORKER_QUEUES serves exactly that list instead. Empty is X_CONFIG_INVALID
|
jobs.concurrency |
number | Record<queue, number> |
8 (JOBS_CONCURRENCY_DEFAULT) |
slots per queue, per worker process. A number applies to every queue served; a table gives each its own ({ banks: 4, 'banks-long': 2 }), and a served queue the table does not name runs at 8. As of 25.0.0. A count below 1 or not whole, an empty table or a blank queue name is X_CONFIG_INVALID naming the entry (jobs.concurrency.banks). An overlay's table replaces the base's whole |
jobs.maxAttempts |
number |
5 |
per-job retry overrides it |
jobs.backoff |
'exponential' | 'fixed' |
'exponential' |
two values, not three — there is no 'linear'
|
jobs.visibilityTimeoutMs |
number |
30000 |
milliseconds, not a duration string. Lease length; expiry is how a killed worker's job resumes |
There is no jobs.retry object, no jobs.visibilityTimeout and no jobs.retention block As of 2026-08-22 — JobsConfig is { queues, concurrency, maxAttempts, backoff, visibilityTimeoutMs } and the flat spellings above are the whole surface.
Splitting queues across Deployments (As of 25.0.0). Every Deployment runs the same image and the same app.config.ts, so which queues a Deployment claims is env, never config:
# worker-short: 15 min grace # worker-long: 2 h grace
ROLE: worker ROLE: worker
WORKER_QUEUES: banks WORKER_QUEUES: banks-longEach worker then claims its list and nothing else. At boot it logs jobs.worker.queue-unserved (a warning, not a refusal) naming every queue a registered job is enqueued on that it will not claim, so a queue no Deployment serves is visible in the first log line rather than as a backlog. x jobs list --json shows each queue's workers.
RealtimeConfig is five fields and no more As of 2026-10-05 (packages/core/src/config.ts):
| field | type | default | notes |
|---|---|---|---|
realtime.enabled |
boolean |
true |
on by default since 22.0.0, when the boot began obeying the key: an app with no realtime section keeps the sync node it always got. enabled: false is the opt-out — for an app that declares no channel() topic and no live: true query |
realtime.transport |
'memory' | 'nats' | 'redis' |
'memory' |
memory = in-process, single node, dev and small deploys. redis type-checks and is never built — selectTransport resolves in-process or NATS only |
realtime.urlEnv |
string |
— | the env key name, never a URL. Required unless memory; missing → X_CONFIG_INVALID
|
realtime.maxSubscriptionsPerActor |
number |
unset = 1000
|
live-query subscriptions one actor may hold on one sync node, across all its sockets. An anonymous socket counts against the client NETWORK resolved at the upgrade — an IPv4 address exactly, an IPv6 address by its /64, an IPv4-mapped IPv6 as its IPv4 (TRUSTED_PROXY_HOPS honoured, as ctx.ip is). Exceeded → X_SUBSCRIPTION_LIMIT, scope actor. A whole number ≥ 1, else X_CONFIG_INVALID
|
realtime.maxSocketsPerActor |
number |
unset = 16
|
sockets one signed-in actor may hold on one sync node. An anonymous NETWORK (keyed exactly as above) gets 8 × this — 128 at the default — because it is often many people: one office behind a corporate NAT viewing a public live page. The next upgrade is refused before a socket exists: 429 X_SOCKET_LIMIT. Closing a socket frees its slot. 16 × the per-socket 128 subscriptions passes the 1,000 above, so normal tabs hit neither. A whole number ≥ 1, else X_CONFIG_INVALID
|
Behind a proxy, set TRUSTED_PROXY_HOPS. An anonymous socket is keyed by the client network the upgrade resolved (IPv6 grouped by /64, so one host cannot rotate through its block for fresh budgets). With nothing trusted, that address is the proxy's own, so every anonymous visitor shares ONE 1,000-subscription and 128-socket budget — the same failure as the anonymous rate-limit bucket. A socket that names no actor and no address at all shares one address:unknown budget. That fails closed: those sockets are still bounded, just together.
Why 1,000. A node holds at most 10,000 live windows (maxEntries) and a socket 128 subscriptions, so before this cap one actor with 79 sockets filled the node and every other user's next subscribe was refused. A tenth of the node keeps any one principal out of reach of the node cap, and still fits eight tabs at the per-socket 128. Raise it for an app whose single users legitimately watch more; lower it for a public, anonymous surface:
// app.config.ts
export const config = defineConfig({ name: 'shop', realtime: { maxSubscriptionsPerActor: 200 } });Since 22.0.0 the transport is chosen by realtime.transport, never by NATS_URL alone — the config decides, the env supplies the URL realtime.urlEnv names. 'memory' with NATS_URL set, and 'nats' with the named variable unset, are both X_CONFIG_INVALID at boot (17-scale-ladder.md).
realtime.tier is gone, As of 2026-08-23 — the same shape as jobs.driver and
realtime.heartbeatMs before it. It accepted
'channels' | 'live-queries' | 'local-first', defaulted to 'channels' — and nothing
read it: no comparison, no branch, no dereference anywhere in packages/*/src. The only code
that touches config.realtime reads .transport and .urlEnv. So an app declaring
tier: 'local-first' got exactly what an app declaring tier: 'channels' got, and this page
documented per-value semantics the framework never had. Which realtime tier you are on is decided
by what you declare — a channel() topic, a live: true query, a local store — never by a
config key → Realtime. Delete tier: from the realtime block in app.config.ts;
that is the whole migration. Since 25.0.0 a stale tier: is also refused at boot
(realtime.tier was removed in 10.0.0), where before it was a typecheck error only. bun run scripts/config-readers.ts is the ratchet that
now refuses the next one.
realtime.heartbeatMs is gone, As of 2026-08-19. It was declared here with a default of
15 000 and read by nothing; the socket beat is the page socket's own 10 s,
fixed in browser code, which cannot read server config, and the presence beat is derived
(PresenceRegistry.heartbeatMs is max(1000, floor(ttlMs / 3))). A config still writing the key
is refused at boot since 25.0.0 (realtime.heartbeatMs was removed in 4.0.0,
X_CONFIG_INVALID). Before 25.0.0 section() copied every own key of the patch, so a leftover
heartbeatMs was silently kept at runtime and only TS2353 caught it — never for an app that built
its config into a variable first.
realtime.limits.*, realtime.changeBuffer.* and realtime.drain.* are not app.config.ts fields As of 2026-08-19, and never were — RealtimeConfig is { enabled, transport, urlEnv, maxSubscriptionsPerActor, maxSocketsPerActor } (packages/core/src/config.ts). Writing one is a typecheck failure (TS2353, excess property on Input<RealtimeConfig>) and, since 25.0.0, a boot refusal (X_CONFIG_INVALID, the closed shape above) for a config the compiler never saw. The caps and the ring are constructor options, passed where the node is built:
| Option | Where | Default | Effect |
|---|---|---|---|
maxPerSocket |
new LiveQueryRegistry({ … }) |
128 |
subscriptions per socket. Exceeded → X_SUBSCRIPTION_LIMIT, scope socket. Always applies
|
maxPerTenant |
same | none | subscriptions per tenant. Exceeded → X_SUBSCRIPTION_LIMIT, scope tenant
|
tenantOf |
same | none |
(actor) => tenantId | null. Required for maxPerTenant to do anything — assertCapacity returns early when either is absent |
maxPerActor |
same | 1000 |
subscriptions per actor (anonymous: per resolved client address) across its sockets. Exceeded → X_SUBSCRIPTION_LIMIT, scope actor. Always applies; the boot passes realtime.maxSubscriptionsPerActor
|
maxEntries |
same | 10000 |
distinct (query, input) pairs this node will hold — a qid derives from client-chosen input, so each one is a matcher and a row window. Exceeded → X_SUBSCRIPTION_LIMIT, scope node
|
maxTopicsPerSocket |
new ChannelHub({ … }) |
64 |
channel topics one socket may join. Exceeded → X_SUBSCRIPTION_LIMIT, scope socket
|
maxTopicsPerNode |
same | 10000 |
distinct topics this node bridges, one transport subscription each. Exceeded → X_SUBSCRIPTION_LIMIT, scope node
|
maxSocketsPerActor |
syncNode({ … }) |
16 |
sockets per actor at the upgrade; an anonymous network gets 8 × it (ANONYMOUS_SOCKET_MULTIPLIER). Exceeded → 429 X_SOCKET_LIMIT. Always applies; the boot passes realtime.maxSocketsPerActor
|
maxBufferedBytes |
syncNode({ … }) |
1 MiB, exported as DEFAULT_MAX_BUFFERED_BYTES
|
outbound bytes queued on one socket before this node starts dropping its frames. A live-query patch is re-snapshotted; a channel frame is lost → Realtime |
maxDroppedFrames |
same | 32 |
drops one socket may take before it is closed with 1013 (overloaded), reason backpressure
|
capacity |
new RingChangeBuffer({ … }) |
1024 |
retained patches per query hash; a reconnect inside the window is a delta, not a snapshot |
maxQueries |
same | 4096 |
retained query hashes, least-recently-written dropped first |
The per-socket and per-actor caps are enforced by every boot; the per-tenant cap is opt-in As of 2026-10-04. The sync role (role-sync.ts) passes realtime.maxSubscriptionsPerActor into the registry, or leaves the registry's 1,000. It sets no tenant cap: one tenant is one person and the next is five thousand seats, so no default is defensible — set maxPerTenant and tenantOf on the registry yourself if you need one (either alone arms nothing).
CacheConfig is two flat fields. See Caching and invalidation.
| field | type | default | notes |
|---|---|---|---|
cache.defaultTtlMs |
number |
60000 |
milliseconds, not a duration string |
cache.tiers |
CacheTierName[] |
['request-memo', 'lru'] |
'request-memo' | 'lru' | 'redis' | 'cdn'; order is fixed regardless of listing order, an EMPTY list is refused (X_CONFIG_INVALID), and a rung the environment cannot supply refuses the boot |
cache.driver |
— | — |
Deleted in 9.0.0. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). It was the second way to ask for Redis and the losing one: the ladder is built from cache.tiers, so driver: 'redis' beside tiers: ['request-memo', 'lru'] asked for a rung nothing built. Name redis in tiers
|
cache.urlEnv |
— | — |
Deleted in 9.0.0 and refused at boot since 25.0.0, database.urlEnv's defect verbatim: the Redis tier reads the literal REDIS_URL, so urlEnv: 'MY_REDIS' made nothing read MY_REDIS
|
The per-tier byte caps and TTLs are constructor options, not config — the same shape as the realtime caps above. cache.memo.maxBytes, cache.lru.maxBytes, cache.redis.* and cache.ttl.* are not fields and never were; writing one is TS2353, excess property on Input<CacheConfig>, and X_CONFIG_INVALID at boot.
| Option | Where | Default | Effect |
|---|---|---|---|
maxBytes |
lruTier({ … }) |
64 MiB | byte budget for the whole tier; a single value over it is X_CACHE_TOO_LARGE rather than a silent drop |
defaultTtlMs |
same | 60_000 |
applied when a set omits ttlMs
|
jitterFraction |
same | DEFAULT_TTL_JITTER_FRACTION |
TTL spread in [0, 1); 0 disables it, which is how a stampede is reproduced in a test |
clock / rng
|
same | system | injected so a jittered expiry is deterministic |
loadDeadlineMs |
cacheStack(tiers, { … }) |
30_000 |
how long one load() may hold its key before a later reader starts its own. Frees the KEY, never the load — the wedged call runs on. Anchored to http's own request timeout: a load still running at 30 s has no reader left to serve |
schedule |
same | setTimeout |
injected so the deadline above is provable without a test waiting one out |
One vocabulary names the tiers, As of 2026-08-23. CACHE_TIERS in packages/core/src/cache-vocabulary.ts is request-memo | lru | redis | cdn, in ladder order — it is what @ultimat3/cache's sortTiers orders a stack by, imported, not a second list that agrees with it (the TIER_ORDER alias was deleted in 25.0.0). Until 9.0.0 there were two: the config accepted memo | lru | shared | isr | cdn while the ladder built request-memo | lru | redis | cdn, so memo/request-memo and shared/redis were one rung spelled twice and isr named a rung that did not exist — it is a RenderMode, not a cache tier. bun run render-modes refuses a second declaration on the member set, so the two cannot re-diverge.
The purge driver is selected from the environment, not from a config field — the same law the
mail transports follow, and for the same reason: nothing loads app.config.ts's contents at
runtime, so one image deploys to every environment.
| Key | Selects | Notes |
|---|---|---|
FASTLY_API_TOKEN + FASTLY_SERVICE_ID
|
Fastly | batch surrogate-key purge, 256 keys per call |
CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID
|
Cloudflare | cache-tag purge, 30 tags per call, Enterprise zones |
The surrogate keys are built from the tags by @ultimat3/cache's surrogateKeys() — post, post:1, plus one e:<entity> index key per entity (Caching). They are sent on an isr document whose route declares revalidate.tags while the response is shared-cacheable — the same document answered to a signed-in visitor is rewritten to private and carries neither header — as Surrogate-Key (space-separated, what Fastly reads) and Cache-Tag (comma-separated, what Cloudflare reads), so the edge purges what invalidates: [tag.post] busts. As of 2026-10-02 — before it no shipped response carried either header; x-cache-tags, which neither edge reads, is gone.
| Failure | Code | Raised by | Lands |
|---|---|---|---|
| both pairs set — two CDNs claim one purge | X_CONFIG_INVALID |
selectPurgeDriver |
boot |
| half a pair — a token with no id, or an id with no token | X_CONFIG_INVALID |
selectPurgeDriver |
boot |
the provider refused — 401, 429, success: false
|
X_CACHE_PURGE_FAILED |
the purge driver |
report.errors, never the write |
Half a pair is refused because "no CDN" is the one wrong reading — a deployment then ships believing it purges. Both refusals name the keys that are actually set, never their values.
X_CONFIG_INVALID covers a configuration that cannot boot, env or app.config.ts
(Env vars). X_CACHE_PURGE_FAILED is provider refusal only — never a configuration
problem, and never fatal to the write that triggered the bust.
x dev prints which one it installed — cdn=none, or cdn=external(fastly via FASTLY_API_TOKEN). The env key is reported, never its value.
As of 22.3.0. The public origin every absolute URL is built against, and what a production robots.txt keeps crawlers out of.
site: { origin: 'https://www.example.com' },
seo: {
robots: { disallow: ['/panel', '/api'] },
sitemap: { extra: ['/verificar', '/estado'], lastmod: 'git' },
},| field | type | default | notes |
|---|---|---|---|
site.origin |
string | null |
null |
scheme + host (+ port) only; a path, query or fragment is X_CONFIG_INVALID. Canonical, og:url, hreflang and the sitemap are absolute against the first of APP_URL, SITE_ORIGIN, site.origin; with none, the request's own origin (served) or https://localhost (static build, which warns on stderr when ULTIMATE_ENV=production) |
seo.robots.disallow |
string[] |
[] |
each starts with /. Added to every production group — a crawler obeys only the group that names it — with a User-agent: * group emitted when none is declared. Any other environment still emits Disallow: / alone |
seo.sitemap.extra |
string[] |
[] |
public pages outside site/ to list — a path (/verificar) answered by an app/ route with no policy; listed per routed locale with hreflang alternates like a site/ page. No origin, query or fragment (X_CONFIG_INVALID); a path no ungated app/ route answers is X_SITEMAP_EXTRA_INVALID when the sitemap is built. As of 22.10
|
seo.sitemap.lastmod |
'none' | 'git' | 'mtime' | 'build' |
'none' |
each <lastmod>: 'git' the last commit touching the route's source file (its mtime where there is no work tree — a container image), 'mtime' the file's mtime, 'build' one timestamp for every URL. Read once per file per process. As of 22.10
|
offline is a block, not a string, As of 2026-08 — see Upgrading. Three booleans, two blocks; every field is optional except pwa.name, pwa.colors and pwa.offline.fallback, which enabled: true makes required, and pwa.vapid.subject, which a pwa.vapid block does.
pwa: {
enabled: true,
offline: { fallback: '/offline' },
name: 'My App',
colors: {
light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
},
// Web Push — the key pair is env (`x vapid create`), never here.
push: true,
vapid: { subject: 'mailto:ops@example.com' },
},| field | type | default | notes |
|---|---|---|---|
pwa.enabled |
boolean |
false |
read — true makes x dev, the container and x build --target static emit manifest.webmanifest, sw.js, x-sw-register.js and the <head> that names all three, and requires pwa.name, pwa.colors and pwa.offline.fallback beside it. It generated no service worker until 2026-08-27 (#390) |
pwa.name |
string |
'' |
The install title a browser shows a person. Required when pwa.enabled is true — app.name is a slug (^[a-z][a-z0-9-]{1,63}$), so it is the wrong answer rather than a rough one |
pwa.colors |
{ light, dark } of { themeColor, backgroundColor }
|
undefined |
theme_color and background_color, per colour scheme. Required when pwa.enabled is true: a browser paints the install splash and the address bar from these before a stylesheet has loaded, so there is nothing to derive them from and no defensible default. The one place in app.config.ts a raw colour is legal (theme.tokens, the other, was deleted in 25.0.0) |
pwa.offline.fallback |
string | null |
null |
read — the absolute route path of the document an offline navigation gets when the cache has no answer. Required when pwa.enabled is true, and it must start with /: a relative path resolves against whatever document registered the worker, so offline under /posts/1 is /posts/offline — a 404 cached as the answer to every offline navigation |
pwa.offline.image |
string | null |
null |
read — the URL of the placeholder served for an image request neither the network nor the cache can answer. It must be a path on this origin (/…, never https://…, //host or /\host — the build refuses with X_PWA_NO_OFFLINE_FALLBACK). The build precaches it beside the offline document and on the same terms: its existence is not checked (a URL that does not exist costs that one precache entry, and the request gets the worker's 503), revisioned by the build id unless it is a content-hashed /assets/… URL |
pwa.offline.font |
string | null |
null |
read — the same, for a font request |
pwa.offline.neverCache |
string[] |
[] |
read — path prefixes the worker passes straight through. Auth and payments belong here: a stale 200 is worse than a failure |
pwa.offline as a string |
— | — |
Changed in the release that closed #390. It was 'precache' | 'runtime' | 'network-only', an app-wide default for a field defineRoute makes required on every route — so it defaulted nothing and was read by nobody. Migration: offline: 'runtime' → offline: { fallback: '/offline' }, plus a route at that path (Upgrading) |
pwa.backgroundSync |
boolean |
false |
read. The emitted worker's sync event posts OUTBOX_DRAIN_MESSAGE to every open tab, which drains realtime's outbox (21.0.0) |
pwa.push |
boolean |
false |
read As of 26.1.0 — Web Push, end to end: every role's boot resolves the VAPID pair and installs the push runtime (refused with X_PWA_VAPID_KEY_MISSING in a deployed environment with no keys), sw.js carries the push and notificationclick handlers, every document carries <meta name="x-push-key">, and pushSubscribe() / pushUnsubscribe() / webPush() work. Wired only with pwa.vapid: push: true alone boots as it did on 26.0.0, where the key wired nothing — push stays off and the boot logs pwa.push unwired with the fix; a later major refuses it. A static export has no runtime behind it, so it emits no handler and says so in serviceWorkerWarnings. Notify
|
pwa.vapid.subject |
string |
— |
read — mailto: or an https: URL: who a push service writes to about this server (RFC 8292). What wires pwa.push; refused when pwa.push is false (a key with no reader), and a block with no valid subject is refused. The KEYS are never config: ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY are environment variables, sealed together by x vapid create, because a public key in committed config and a private key per deploy are two places that can disagree. A development or test process with neither set signs with a published development pair |
pwa.vapid.pushHosts |
string[] |
[] |
read As of 26.1.0 — push services this app sends to beyond the built-in ones (PUSH_SERVICE_HOSTS: fcm.googleapis.com, push.services.mozilla.com, push.apple.com, notify.windows.com), for a self-hosted push service. Bare host names — no scheme, port, path or wildcard; each admits its subdomains on a dot boundary; localhost and IP literals are refused (X_CONFIG_INVALID). A subscription on any other host is refused when it is stored and deleted unsent (X_PWA_PUSH_HOST_UNLISTED): an endpoint comes from a request body, and a hostname resolves wherever its owner points it, so the list of services — not the name's shape — is what keeps the sender off private addresses |
pwa.installPrompt |
— | — |
Deleted in 8.0.0. Refused at boot since 25.0.0 (X_CONFIG_INVALID, naming the replacement). Declared, defaulted and merged, and read by nothing — @ultimat3/pwa's installController is real and complete and no code ever threaded this flag into it, so both tracked apps and every scaffolded app carried a switch with no wire. Migration: delete the key and call installController from your own affordance (PWA and offline) |
Which surfaces move between their pages by client-side navigation over server-rendered documents, and what the browser may prefetch on the rest — Client navigation.
navigation: { client: ['app'], speculation: { prefetch: 'moderate', exclude: [] } },| field | type | default | notes |
|---|---|---|---|
navigation.client |
('site' | 'app')[] |
[] |
read by x dev, the container and x build --target static: a listed surface's documents carry the router (/_x/assets/navigation/<hash>.js, charged to each route's budget.js). api, shared, a duplicate or a non-list → X_CONFIG_INVALID. The last layer that lists surfaces wins |
navigation.speculation.prefetch |
'moderate' | 'conservative' | false |
'moderate' |
read by the same three: every document WITHOUT the router carries <script type="speculationrules"> (prefetch only, admitted to the CSP by hash, 0 bytes against budget.js). false emits nothing. Any other value → X_CONFIG_INVALID
|
navigation.speculation.exclude |
string[] |
[] |
read — URL patterns subtracted from the candidates the route table yields. Each must start with /, else X_CONFIG_INVALID. The last layer that lists any wins |
How the build bundles the app's *.island.tsx client entries.
islands: { sharedChunks: true },| field | type | default | notes |
|---|---|---|---|
islands.sharedChunks |
boolean |
false |
read by x dev, the container and x build: true builds every island in one split bundle, so a module two islands import is one /islands/chunk-<hash>.js a page fetches once and a browser caches across pages; each route's budget.js counts it once. false builds each island alone, self-contained. Off by default because tree shaking across one split build keeps what ANY importer uses: a small island importing a helper from a module other islands use heavily pays for all of it (examples/dummy's update banner 712 → 16,288 B). Turn it on where pages render several islands over one graph, then weigh the routes with x verify. A non-boolean → X_CONFIG_INVALID
|
Not an app.config.ts block, and never was. AppConfigInput has no http key. @ultimat3/core is tier 0 and cannot hold @ultimat3/http's types, so an http block here would be a second declaration of HttpConfigInput in a package that can never check it against the real one. An app declares its half with configureHttp(), at module scope in a file under apps/*/ — the same seam configureAuthenticator() and defineStorage() are, and for the same reason.
Until 12.0.0 the whole tuning surface was unreachable from a shipped app: the only HttpConfig any framework-booted process built was one fixed literal inside the CLI, so cors.origins was [] in every deployment, bodyLimitBytes was 1 MiB for a 4 MB CSV endpoint and rateLimit.buckets was 120 burst / 2 rps for a bank and a blog alike. Shipped fix: lines across @ultimat3/http told the reader to edit http.<key> in app.config.ts, which has never held one — bun run scripts/doc-config-keys.ts is what now refuses that sentence in a doc.
// apps/web/http.ts — module scope. The app load imports every `apps/*/*.ts`, so this runs
// before any listener binds; `apps/web/server.ts` and `prerender.ts` are entry points and
// are deliberately NOT imported, so the call may not live in either.
import { configureHttp, DEFAULT_RATE_LIMIT } from '@ultimat3/http';
configureHttp({
requestTimeoutMs: 120_000,
bodyLimitBytes: 8 * 1_048_576,
maxInflight: 2_000,
cors: { origins: ['https://app.example.com'], credentials: true },
csrf: { mode: 'origin' },
rateLimit: {
// The WHOLE table, never a patch: `buckets` replaces the default one rather than merging
// into it, and a name nothing declares falls through to a built-in 120 / 2.
buckets: {
...DEFAULT_RATE_LIMIT.buckets,
login: { capacity: 5, refillPerSecond: 0.01 },
tenant: { capacity: 5_000, refillPerSecond: 100 },
},
tenantBucket: 'tenant',
},
});| field | default | notes |
|---|---|---|
basePath |
'/' |
stripped before matching, on a segment boundary — a mount at /api owns /api and /api/…, never /apix
|
bodyLimitBytes |
1_048_576 |
enforced while the body streams, so a transfer-encoding: chunked payload is cancelled the instant the running total passes it |
requestTimeoutMs |
30_000 |
0 disables. At most 2_147_483_647 (the longest a timer holds, ~24.8 days) — more is X_CONFIG_INVALID, where it used to arm a 1 ms timer and 504 every request. A caller may only shorten it, with x-request-timeout-ms; an ask above that ceiling is ignored. The framework's own typed clients send what is LEFT of the current request's budget |
maxInflight |
1_000 |
0 disables. Past it a request is shed X_OVERLOADED before any work — no route match, no auth, no body |
drainTimeoutMs |
— |
Deleted in 25.0.0. A second drain budget for the web role alone; drain.deadlineMs is the one budget for every role. Passing the key to configureHttp or defineHttpConfig is X_CONFIG_INVALID naming drain.deadlineMs
|
cors |
origins: [], credentials: true
|
origins: ['*'] with credentials: true is X_CORS_CONFIG_INVALID at boot — no browser accepts the pair |
csrf |
mode: 'origin' |
'origin' | 'off'. mode: 'token' is deliberately not shipped. 'origin' judges every unsafe request that carries browser evidence (Origin or sec-fetch-site), signed in or not — a forged sign-in is a forged write. Exempt: an Authorization header, and an anonymous request carrying neither header (a webhook, a server-to-server call) |
trustClientCertHeader |
false |
read Envoy's x-forwarded-client-cert into ctx.peer. Its own declaration: TRUSTED_PROXY_HOPS says the proxy appends to x-forwarded-for, not that it strips a certificate header the client sent. Read at the same hop index, so it does nothing while no proxy is trusted |
healthDetailPeers |
['loopback'] |
who /healthz and /readyz tell more than { state, ready, role }. Each entry is an address class (loopback, private, link-local, ula, cgnat, unspecified, reserved, public) or one IP literal; anything else is X_CONFIG_INVALID. [] tells nobody. Behind a trusted proxy the forwarded caller must be listed as well as the socket. The sync role's own listener reads the same list; it trusts no proxy, so a request carrying Forwarded or X-Forwarded-For gets the verdict only |
security |
HSTS off until https is affirmed, CSP report-only in dev |
security.csp.extend merges per directive with the boot's own hashes, so admitting a CDN source does not evict the hydration runtime's and lock every island out. connect-src is 'self' blob: — no bare ws:/wss: (a socket to any host); a sync node on another origin (SYNC_URL) is added by that origin alone (As of 22.6.0). security.hsts merges key by key: { preload: true } alone opts into preload over the two-year default, null sends none |
locale / tz
|
header + cookie names | it decides WHERE the request's locale and zone are read from; @ultimat3/i18n and @ultimat3/time decide what they mean |
rateLimit |
enabled, buckets, defaultBucket, tenantBucket: null
|
scope is boot-owned (below). tenantBucket names a bucket a whole tenant spends beside the caller's own; a name buckets does not declare is X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN at boot. A request that fails auth: 'required' spends defaultBucket under one key per client address, across every route — the 401 leaves before the rate-limit stage, so it is metered there or nowhere. Once that allowance is spent, the address is refused X_RATE_LIMITED on every required route before authenticate runs — a valid credential included, so a right guess cannot be told from a wrong one — until it refills (As of 2026-10) |
Seven keys plus rateLimit.scope are boot-owned, and writing one is a compile error — AppHttpConfig is Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'> with rateLimit re-added minus scope, so the refusal is TS2353 at the call and never a value silently discarded at every boot:
| boot-owned key | what decides it |
|---|---|
port / hostname
|
PORT and the role's binding |
dev |
x dev, or ULTIMATE_ENV
|
buildId |
BUILD_ID, stamped by x build
|
signInPath |
auth.signInPath in app.config.ts
|
trustProxy / trustedProxyHops
|
TRUSTED_PROXY_HOPS in the environment — one image runs behind an ingress in one cluster and behind nothing on a laptop, so an app that hardcoded it would be wrong in one of the two |
rateLimit.scope |
the store the boot installed. A literal here would be a second declaration quietly contradicting it, and assertRateLimitScope compares exactly those two halves |
An embedder that builds its own server — httpServer({ routes, config: defineHttpConfig({ … }) }) — passes the whole HttpConfigInput, boot-owned keys included, and owns every consequence: that is the one path on which X_RATE_LIMIT_SCOPE_UNSET and X_TRUST_PROXY_UNSET are reachable.
Not an app.config.ts block. There is no seo key on AppConfigInput. @ultimat3/seo's builders take their options at the call site, from the route that renders them:
| Call | Options | Notes |
|---|---|---|
buildRobots(config) |
baseUrl, environment?, groups?, disallow?, sitemaps?, extra?
|
fail-closed: only the exact string production opts a deploy into indexing, so staging, a laptop, a typo and an unset ULTIMATE_ENV all emit Disallow: / — a branch deploy that gets indexed outranks and cannibalises the real site. environment omitted resolves from ULTIMATE_ENV, and an unreadable one falls back to core's default rather than 500ing a robots.txt
|
buildSitemap(routes, options) |
baseUrl, locales?, localizePath?, defaultLocale?, maxUrls?, lastmod?
|
splits past SITEMAP_MAX_URLS (50,000) into the index /sitemap.xml and parts /sitemaps/<n>.xml (SITEMAP_PARTS_DIR), which the web role serves from GET /sitemaps/:file. maxUrls must be a positive integer — 0 never advances the chunk cursor and used to allocate empty slices until the box ran out of memory |
baseUrl is the argument every one of them takes, so the canonical origin stays an env key the app reads (APP_URL) and never a config field. There is no seo.lighthouse gate and no seo.ogImage renderer As of 2026-08-22.
Not an app.config.ts block. There is no budgets key on AppConfigInput and no per-surface default table. A budget is declared per route, as budget on defineRoute — RouteBudget in packages/render/src/route.ts:
Two keys, As of 2026-08-24, and both are optional:
| field | type | notes |
|---|---|---|
budget.js |
string |
'40kb' — measured from the real bundle graph, not the source size. The one budget the framework weighs: x build --target static writes a jsBytes per prerendered route into .x/build-stats.json, counted off the emitted document's own <script> tags, and the budgets step compares against that |
budget.lcp |
— |
Deleted, with budgetLcp on the route descriptor, the lcp in x routes --json and x.manifest.json, and lcpMs in .x/build-stats.json. Nothing in the framework observes a paint — the build emits static HTML — so it was published and never weighed. A route still declaring it is a type error, and registerRoute refuses one that arrives by a cast (X_ROUTE_MODE_INVALID). Migration: delete the key |
budget.css |
— |
Deleted in 12.0.0, with budget.cls and budget.tbt. All three were declared on the route contract for four majors, flattened away by registerRoute — which projects a budget to budgetJs and nothing else — and read by no consumer anywhere, so budget: { cls: 0.1 } was accepted, normalised, stored and ignored while x verify's budgets step, the one thing that exists to enforce a budget, reported green. Deleted rather than wired, for pwa.installPrompt's reason: the measuring half does not exist either. Migration: delete the key |
budget.cls |
— | as above |
budget.tbt |
— | as above. A new budget key is now a build error until the descriptor projects it: _EveryBudgetKeyIsProjected in packages/render/src/type-pins.tsx derives 'js' from RouteDescriptor's own budgetJs, so neither side can move alone |
A declared budget with no measurement is itself a failure (X_BUDGET_UNMEASURED), never a pass — a route that clears the gate without being weighed is the false green axiom 5 exists to prevent — and the finding names the import chain that blew it, because "your bundle got bigger" is not actionable for a human or an agent. Only render: 'static' routes are weighed today; every other mode needs a running process. The 0 kb JS baseline on site/ is not a default in a table: a site/ route off hydrate: 'never' with no budget.js is refused at registration, which is structural rather than aspirational.
The precache warning is its own number and not a budget: DEFAULT_PRECACHE_WARN_BYTES is 5 MiB, overridable per build as warnBytes (packages/pwa/src/precache.ts).
The transport is selected by environment, like every other external service — an unset
variable means the embedded default, so the same image deploys everywhere and no credential is ever
committed. The app.config.ts block says only what the chosen transport keeps:
mail: { retainMime: { maxBytes: 262_144 } },| field | type | default | notes |
|---|---|---|---|
mail.retainMime |
boolean | { maxBytes?: number } |
false |
read by x dev and the container at boot, handed to selectMailDriver: the SMTP and SES transports keep the exact MIME bytes they sent, on SendResult.mime. true is mail's default cap (DEFAULT_RETAIN_MIME_MAX_BYTES, 256 KiB); a message over the cap keeps its digest and length only. maxBytes must be a whole number above 0 (X_CONFIG_INVALID at defineConfig) and at most RETAIN_MIME_CEILING_BYTES (X_CONFIG_INVALID at boot). With RESEND_API_KEY selected, any retention refuses the boot: Resend builds the MIME on its own side. The durable onRetained callback is code, not config: build it with selectMailDriver(env, { retainMime: { onRetained } }) — a MailSelection — and hand its .driver over as the mail override in apps/<app>/runtime.ts (Runtime overrides, below) |
| env key | selects | notes |
|---|---|---|
| (none set) | memory | caught, never sent; the /_x mail panel reads this outbox |
SMTP_URL |
SMTP |
smtps://user:pass@host:465, or smtp://host:587 for STARTTLS |
RESEND_API_KEY |
Resend | one POST /emails per message, with an Idempotency-Key
|
SES_REGION |
Amazon SES | one SES v2 SendEmail with the raw MIME, SigV4-signed, no SDK |
SES_ACCESS_KEY_ID |
— | SES only, required with SES_REGION
|
SES_SECRET_ACCESS_KEY |
— | SES only, required with SES_REGION
|
SES_SESSION_TOKEN |
— | SES only, optional: temporary credentials |
SES_ENDPOINT |
— | SES only, optional: a VPC endpoint or a local stand-in. Default https://email.<region>.amazonaws.com
|
SES_CONFIGURATION_SET |
— | SES only, optional: the configuration set that publishes delivery events |
MAIL_FROM |
— | required by SMTP, Resend and SES; the memory and unconfigured drivers do not read it. Name <addr>; also the envelope sender and the Message-ID domain |
MAIL_POOL_SIZE |
— | SMTP connections open at once. Default 4, whole number ≥ 1 |
The list is MAIL_ENV_KEYS in packages/mail/src/driver-env.ts, As of 2026-10. More than one of
SMTP_URL, RESEND_API_KEY and SES_REGION is X_CONFIG_INVALID: a process delivers through
exactly one transport, and picking a winner would send half of an operator's mail the wrong way.
A transport without MAIL_FROM is refused at boot rather than on the first send.
x dev prints which one it installed — mail=embedded, or mail=external(smtp via SMTP_URL).
The env key is reported, never its value, because SMTP_URL carries a password.
Not an app.config.ts block. There is no storage key on AppConfigInput. Storage is defineStorage(), called from app.config.ts, and it declares named disks (Laravel's model) rather than a driver and a bucket — so disk('uploads').put(…) never changes when local becomes s3:
defineStorage({ disks: { uploads: localDriver({ root: '.storage/uploads' }) } });
defineStorage key |
shape | notes |
|---|---|---|
disks |
Record<string, StorageDriver> |
required and non-empty. Drivers are localDriver({ root }) and s3Driver({ … }) (Bun.s3) |
default |
string |
the disk disk() resolves with no name. Defaults to the first declared disk; naming one that is not declared is X_CONFIG_INVALID
|
Two disks may not share one driver instance: a driver learns its disk name at boot (registerAs) and mints signed URLs under it, so one instance told two names would 404 every URL it wrote under the first. Two disks over one root are two localDriver() calls. There is no storage.dir and no storage.bucket field.
Not an app.config.ts block. Tracing is always on, not a flag, and the wire is configured by the standard OpenTelemetry environment variables — which is what lets a collector be attached to a running image without a rebuild:
| var | notes |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP/HTTP JSON only, port :4318. Absent = spans still recorded, exported nowhere. Invalid → X_OTLP_ENDPOINT_INVALID
|
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / ..._METRICS_ENDPOINT
|
per-signal override |
OTEL_EXPORTER_OTLP_PROTOCOL |
anything but http/json → X_OTLP_PROTOCOL_UNSUPPORTED, naming :4318. gRPC (:4317) needs HTTP/2 and protobuf and is out of scope |
OTEL_EXPORTER_OTLP_HEADERS |
OTEL_EXPORTER_OTLP_TRACES_HEADERS / ..._METRICS_HEADERS REPLACES it for that signal. Percent-decoded, so %zz is X_OTLP_HEADERS_INVALID rather than a bare URIError at exporter construction. The header key appears in the cause and the fix; the value never does — it is the collector's credential |
OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG
|
read at the first span, never at module scope. parentbased_always_on takes no arg and ignores one. configureTelemetry({ sampler }) is the programmatic form |
An empty spanId means "no inbound decision" and every reader honours it: a synthesised parent used to make the ratio sampler inherit a bit nobody sent, which exported every HTTP root span at every ratio.
Retention for x_notify_inbox, and the only framework table whose window the framework refuses to
pick for you. Every other one holds bookkeeping whose job ends — an idempotency key, a rate-limit
bucket, an auth challenge, a delivery claim — so a sweep is unambiguously right. An inbox row is a
message a person has not read yet, and when that disappears is a product decision (axiom 8).
| field | type | default | notes |
|---|---|---|---|
notify.inboxReadRetentionMs |
number |
— | age of a row's read_at. A read row ages from when it was read, never from when it arrived |
notify.inboxUnreadRetentionMs |
number |
— | age of an unread row's created_at. Setting this deletes messages nobody has read |
Absent means never swept, and that is the default for both. The failure mode of keeping rows is a table that grows; the failure mode of guessing a number is a notification the recipient never got to read. Two windows rather than one because the objection is specifically about unread messages — an app that wants read notices gone in a month and unread ones kept forever writes exactly that:
notify: { inboxReadRetentionMs: 30 * 24 * 60 * 60 * 1000 }Milliseconds and not a duration string: DurationInput lives in @ultimat3/jobs (tier 3) and
AppConfig is tier 0 — the same reason cache.defaultTtlMs and jobs.visibilityTimeoutMs are
spelled this way. A value that is not a positive finite number is X_CONFIG_INVALID at boot; zero
is refused rather than read as "immediately", because a sweep at age 0 is an inbox that silently
receives nothing.
The sweep only runs against the Postgres inbox. postgresInboxStore carries purgeBefore; the
memory inbox does not, and a boot that installed the memory one — or no inbox at all — sweeps
nothing and logs nothing. Installing the store is setNotifyStores, your app's boot line; the
framework applies the DDL either way.
x_notify_deliveries has no config key, deliberately. Its window is
postgresDeliveryLedger({ executor, windowMs }), stated beside the statement that reads it, and it
must never be shorter than your idempotency window: a job replayed inside the idempotency window
against a claim that has already been purged claims cleanly and sends the notification a second
time. Pass idempotency.windowMs and the two cannot disagree.
x_notify_digests has no config key either. A digest window is deleted by its own flush; the
sweep takes only a window CLOSED longer than postgresDigestStore({ executor, retentionMs }) ago —
a week by default, longer than any flush's retries — which is a window whose flush dead-lettered on
a slot that never digests again. Up to 50 batches of 1,000 rows per hourly pass.
One live field, and ai.mcp is where the app's own MCP surface is configured — there is no top-level mcp block.
| field | type | default | notes |
|---|---|---|---|
ai.mcp.expose |
boolean |
true |
the app's own MCP surface. Actions still opt in per action mcp.expose
|
ai.mcp.path |
— | — |
Deleted in 25.0.0, refused with X_CONFIG_INVALID. Endpoint #0 mounted here while every other endpoint mounted at its own defineAppMcp({ path }), so the two could disagree. Now every endpoint, #0 included, mounts at its own defineAppMcp({ path }) (default /mcp). Migration: delete the key; if it wasn't /mcp, move it to the first defineAppMcp({ path }) in apps/<app>/mcp.ts
|
ai.modelEnv |
— | — |
Deleted in 8.0.0. It named the env key holding the model id "so no model string is baked into the image", and its only reader was defineConfig's own merge: @ultimat3/ai reads env for API keys only, and the model was a compile-time default (DEFAULT_MODEL, itself deleted in 25.0.0 — an app names its model on the prompt or on llm({ model })). The one thing the key existed to prevent is what it delivered. Refused at boot since 25.0.0 (ai.modelEnv was removed in 8.0.0). Migration: delete the key and pass model, reading your own env key if you want one |
ai.models, ai.fallback, ai.cache and ai.budget are per-llm() declarations, not config (MCP and AI). i18n has no config block either: defineCatalogs({ default, locales }) is the whole surface.
How a SIGTERM'd process leaves: DrainConfig in packages/core/src/config-health.ts. The production boot applies it to every role (lifecycleForRole, packages/cli/src/serve-boot.ts), not only to the web server.
| field | type | default | notes |
|---|---|---|---|
drain.readinessGraceMs |
whole ms, 0–60000
|
0 in development/test, 5000 everywhere else (a process naming no environment included) |
/readyz answers 503 for this long with the listener still open, so endpoints stop routing here before it closes. web and sync only: every other role drains with no grace |
drain.deadlineMs |
whole ms, 1–3600000
|
25000 |
the drain budget, after the grace: the time in-flight requests and a running job have to finish on a deploy before the lifecycle abandons the rest (X_SHUTDOWN_TIMEOUT). Raise it for long jobs. NaN, Infinity, a fraction, 0 or more than an hour is X_CONFIG_INVALID naming the key — past an hour, the job belongs in more steps. As of 2026-10-05
|
drain.workerDeadlineMs |
whole ms, 1–86400000, or unset |
unset |
ROLE=worker's budget in place of drain.deadlineMs, for a job that must finish rather than be cut off and replayed and may run past the hour — a bank login, a payment. Also what bounds a retire (SIGUSR2) that a SIGTERM lands in. Unset: the worker drains on drain.deadlineMs like every role. More than a day is X_CONFIG_INVALID naming the key. As of 2026-10
|
export const config = defineConfig({
name: 'postly',
// A report job runs up to 8 minutes; a deploy lets it finish.
drain: { deadlineMs: 600_000 },
});The platform's own kill timer must outlast the sum, or SIGKILL truncates the drain:
| Rung | Who sizes it | Rule |
|---|---|---|
| Helm |
x deploy --method helm passes drain.deadlineSeconds, drain.workerDeadlineSeconds and drain.readinessGraceSeconds from this section; the chart derives each role's terminationGracePeriodSeconds
|
preStop (web/sync, 1.30+) + grace (web/sync) + deadline (the worker's: workerDeadlineSeconds when > 0) + retire (worker, roles.worker.retireSeconds) + 10 s margin |
| Compose | you: stop_grace_period in docker-compose.prod.yml
|
≥ grace + deadline + 10 s (40s ships, for the defaults); the worker service's ≥ workerDeadlineMs + 10 s when declared — the worker drains with no grace |
import { defineConfig } from '@ultimat3/core';
export const config = defineConfig({
name: 'banks',
// A bank run takes up to 2 h and must never re-login: the worker waits it out on a deploy.
drain: { deadlineMs: 25_000, workerDeadlineMs: 7_500_000 },
});The boot applies this section after the app's modules load (bootRoles, packages/cli/src/serve-boot.ts), so a configureLifecycle({ deadlineMs }) an app makes at import time or before serveApp is overwritten by it — drain.deadlineMs always has a value (its default). Declare the budget here, never in code.
drain.deadlineMs is the only drain budget, on every role, web included — the worker's alone may be drain.workerDeadlineMs. http.drainTimeoutMs re-set it on the web role after this section was applied; 25.0.0 deleted it, and a configureHttp({ drainTimeoutMs }) is refused X_CONFIG_INVALID with the drain: { deadlineMs } edit.
The one place a deployment hands the framework a driver, a middleware or a plain route. Not a
config key: an object, exported as runtime from apps/<app>/runtime.ts. x dev reads it, and so
does runRole (the container boot) when its caller passed none — one chain in development and in
production, never two.
// apps/web/runtime.ts
import type { ServeOptions } from '@ultimat3/cli/serve';
import type { Middleware } from '@ultimat3/http';
// `ctx.headers` is merged into whatever response the route produces.
const servedBy: Middleware = (request, ctx, next) => {
ctx.headers.set('x-served-by', 'web');
return next(request, ctx);
};
export const runtime: NonNullable<ServeOptions['runtime']> = { middleware: [servedBy] };Every field is optional, and every field replaces the default the environment would have
picked — it never sits beside it. The type is RuntimeOverrides (packages/cli/src/runtime-overrides.ts).
| Field | Replaces |
|---|---|
jobs |
the queue driver every enqueue and every claim uses; installed as the ambient driver too, so the boot refuses a process where the two disagree |
storage |
the S3_ENDPOINT / embedded-disk decision, whole: disks, default disk and all |
mail |
the SMTP_URL / RESEND_API_KEY / SES_REGION selection |
transport |
the NATS_URL selection. Arrives connected and is not closed on stop — whoever built it owns its socket |
purge |
the CLOUDFLARE_* / FASTLY_* selection behind the cdn cache tier |
rateLimitStore |
the Postgres store the boot installs over its own pool. The store's scope decides rateLimit.scope; a 'process' store is legal and warned about — every limit then applies once per replica |
isrStore |
the per-process memory store for regenerated ISR pages (one per replica, so a purge tag reaches one of them) |
middleware |
nothing — prepended to the request pipeline, outermost first |
routes |
nothing — plain HTTP routes at paths no primitive projects to (an OAuth token endpoint, a feed with its own content type), mounted after the framework's routes and before the pages, through the whole pipeline |
images |
builtinImageDriver, the /media/* transform |
syncAuthenticate |
the adapter over the app's own configureAuthenticator() that authenticates a sync socket |
| Rule | Why |
|---|---|
| middleware wraps a matched route only | the router runs first (packages/http/src/stages.ts). An unmatched path is answered 404 (or 405 with allow) before any middleware runs, so middleware cannot rewrite, redirect or serve a URL that no route declares |
| a redirect from a path nothing serves needs a route | declare a page at the old path that answers the redirect, or add one to runtime.routes. api/ holds no route file: it is actions and queries only (Project layout) |
/healthz and /readyz never reach it |
they are answered by the listener before the pipeline, so a draining or rate-limited process can still say what it is doing |
a /<locale>/ prefix is stripped before the match |
middleware sees the route the stripped path matched, never the prefix as a route of its own |
Routes, their files and their render modes → Routes and render modes.
One typed schema, declared with defineEnv at module scope in app.config.ts, validated at boot. There is no env.ts — the one config file is also the one env gate. A missing or malformed key fails in ~40ms with X_ENV_MISSING, every offender named in one error — never a 500 an hour later.
| var | roles | required | notes |
|---|---|---|---|
ROLE |
all | no — default web, as is empty or blank |
exactly web | sync | worker | scheduler | migrate | replicator. There is no all. Invalid → X_ROLE_UNKNOWN from the production boot path, X_ROLE_INVALID from assertRole() inside the framework |
PORT |
web, sync
|
no — default 3000
|
the TCP port the role binds. Non-decimal (0x1F90, 8e3, +80) or outside 0–65535 → X_PORT_INVALID, refused rather than defaulted past, because a web role that quietly bound 3000 fails the platform's health probe with nothing in the log that names the cause |
HOST |
web, sync
|
no — default 0.0.0.0
|
the interface the role binds, and its metrics endpoint with it. Empty or whitespace is the default. 127.0.0.1 inside a container is unreachable through a port mapping and reachable only where the container shares the host's network namespace (--network host, a sidecar, ssh -L) — what an app that admits one implicit actor without a login wants. runRole({ hostname }) overrides it, as port overrides PORT
|
ULTIMATE_ENV |
all | no — default development
|
development | test | staging | production, read by resolveEnvironment(). NODE_ENV is a fallback only, and is never policed |
DATABASE_URL |
all | yes | |
DATABASE_REPLICA_URL |
all | no — unset is one pool |
As of 2026-08-24: a read-only standby. Set it and baseClient() builds a primary + replica client; unset and the client is byte-identical to the single-pool one. Routing is still opt-in per scope (withReplicaReads), so setting it alone changes nothing — see Read replicas
|
APP_URL |
web, sync
|
yes | the canonical origin. An app-read key, not a config field — declare it in defineEnv. On sync it is also the page origin the node admits a socket from: the node compares the handshake's Origin exactly (scheme, host, port), and a declared APP_URL is the WHOLE list — the origin the node was reached on is admitted only when none is declared. So it must be RIGHT wherever it is set, and set wherever host or port differ — the Compose rung (page on :3000, socket on :3001, where the shipped compose file requires it), a SYNC_URL on another host. The Helm chart sets it on the sync role from ingress.host. Undeclared behind a TLS proxy on the same host and port, a node reached over plain http admits that host's https spelling too. x dev adds its own web role's origin (and its localhost, 127.0.0.1, [::1] spellings) to a declared one, and keeps admitting the origin it was reached on, so x dev --port 4000 and a forwarded host (a Codespace, a tunnel) beside APP_URL=http://localhost:3000 connect; a container adds nothing. A socket refused: X_SOCKET_ORIGIN_REFUSED, not APP_URL's origin when one is declared; with none declared, not the origin the node was reached on |
SESSION_SECRET |
web, sync
|
yes | >=32 chars |
ULTIMATE_STATE_DIR |
all, under x dev
|
no — default <app>/.x
|
relocates the whole of .x/ (the embedded database, the local disk, the dev lock) for one process tree. The e2e step uses it to boot the app on a throwaway database beside a running x dev (packages/cli/src/runtime-bindings.ts). 21.0.0 |
SYNC_URL |
web |
no — unset dials /_x/sync on the page's own origin |
where the page's one socket dials, rendered into every document as <meta name="ultimate-sync">. Must be ws:// or wss://; anything else is X_CONFIG_INVALID at boot. Required on the Compose rung, where sync is published on its own port with no proxy in front: SYNC_URL=ws://<host>:3001/_x/sync. The shipped compose file refuses to start web without it. x dev, a combined-role container and the Helm chart's ingress all serve /_x/sync on the page origin, so they need nothing. As of 2026-09-22 (21.0.0) |
WORKER_QUEUES |
worker |
no — unset or blank serves jobs.queues plus every registered job's queue |
comma-separated, trimmed, deduplicated. Set, it is the EXACT list this worker claims — jobs.queues and the registered queues are not added (As of 25.0.0; documented here before then and read by nothing). An empty entry (banks,,long) is X_CONFIG_INVALID at boot. See Splitting queues across Deployments
|
NATS_URL |
sync, replicator
|
no — unset is in-process fanout | one node only; a second replica shares nothing. Unreachable → X_TRANSPORT_UNAVAILABLE at boot, not at readiness |
NATS_KV_BUCKET |
sync |
no — default x_presence
|
the JetStream KV bucket presence lives in. [a-zA-Z0-9_-]+; anything else is X_TRANSPORT_PROTOCOL at boot |
REPLICATION_URL |
replicator |
no — defaults to DATABASE_URL
|
the connection the WAL is read from; this role must have REPLICATION privilege |
REPLICATION_SLOT |
replicator |
no — default x_replicator
|
logical replication slot name |
REPLICATION_PUBLICATION |
replicator |
no — default x_changes
|
the pgoutput publication the slot decodes |
REDIS_URL |
any tier-3 cache user | if redis in cache.tiers
|
|
BUILD_ID |
all | set by x build
|
content hash. Never a timestamp, never latest
|
LOG_LEVEL |
all | no — unset or empty is info
|
trace | debug | info | warn | error | fatal | silent, lowercase. Any other value — DEBUG, verbose — is REFUSED at import (X_INVARIANT), never read as info
|
TRUSTED_PROXY_HOPS |
web, sync
|
no — unset trusts no proxy header | how many proxies append to x-forwarded-for between the client and this process: 1 for a single ingress or ALB, 2 for a CDN in front of one. Decimal digits, 1–64 (@ultimat3/http's MAX_PROXY_HOPS); anything else is X_TRUSTED_PROXY_HOPS_INVALID, refused rather than defaulted, because reading the header at the wrong index is trusting a value the client typed. Unset means ctx.ip is the socket address, ctx.peer is null and no inbound x-request-id is echoed |
OTEL_EXPORTER_OTLP_ENDPOINT |
all | no | the OTLP collector. There is no otel config block for it to override — see otel
|
Rules:
| Rule | Detail |
|---|---|
| Secrets are env or a mounted file | the framework never talks to a vendor secret API (axiom 7) |
env.X reads through defineEnv's schema |
a declared key that is missing or malformed is X_ENV_MISSING at boot, every offender in one error. A process.env read outside the schema is a lint error, never a runtime one |
X_CONFIG_INVALID is env and app.config.ts
|
one code for a configuration that cannot boot: what defineConfig's own validation throws — a bad locale or two spellings of one, an unknown time zone, a section or list of the wrong shape (jobs: null), a value outside its closed set (roles, jobs.backoff, theme.defaultMode), a switch that is not a boolean (database.ssl: 'false'), jobs.concurrency < 1, a realtime.transport other than memory with no realtime.urlEnv, a cache.tiers entry the environment cannot supply — and any env combination no boot can resolve, thrown by the selector that reads it. Both CDN pairs or half a pair (selectPurgeDriver), more than one of SMTP_URL / RESEND_API_KEY / SES_REGION, or a transport with no MAIL_FROM (selectMailDriver), or REPLICATION_URL naming a different host, port or database than DATABASE_URL (selectChangeFeed) |
X_ENV_MISSING is one key, X_CONFIG_INVALID is the shape |
absent or malformed key → X_ENV_MISSING at the defineEnv gate. Keys that each parse but contradict each other → X_CONFIG_INVALID. The two never overlap |
| No runtime mutation | config is frozen after defineConfig; there is no setConfig
|
| Same image, all environments | only env differs. That is what makes staging a real rehearsal (Deployment) |
Four names, one env var, and a spelling the framework does not use is a refusal rather than a guess.
import { isLocal, isProduction, resolveEnvironment, tryResolveEnvironment } from '@ultimat3/core';
resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
resolveEnvironment({ fallback: 'production' });
resolveEnvironment({ env: someRecord });
tryResolveEnvironment() ?? 'development'; // the same, `undefined` instead of the throw| Concern | Behaviour |
|---|---|
| Precedence |
ULTIMATE_ENV if set and non-empty → NODE_ENV only if it is one of the four → fallback → 'development'
|
Invalid ULTIMATE_ENV
|
throws X_ENVIRONMENT_INVALID — prod, dev and preview are all refusals |
Invalid NODE_ENV
|
never throws. CI images legitimately set it to anything, so it is a fallback that is silently ignored, not a second gate |
isProduction() |
=== 'production' |
isLocal() |
development or test. staging is deliberately excluded — staging is a real rehearsal |
tryResolveEnvironment() |
the same resolution, undefined instead of the throw — and undefined means exactly one thing, an unrecognised ULTIMATE_ENV. For a caller that must answer rather than fail: ULTIMATE_ENV is not in the env schema, so nothing validates it at boot and a robots.txt render is routinely its first reader, where a typo would 500 the one response whose body was already going to be Disallow: /. It names no fallback of its own; the caller does |
@ultimat3/core is the only reader of ULTIMATE_ENV and Environment is the only spelling of a
deploy, As of 2026-08. @ultimat3/seo exported a second resolveEnvironment with its own union
through 1.2.0; as of 2.0.0 it exports neither that nor SeoEnvironment, and its 'preview' is
'staging' → Known gaps.
x env example # writes .env.example from the app's envSchemaimport { renderEnvExample, ENV_EXAMPLE_PATH } from '@ultimat3/core';
await Bun.write(ENV_EXAMPLE_PATH, renderEnvExample(schema)); // '.env.example'renderEnvExample(schema, { extras }) returns the whole file, deterministic in declaration order. Per key it writes the description, then an annotation line — required|optional · <type or enum values> · secret · role a/b — then KEY=<example>. A secret: true key's example is always empty, even when the declaration has a default. extras are appended as commented # NAME= lines.
x env example writes more than the schema. After the app's own keys it appends a # --- Framework section: the secrets the framework refuses to boot without outside development/test, whatever envSchema declares — ULTIMATE_CURSOR_SECRET (cursor signing, X_CURSOR_SECRET_DEV) and STORAGE_SIGNING_SECRET (the embedded disk's upload grants, X_ENV_MISSING; not read when S3_ENDPOINT/S3_BUCKET select object storage), always; and, once pwa.push is wired (As of 26.1.0), ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY (X_PWA_VAPID_KEY_MISSING), minted together by x vapid create — never by openssl, and the section header then says which generator mints which. Each is annotated required when deployed · string (and · secret, all but the public key) with its reason and an empty value, so the file is the whole deploy contract an infra repo builds its Secret from. A key the app declares itself is rendered once, as the app declared it. The list is FRAMEWORK_SECRETS in packages/cli/src/framework-env.ts — x env check and x doctor read the same one. renderEnvExample itself is the app's projection alone.
| Command | Framework secrets |
|---|---|
x env example |
rendered, blank, after the app's keys |
x env check |
checked against the environment it runs in, by the boot's own rule (core's devSecretsRefused, deployed in framework-env.ts): anything but development/test, and an environment naming none — the boot fails closed. ULTIMATE_ENV=production x env check answers what the first deployed boot would (X_CURSOR_SECRET_DEV, X_STORAGE_SECRET_DEV, and X_PWA_VAPID_KEY_MISSING for a push app; exit 1); the published development keys count as unset |
x doctor |
the same findings, cause and fix — except the VAPID pair, which needs the app's config and doctor reads none — but about this machine: only where ULTIMATE_ENV (else NODE_ENV) names staging or production (namedDeployed). A developer shell naming no environment gets none; ask x env check what a deployed boot would do with it |
x verify holds the committed file to it, on the manifest step: .env.example must be byte-for-byte the projection of envSchema in app.config.ts, so a moved description, default or required flag fails the gate as well as a missing key — and so does a file missing the framework section (an example written by 24.x). A miss is X_ENV_EXAMPLE_DRIFT, missing keys named first; the fix is x env example. An app that exports no envSchema has nothing to project and the check is silent.
assertEnvExample(schema, text), the older, weaker check (key presence only, called by nothing), was deleted in 25.0.0 with EnvExampleDriftError; the gate above is the one check, and checkEnvExample(schema, text) returns the same report as data.
Which files are read at boot: .env and .env.<mode> always, plus .env.local unless the mode is test. Mode is production or test verbatim, otherwise development — there is no .env.staging.
A value that redacts by value, so it stays redacted under a key nobody thought to add to a deny-list.
import { secret, revealSecret, isSecret } from '@ultimat3/core';
const token = secret(process.env.API_TOKEN, 'API_TOKEN');
`${token}`; // '[redacted]'
JSON.stringify({ token }); // '{"token":"[redacted]"}'
logger.info('boot', { token }); // token=[redacted]
revealSecret(token); // the real string — the one call that unwraps| Surface | Redacted |
|---|---|
toString() |
✅ |
toJSON() |
✅ |
Symbol.toPrimitive |
✅ — template literals and coercion |
| the Node inspect symbol | ✅ — console.log, util.inspect
|
| the framework logger | ✅ — checked before every other branch, at any depth, under any key |
spread / Object.entries / structured clone |
✅ — only label is enumerable, so the value cannot ride out |
Frozen and non-configurable. revealSecret(value) and revealOptionalSecret(value) are the only ways out; isSecret(value) is a structural brand check, so it survives two copies of @ultimat3/core in one tree.
Key-name redaction still exists and is separate: defineEnv() registers every secret: true key with the logger unless you pass { redact: false }. And checkEnv() returns real values in report.values — anything that prints a report must pass them through maskedEnvValues(schema, values) first. That is the bug 1.1.0 fixed: { dsn: 'postgres://user:pw@host/db' } printed the credential, because redaction was by key name and dsn was not on the list.
x doctor --json # env + connectivity + version checks
x verify --json # the gate
x config show is planned, not shipped — it throws X_NOT_IMPLEMENTED naming x manifest --json as the closest thing today → CLI reference.
Error shapes: Error codes. Symptom-first fixes: Troubleshooting. Metrics, spans and logs: Observability.
Ultimate — v27.8.1 As of 2026-09. Stable API, semver from here. MIT licensed. What npm serves is npm view @ultimat3/core version, never this line.
This footer is the only page that stamps a version. It renders under every wiki page, so one release bumps one line; a stamp on a second page is 46 hand-copies of one fact, and every one of them goes stale on the next tag.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Building your own base
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Client data
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
- Realtime
- Caching and invalidation
- Batching and preloading
- N+1 detection
- PWA and offline
- Client navigation
- MCP and AI
- Agents
- Admin dashboard
- Scraping
- Auth
- Notify
- Storage and uploads
- Feature flags
- SEO
- Static assets
Cross-cutting
- I18n
- Theming
- UI components
- Interface rules
- Timezones and dates
- Money
- Resource management
- Migrations and backfills
- Testing
- CI: the gate across parallel jobs
Reference