diff --git a/docker/Dockerfile b/docker/Dockerfile index 18ee2c025..de6a398ed 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -109,11 +109,13 @@ USER nonroot:nonroot # `/bin/sh -c`, which is the very thing this stage does not have. RUN ["/app/x", "--version"] # No HEALTHCHECK, deliberately. One image serves six roles and `worker`, `scheduler` and -# `replicator` open no HTTP listener at all, so any single probe baked in here is wrong for half of +# `replicator` open no APPLICATION listener — only the metrics socket above, which answers +# METRICS_PATH and 404s everything else — so any single probe baked in here is wrong for half of # them — the exact shape of the bug that made sync's readiness probe meaningless. The probe belongs # where the role is chosen: `healthcheck:` per service in docker-compose.prod.yml, and per-role # readiness/liveness in docker/helm/templates/_helpers.tpl. # -# All roles that serve HTTP expose /healthz and /readyz; every role drains on SIGTERM. +# Only `web` and `sync` expose /healthz and /readyz — they are the only roles that construct a +# server. Every role drains on SIGTERM. ENTRYPOINT ["/app/x"] CMD ["dev", "--once"] diff --git a/docker/README.md b/docker/README.md index b1cceb48d..1fd35b2e5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,7 +13,7 @@ Build once. `ROLE` selects behaviour at start; nothing else differs between proc | `web` | RPS | `:3000` | `:9090` | `/readyz` + `/healthz` on `:3000` | | `sync` | concurrent websockets | `:3001` | `:9090` | `/readyz` + `/healthz` on `:3001` | | `worker` | queue depth | — | `:9090` | liveness on `/metrics` | -| `scheduler` | fixed 1 (advisory-lock leader election) | — | `:9090` | liveness on `/metrics` | +| `scheduler` | fixed 1 — leader election is an expiring **row**, never `pg_try_advisory_lock`: that grant is session-scoped and dies when a pooled connection returns, so every node reads itself as leader | — | `:9090` | liveness on `/metrics` | | `replicator` | 1 per database | — | `:9090` | liveness on `/metrics` | | `migrate` | run-once, pre-deploy | — | — | none — it exits | diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index a8115c273..282fffa3e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -73,7 +73,9 @@ services: deploy: replicas: 2 - # Exactly one. Leader election is an advisory lock, so a second instance is harmless but idle. + # Exactly one. Leader election is an expiring lease row in x_scheduler_leader (NOT an advisory + # lock, which is session-scoped and dies with a pooled connection), so a second instance is + # harmless but idle. scheduler: <<: *common environment: { ROLE: scheduler } diff --git a/docker/helm/templates/pdb.yaml b/docker/helm/templates/pdb.yaml index de5f2a712..d1dd0a2af 100644 --- a/docker/helm/templates/pdb.yaml +++ b/docker/helm/templates/pdb.yaml @@ -12,7 +12,7 @@ a drain always finishes. A `minAvailable` equal to the replica count deadlocks t which is how a PDB turns into an outage of its own. Emitted only where the floor is above 1. `scheduler` is pinned to one replica by design (leader -election is a Postgres advisory lock), and a budget over a single pod means that pod can never be +election is an expiring lease row), and a budget over a single pod means that pod can never be evicted — so those roles get none, deliberately. */}} {{- if and .Values.podDisruptionBudget .Values.podDisruptionBudget.enabled }} diff --git a/docker/helm/values.yaml b/docker/helm/values.yaml index 91587a19c..0d5dab234 100644 --- a/docker/helm/values.yaml +++ b/docker/helm/values.yaml @@ -101,7 +101,10 @@ roles: scheduler: enabled: true - replicas: 1 # fixed 1; leader election is a Postgres advisory lock + # Fixed 1. Leader election is an expiring row in x_scheduler_leader, NOT an advisory lock: that + # grant is session-scoped and the executor is a pool, so it dies when the connection returns and + # every replica reads itself as leader. A second replica here is safe but pointless — it stands by. + replicas: 1 resources: requests: { cpu: 50m, memory: 128Mi } limits: { memory: 256Mi } diff --git a/docs/architecture/02-boundaries.md b/docs/architecture/02-boundaries.md index 8b149c122..c703f08b5 100644 --- a/docs/architecture/02-boundaries.md +++ b/docs/architecture/02-boundaries.md @@ -2,65 +2,84 @@ Every boundary is a **build error**, never a lint warning, never a review convention. Axiom 3: a convention that isn't a build error doesn't exist ([`../idea/00-thesis.md`](../idea/00-thesis.md)). -One error code for the whole family: `X_BOUNDARY_VIOLATION`, plus a `rule` field naming which rule broke. One code keeps `errors.explain` and the docs URL stable while rules are added. - -## Framework rules (tiers) - -| Rule id | Forbids | Prevents | -|---|---|---| -| `tier-upward` | tier N importing tier > N | `core` depending on `render` — a cycle that makes every package a build-order problem | -| `tier-sideways` | tier N importing tier N (except `schema` → `core`) | `pwa` ↔ `manifest` cycles; two packages that must be released together | -| `tier-cycle` | any cycle at all, including through types | `tsc --build` deadlock, unresolvable publish order | -| `barrel-star` | `export *` from a non-type module | an accidental public API that cannot be removed without a major | -| `deep-import` | `@ultimat3/x/src/internal/y` | reaching past `src/index.ts`; every internal file becomes public surface | -| `no-any` | `any`, `as any`, `@ts-expect-error` without a code reference | the escape hatch that erases the whole type chain ([`05-type-chain.md`](./05-type-chain.md)) | -| `node-api` | `node:*` imports without an allowlist entry | silently breaking the Bun-only guarantee ([`../idea/01-stack.md`](../idea/01-stack.md)) | - -## Generated-app rules - -| Rule id | Forbids | Prevents concretely | -|---|---|---| -| `site-imports-app` | `site/` → `app/`, transitively | the marketing page that ships a charting library three hops away from any reviewed file ([`../idea/06-surfaces.md`](../idea/06-surfaces.md)) | -| `shared-imports-surface` | `shared/` → `site/` or `app/` | `shared/` stops being a leaf; the graph becomes bidirectional and unbudgetable | -| `app-imports-api-runtime` | `app/` → `api/` as a value import | bundling server handlers into the client. `import type` is allowed; call the typed client instead | -| `route-touches-db` | a route module importing `db` / `drizzle-orm` / `Bun.sql` | N+1 queries in a `
` computation, SQL that no policy guards, and a route that cannot be unit-tested | -| `component-holds-logic` | `ui/**` importing `repo.ts` / `service.ts` / `db` | business rules duplicated per component, and a rule that changes in one screen but not the other | -| `service-imports-http` | `service.ts` importing `Request`/`Response`/`@ultimat3/http` | a service that only works inside a request — so the identical logic gets re-implemented in a job | -| `cross-feature-repo` | `feature-a/**` → `feature-b/repo.ts` | "just add a join" turning a feature slice into a distributed monolith | -| `site-emits-js` | a `site/` route emitting JS without an explicit `hydrate` | the 0kb baseline decaying invisibly ([`09-rendering-internals.md`](./09-rendering-internals.md)) | -| `raw-img` | ` to wiki/Error-Codes.md` |
-| `X_MANIFEST_STALE` | `manifest` | build | `x.manifest.json` / `openapi.json` differ from the code | `x manifest write` |
+| `X_MANIFEST_STALE` | `manifest` | build | `x.manifest.json` / `openapi.json` differ from the code | `x manifest` |
| `X_BUDGET_EXCEEDED` | `render` | build | route bytes / LCP over budget; `data.cause` names the import | `x fix boundary ` |
| `X_BUILD_SKEW` | `http` | 409 | client build ID incompatible with the server contract | `reload` (client-side signal) |
| `X_RATE_LIMITED` | `http` | 429 | token bucket exhausted for `(tenant, actor, route)` | retry after `data.retryAfterMs` |
-| `X_I18N_MISSING_KEY` | `i18n` | build | a key rendered as `⟦key⟧` | `x i18n add ` |
-| `X_MONEY_CURRENCY_MISMATCH` | `money` | 500 | arithmetic across two currencies | convert explicitly first |
-| `X_TIME_NO_ZONE` | `time` | build | formatting without an explicit IANA zone | pass `timeZone: ctx.tz` |
-| `X_TEST_NETWORK_EGRESS` | `testing` | test | unmocked egress inside a test | mock the named URL |
+| `X_CATALOG_MISSING_KEYS` | `i18n` | build | a locale's catalog lacks a key source calls `t()` with | `x i18n sync ` |
+| `X_CURRENCY_MISMATCH` | `money` | 500 | arithmetic across two currencies | convert explicitly first |
+| `X_TIMEZONE_INVALID` | `time` | 500 | a `zone` that is not an IANA identifier | pass an IANA id such as `Europe/Berlin`, never `CET` |
+| `X_TEST_NETWORK_SEALED` | `testing` | test | unmocked egress inside a test | mock the named URL |
+
+There is no runtime code for *formatting with no zone at all*: `FormatContext.zone` is required
+(`packages/time/src/format.ts:14-18`), so an omitted zone is a type error and never reaches a throw.
+`X_TIMEZONE_INVALID` covers the case a type cannot — a string that is not an IANA name.
Package-local codes live with their subsystem: jobs in [`08-jobs-internals.md`](./08-jobs-internals.md), realtime in [`07-realtime-internals.md`](./07-realtime-internals.md), rendering/SEO/PWA in [`09-rendering-internals.md`](./09-rendering-internals.md).
diff --git a/docs/architecture/05-type-chain.md b/docs/architecture/05-type-chain.md
index 11aac497a..8901bec45 100644
--- a/docs/architecture/05-type-chain.md
+++ b/docs/architecture/05-type-chain.md
@@ -100,9 +100,9 @@ Rename `excerpt` → `summary` in `packages/db/src/schema/posts.ts` and change n
| `apps/web/app/posts/repo.ts` | `tsc` | any explicit column list referencing `excerpt` |
| `apps/web/api/posts.contract.test.ts` | `tsc` | the generated contract test asserts the view's keys |
| migrations | `X_DB_DRIFT` | `cause: table "posts" has column "summary" not present in any migration` / `fix: x db gen "rename excerpt to summary"` |
-| `openapi.json`, `x.manifest.json` | `X_MANIFEST_STALE` | `fix: x manifest write` |
+| `openapi.json`, `x.manifest.json` | `X_MANIFEST_STALE` | `fix: x manifest` |
-Eight failures, each naming a file and a line. Zero silent behavior changes. The rename is **complete when the build is green** — there is no seventh place to remember.
+Eight failures, each naming a file and a line. Zero silent behavior changes. The rename is **complete when the build is green** — there is no ninth place to remember.
Contrast: the same rename in a stack where the API response is hand-written and the client types are a copied interface. Nothing fails. The field arrives as `undefined`, renders as an empty paragraph, and the SEO description silently becomes blank on every blog post.
diff --git a/docs/architecture/06-data-layer.md b/docs/architecture/06-data-layer.md
index 6f3d3c676..f302f5a5e 100644
--- a/docs/architecture/06-data-layer.md
+++ b/docs/architecture/06-data-layer.md
@@ -126,8 +126,8 @@ Defense in depth, because a single missed `WHERE` is a data breach.
|---|---|---|
| 1. Context | `ctx.tenantId` set once, at stage 7 of the pipeline ([`03-request-lifecycle.md`](./03-request-lifecycle.md)) | a repo call with no tenant in context throws `X_NO_CONTEXT` |
| 2. Repo | every generated statement injects `tenant_col = $ctx.tenantId` | a hand-written repo query without the filter fails a static check in `x verify` |
-| 3. Write guard | insert/update stamps the tenant from context, refuses a mismatching literal | `X_TENANT_MISMATCH` |
-| 4. Read guard | a returned row whose tenant ≠ context tenant is a bug, not a filter miss | `X_TENANT_MISMATCH`, logged with the query hash |
+| 3. Write guard | insert/update stamps the tenant from context, refuses a mismatching literal (`tenancyRowMismatch`) | `X_TENANCY_ACTOR_MISMATCH` |
+| 4. Plan guard | a plan built with no org predicate, or one naming a tenant the actor does not act as | `X_TENANCY_UNSCOPED` / `X_TENANCY_ACTOR_MISMATCH` — one code for the predicate and the row, because it is one mistake in two places |
| 5. Postgres RLS | optional, opt-in per entity; policy uses a session variable set on checkout | last line of defense for raw SQL and admin sessions |
| 6. Cache keys | query name + parsed-input fingerprint + tags, never hand-built. `As of 2026-08` the actor is **not** a part, so the tenant must be in the read's input | a `cache:` read scoped by actor rather than by input is a cross-tenant hit ([`../idea/05-caching.md`](../idea/05-caching.md)) |
| 7. Live queries | subject includes the tenant; policy re-checked per delivered row | [`07-realtime-internals.md`](./07-realtime-internals.md) |
@@ -242,12 +242,12 @@ stage 14 post-commit
|---|---|
| Schema is the source; migrations are the ledger | `x db gen ""` diffs schema vs. applied migrations and writes SQL |
| Applied set is recorded in-DB | `x_migrations` table: name, checksum, applied_at, build id |
-| Editing an applied migration | checksum mismatch → `X_MIGRATION_TAMPERED`, `fix: x db gen ""` |
+| Editing an applied migration | checksum mismatch → `X_MIGRATION_CONFLICT`, `fix: x db gen ""` |
| Drift in the source | `X_DB_DRIFT` from `checkSourceDrift`, naming the schema and migration hashes that moved — `x verify`, no database needed |
| Drift in the database | `X_DB_DRIFT` from `checkDrift`, naming the table, column or index the live catalog disagrees on — `x db migrate` and `ROLE=migrate` |
-| Irreversible migrations | allowed only with `-- irreversible: `; otherwise `X_MIGRATION_NOT_REVERSIBLE` |
+| Irreversible migrations | `x db gen` refuses to **generate** a drop whose `down` cannot restore the rows: `X_MIGRATION_IRREVERSIBLE`, whose `fix:` is the same command plus `--allow-destructive` |
| Concurrent versions | `ROLE=migrate` takes an advisory lock; a second version in flight is `X_MIGRATE_CONCURRENT` |
-| Destructive statements | `DROP COLUMN` / `DROP TABLE` require `--allow-destructive`, and are refused outright against a production-tagged URL |
+| Destructive statements | a committed `up` that drops, truncates or retypes must carry the `-- destructive: true` line, or `x verify`'s `drift` step refuses to **ship** it: `X_MIGRATION_DESTRUCTIVE`, one finding per file. `@ultimat3/db`'s `destructive.ts` owns the classifier both the generator and the gate read, so they cannot disagree about one file |
```
X_DB_DRIFT: schema differs from migrations
@@ -264,7 +264,7 @@ bun test --workers 8
once: migrate + seed → myapp_test_tpl
per worker: CREATE DATABASE myapp_test_N TEMPLATE myapp_test_tpl (~100-400ms)
per file: truncate the tables that file touched
- teardown: drop on exit (--keep-db to inspect)
+ teardown: drop on exit
```
Real Postgres, truly parallel. Rejected alternatives and why, plus worker→DB assignment mechanics: [`14-testing-internals.md`](./14-testing-internals.md).
@@ -276,12 +276,14 @@ The short version of why not transaction-rollback isolation: the outbox commits,
| Code | Meaning | Fix |
|---|---|---|
| `X_DB_DRIFT` | schema ≠ migrations ≠ catalog | `x db gen ""` |
-| `X_MIGRATION_TAMPERED` | an applied migration's checksum changed | `x db gen ""` |
-| `X_MIGRATION_NOT_REVERSIBLE` | no down path and no `irreversible:` marker | add the marker or a down migration |
-| `X_MIGRATE_CONCURRENT` | another version's migration is in flight | wait, then `x db status --json` |
-| `X_TENANT_MISMATCH` | row tenant ≠ request tenant | scope the query to `ctx.tenantId` |
+| `X_MIGRATION_CONFLICT` | the ledger disagrees with this build — an applied migration's checksum changed | `x db gen ""` |
+| `X_MIGRATION_IRREVERSIBLE` | generating this plan would drop rows the `down` cannot restore | `x db gen "" --allow-destructive` |
+| `X_MIGRATION_DESTRUCTIVE` | a committed `up` destroys data and does not say so | add `-- destructive: true` to the migration file |
+| `X_MIGRATE_CONCURRENT` | another migrator holds the advisory lock | `psql "$DATABASE_URL" -c "select pid, state from pg_stat_activity join pg_locks using (pid) where locktype = 'advisory'"` — terminate the wedged backend, then `x db migrate` |
+| `X_TENANCY_ACTOR_MISMATCH` | a predicate, row or patch named a tenant other than the actor's | drop the `orgId` argument, or `crossTenant('', fn)` |
+| `X_TENANCY_UNSCOPED` | a tenant-scoped plan has no org predicate | `scopedPlan('', tenantColumn, '', plan)` |
| `X_CURSOR_INVALID` | signature mismatch, or a cursor from another query, filter or sort order | restart pagination from `after: null` |
| `X_INVARIANT_VIOLATED` | a write broke a declared invariant; `data.invariant` names it | fix the value, or relax the invariant |
-| `X_QUERY_UNBOUNDED` | `list`/live query without `limit` + total order | add `limit` and a unique tiebreak column |
+| `X_MATCHER_UNSUPPORTED` | a `live:` query the matcher cannot patch incrementally — no total order, or an operator outside `= != in > >= < <=` | set `live: false` and poll, or reshape to equality filters + `orderBy` + `limit` |
| `X_WRITE_UNFILTERED` | `deleteWhere({})` / `updateWhere({}, patch)` — no filter, so every row | name the columns that bound it; a whole-table write is a migration, `x db gen ""` |
| `X_PATCH_EMPTY` | `updateWhere(filter, {})` — nothing to write | name the columns to write |
diff --git a/docs/architecture/07-realtime-internals.md b/docs/architecture/07-realtime-internals.md
index 8f2533e85..85b3a6047 100644
--- a/docs/architecture/07-realtime-internals.md
+++ b/docs/architecture/07-realtime-internals.md
@@ -91,8 +91,9 @@ For each `ChangeEvent`:
Constraints that make step 2 cheap enough to be honest about:
- `live: true` requires a **deterministic, bounded** `sql`: total `orderBy` + `limit`, no non-deterministic functions. What enforces it is `assertMatchable` (`packages/query/src/matcher.ts`), which refuses a shape the matcher cannot patch incrementally — an unsupported clause, or a filter operator outside `= != in > >= < <=` — with `X_MATCHER_UNSUPPORTED` and the fix "set `live: false` and poll, or reshape to equality filters + `orderBy` + `limit`". There is no separate `X_QUERY_UNBOUNDED`.
-- Predicates must be evaluable against a single row. A live query joining more than the configured table count falls back to **re-execution on change** — correct, more expensive, and reported by `x live explain ` so the cost is never a surprise.
-- Aggregates (`count`, `sum`) are supported as incremental deltas only for `+1/-1` shapes; anything else re-executes.
+- Predicates must be evaluable against a **single row of a single entity**. `match` returns no patch at all when `event.entity !== shape.entity` (`packages/query/src/matcher.ts:56`), so a join is not a slower live query — it is not one. `Builder#raw(feature)` is how a source declares a shape the matcher cannot patch, and `assertMatchable` refuses it at subscribe time.
+- **There is no re-execution fallback, and no aggregate deltas.** `As of 2026-08` the refusal is the whole behaviour: unmatchable is `X_MATCHER_UNSUPPORTED`, never a more expensive correct answer. An honest refusal beats a silently wrong result set, and a fallback nobody wrote is worse than both.
+- **Nothing reports which class a query is in before it is subscribed to.** `assertMatchable` runs inside `toLiveQuery` (`packages/query/src/live.ts:125`), which nothing outside `@ultimat3/query` calls, so the refusal arrives at subscribe time and not at build time. There is no explain command — shipped, planned or otherwise.
## Policy is per subscriber, never per query
@@ -264,7 +265,7 @@ Removing it moved **no** `PROTOCOL_VERSION` (still `1`), and both skews are read
| Reconnect outside the window | snapshot | 1 bounded query per subscription | bounded by `limit`, index-backed |
| Cold subscribe | snapshot | 1 bounded query | same as any page load |
| Steady state | matcher + fanout | 0 | changes only |
-| Buffer sizing | `buffer.window` per query group; default 30s of ops, capped by bytes | memory | tuned by `x live explain` |
+| Buffer sizing | `buffer.window` per query group; default 30s of ops, capped by bytes | memory | tuned by hand — no command reports the window |
| Never | replaying arbitrary WAL history per client | — | rejected design: it makes reconnect O(history) |
**Snapshot fallback, never WAL replay.** Outside the window the client gets a fresh snapshot at a current LSN. Cost is one bounded query, never history traversal.
@@ -328,7 +329,7 @@ Requirements this places on `mutator.local`: pure function of `(tx, input)` —
| Limit | Reality |
|---|---|
-| Matcher generality | single-row predicates are fast; multi-table joins and non-trivial aggregates re-execute. `x live explain` tells you which class your query is in |
+| Matcher generality | single-row, single-entity predicates only. Multi-table joins and non-trivial aggregates are **refused** at subscribe time (`X_MATCHER_UNSUPPORTED`), never re-executed, and no command tells you the class in advance |
| Matcher throughput | CPU on one `replicator` per database. A high-write table with many distinct query shapes is the bottleneck, not socket count |
| Memory per subscriber | grows with subscribed-result size. 100k sockets holding 50-row results is fine; 100k holding 5k-row results is not |
| Ordering | per table, by LSN. There is no cross-table transactional snapshot on the wire; a UI that requires one must read via a query, not a subscription |
diff --git a/docs/architecture/08-jobs-internals.md b/docs/architecture/08-jobs-internals.md
index b0e0b3b59..54aa68b3f 100644
--- a/docs/architecture/08-jobs-internals.md
+++ b/docs/architecture/08-jobs-internals.md
@@ -6,12 +6,14 @@ Postgres queue by default, one driver interface, durable steps. Why an outbox an
`run()` is re-entered from the top on every attempt. Completed steps are **not re-executed** — their stored results are returned.
-```ts
-// packages/jobs/src/step-executor.ts — one responsibility: replay a run deterministically
-export function createStep(jobId: JobId, memo: Record, driver: JobDriver) {
- return {
+Sketch of `createStepRunner` ([`packages/jobs/src/steps.ts`](../../packages/jobs/src/steps.ts)) —
+the shape, not the source:
+
+```text
+// one responsibility: replay a run deterministically
+createStepRunner(options) => {
async run(name: string, fn: () => Promise): Promise {
- assertUniqueStepName(jobId, name); // X_JOB_DUPLICATE_STEP
+ assertUniqueStepName(jobId, name); // X_STEP_DUPLICATE
if (name in memo) return memo[name] as T; // replay: no call, no side effect
const result = await fn(); // executed once, ever
await driver.saveStep(jobId, name, result); // durable before returning
@@ -23,9 +25,8 @@ export function createStep(jobId: JobId, memo: Record, driver:
if (key in memo) return;
await driver.saveStep(jobId, key, true);
await driver.sleepUntil(jobId, addDuration(now(), duration));
- throw SUSPEND; // releases the worker; no held connection
+ throw StepSuspension; // releases the worker; no held connection
},
- };
}
```
@@ -139,24 +140,31 @@ RETURNING id, name, attempt;
| Long step | heartbeats keep it alive; a step exceeding `maxStepMs` is killed and retried, never left leased forever |
| SIGKILL | no heartbeat → the lease expires → the reaper requeues. Completed steps are memoized, so recovery resumes at the failed step |
| Clock | `now()` is the **database's** clock, so a skewed worker cannot steal or hold leases |
-| Reaped job | logged with `X_JOB_LEASE_EXPIRED` and the attempt count; repeated reaping is the signal for a stuck external call |
+| Reaped job | `logger.error('jobs.lease.lost', …)` (`packages/jobs/src/heartbeat.ts:71`); the run itself fails as `X_JOB_LEASE_LOST`. Repeated reaping is the signal for a stuck external call |
## Scheduler leader election
-`scheduler` is fixed-1 by design. Election is a **session-level Postgres advisory lock**, so a crash releases it automatically — no TTL, no heartbeat table, no split brain from a paused process.
+`scheduler` is fixed-1 by design. Election is an **expiring row**, `createPgLeaseLeader`
+([`packages/jobs/src/scheduler-pg.ts`](../../packages/jobs/src/scheduler-pg.ts)) — one row per
+`lock_key` in `x_scheduler_leader`, holder plus expiry, and `acquire()` is also the renewal.
-```sql
-SELECT pg_try_advisory_lock(hashtext('ultimate:scheduler')); -- true = leader
-```
+**Not `pg_try_advisory_lock`, and that is the whole point.** An advisory lock is *session*-scoped,
+and the executor this package is handed is a **pool** — the grant dies the moment the connection
+goes back, so every node reads itself as leader and a rolling update double-fires every task.
+`createPgLeader` does not exist; `@ultimat3/realtime`'s `PgAdvisoryLock` solves the same problem by
+owning its connection, and this package holds no wire protocol, so it solves it with a row.
| Property | Detail |
|---|---|
-| Held for | the process's DB session lifetime. Connection dies → lock released by Postgres |
-| A non-leader | reports `/readyz` **not ready** by design and stays a warm standby; it never dispatches |
-| Handover | the leader releases on SIGTERM (drain step 4), so the standby promotes within one lock-retry interval (default 5s) |
-| Missed tick | fires **late** rather than being skipped |
+| Held for | `ttlMs`, default `DEFAULT_LEADER_TTL_MS` = 30s, against a 1s tick. Comfortably longer than the tick, or a slow round loses the lock mid-dispatch |
+| Renewal | the scheduler's per-round `acquire()`. It both extends the lease and answers the round the node stops being leader |
+| Holder identity | a per-process uuid, never a hostname a pod reuses |
+| A non-leader | stays a warm standby and never dispatches. It reports **no** readiness — the `scheduler` role opens no HTTP socket at all, only the metrics listener on `DEFAULT_METRICS_PORT` (`packages/cli/src/metrics-endpoint.ts:22-26`) |
+| Crash | the lease is reclaimed by expiry, with nothing to clean up — the one property the advisory lock had, and one a plain `insert … on conflict do nothing` would not |
+| Single node | `soleLeader()`, which acquires unconditionally |
+| Missed tick | decided against the durable watermark in `x_scheduler_state` (`pgSchedulerState`), per `catchUp` — `skip` (default), `run-once` or `run-all` bounded by `maxCatchUp` |
| Double fire during handover | absorbed by the enqueued job's `idempotencyKey` |
-| `replicator` | same mechanism, key `x:replicator:`; a second instance exits non-zero with `X_REPLICATOR_SLOT_HELD` rather than double-delivering |
+| `replicator` | a second instance exits non-zero with `X_REPLICATOR_SLOT_HELD` rather than double-delivering |
`task` only enqueues. A `task` with a handler body is a rejected design — if it does work, it is a `job` ([`../idea/02-primitives.md`](../idea/02-primitives.md)).
@@ -183,7 +191,7 @@ export const onboardOrg = job({
|---|---|
| Enforcement | unique partial index: `CREATE UNIQUE INDEX ON x_jobs (idempotency_key) WHERE state <> 'done'` |
| Duplicate enqueue with a live key | the insert conflicts; `enqueue` returns the existing handle, no new row, no error |
-| Key must be | deterministic from `input` only. No timestamps, no randomness, no `ctx` — checked by `x verify` (`X_JOB_KEY_NONDETERMINISTIC`) |
+| Key must be | deterministic from `input` only. No timestamps, no randomness, no `ctx`. **A convention, not a rule** — `As of 2026-08` nothing checks it: no code, no step, no lint. A non-deterministic key is a duplicate charge the unique index cannot see |
| Uniqueness window | `retention` per queue; default 24h after terminal state |
| Non-idempotent external call inside a step | pass the provider's idempotency header keyed `${jobId}:${stepName}` |
@@ -235,10 +243,15 @@ Rule: after the queue is wiped, the business must be reconstructible from Postgr
| Code | Meaning | Fix |
|---|---|---|
-| `X_JOB_NO_IDEMPOTENCY_KEY` | runtime guard behind the compile-time requirement | add `idempotencyKey` to the job |
-| `X_JOB_KEY_NONDETERMINISTIC` | key derived from time/randomness/`ctx` | derive it from `input` only |
-| `X_JOB_DUPLICATE_STEP` | two `step.run` calls with the same name in one `run` | rename one step |
-| `X_JOB_STEP_FAILED` | a step exhausted its attempts; `data.step` names it | `x jobs retry --from ` |
-| `X_JOB_LEASE_EXPIRED` | a claimed job's lease expired and it was requeued | raise `leaseMs` or split the step |
-| `X_JOB_QUEUE_UNKNOWN` | `queue` not present in `WORKER_QUEUES` anywhere | add the queue to a worker role |
-| `X_NOT_IMPLEMENTED` | a driver path with no implementation yet | `set jobs.driver = "pg" in app.config.ts` |
+| `X_IDEMPOTENCY_REQUIRED` | runtime guard behind the compile-time requirement | add `idempotencyKey` to the job definition — the factory's own fix line writes the interpolation out |
+| `X_STEP_DUPLICATE` | two `step.run` calls share a name in one `run` | `rename one of them, e.g. step.run('-2', ...)` — step names are the replay key |
+| `X_JOB_MAX_ATTEMPTS` | the job exhausted its retries | `x jobs retry ` |
+| `X_JOB_TIMEOUT` | the job exceeded its wall-clock limit | `raise timeout on the job definition, or split the work into step.run() calls` |
+| `X_JOB_LEASE_LOST` | the queue took this job back mid-run | `x jobs show --json` |
+| `X_JOB_SLOT_LOST` | the fleet concurrency slot was taken by another worker | `x jobs ls --state running --json` |
+| `X_JOB_NOT_CANCELLABLE` | the driver cannot cancel | `set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs cancel --json` |
+| `X_JOB_TENANT_REQUIRED` | the job declares no tenant | `add tenant: (input) => input.orgId to the job — or tenant: 'none', which declares NO org` |
+| `X_JOB_CONCURRENCY_UNENFORCEABLE` | `concurrency` declared on a driver that cannot enforce it | `remove concurrency from the job, or set jobs: { driver: 'postgres' } in app.config.ts` |
+| `X_OUTBOX_NO_TX` | `enqueue` outside a transaction | `wrap the call in ctx.tx(async (tx) => ...), or enqueue with { outbox: false }` |
+| `X_DRIVER_UNAVAILABLE` | the queue driver is unreachable | the factory takes the `fix` from the driver — it names the connection to repair |
+| `X_NOT_IMPLEMENTED` | a driver path with no implementation yet | `set jobs: { driver: 'postgres' } in app.config.ts` |
diff --git a/docs/architecture/09-rendering-internals.md b/docs/architecture/09-rendering-internals.md
index 9080c4f76..8d0cd8b12 100644
--- a/docs/architecture/09-rendering-internals.md
+++ b/docs/architecture/09-rendering-internals.md
@@ -70,7 +70,7 @@ Emits:
| Rule | Detail |
|---|---|
-| Props cross a boundary | must be structured-clone serializable. A function or class instance is a build error (`X_ISLAND_PROPS_UNSERIALIZABLE`) |
+| Props cross a boundary | must be structured-clone serializable. A function or class instance is `X_ISLAND_PROPS_INVALID` |
| Props transport | inlined JSON for small payloads, a separate `p` fetch above the size cap |
| Children | server-rendered HTML by default; a child needing its own timing becomes its own island, nested |
| Chunking | one chunk per island, content-hashed. Two islands importing the same module share a sub-chunk **within the same graph** |
@@ -142,7 +142,7 @@ GET /blog/hello
| Regeneration | a `job`, not an inline render — so a burst of 10k requests costs one render and the origin is never the bottleneck |
| Storage | tier 3 (Redis) is **required** for ISR: regeneration happens on a different instance than the write ([`../idea/05-caching.md`](../idea/05-caching.md)) |
| Serving during regen | the stale copy, with `stale-while-revalidate` so the CDN does the same |
-| Failure | regen failure keeps the stale copy and records `X_ISR_REGEN_FAILED` with the trace; a stale page never becomes a 500 |
+| Failure | regen failure keeps the stale copy and logs `isr.regenerate.failed` (`packages/render/src/render-isr.ts:250-256`); a stale page never becomes a 500. It throws no error and carries no `X_*` code — the request already had an answer |
| Prerender | `prerender()` supplies the initial param set at build; params discovered at runtime are recorded for the reverse index below |
## Tag → page dependency lookup
@@ -174,16 +174,32 @@ Fanout is enqueued in the same transaction as the write and executed post-commit
Live queries are a **separate path**: the same commit already flows through logical replication ([`07-realtime-internals.md`](./07-realtime-internals.md)). Realtime never depends on cache invalidation succeeding.
-A rolled-back write never purges. A committed write always does. `x cache graph --json` prints exactly what a write will evict, before you run it.
+A rolled-back write never purges. A committed write always does. `x cache graph --json` would print
+what a write will evict before you run it — **planned, not shipped**: `x cache` is in the registry
+and `x help` lists it, and running it exits `X_NOT_IMPLEMENTED` naming its own fix,
+`x dev` and the cache panel at `/_x`. The graph itself is real — `x.manifest.json` carries it — and
+`recentInvalidations()` is what the panel reads.
## Codes
| Code | Meaning | Fix |
|---|---|---|
| `X_BUDGET_EXCEEDED` | route bytes / LCP over budget; `data.cause` names the import chain | `x fix boundary ` |
-| `X_ISLAND_PROPS_UNSERIALIZABLE` | a non-clonable prop crossed an island boundary | pass an id and look it up inside the island |
-| `X_ISR_REGEN_FAILED` | background regeneration threw; the stale copy stands | read the linked trace, then `x cache purge ` |
-| `X_SEO_NO_TITLE` / `X_SEO_NO_DESCRIPTION` | required metadata missing | add it to `meta` in the named route file |
-| `X_SW_UNCACHEABLE` | `offline: 'precache'` on an `ssr` route | change `offline` or the render mode |
-| `X_SW_HAND_EDITED` | `sw.js` checksum mismatch | `x build` — never edit the artifact |
+| `X_ISLAND_PROPS_INVALID` | a non-clonable prop crossed an island boundary | pass an id and look it up inside the island |
+| `X_ISLAND_INVALID` | an island declaration cannot become a client entry | correct the `src` to the `.island.tsx` file the cause names |
+| `X_ISLAND_NOT_HYDRATED` | a page renders an island that nothing would ever boot | declare it on the route, or drop the render |
+| `X_ROUTE_META_MISSING` | required metadata missing (`@ultimat3/render`) | `add description to meta in ` |
+| `X_SEO_META_MISSING` | a `site/` route is missing required metadata (`@ultimat3/seo`) | same edit, named by `packages/seo/src/validate.ts` |
+| `X_ROUTE_OFFLINE_MISSING` | `defineRoute()` with no `offline`, or one outside `precache \| runtime \| network-only` | `add offline: 'precache' \| 'runtime' \| 'network-only' to defineRoute` |
+| `X_ROUTE_MODE_INVALID` | render mode not allowed on this surface | change `render` to one this surface permits |
+| `X_SURFACE_BOUNDARY` | a surface imported across the hard boundary | `x fix boundary ` |
+| `X_PRERENDER_FAILED` | a prerendered path threw during build | fix the `load` the cause names |
| `X_BUILD_SKEW` | client build ID incompatible with the current contract | client-side reload signal |
+
+Two names the design docs use are **reserved, not raised** `As of 2026-08`
+([Error codes → Reserved codes](../../wiki/Error-Codes.md#reserved-codes)):
+
+| Reserved | What happens today |
+|---|---|
+| `X_SW_UNCACHEABLE` | an `offline` strategy contradicting the route's `render` mode is **accepted**. `X_ROUTE_OFFLINE_MISSING` checks the strategy's own validity, never its agreement with the mode; `X_SW_SCOPE_INVALID` covers only the scope half |
+| `X_SW_HAND_EDITED` | `sw.js` carries no checksum, so a hand edit survives `x build` and is silently overwritten on the next one |
diff --git a/docs/architecture/10-cross-cutting.md b/docs/architecture/10-cross-cutting.md
index b21b2bc7b..968bf85ed 100644
--- a/docs/architecture/10-cross-cutting.md
+++ b/docs/architecture/10-cross-cutting.md
@@ -17,7 +17,7 @@ Pattern per concern: the rule, the mechanism that makes violating it fail, and t
```ts
t('post.publish'); // static key — extractable
t('cart.items', { count: 3 }); // plural — selects cart.items.{one,other,…}
-t('greeting', { name: user.firstName }); // typed slot — a missing var is a compile error
+t('greeting', { name: user.firstName }); // vars are open: a missing one renders the placeholder
```
```json
@@ -28,24 +28,25 @@ t('greeting', { name: user.firstName }); // typed slot — a missing var is a c
| Mechanism | Detail |
|---|---|
-| Lint rule `hardcoded-string` | a user-facing literal outside `t()` is a build error ([`02-boundaries.md`](./02-boundaries.md)) |
+| No hardcoded user-facing string | **a convention, not a rule.** `As of 2026-08` no Biome rule and no gate step refuses a literal outside `t()` — `biome.json` declares no such rule, and `x i18n check` reads `t()` calls, never the strings beside them. Per axiom 3 this is a review discipline until a check exists |
| Loud miss | dev renders `⟦key⟧`; the string is impossible to miss in a screenshot or a snapshot test |
-| Extraction gate in `x verify` | AST-scan every `t()` call → the key set; diff against every configured locale's catalog. A key missing in **any** locale is `X_I18N_MISSING_KEY` with `fix: x i18n add ` |
-| Unused keys | reported; `x i18n prune --json` removes them. Failing on unused is opt-in, since a key can be used by a template |
-| Dynamic keys | extraction cannot see `t(someVar)`. Declare the set: `t.oneOf(POST_STATES, state)` where `POST_STATES` is a const union — then extraction enumerates it. A raw `t(variable)` is a build error |
-| Typed slots | interpolation variables are inferred from the catalog string, so `t('greeting', {})` fails to compile |
-| Plural completeness | a locale's catalog must supply every CLDR category that locale uses (`ru` needs `one/few/many/other`) — missing category is a build error, not a runtime fallback |
+| Extraction gate | `x i18n check` — scan every `t()` call → the key set; diff against every configured locale's catalog. A key missing in **any** locale is `X_CATALOG_MISSING_KEYS` with `fix: x i18n sync ` |
+| `x i18n add ` | takes a **locale**, not a key: it creates that locale's catalog file. `x i18n sync ` is the one that writes the missing keys into it |
+| Unused keys | reported by `x i18n check` and never a failure — only `missing` becomes a finding (`packages/cli/src/cmd-i18n.ts:102-108`). **No command removes them**: there is no `prune`, planned or shipped. Delete the key from `packages/i18n/catalogs/.json` by hand |
+| Dynamic keys | extraction cannot see `t(someVar)`. `x i18n check` **reports** each one with its file, line and expression, and `runtimeKeyPatterns` uses them to stop the unused half firing on a key only a template reaches. It is not a build error and there is no `t.oneOf` |
+| Typed keys | `TranslationKey` makes an unknown **key** a compile error. The interpolation **vars** are not typed against the catalog string — `vars?: TranslateVars` is optional and open (`packages/i18n/src/translator.ts:46`), so a missing slot renders the placeholder rather than failing to compile |
+| Plural completeness | selection is `Intl.PluralRules`, CLDR categories, never an English `n === 1` (`packages/i18n/src/interpolate.ts:57-70`). A **missing** category falls back at runtime; no gate step requires a locale to supply every category it uses |
| SEO | `hreflang` reciprocal set + per-locale prerender come from the route table ([`../idea/07-rendering-seo.md`](../idea/07-rendering-seo.md)) |
### Bugs prevented
| Bug | Why it cannot happen |
|---|---|
-| "1 items" | plural category comes from CLDR, and the catalog must define the categories the locale uses |
+| "1 items" | plural category comes from CLDR (`Intl.PluralRules`), never `count === 1`. A category the catalog omits falls back down `pluralKeyCandidates` rather than rendering the English form |
| Russian/Polish/Arabic plurals silently wrong | same — `one/other` is not a valid `ru` catalog |
-| English leaking into `/es/` | a key missing from `es` fails the build, not the page |
-| A string that never gets translated | it could not be written in the first place |
-| Sentences broken by concatenation | slots are typed and ordered by the catalog, so word order is the translator's decision |
+| English leaking into `/es/` | a key missing from `es` renders `⟦key⟧`, and `x i18n check` reports it as `X_CATALOG_MISSING_KEYS`. `x verify` has no i18n step, so this one is a command you run, not a step you inherit |
+| A string that never gets translated | `x i18n check` names it — but only once it is inside a `t()`. A literal outside one is still reachable, see Enforcement above |
+| Sentences broken by concatenation | the whole sentence is one catalog entry, so word order is the translator's decision rather than the caller's |
## Theming
@@ -80,12 +81,12 @@ Generated from it: the CSS custom properties for both themes, the TS union of va
| Mechanism | Detail |
|---|---|
-| Lint rule `raw-hex` | any colour literal (`#hex`, `rgb()`, `hsl()`, a named colour) in a component or `.scss` module is a build error |
-| Token existence | `var(--foo)` referencing a token not in `tokens.ts` is a build error — the generated CSS and the checked set come from the same file |
-| Palette isolation | `palette.*` is importable **only** by `tokens.ts`; anywhere else is a boundary violation. A component cannot reach a raw colour even indirectly |
-| Contrast | generated pairs are checked against WCAG AA at build; a failing pair is a build error with the measured ratio |
+| No raw colour | **a convention, not a rule.** `As of 2026-08` there is no `raw-hex` lint rule and no gate step that refuses a colour literal in a component or `.scss` module. The check that should exist would scan the same file set `errors` and `filesize` already walk |
+| Token existence | `ColorRole` is a union derived from `colorTokens`, so naming a token TypeScript does not know is a compile error **in TS**. A `var(--foo)` in SCSS is not checked against it — SCSS is not typechecked |
+| Contrast | `contrastRatio` / `meetsContrast` measure every pairing against `AA_TEXT` (4.5) and `AA_LARGE` (3), and `packages/ui/src/tokens/contrast.test.ts` fails on a pair that misses. That is the framework's own palette and a brand override run through the same function; it is a **test**, so it reaches the gate through the `unit` step, not through a check of its own |
| Specificity | generated CSS emits `@media (prefers-color-scheme: dark)` **first**, then `:root[data-theme="dark"]` / `:root[data-theme="light"]` overrides — so an explicit choice always wins in both directions |
-| Pre-paint script | a byte-capped inline `