@ultimat3/cli 30.3.0 → 30.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -5,7 +5,7 @@ dashboard), `cli → testing` (islands, the e2e step, `x shot`'s `launchChrome`)
5
5
  this except `create-ultimate`. The reasoning behind every rule below, verbatim and dated, is
6
6
  [`docs/history/cli.md`](../../docs/history/cli.md); where the two disagree this file wins.
7
7
 
8
- Commands: `bun test packages/cli` (from the repo root — the test preload lives there),
8
+ Commands: `bun test packages/cli` (from the repo root: the preload),
9
9
  `bunx tsc --noEmit -p packages/cli/tsconfig.json`.
10
10
 
11
11
  ## Rules
@@ -51,13 +51,13 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
51
51
  | `boundary-findings.ts` / `app-boundaries.ts` / `boundary-cuts.ts` / `package-boundaries.ts` | `package-boundaries.ts`: `X_BOUNDARY_PACKAGE_TO_APP`, its own read of `packages/`, never `readAppSources`. surface + layer rules; a finding's `fix:` is the concrete cut |
52
52
  | `app-transport.ts` / `browser-transport.ts` / `transport-calls.ts` / `import-closure.ts` / `server-barrels.ts` | `boundaries`, built in: one browser transport. Never a `guards/` file: an app must not be able to delete it. Full rule: `docs/history/cli.md` |
53
53
  | `guards.ts` / `guard-sources.ts` | an app's `guards/*.ts`, discovered, run on `boundaries`, held to the error contract; every `check(root, sources)` shares ONE walk, read and Sass compile per file (`GuardSources`) |
54
- | `templates/scaffold-guards.ts` | `SHIPPED` — the one list of the twenty-one shipped guards (`native-confirm` the newest): `x new` writes all, `x g guard <name>` writes one, `doctor-guards.ts` lists the ones an app lacks (a listing, never a finding). Full rule: `docs/history/cli.md` |
54
+ | `templates/scaffold-guards.ts` | `SHIPPED` — the one list of the twenty-one shipped guards: `x new` writes all, `x g guard <name>` writes one, `doctor-guards.ts` lists the ones an app lacks (a listing, never a finding). Full rule: `docs/history/cli.md` |
55
55
  | `error-contract.ts` / `ts-scan.ts` / `fix-scan.ts` / `fix-imports.ts` / `fix-command.ts` / `fix-path.ts` | the `errors` step: every `fix:` names a runnable command, call or existing file |
56
56
  | `workspace-checks.ts` / `test-only-shipped.ts` / `workspace-graph.ts` / `tsconfig-references.ts` / `publish-closure.ts` | `package-shape`, `filesize`. Full rule: `docs/history/cli.md` |
57
57
  | `app-permissions.ts` / `permission-grants.ts` / `app-permissions-borrowed.ts` / `app-permissions-site.ts` | `policy`: every permission granted or required is declared and granted (`X_PERMISSION_UNGRANTED`, `X_PERMISSION_BORROWED`). Full rule: `docs/history/cli.md` |
58
58
  | `job-registration.ts` | `manifest`: no job or task under a positional `anonymous-*` name (`X_JOB_UNREGISTERED`) |
59
59
  | `unscanned-admin.ts` | `manifest`: a `defineAdmin()` outside the app scan (`apps/*/src/`) is `X_ADMIN_UNSCANNED` |
60
- | `tool-gate-names.ts` | `manifest`: a gate on no tool |
60
+ | `tool-gate-names.ts` · `verify-mcp-mount.ts` | `manifest`: a gate on no tool · `expose` and no `POST /mcp` |
61
61
  | `verify-role-load.ts` / `verify-role-load-probe.ts` | `manifest`: a child imports the app as a worker does, then the rest module by module; a registration only a document-reaching module makes is `X_ROLE_LOAD_INCOMPLETE` with the module and the import the API index lacks |
62
62
  | `async-pages.ts` / `live-routes.ts` / `budgets.ts` / `budgets-unmeasured.ts` | `budgets` riders: an async `Page` with no `load`, a live read no island imports, bytes per route; an unweighed route's finding names a refused measurement actor and `defineMeasurementActor()` |
63
63
  | `budgets-freshness.ts` / `verify-budgets-build.ts` | the step builds first when stats are missing, of an old rule, or older than source: `x build --target static --no-preflight --out .x/verify-static` through `ctx.runner` |
@@ -95,7 +95,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
95
95
  | `generate-feature-required.ts` | `x g query`/`backfill` with no slice holding an `entity.ts` is `X_FEATURE_REQUIRED` |
96
96
  | `generate-sign-in*.ts` | `x g sign-in`: one flag, no name; OIDC swaps only `x new`'s bytes |
97
97
  | `generate-format-written.ts` | the app's Biome over the TypeScript a run created, best effort; `lint` judges |
98
- | `templates/scaffold-mcp-json.ts` / `templates/scaffold-mcp-endpoint.ts` | `.mcp.json` (`bunx @ultimat3/cli mcp serve`, never `bunx x`) and the refusing `apps/web/mcp.ts` + `app/auth/agent-token.ts`; `scaffold-agents-md.test.ts` resolves every command `AGENTS.md` names |
98
+ | `templates/scaffold-mcp-{json,endpoint,admin}.ts` | `.mcp.json` (`bunx @ultimat3/cli mcp serve`, never `bunx x`), the refusing `apps/web/mcp.ts` + `app/auth/agent-token.ts`, `apps/admin/mcp.ts`; `scaffold-agents-md.test.ts` resolves every command `AGENTS.md` names |
99
99
 
100
100
  ### Boot (`x dev`, the container)
101
101
 
@@ -113,7 +113,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
113
113
  | `app-config-load.ts` | the ONE reader of an app's `app.config.ts`: `loadAppConfig(root)` — the `config` export through core's `defineConfig`, so validated and default-merged; `undefined` with no file; no `config` object is `X_CONFIG_INVALID`. Full rule: `docs/history/cli.md` |
114
114
  | `theme-brand.ts` | the ONE reader of an app's theme: `export const brand` in `apps/web/shared/theme.ts` (ui's `defineTheme`), inlined after the surface stylesheet with its hash in `style-src`; none → nothing emitted |
115
115
  | `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys (`*Of(config)` over the one loaded config), and what each wires |
116
- | `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered queues; `WORKER_QUEUES` = the EXACT set, each entry in that union or `X_WORKER_QUEUE_UNKNOWN`; `queue-unserved` warns the rest |
116
+ | `runtime-jobs.ts` (queues) | `jobs.queues` ∪ registered; `WORKER_QUEUES` = the EXACT set, each in that union or `X_WORKER_QUEUE_UNKNOWN`; `-unserved.ts`: what no live worker serves |
117
117
  | `serve-retire.ts` / `dev-restart-retire.ts` | the worker retire, `stop()` claiming and aborting nothing: SIGUSR2 under `runRole`, and before a supervised `x dev` restart's drain (#677). Full rule: `docs/history/cli.md` |
118
118
  | `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` / `role-start-devices.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator`; device mounts, swapped per path on save |
119
119
  | `role-wake.ts` | the worker's LISTEN session (`@ultimat3/jobs`' `startQueueWake`) — worker role only, only over a client that `canListen` — and `queue_wake_live` |
@@ -131,7 +131,7 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
131
131
  | `island-bundle.ts` / `island-store.ts` / `island-sources.ts` / `island-realtime.ts` / `island-realtime-state.ts` / `island-runtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; the image build writes a verified store, and an edit since the build is a rebuild, never the old chunk |
132
132
  | `local-cli.ts` / `local-cli-handoff.ts` | a global `x` hands over to the app's own CLI (`local-cli.ts` decides whether; `x upgrade` alone stays when the app's is strictly older — `upgrade-handover.test.ts`) |
133
133
  | `serve-prebuilt.ts` / `serve-prebuilt-paths.ts` | the image's prebuilt store (`node_modules/.cache/ultimate`, never `.x/` — state, tmpfs): every role adopts its compiled stylesheets before importing the app; a boot that built an island or ran Sass logs `X_IMAGE_NOT_PREBUILT` once |
134
- | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
134
+ | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, watcher, reload, lock, port |
135
135
  | `cmd-stop.ts` / `cmd-stop-spec.ts` | `x stop`: the lock's pid only (its recorded start time must match), SIGTERM, then wait for pid gone and lock clear; Windows clears the lock (`drained: false`); `X_DEV_STOP_TIMEOUT` |
136
136
  | `dev-dashboard-guard.ts` / `loopback-host.ts` | `/_x` answers a loopback `Host` only (`X_DEV_HOST_REFUSED`, 421 — DNS rebinding); the SQL panel runs on a same-origin `POST /_x/db` only, through admin's `readOnlySql` |
137
137
  | `doctor-pooler.ts` / `runtime-statement-bound.ts` | the applied bound on demand (`data.statementBound`), a pooler's refusal as the one database finding; the one boot line, total. `startDb` hands `poolerFromEnv(env)` to both pools |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "30.3.0",
3
+ "version": "30.4.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -53,33 +53,33 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@babel/core": "^8.0.7",
56
- "@ultimat3/action": "30.3.0",
57
- "@ultimat3/admin": "30.3.0",
58
- "@ultimat3/ai": "30.3.0",
59
- "@ultimat3/auth": "30.3.0",
60
- "@ultimat3/cache": "30.3.0",
61
- "@ultimat3/core": "30.3.0",
62
- "@ultimat3/db": "30.3.0",
63
- "@ultimat3/entity": "30.3.0",
64
- "@ultimat3/flags": "30.3.0",
65
- "@ultimat3/http": "30.3.0",
66
- "@ultimat3/i18n": "30.3.0",
67
- "@ultimat3/jobs": "30.3.0",
68
- "@ultimat3/mail": "30.3.0",
69
- "@ultimat3/manifest": "30.3.0",
70
- "@ultimat3/mcp": "30.3.0",
71
- "@ultimat3/money": "30.3.0",
72
- "@ultimat3/notify": "30.3.0",
73
- "@ultimat3/policy": "30.3.0",
74
- "@ultimat3/pwa": "30.3.0",
75
- "@ultimat3/query": "30.3.0",
76
- "@ultimat3/realtime": "30.3.0",
77
- "@ultimat3/render": "30.3.0",
78
- "@ultimat3/schema": "30.3.0",
79
- "@ultimat3/seo": "30.3.0",
80
- "@ultimat3/storage": "30.3.0",
81
- "@ultimat3/testing": "30.3.0",
82
- "@ultimat3/time": "30.3.0",
56
+ "@ultimat3/action": "30.4.0",
57
+ "@ultimat3/admin": "30.4.0",
58
+ "@ultimat3/ai": "30.4.0",
59
+ "@ultimat3/auth": "30.4.0",
60
+ "@ultimat3/cache": "30.4.0",
61
+ "@ultimat3/core": "30.4.0",
62
+ "@ultimat3/db": "30.4.0",
63
+ "@ultimat3/entity": "30.4.0",
64
+ "@ultimat3/flags": "30.4.0",
65
+ "@ultimat3/http": "30.4.0",
66
+ "@ultimat3/i18n": "30.4.0",
67
+ "@ultimat3/jobs": "30.4.0",
68
+ "@ultimat3/mail": "30.4.0",
69
+ "@ultimat3/manifest": "30.4.0",
70
+ "@ultimat3/mcp": "30.4.0",
71
+ "@ultimat3/money": "30.4.0",
72
+ "@ultimat3/notify": "30.4.0",
73
+ "@ultimat3/policy": "30.4.0",
74
+ "@ultimat3/pwa": "30.4.0",
75
+ "@ultimat3/query": "30.4.0",
76
+ "@ultimat3/realtime": "30.4.0",
77
+ "@ultimat3/render": "30.4.0",
78
+ "@ultimat3/schema": "30.4.0",
79
+ "@ultimat3/seo": "30.4.0",
80
+ "@ultimat3/storage": "30.4.0",
81
+ "@ultimat3/testing": "30.4.0",
82
+ "@ultimat3/time": "30.4.0",
83
83
  "babel-preset-solid": "^1.9.16"
84
84
  }
85
85
  }
package/src/app-mcp.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  // every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
6
6
  // exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
7
7
  // affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
8
- // own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name.
8
+ // own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name — and
9
+ // every app directory's file mounts, so an admin surface's `apps/admin/mcp.ts` stands beside the web one.
9
10
 
10
11
  // why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
11
12
  import { existsSync } from 'node:fs';
@@ -16,6 +17,7 @@ import type { Route } from '@ultimat3/http';
16
17
  import { jsonResponse, originListed, selfOrigin } from '@ultimat3/http';
17
18
  import {
18
19
  type AppMcp,
20
+ assertIncludeCurrent,
19
21
  assertRecipeLinks,
20
22
  McpAppUnmountedError,
21
23
  McpPathDuplicateError,
@@ -75,22 +77,44 @@ function endpointsOf(exported: unknown): readonly AppMcp[] | undefined {
75
77
  return exported.every(isAppMcp) ? (exported as readonly AppMcp[]) : undefined;
76
78
  }
77
79
 
78
- /** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
80
+ /** The web surface's file: endpoint #0 — the default — wherever its directory sorts. */
81
+ const DEFAULT_MCP_FILE = 'apps/web/mcp.ts';
82
+
83
+ /**
84
+ * Every `apps/<app>/mcp.ts`, app-root-relative: the web surface's first, then the rest sorted, so
85
+ * two machines answer in one order and an `apps/admin/mcp.ts` never takes the default endpoint.
86
+ */
79
87
  async function candidates(root: string): Promise<readonly string[]> {
80
88
  // A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
81
89
  // is answered as "none", and the warning below says which file to write.
82
90
  if (!existsSync(join(root, 'apps'))) return [];
83
91
  const files: string[] = [];
84
92
  for await (const file of new Bun.Glob(APP_MCP_GLOB).scan({ cwd: root })) files.push(file);
85
- return files.sort();
93
+ files.sort();
94
+ return files.includes(DEFAULT_MCP_FILE)
95
+ ? [DEFAULT_MCP_FILE, ...files.filter((file) => file !== DEFAULT_MCP_FILE)]
96
+ : files;
86
97
  }
87
98
 
88
99
  const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undefined };
89
100
 
101
+ /** One mount file's endpoints. */
102
+ interface MountFile {
103
+ readonly file: string;
104
+ readonly endpoints: readonly AppMcp[];
105
+ }
106
+
90
107
  /**
91
108
  * The routes to mount, or the reason there are none. Pure over the filesystem it is pointed at;
92
109
  * `mountAppMcp` below is the one place the warning becomes a log line.
93
110
  *
111
+ * EVERY app directory's `mcp.ts` mounts — an admin surface's `apps/admin/mcp.ts` beside the web
112
+ * one — on one route table. The first sorted file used to win alone, so an admin surface's file
113
+ * silently took the app's `/mcp` away. Each is imported HERE, after the boot scan: the scan
114
+ * evaluates `apps/<app>/mcp.ts` after every `apps/<app>/{site,app,api,shared}/` module, so an
115
+ * `include: 'exposed'` built at its top level sees every primitive — and one built earlier, in a
116
+ * module it re-exports, is `X_MCP_INCLUDE_EARLY` rather than a catalog missing tools.
117
+ *
94
118
  * `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
95
119
  * `mcp.route.handle` is the one evaluation, and it reads `Authorization: Bearer` through the
96
120
  * `resolveToken` the app gave `defineAppMcp`, then decides per tool through the same policy every
@@ -100,67 +124,78 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
100
124
  export async function appMcpMount(root: string): Promise<AppMcpMount> {
101
125
  if (!(await exposed(root))) return NOTHING;
102
126
  const files = await candidates(root);
103
- const fallbackFile = 'apps/web/mcp.ts';
127
+ const mounted: MountFile[] = [];
104
128
  for (const file of files) {
105
129
  const module = (await import(join(root, file))) as Record<string, unknown>;
106
130
  const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
107
131
  if (endpoints === undefined) continue;
108
- // Every endpoint is built by now: an error's declared `docs://recipes/` link must name a recipe
109
- // one of them serves (`X_MCP_RECIPE_UNKNOWN`), the union and never one endpoint alone.
110
- assertRecipeLinks(servedRecipes());
111
- return mountEndpoints(endpoints, file);
132
+ for (const endpoint of endpoints) assertIncludeCurrent(endpoint, file);
133
+ mounted.push({ file, endpoints });
112
134
  }
113
- return {
114
- ...NOTHING,
115
- warning: new McpAppUnmountedError({
116
- reason: 'missing',
117
- path: UNROUTED_MCP_PATH,
118
- file: files[0] ?? fallbackFile,
119
- }),
120
- };
135
+ if (mounted.length === 0) {
136
+ return {
137
+ ...NOTHING,
138
+ warning: new McpAppUnmountedError({
139
+ reason: 'missing',
140
+ path: UNROUTED_MCP_PATH,
141
+ file: files[0] ?? DEFAULT_MCP_FILE,
142
+ }),
143
+ };
144
+ }
145
+ // Every endpoint is built by now: an error's declared `docs://recipes/` link must name a recipe
146
+ // one of them serves (`X_MCP_RECIPE_UNKNOWN`), the union and never one endpoint alone.
147
+ assertRecipeLinks(servedRecipes());
148
+ return mountEndpoints(mounted);
121
149
  }
122
150
 
123
151
  /**
124
- * One file's endpoints onto one route table. An endpoint built without `resolveToken` has no
152
+ * Every file's endpoints onto one route table. An endpoint built without `resolveToken` has no
125
153
  * route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
126
154
  * the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
155
+ * Numbering: `#n` in a message is the endpoint's index in its own file's export; only the very
156
+ * first endpoint mounted is the default (#0 of the route names and the root metadata document).
127
157
  */
128
- function mountEndpoints(endpoints: readonly AppMcp[], file: string): AppMcpMount {
158
+ function mountEndpoints(files: readonly MountFile[]): AppMcpMount {
129
159
  const routes: Route[] = [];
130
160
  const paths: string[] = [];
131
- const claimed = new Map<string, number>();
161
+ const claimed = new Map<string, { readonly file: string; readonly index: number }>();
132
162
  let warning: McpAppUnmountedError | undefined;
133
- endpoints.forEach((endpoint, index) => {
134
- const route = endpoint.route;
135
- if (route === undefined) {
136
- warning ??= new McpAppUnmountedError({
137
- reason: 'no-route',
138
- path: UNROUTED_MCP_PATH,
139
- file,
140
- ...(index === 0 ? {} : { endpoint: index }),
141
- });
142
- return;
143
- }
144
- // The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
145
- // over. A mount path from anywhere else is a resource advertised where nothing answers.
146
- const path = route.path;
147
- for (const mounted of endpointRoutes(route, path, index)) {
148
- const key = `${mounted.method} ${mounted.path}`;
149
- const first = claimed.get(key);
150
- if (first !== undefined) {
151
- throw new McpPathDuplicateError({
152
- method: mounted.method,
153
- path: mounted.path,
154
- endpoints: [first, index],
163
+ let mountedIndex = 0;
164
+ for (const { file, endpoints } of files) {
165
+ endpoints.forEach((endpoint, index) => {
166
+ const route = endpoint.route;
167
+ if (route === undefined) {
168
+ warning ??= new McpAppUnmountedError({
169
+ reason: 'no-route',
170
+ path: UNROUTED_MCP_PATH,
155
171
  file,
172
+ ...(index === 0 ? {} : { endpoint: index }),
156
173
  });
174
+ return;
157
175
  }
158
- claimed.set(key, index);
159
- routes.push(mounted);
160
- }
161
- paths.push(path);
162
- });
163
- return { routes, path: endpoints[0]?.route?.path ?? null, paths, warning };
176
+ // The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
177
+ // over. A mount path from anywhere else is a resource advertised where nothing answers.
178
+ const path = route.path;
179
+ for (const one of endpointRoutes(route, path, mountedIndex)) {
180
+ const key = `${one.method} ${one.path}`;
181
+ const first = claimed.get(key);
182
+ if (first !== undefined) {
183
+ throw new McpPathDuplicateError({
184
+ method: one.method,
185
+ path: one.path,
186
+ endpoints: [first.index, index],
187
+ file,
188
+ firstFile: first.file,
189
+ });
190
+ }
191
+ claimed.set(key, { file, index });
192
+ routes.push(one);
193
+ }
194
+ paths.push(path);
195
+ mountedIndex += 1;
196
+ });
197
+ }
198
+ return { routes, path: paths[0] ?? null, paths, warning };
164
199
  }
165
200
 
166
201
  type Descriptor = NonNullable<AppMcp['route']>;
@@ -15,7 +15,7 @@ export const upgradeSpec: CommandSpec = {
15
15
  name: 'to',
16
16
  type: 'string',
17
17
  summary:
18
- 'an exact release inside the next major, or the higher version a mixed set was told to align to (default: the next major’s newest)',
18
+ 'an exact release inside the next major; over a mixed set, inside the lowest pins’ next major, and only those pins move (default: the next major’s newest)',
19
19
  },
20
20
  {
21
21
  name: 'dry-run',
@@ -189,7 +189,9 @@ async function buildAll(
189
189
  // gets the same rule (`island-package-dedupe.ts`): a nested copy is a second module where no
190
190
  // symlink folds it, which is every `file:` install on Windows.
191
191
  plugins: [
192
- ...(runtime === undefined ? [] : [islandRealtimePlugin(root, runtime, follows)]),
192
+ ...(runtime === undefined
193
+ ? []
194
+ : [islandRealtimePlugin(root, runtime, follows, live.length === files.length)]),
193
195
  solidDedupePlugin(root),
194
196
  ...frameworkDedupePlugins(root),
195
197
  solidJsxPlugin,
@@ -5,8 +5,9 @@
5
5
  // that reads the record store or the outbox also waits for the page's restore before it mounts
6
6
  // (#506); one that only follows the socket does not (`island-realtime-state.ts`).
7
7
 
8
- // why: Bun ships no path API; the entry is joined to the root, the runtime named by its basename.
9
- import { basename, join } from 'node:path';
8
+ // why: Bun ships no path API; the entry is joined to the root, the runtime named by its basename,
9
+ // core's transport found beside the core module importing it.
10
+ import { basename, dirname, join } from 'node:path';
10
11
  import type { BunPlugin } from 'bun';
11
12
  import { followsOnly, type IslandRealtime } from './island-realtime-state';
12
13
  import { RUNTIME_CHUNK_SPECIFIER, type RuntimeChunk } from './island-runtime';
@@ -29,6 +30,10 @@ const INSTALL = 'ultimate:island-realtime';
29
30
  const RESTORE = 'ultimate:island-restore';
30
31
  const MODULE = 'ultimate:island-module:';
31
32
  const WAIT = 'ultimate:island-page-wait';
33
+ const CORE_TRANSPORT = 'ultimate:island-core-transport';
34
+ /** What core's own modules import the transport by — the one specifier the swap answers. */
35
+ const TRANSPORT_SPECIFIER = /^\.\/client-transport$/;
36
+ const TRANSPORT_MODULE = 'transport:';
32
37
 
33
38
  /**
34
39
  * Whether the island's own graph value-imports realtime. Relative specifiers only, which is the
@@ -108,6 +113,41 @@ const restoreSource = (): string =>
108
113
  `import { holdFirstPaint } from '${REALTIME}';\n` +
109
114
  `await holdFirstPaint();\n`;
110
115
 
116
+ /**
117
+ * Core's `client-transport.ts`, as a realtime island's bundle sees it: `clientTransport` sends
118
+ * through the page runtime's copy (`runtimeSend`), and `settleAnswer` — `clientStream`'s, which
119
+ * the runtime does not expose — is the real one, tree-shaken to what it needs. The island awaited
120
+ * the runtime before its module body ran, so a send can never find it absent. Measured on
121
+ * `examples/dummy`'s run console, As of 2026-11-07: see `island-transport.test.ts`'s header.
122
+ */
123
+ const transportSource = (): string =>
124
+ `import { runtimeSend } from '${WAIT}';\n` +
125
+ `export { settleAnswer } from '${CORE_TRANSPORT}';\n` +
126
+ `export function clientTransport(req) {\n return runtimeSend(req);\n}\n`;
127
+
128
+ /** Whether `file` is a source file of an installed `@ultimat3/core` — any copy of it. */
129
+ async function inCore(file: string, cache: Map<string, Promise<boolean>>): Promise<boolean> {
130
+ const dir = dirname(file);
131
+ let known = cache.get(dir);
132
+ if (known === undefined) {
133
+ known = packageNameAbove(dir).then((name) => name === '@ultimat3/core');
134
+ cache.set(dir, known);
135
+ }
136
+ return await known;
137
+ }
138
+
139
+ async function packageNameAbove(start: string): Promise<string | undefined> {
140
+ for (let dir = start; dir !== dirname(dir); dir = dirname(dir)) {
141
+ const manifest = Bun.file(join(dir, 'package.json'));
142
+ if (!(await manifest.exists())) continue;
143
+ const parsed: unknown = await manifest.json();
144
+ const name =
145
+ typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'name') : undefined;
146
+ return typeof name === 'string' ? name : undefined;
147
+ }
148
+ return undefined;
149
+ }
150
+
111
151
  /**
112
152
  * `runtime` names the realtime every bare `@ultimat3/realtime` of the build resolves to — the copy
113
153
  * the runtime chunk was built against, resolved from the first realtime island's directory. One
@@ -115,15 +155,36 @@ const restoreSource = (): string =>
115
155
  * sets is only read by hooks from the same module instance, and the page object it reads is the
116
156
  * shape that copy's runtime installs. `follows` is THIS build's islands that restore nothing: an
117
157
  * island absent from it waits for the restore, which is the answer that can never paint stale.
158
+ *
159
+ * `sendThroughRuntime` swaps core's transport for the runtime's (`transportSource`). Only for a
160
+ * build whose every entry is a realtime island: one split build shares a module between its
161
+ * entries, and a plain island never waits for a runtime to send through.
118
162
  */
119
163
  export function islandRealtimePlugin(
120
164
  root: string,
121
165
  runtime: RuntimeChunk,
122
166
  follows: ReadonlySet<string> = new Set(),
167
+ sendThroughRuntime = false,
123
168
  ): BunPlugin {
169
+ const cores = new Map<string, Promise<boolean>>();
124
170
  return {
125
171
  name: 'ultimate-island-realtime',
126
172
  setup(build) {
173
+ if (sendThroughRuntime) {
174
+ // Only core's own `./client-transport` — an app file of that name is the app's. The real
175
+ // file rides in the namespace path, so the module's SOURCE names no path on this machine.
176
+ build.onResolve({ filter: TRANSPORT_SPECIFIER }, async (args) =>
177
+ args.namespace === 'file' && (await inCore(args.importer, cores))
178
+ ? {
179
+ path: `${TRANSPORT_MODULE}${join(dirname(args.importer), 'client-transport.ts')}`,
180
+ namespace: NAMESPACE,
181
+ }
182
+ : undefined,
183
+ );
184
+ build.onResolve({ filter: /^ultimate:island-core-transport$/ }, (args) => ({
185
+ path: args.importer.slice(TRANSPORT_MODULE.length),
186
+ }));
187
+ }
127
188
  build.onResolve({ filter: /^ultimate:island-entry:/ }, (args) => ({
128
189
  path: `entry:${args.path.slice(REALTIME_ISLAND_ENTRY.length)}`,
129
190
  namespace: NAMESPACE,
@@ -158,7 +219,9 @@ export function islandRealtimePlugin(
158
219
  )
159
220
  : args.path === 'restore'
160
221
  ? restoreSource()
161
- : installSource(runtime.url),
222
+ : args.path.startsWith(TRANSPORT_MODULE)
223
+ ? transportSource()
224
+ : installSource(runtime.url),
162
225
  loader: 'js',
163
226
  }));
164
227
  },
package/src/role-start.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  outboxRelay,
27
27
  postgresLeaseLeader,
28
28
  postgresSchedulerState,
29
+ registeredJobs,
29
30
  } from '@ultimat3/jobs';
30
31
  import type { SyncWs } from '@ultimat3/realtime/server';
31
32
  import { errorPageHook } from './error-pages';
@@ -45,6 +46,7 @@ import { prepareSync, type RunningSync, syncReadsChanges } from './role-sync';
45
46
  import { startWorkerWake } from './role-wake';
46
47
  import { devHooks } from './runtime-hooks';
47
48
  import { workerOptionsFor } from './runtime-jobs';
49
+ import { reportUnservedQueues } from './runtime-jobs-unserved';
48
50
  import { startLiveFeed } from './runtime-live-feed';
49
51
  import { type RunningServices, releaseOrThrow } from './runtime-services';
50
52
  import { inlineScriptSources } from './script-csp';
@@ -335,16 +337,30 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
335
337
  prepared === null ? null : await prepared.listen(server === null ? null : server.url());
336
338
  if (sync !== null) releaseSync = sync.stop;
337
339
 
338
- const worker = selected.includes('worker')
339
- ? jobWorker({
340
- driver: options.runtime.jobs,
341
- build: options.buildId,
342
- context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
343
- ...workerOptionsFor(options.runtime.workerConfig),
344
- })
340
+ const workerOptions = selected.includes('worker')
341
+ ? workerOptionsFor(options.runtime.workerConfig)
345
342
  : null;
343
+ const worker =
344
+ workerOptions === null
345
+ ? null
346
+ : jobWorker({
347
+ driver: options.runtime.jobs,
348
+ build: options.buildId,
349
+ context: () => ctxOf({ role: 'worker', buildId: options.buildId }),
350
+ ...workerOptions,
351
+ });
346
352
  worker?.start();
347
353
  if (worker !== null) started.push(() => worker.stop('x dev stopped'));
354
+ // An exact worker (`WORKER_QUEUES`) names the registered queues NO live worker serves — after
355
+ // its start, so the registry holds its own row and the rest of the fleet's. Never rejects.
356
+ if (workerOptions !== null && options.runtime.workerConfig?.exact === true) {
357
+ const introspect = options.runtime.jobs.introspect;
358
+ void reportUnservedQueues({
359
+ served: workerOptions.queues ?? [],
360
+ registered: registeredJobs().map((handle) => handle.queue),
361
+ workers: introspect === undefined ? undefined : () => introspect.workers(),
362
+ });
363
+ }
348
364
 
349
365
  // The half of the transactional outbox that makes a staged row a running job. Without it
350
366
  // `handle.enqueue()` inside a transaction writes to `x_outbox` and nothing ever reads it back
@@ -0,0 +1,46 @@
1
+ // An exact worker's (`WORKER_QUEUES`) one boot line about the registered queues it leaves: warned
2
+ // only for those NO live worker in the registry serves. Another Deployment serving the rest is the
3
+ // whole point of the split, and a warning at every boot of a correct fleet is noise that hides the
4
+ // one boot where a queue really has no worker.
5
+
6
+ import { logger, renderThrowable } from '@ultimat3/core';
7
+ import type { WorkerRecord } from '@ultimat3/jobs';
8
+
9
+ export interface UnservedQueuesInput {
10
+ /** What this worker claims. */
11
+ readonly served: readonly string[];
12
+ /** Every queue a registered job is enqueued on. */
13
+ readonly registered: readonly string[];
14
+ /** The fleet's live rows (`introspect.workers`); `undefined` on a driver with no registry. */
15
+ readonly workers: (() => Promise<readonly WorkerRecord[]>) | undefined;
16
+ }
17
+
18
+ const FIX =
19
+ 'x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one';
20
+
21
+ /** Never rejects: a boot line is advice, and a failed read of it must not fail the boot. */
22
+ export async function reportUnservedQueues(input: UnservedQueuesInput): Promise<void> {
23
+ const left = [...new Set(input.registered)].filter((q) => !input.served.includes(q)).sort();
24
+ if (left.length === 0) return;
25
+ // Unknowable here: said at info, because the split may well be served elsewhere.
26
+ const unknown = (reason: string): void =>
27
+ logger.info('jobs.worker.queue-unserved', {
28
+ queues: left,
29
+ served: input.served,
30
+ checked: false,
31
+ reason,
32
+ fix: FIX,
33
+ });
34
+ if (input.workers === undefined) return unknown('this driver keeps no worker registry');
35
+ let rows: readonly WorkerRecord[];
36
+ try {
37
+ rows = await input.workers();
38
+ } catch (error) {
39
+ return unknown(renderThrowable(error));
40
+ }
41
+ // A draining worker is leaving: it claims nothing new, so it serves no queue.
42
+ const covered = new Set(rows.filter((one) => one.draining !== true).flatMap((one) => one.queues));
43
+ const queues = left.filter((queue) => !covered.has(queue));
44
+ if (queues.length === 0) return;
45
+ logger.warn('jobs.worker.queue-unserved', { queues, served: input.served, fix: FIX });
46
+ }
@@ -6,7 +6,6 @@
6
6
  import type { AppConfig, JobsConcurrency } from '@ultimat3/core';
7
7
  import {
8
8
  ConfigInvalidError,
9
- logger,
10
9
  renderCauseValue,
11
10
  renderFixShellArg,
12
11
  UltimateError,
@@ -99,21 +98,6 @@ export function workerQueuesFor(
99
98
  return served.length === 0 ? undefined : served;
100
99
  }
101
100
 
102
- /**
103
- * An exact worker's one boot line about what it leaves: the queues registered jobs are enqueued
104
- * on that it will never claim. A warning and not a refusal — another Deployment serving them is
105
- * the whole point of the split — but this process cannot see that one, so it says what to check.
106
- */
107
- function reportUnserved(served: readonly string[], registered: readonly string[]): void {
108
- const queues = [...new Set(registered)].filter((queue) => !served.includes(queue)).sort();
109
- if (queues.length === 0) return;
110
- logger.warn('jobs.worker.queue-unserved', {
111
- queues,
112
- served,
113
- fix: `x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
114
- });
115
- }
116
-
117
101
  /**
118
102
  * A `WORKER_QUEUES` entry no registered job and no `jobs.queues` entry names. The worker would
119
103
  * claim a queue nothing is enqueued on and leave the real ones waiting while its pod stays Ready —
@@ -183,7 +167,6 @@ export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfig
183
167
  const exact = config?.exact === true;
184
168
  if (exact) refuseUnknownQueues(config?.queues ?? [], config?.configured ?? [], registered);
185
169
  const queues = workerQueuesFor(config?.queues ?? [], registered, exact);
186
- if (exact) reportUnserved(queues ?? [], registered);
187
170
  // Passed as written: `jobWorker` reads a table by own key and runs a queue it does not name at
188
171
  // core's `JOBS_CONCURRENCY_DEFAULT` — the one slot default, so no second table is built here.
189
172
  const concurrency = config?.concurrency;
@@ -3,9 +3,14 @@
3
3
  // "the entity files this run wrote" asks here: three of them matched `/entity.ts` alone, so a
4
4
  // second entity in a feature could be written by nothing and registered by nothing.
5
5
 
6
- /** `apps/<app>/<surface>/<feature>/entity.ts` or `…/entity-<name>.ts` — never a test beside it. */
6
+ /**
7
+ * `apps/<app>/<surface>/<feature>/entity.ts` or `…/entity-<name>.ts` — never a test beside it. A
8
+ * slice nests (`app/runs/canary/entity.ts`) as freely as any other feature directory does, so the
9
+ * feature is one segment or more: a depth-one rule refused a nested slice's entity at the package
10
+ * boundary while the app's own surface rules accepted the same file.
11
+ */
7
12
  const ENTITY_MODULE =
8
- /^apps\/[^/]+\/(?<rest>[^/]+\/[a-z0-9-]+)\/(?<stem>entity(?:-[a-z0-9-]+)?)\.ts$/;
13
+ /^apps\/[^/]+\/(?<rest>[^/]+(?:\/[a-z0-9-]+)+)\/(?<stem>entity(?:-[a-z0-9-]+)?)\.ts$/;
9
14
 
10
15
  /** Whether `path` is a module a generator declares an entity in. */
11
16
  export const isEntityModule = (path: string): boolean => ENTITY_MODULE.test(path);
@@ -111,7 +111,9 @@ const clientTest = (
111
111
  ): string => `// Which store the handle reads: memory under \`bun test\`, the process's Postgres client
112
112
  // everywhere else. A handle that silently took the memory driver in a container would serve an
113
113
  // empty database with a green gate.
114
+ import { ctxOf, runWithContext } from '@ultimat3/core';
114
115
  import { recordingClient, setDbClient } from '@ultimat3/db';
116
+ import { testActor } from '@ultimat3/policy';
115
117
  import { expect, unitTest } from '@ultimat3/testing';
116
118
  import { db, driver, selectDriver } from './client';
117
119
 
@@ -123,9 +125,27 @@ unitTest('${app.kebab} reads memory under test and Postgres everywhere else', ()
123
125
  expect(selectDriver({ ULTIMATE_ENV: 'development' }).reset).toBeUndefined();
124
126
  });
125
127
 
126
- unitTest('the handle is built over the driver this module exports', () => {
128
+ // Every table on \`db\` reads through \`driver\`: a client installed under it sees each table's
129
+ // statement. \`database(entities)\` with no driver takes the process default — memory — and would
130
+ // answer every test from rows no migration made while this file stayed green. The reads run as an
131
+ // actor because a tenant table refuses one with no org to scope it (\`X_TENANCY_UNSCOPED\`).
132
+ unitTest('the handle is built over the driver this module exports', async () => {
127
133
  expect(driver.reset).toBeDefined();
128
- expect(typeof db).toBe('object');
134
+ const tables: readonly { all(): Promise<readonly unknown[]> }[] = Object.values(db);
135
+ const { actor } = testActor('client-test', { orgId: '00000000-0000-4000-8000-000000000001' });
136
+ const client = recordingClient();
137
+ setDbClient(client);
138
+ try {
139
+ await runWithContext(ctxOf({ actor }), async () => {
140
+ for (const table of tables) {
141
+ const before = client.statements.length;
142
+ await table.all();
143
+ expect(client.statements.length).toBeGreaterThan(before);
144
+ }
145
+ });
146
+ } finally {
147
+ setDbClient(undefined);
148
+ }
129
149
  });
130
150
 
131
151
  // The test driver decides per call: a migrated database installed with \`setDbClient\` is what
@@ -0,0 +1,99 @@
1
+ // The admin surface's own MCP endpoint, as `x new` writes it: `apps/admin/mcp.ts` — the file the
2
+ // mount reads for that app directory (`app-mcp.ts`) — serving the admin catalog and the app's
3
+ // exposed tools at `/admin/mcp`, behind the web surface's agent-token resolver. Mounted beside
4
+ // `apps/web/mcp.ts`, and REFUSING without a token, like it.
5
+
6
+ import { sortedImports } from './imports';
7
+ import type { GeneratedFile, NameSet } from './naming';
8
+
9
+ const endpoint = (
10
+ app: NameSet,
11
+ ): string => `// The admin surface's MCP endpoint: \`POST /admin/mcp\`, mounted beside \`apps/web/mcp.ts\` by \`x dev\`
12
+ // and the web role, which mount every \`apps/<app>/mcp.ts\` by its path and its \`mcp\` export. Staff
13
+ // agents get the admin's own tools (\`admin.<entity>.list\`, …) AND the app's exposed actions and
14
+ // queries (\`include: 'exposed'\`), each gated by the actor's permissions and the token's scope.
15
+ //
16
+ // Built HERE, at module scope, and nowhere under \`apps/admin/app/\`: the boot scan evaluates this
17
+ // file after every \`apps/*/{site,app,api,shared}\` module has registered its primitives, and the mount
18
+ // refuses a catalog projected before that (X_MCP_INCLUDE_EARLY) rather than serve it short.
19
+ ${sortedImports([
20
+ `import { type AgentCaller, resolveAgentToken } from '@${app.kebab}/web/app/auth/agent-token';`,
21
+ "import { type AdminMcpActor, adminMcp } from '@ultimat3/admin';",
22
+ ])}
23
+ import { admin } from './app/admin/admin';
24
+
25
+ /**
26
+ * The admin's actor for the agent the app's resolver answered — the same agent \`/mcp\` serves,
27
+ * tenant and grants included. A key issued with scopes is held to its \`admin:*\` ones (a key
28
+ * scoped \`posts:write\` alone may call nothing here); a key with none is as strong as its actor.
29
+ */
30
+ export const adminAgentOf = (caller: AgentCaller | null): AdminMcpActor | null => {
31
+ if (caller === null) return null;
32
+ const { id, roles, permissions, orgId } = caller.actor;
33
+ return {
34
+ id,
35
+ roles,
36
+ permissions,
37
+ ...(orgId === undefined ? {} : { orgId }),
38
+ ...(caller.scopes.size === 0 ? {} : { tokenScopes: [...caller.scopes] }),
39
+ };
40
+ };
41
+
42
+ export const mcp = adminMcp({
43
+ app: admin,
44
+ path: '/admin/mcp',
45
+ include: 'exposed',
46
+ // The app's one resolver: an issued API key in every environment, \`dev-agent.<role>\` under
47
+ // \`x dev\` from this machine alone — decided on how the request arrived (\`facts\`).
48
+ actor: async ({ token, facts }) =>
49
+ adminAgentOf(token === undefined ? null : await resolveAgentToken(token, facts)),
50
+ });
51
+ `;
52
+
53
+ const endpointTest = (
54
+ app: NameSet,
55
+ ): string => `// The admin endpoint is mounted and refusing: a route at /admin/mcp, and a 401 for a request that
56
+ // carries no token or one this process does not honour — before any tool, or the body, is read.
57
+ ${sortedImports([
58
+ `import { DEV_AGENT_TOKEN_PREFIX } from '@${app.kebab}/web/app/auth/agent-token';`,
59
+ `import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
60
+ "import { agentActor } from '@ultimat3/core';",
61
+ "import { expect, unitTest } from '@ultimat3/testing';",
62
+ ])}
63
+ import { adminAgentOf, mcp } from './mcp';
64
+
65
+ const post = (headers: Record<string, string>) =>
66
+ new Request('http://localhost/admin/mcp', { method: 'POST', headers, body: '{}' });
67
+
68
+ unitTest('POST /admin/mcp is mounted, and answers 401 without a token', async () => {
69
+ const route = mcp.route;
70
+ if (route === undefined) return expect.unreachable('apps/admin/mcp.ts built no route');
71
+ expect(route.path).toBe('/admin/mcp');
72
+ const response = await route.handle(post({}));
73
+ expect(response.status).toBe(401);
74
+ // The development token is refused outside \`x dev\`: \`bun test\` is not development.
75
+ const dev = await route.handle(post({ authorization: \`Bearer \${DEV_AGENT_TOKEN_PREFIX}admin\` }));
76
+ expect(dev.status).toBe(401);
77
+ });
78
+
79
+ unitTest('the agent /mcp resolves is the admin actor, tenant kept and scopes held', () => {
80
+ const actor = agentActor({ id: 'k1', orgId: DEMO_ORG_ID, roles: ['admin'] });
81
+ expect(adminAgentOf(null)).toBeNull();
82
+ // No scopes on the key: as strong as its actor, whose own grants still decide each tool.
83
+ expect(adminAgentOf({ actor, scopes: new Set() })).toEqual({
84
+ id: 'k1',
85
+ roles: ['admin'],
86
+ permissions: [],
87
+ orgId: DEMO_ORG_ID,
88
+ });
89
+ expect(adminAgentOf({ actor, scopes: new Set(['admin:read']) })?.tokenScopes).toEqual([
90
+ 'admin:read',
91
+ ]);
92
+ });
93
+ `;
94
+
95
+ /** `apps/admin/mcp.ts` and the test that pins it mounted and refusing. */
96
+ export const mcpAdminFiles = (app: NameSet): readonly GeneratedFile[] => [
97
+ { path: 'apps/admin/mcp.ts', contents: endpoint(app) },
98
+ { path: 'apps/admin/mcp.test.ts', contents: endpointTest(app) },
99
+ ];
@@ -1,8 +1,10 @@
1
1
  // The generated app's `packages/mcp`: the app's own MCP surface, projected straight from the
2
2
  // action registry rather than re-listed, plus the test that pins every projected tool describing
3
- // itself — and the `apps/web/mcp.ts` endpoint that mounts it (`scaffold-mcp-endpoint.ts`).
3
+ // itself — and the endpoints that mount it: `apps/web/mcp.ts` (`scaffold-mcp-endpoint.ts`) and the
4
+ // admin surface's `apps/admin/mcp.ts` (`scaffold-mcp-admin.ts`).
4
5
 
5
6
  import type { GeneratedFile, NameSet } from './naming';
7
+ import { mcpAdminFiles } from './scaffold-mcp-admin';
6
8
  import { mcpEndpointFiles } from './scaffold-mcp-endpoint';
7
9
  import { packageShapeFiles, workspacePackageJson } from './scaffold-package-shape';
8
10
 
@@ -77,7 +79,7 @@ contractTest('the app exposes its actions as MCP tools', () => {
77
79
  });
78
80
  `;
79
81
 
80
- /** Every file the `packages/mcp` workspace ships, then the endpoint that mounts it. */
82
+ /** Every file the `packages/mcp` workspace ships, then the endpoints that mount it: web's, admin's. */
81
83
  export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
82
84
  { path: 'packages/mcp/package.json', contents: workspacePackageJson(app, 'mcp', DESCRIPTION) },
83
85
  ...packageShapeFiles(app, 'mcp', DESCRIPTION),
@@ -85,4 +87,5 @@ export const mcpPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
85
87
  { path: 'packages/mcp/src/index.test.ts', contents: mcpUnitTest() },
86
88
  { path: 'packages/mcp/src/index.contract.test.ts', contents: mcpTest() },
87
89
  ...mcpEndpointFiles(app),
90
+ ...mcpAdminFiles(app),
88
91
  ];
@@ -5,7 +5,7 @@
5
5
  // why: Bun exposes no path-join primitive; Bun.file takes one already joined.
6
6
  import { join } from 'node:path';
7
7
  import { UltimateError } from '@ultimat3/core';
8
- import { pinVersion, type UpgradePin } from './upgrade-plan';
8
+ import { pinMoves, type UpgradePin } from './upgrade-plan';
9
9
  import { workspaceGlobs, workspaceManifestPaths } from './workspace-graph';
10
10
 
11
11
  const ROOT_MANIFEST = 'package.json';
@@ -53,11 +53,12 @@ export function readPins(manifests: readonly WorkspaceManifest[]): readonly Upgr
53
53
  }
54
54
 
55
55
  /**
56
- * Every pin at the exact version, the rest of the file byte-identical. Exact whatever it was: a
57
- * caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent upgrade.
58
- * A spec naming no version never reaches here (`planHop` refused it), and is left as written.
56
+ * Every pin the hop moves at the exact version, the rest of the file byte-identical. Exact whatever
57
+ * it was: a caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent
58
+ * upgrade. A pin above `to` is a workspace a mixed set has further on and stays (`pinMoves`); a
59
+ * spec naming no version never reaches here (`planHop` refused it), and is left as written.
59
60
  */
60
61
  export const rewritePins = (text: string, to: string): string =>
61
62
  text.replace(PIN_ENTRY, (whole, head: string, _name: string, spec: string, tail: string) =>
62
- pinVersion(spec) === undefined ? whole : `${head}${to}${tail}`,
63
+ pinMoves(spec, to) ? `${head}${to}${tail}` : whole,
63
64
  );
@@ -63,7 +63,7 @@ export function releaseOf(version: string): Triple | undefined {
63
63
 
64
64
  const tripleOf = (version: string): Triple => releaseOf(version) ?? [0, 0, 0];
65
65
 
66
- const compare = (a: string, b: string): number => {
66
+ const compareVersions = (a: string, b: string): number => {
67
67
  const left = tripleOf(a);
68
68
  const right = tripleOf(b);
69
69
  return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
@@ -71,6 +71,15 @@ const compare = (a: string, b: string): number => {
71
71
 
72
72
  const majorOf = (version: string): number => tripleOf(version)[0];
73
73
 
74
+ /**
75
+ * Whether a hop to `to` rewrites this pin. One at or below it moves; one above it — a workspace a
76
+ * mixed set already has further on — stays, since rewriting it would move it backwards.
77
+ */
78
+ export const pinMoves = (spec: string, to: string): boolean => {
79
+ const version = pinVersion(spec);
80
+ return version !== undefined && compareVersions(version, to) <= 0;
81
+ };
82
+
74
83
  /** The walkthrough that gets an app into `major`, built from the heading rule. */
75
84
  export function sectionFor(major: number): UpgradeSection {
76
85
  const from = RECORDED_FROM.get(major) ?? `${String(major - 1)}.x`;
@@ -97,7 +106,7 @@ const bareTarget = (from: string, released: readonly string[]): string | undefin
97
106
  const next = nextMajorNewest(from, released);
98
107
  if (next !== undefined) return next;
99
108
  const newest = newestOfMajor(majorOf(from), released);
100
- return newest !== undefined && compare(newest, from) > 0 ? newest : undefined;
109
+ return newest !== undefined && compareVersions(newest, from) > 0 ? newest : undefined;
101
110
  };
102
111
 
103
112
  const unreadablePin = (cause: string, file: string): UltimateError =>
@@ -133,10 +142,21 @@ function readVersions(pins: readonly UpgradePin[]): readonly string[] {
133
142
  */
134
143
  const aligningVersion = (high: string, released: readonly string[]): string => {
135
144
  const newest = newestOfMajor(majorOf(high), released);
136
- if (newest !== undefined && compare(newest, high) >= 0) return newest;
145
+ if (newest !== undefined && compareVersions(newest, high) >= 0) return newest;
137
146
  return nextMajorNewest(high, released) ?? high;
138
147
  };
139
148
 
149
+ /**
150
+ * The one hop out of a mixed set: its lowest pins' next major, one walkthrough at a time, until that
151
+ * major reaches the highest pin's — where the hop that brings the set level is the next one.
152
+ */
153
+ const lowestHop = (low: string, high: string, released: readonly string[]): string => {
154
+ const next = nextMajorNewest(low, released);
155
+ return next !== undefined && majorOf(next) < majorOf(high)
156
+ ? next
157
+ : aligningVersion(high, released);
158
+ };
159
+
140
160
  const tooWide = (cause: string, target: string | undefined): UltimateError =>
141
161
  new UltimateError({
142
162
  code: 'X_UPGRADE_HOP_TOO_WIDE',
@@ -159,16 +179,18 @@ function checkTarget(input: {
159
179
  fix: 'bun pm view @ultimat3/cli versions --json # then: x upgrade --to <one of them> --json',
160
180
  });
161
181
  }
162
- // A mixed set has one way out, so every refusal over it names that way: a fix aimed at the next
163
- // major of the lowest pin would itself be refused, for sitting below the highest.
164
- const aligning = aligningVersion(high, released);
165
- const way = low === high ? bareTarget(low, released) : aligning;
166
- if (compare(to, high) < 0) {
182
+ // A mixed set has one way out, so every refusal over it names that way: its lowest pins, one
183
+ // major further. Never the highest pin's version: that skipped every walkthrough between.
184
+ const mixed = low !== high;
185
+ const step = mixed ? lowestHop(low, high, released) : (nextMajorNewest(low, released) ?? low);
186
+ const way = mixed ? step : bareTarget(low, released);
187
+ if (mixed && compareVersions(to, low) <= 0) {
188
+ throw tooWide(`--to ${to} moves none of the lowest pins, at ${low}`, way);
189
+ }
190
+ if (!mixed && compareVersions(to, high) < 0) {
167
191
  throw tooWide(`--to ${to} is below the ${high} this app already pins`, way);
168
192
  }
169
- // The second operand is what makes the mixed refusal's own fix runnable: `^6` beside `^19` is told
170
- // `--to <19.x>`, which brings the straggler level and crosses nothing the rest of the app has not.
171
- const widest = Math.max(majorOf(nextMajorNewest(low, released) ?? low), majorOf(aligning));
193
+ const widest = majorOf(step);
172
194
  if (majorOf(to) > widest) {
173
195
  throw tooWide(
174
196
  `--to ${to} skips a major: from ${low} a run reaches ${String(widest)}.x at most, so each hop's gate attributes its red to that hop`,
@@ -184,11 +206,11 @@ export function planHop(input: {
184
206
  readonly to?: string;
185
207
  }): UpgradeHop {
186
208
  const { pins, to } = input;
187
- const distinct = [...new Set(readVersions(pins))].sort(compare);
209
+ const distinct = [...new Set(readVersions(pins))].sort(compareVersions);
188
210
  const low = distinct[0] ?? '0.0.0';
189
211
  const high = distinct.at(-1) ?? low;
190
212
  const released = input.versions.filter((version) => releaseOf(version) !== undefined);
191
- released.sort(compare);
213
+ released.sort(compareVersions);
192
214
 
193
215
  if (to === undefined && distinct.length > 1) {
194
216
  // The stragglers, not the first few: `^19.4.0` nineteen times says nothing about the one `^19.3.1`.
@@ -196,19 +218,24 @@ export function planHop(input: {
196
218
  const held = stragglers.map((pin) => `${pin.file}: ${pin.name} ${pin.spec}`);
197
219
  throw new UltimateError({
198
220
  code: 'X_UPGRADE_MIXED_PINS',
199
- cause: `the workspaces pin ${distinct.join(', ')}, and the framework only moves in lockstep. ${String(held.length)} of ${String(pins.length)} below ${high}: ${held.slice(0, 6).join('; ')}`,
200
- fix: `x upgrade --to ${quoteArg(aligningVersion(high, released))} --json`,
221
+ cause: `the workspaces pin ${distinct.join(', ')}, and the framework only moves in lockstep, so a run moves the lowest pins one major until the set is level. ${String(held.length)} of ${String(pins.length)} below ${high}: ${held.slice(0, 6).join('; ')}`,
222
+ fix: `x upgrade --to ${quoteArg(lowestHop(low, high, released))} --json`,
201
223
  });
202
224
  }
203
225
  if (to !== undefined) checkTarget({ to, low, high, released });
204
226
 
205
227
  const target = to ?? bareTarget(low, released) ?? low;
206
- const after = bareTarget(target, released) ?? null;
228
+ // Still below the highest pin, the set stays mixed: the next run is the lowest pins' next hop.
229
+ const after =
230
+ compareVersions(target, high) < 0
231
+ ? lowestHop(target, high, released)
232
+ : (bareTarget(target, released) ?? null);
207
233
  return {
208
234
  from: low,
209
235
  to: target,
210
236
  next: after,
211
237
  atTarget: distinct.length === 1 && target === low,
212
- section: majorOf(target) > majorOf(high) ? sectionFor(majorOf(target)) : null,
238
+ // The lowest pins are the ones that cross: theirs is the walkthrough this hop needs.
239
+ section: majorOf(target) > majorOf(low) ? sectionFor(majorOf(target)) : null,
213
240
  };
214
241
  }
@@ -7,6 +7,7 @@ import type { AgentsLinkState } from './upgrade-agents-link';
7
7
  import type { BunEdit, BunManual } from './upgrade-bun-floor';
8
8
  import type { GateFinding, HopGate, RepoScript } from './upgrade-gate';
9
9
  import type { UpgradeHop, UpgradePin } from './upgrade-plan';
10
+ import { pinMoves } from './upgrade-plan';
10
11
 
11
12
  export interface PlannedWrites {
12
13
  /** Absolute path to final text; a manifest holding both a pin and a floor is written once. */
@@ -125,7 +126,9 @@ export const report = (input: {
125
126
  section: hop.section,
126
127
  pins: hop.atTarget
127
128
  ? []
128
- : pins.map((pin) => ({ file: pin.file, name: pin.name, from: pin.spec, to: hop.to })),
129
+ : pins
130
+ .filter((pin) => pinMoves(pin.spec, hop.to))
131
+ .map((pin) => ({ file: pin.file, name: pin.name, from: pin.spec, to: hop.to })),
129
132
  bun: { from: bunFrom, to: bunTo, edited: writes.edited, manual: writes.manual },
130
133
  agentsMd: input.agentsMd ?? null,
131
134
  guards: gate?.guards ?? null,
@@ -52,6 +52,7 @@ import { unknownToolGateFindings } from './tool-gate-names';
52
52
  import { measureIfStale } from './verify-budgets-build';
53
53
  import { exerciseStep } from './verify-exercise';
54
54
  import { floorProblemFindings, readVerifyFloor } from './verify-floor';
55
+ import { mcpMountFindings } from './verify-mcp-mount';
55
56
  import { roleLoadFindings } from './verify-role-load';
56
57
  import type { VerifyStep } from './verify-step';
57
58
  import { fromExec, fromFindings, hostFindings } from './verify-step';
@@ -400,6 +401,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
400
401
  ...(await unregisteredJobFindings(ctx.root)),
401
402
  ...(await retainUnsweptFindings(ctx.root)),
402
403
  ...(await readJobBudgetFindings(ctx.root)),
404
+ // `ai.mcp.expose: true` and no mounted `POST /mcp`: what every boot only logged.
405
+ ...(await mcpMountFindings(ctx.root)),
403
406
  ...(await unknownToolGateFindings(ctx.root)),
404
407
  ...remote.findings,
405
408
  ]
@@ -0,0 +1,30 @@
1
+ // The `manifest` step's MCP rider: `ai.mcp.expose: true` with nothing mounted is a finding. The
2
+ // boot's own `appMcpMount` decides it — the same answer `x dev` and the web role log — so the gate
3
+ // and the boot can never disagree about whether `POST /mcp` exists. A boot only WARNED, and every
4
+ // tracked app shipped its catalog unmounted behind that warning.
5
+
6
+ import { isUltimateError } from '@ultimat3/core';
7
+ import { loadApp } from './app-load';
8
+ import { appMcpMount } from './app-mcp';
9
+ import type { AppLoader } from './app-permissions';
10
+ import type { Finding } from './output';
11
+ import { findingFrom } from './output';
12
+
13
+ /**
14
+ * Empty when the app did not load (the load findings say so, once) or `expose` is off. A refusal
15
+ * the mount THROWS — two files on one path (`X_MCP_PATH_DUPLICATE`), a catalog projected before the
16
+ * scan (`X_MCP_INCLUDE_EARLY`) — is a finding too: the boot would stop on it.
17
+ */
18
+ export async function mcpMountFindings(
19
+ root: string,
20
+ load: AppLoader = loadApp,
21
+ ): Promise<readonly Finding[]> {
22
+ if ((await load(root)).findings.length > 0) return [];
23
+ try {
24
+ const { warning } = await appMcpMount(root);
25
+ return warning === undefined ? [] : [findingFrom(warning)];
26
+ } catch (error) {
27
+ if (isUltimateError(error)) return [findingFrom(error)];
28
+ throw error;
29
+ }
30
+ }
@@ -253,6 +253,37 @@ mcp: { expose: true, description: 'Publish a draft post' },
253
253
 
254
254
  The projected tool calls `action.run(...)` — the same entry point the HTTP route calls. Policy evaluation lives inside `run`, so there is nothing to keep in sync. Details: [MCP and AI](MCP-And-AI).
255
255
 
256
+ ### An admin surface serves the app's tools
257
+
258
+ `As of 2026-11`. One way: `apps/admin/mcp.ts` exports `adminMcp(...)`, at module scope, and the
259
+ mount serves it beside `apps/web/mcp.ts`. `x new` writes it.
260
+
261
+ ```ts
262
+ // apps/admin/mcp.ts
263
+ import { type AdminApp, type AdminMcpActor, adminMcp } from '@ultimat3/admin';
264
+ import type { McpRequestFacts } from '@ultimat3/mcp';
265
+ declare const admin: AdminApp; // `import { admin } from './app/admin/admin'` — the defineAdmin() module
266
+ // The web surface's resolver (`apps/web/app/auth/agent-token.ts`) and its mapping onto an admin actor,
267
+ // both as `x new` writes them.
268
+ declare function resolveAgentToken(token: string, facts?: McpRequestFacts): Promise<unknown>;
269
+ declare function adminAgentOf(caller: unknown): AdminMcpActor | null;
270
+
271
+ export const mcp = adminMcp({
272
+ app: admin,
273
+ path: '/admin/mcp',
274
+ include: 'exposed',
275
+ actor: async ({ token, facts }) =>
276
+ adminAgentOf(token === undefined ? null : await resolveAgentToken(token, facts)),
277
+ });
278
+ ```
279
+
280
+ | Option | Rule |
281
+ |---|---|
282
+ | `path` | `defineAppMcp`'s own; default `/mcp`, which collides with the web endpoint (`X_MCP_PATH_DUPLICATE`) — an admin surface says `/admin/mcp` |
283
+ | `include: 'exposed'` | every action and query with `mcp: { expose: true }`, projected as on `/mcp` (opt-in, the one predicate), each under its kind's admin scope: a query `admin:read`, an action `admin:write` |
284
+ | `actor({ token, facts })` | `facts` is how the request arrived (address, `Origin`) — what a development token is decided on |
285
+ | where it is built | `apps/<app>/mcp.ts`, never under `apps/admin/app/`: the scan evaluates the former after every primitive registered; one built earlier is `X_MCP_INCLUDE_EARLY` at the mount |
286
+
256
287
  ### Tool scopes
257
288
 
258
289
  `As of 2026-10`. `adminMcp()` hands the MCP registry one scope per tool (`adminToolScopes`, `packages/admin/src/mcp-scopes.ts`). Tool names are unchanged.
@@ -1532,7 +1532,7 @@ One major per run, so each hop's gate attributes its red to that hop. `As of 30.
1532
1532
 
1533
1533
  A dry run refuses exactly what the real run would, then stops before step 4. It never commits; `git diff` is the hop. Under `--json` fd 1 is one document, fd 2 a `{"upgrade":"<stage>"}` line per stage and a heartbeat a minute.
1534
1534
 
1535
- `--to`: an exact release in the next major. Over a mixed set every refusal names the aligning version, the newest release at or above the highest pin: `^6` beside `^19` is told `--to 19.x.y`, it is accepted across any distance, and that report names no section.
1535
+ `--to`: an exact release in the next major. Over a mixed set a run moves the LOWEST pins one major: `^19` beside `^29` is told `--to 20.x.y`, that hop rewrites only the pins at or below it (a pin above is never moved backwards) and names its walkthrough, `next` names the following hop, and the last one levels the set. A straggler jump straight to the highest major is `X_UPGRADE_HOP_TOO_WIDE`: it skipped every walkthrough between.
1536
1536
 
1537
1537
  | `data` field | Meaning |
1538
1538
  |---|---|
@@ -148,7 +148,7 @@ ROLE: worker ROLE: worker
148
148
  WORKER_QUEUES: banks WORKER_QUEUES: banks-long
149
149
  ```
150
150
 
151
- Each worker then claims its list and nothing else. Every entry must be a queue the app knows — in `jobs.queues` or a registered job's — or the boot refuses with `X_WORKER_QUEUE_UNKNOWN` (`As of 29.0.0`). 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.
151
+ Each worker then claims its list and nothing else. Every entry must be a queue the app knows — in `jobs.queues` or a registered job's — or the boot refuses with `X_WORKER_QUEUE_UNKNOWN` (`As of 29.0.0`). After it starts it logs `jobs.worker.queue-unserved` (a warning, not a refusal) naming every queue a registered job is enqueued on that NO live worker in the registry serves (`As of 2026-11-07`), so a queue no Deployment serves is visible in the first log line rather than as a backlog — and a split fleet whose other Deployments serve the rest logs nothing. A driver with no worker registry says it at `info`. `x jobs list --json` shows each queue's workers.
152
152
 
153
153
  ## `realtime`
154
154
 
@@ -248,7 +248,7 @@ services:
248
248
  # WORKER_QUEUES is the EXACT set a worker claims (25.0.0): `integrations` runs on its own
249
249
  # service, so the other worker names every remaining queue itself. Unset, a worker claims
250
250
  # jobs.queues plus every queue a registered job is enqueued on; set, `jobs.worker.queue-unserved`
251
- # at boot names the registered queues this one leaves to another.
251
+ # names the registered queues it leaves that NO live worker serves — none, once `integrations` is up.
252
252
  worker: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: 'myapp-default,default' },
253
253
  deploy: { replicas: 4 } }
254
254
  worker-integrations: { <<: *app, environment: { ROLE: worker, WORKER_QUEUES: integrations },
@@ -148,6 +148,8 @@ run: async ({ input, ctx, step }) => {
148
148
 
149
149
  **A cancelled body writes nothing** (`As of 2026-11-02`). Once `ctx.signal` is aborted with a coded reason (`X_ABORTED`, `X_DRAINING`, `X_JOB_TIMEOUT`, `X_JOB_SLOT_LOST`, `X_JOB_LEASE_LOST`), a transaction, an entity write and an `enqueue` rethrow that reason. The step record stays fenced as before and a drained step still lands. Wrap work that must land in `runToCompletion(fn)`.
150
150
 
151
+ **A finished attempt is not a cancelled one** (`As of 2026-11-07`). A body that completed, and whose `ack` landed, leaves `ctx.signal` un-aborted: no other attempt owns the run, so a timer it armed — a log sink's flush, an `end()` timeout, a deferred `enqueue` — still writes. A step it never awaited is refused (`X_ABORTED`, "had already ended"). A failed or suspended attempt, or a completed one whose `ack` did not land, aborts with `X_ABORTED` as before. Work meant to outlive the attempt whatever its end has ONE way: `runToCompletion(() => work)` from `@ultimat3/core`.
152
+
151
153
  **SIGTERM fires it too** (`As of 2026-09-07`). The moment the process starts draining, the worker aborts `ctx.signal` on every job it holds with `X_DRAINING` — naming the worker and the signal — and then gives the body the drain's budget to unwind in. A body that stops is **interrupted**, not failed: the job goes straight back to the ready bucket with the attempt uncounted, so `attempts: 1` survives a rollout and the worker replacing this one claims it at once. The verdict is read off the signal, not off whatever the body threw, so an app's own error for a child the shutdown killed is an interruption too. A body that ignores the signal is waited on to the deadline and abandoned there, as before; a manual `worker.stop()` aborts nothing and waits for its work — until a SIGTERM lands on it, at which point the teardown already waiting adopts the shutdown's deadline and every held job hears the abort.
152
154
 
153
155
  | Past the cancel | What happens |
@@ -257,16 +257,19 @@ developer's.
257
257
  `As of 2026-09-05`. `defineAppMcp({ …, resolveToken })` builds `mcp.route`, and `app.config.ts`
258
258
  declares `ai: { mcp: { expose: true } }` **by default** (the path is `defineAppMcp`'s own, `/mcp` by default) — and until this date nothing
259
259
  between the two served it: `POST /mcp` was `X_ROUTE_NOT_FOUND` under `x dev` and in every
260
- container. The contract is one file:
260
+ container. The contract is one file per app directory:
261
261
 
262
262
  | Piece | Where | Rule |
263
263
  |---|---|---|
264
264
  | the declaration | `apps/<app>/mcp.ts` | `export const mcp = defineAppMcp({ include: 'exposed', resolveToken })` |
265
- | the switch and the path | `app.config.ts` → `ai.mcp` | `expose` (default `true`), `path` (default `/mcp`) |
265
+ | the switch | `app.config.ts` → `ai.mcp` | `expose` (default `true`). The path is each endpoint's own `defineAppMcp({ path })` (default `/mcp`) |
266
+ | several app directories | every `apps/<app>/mcp.ts` mounts (`As of 2026-11`) | `apps/web/mcp.ts` first — its first endpoint is the default — then the rest sorted. An admin surface's `apps/admin/mcp.ts` stands beside the web one; until 2026-11 the first sorted file mounted ALONE, so it silently took `/mcp` away. Two files claiming one route: `X_MCP_PATH_DUPLICATE` naming both |
267
+ | when it is built | at the top level of `apps/<app>/mcp.ts` | the boot scan evaluates that file after every `apps/*/{site,app,api,shared}` module has registered, so `include: 'exposed'` sees every exposed primitive. An endpoint projected earlier — in a module under `apps/<app>/app/` that `mcp.ts` re-exports — is `X_MCP_INCLUDE_EARLY` at the mount, naming the tools it lacks, never a short catalog. Never import the API index to fill the registry (`X_BOUNDARY_APP_TO_API`) |
266
268
  | the mount | `x dev` **and** `runRole`, through one call | `POST <path>` → `mcp.route.handle(request)`; the boot log says `app mcp mounted`, `x dev`'s summary prints `mcp POST /mcp` |
267
269
  | auth | the route's own | `auth: 'public'`, `enforcedBy: 'handler'` on the http route — the bearer token is read by `resolveToken`, never pre-judged by the pipeline |
268
270
  | exposed and unmountable | `X_MCP_APP_UNMOUNTED`, logged once | no file exports `mcp`, or it was built without `resolveToken` (no `route`); the fix names the file to write |
269
271
  | a new app | `x new` writes it | `apps/web/mcp.ts` over the `@<app>/mcp` catalog, `resolveToken` from `apps/web/app/auth/agent-token.ts`: an API key (`issueApiKey`, `x_api_keys`) is an agent in every environment; `dev-agent.<role>` is one under `x dev` alone, and only from a loopback address with no `Origin` (a page rebound to 127.0.0.1 sends one). No token, or one it does not know: 401 |
272
+ | the admin surface | `x new` writes it too | `apps/admin/mcp.ts`: `adminMcp({ app: admin, path: '/admin/mcp', include: 'exposed', actor })` — the admin's own tools and the app's exposed ones, the actor from the same `resolveAgentToken` (handed the request's `facts`). A key with scopes is held to its `admin:*` ones, a key with none is as strong as its actor → [Admin dashboard](Admin-Dashboard#an-admin-surface-serves-the-apps-tools) |
270
273
 
271
274
  **A token from an external issuer** (`As of 2026-11-05`): an app whose agents present an IdP's JWT
272
275
  rather than an API key hands `defineAppMcp` `jwtBearerResolver` from `@ultimat3/auth` — the twin of
package/wiki/Testing.md CHANGED
@@ -594,6 +594,7 @@ unitTest('a row deleted before the run is skipped, not failed', async ({ runJobs
594
594
  | On the trace or the call | Is |
595
595
  |---|---|
596
596
  | `trace.executions[n].result` | what the body returned. Absent on a run that failed, suspended or was interrupted |
597
+ | `trace.executions[n].code` | the stable `X_*` code a failed run's error carried, beside its `error` message. Absent on an uncoded throw and on a run that did not fail |
597
598
  | `trace.steps[name].executions` | times the step body ran — a replay from storage does not count. Cumulative for the test |
598
599
  | `runJobs(job, input, { actor })` · `drain({ actor })` | the identity the **worker** runs as (`WorkerOptions.context()` in a real one). Its org is still the job's declared `tenant`; `tenant: 'none'` strips it. Absent is the anonymous actor |
599
600
  | `runJobs(job, input, { tenantId })` · `enqueue(…, { tenantId })` | the **enqueuer's** tenant on the row: the limiter's bucket and the dedupe namespace, as `handle.as(actor, input)` files it. One key in two tenants is two jobs |
@@ -673,6 +674,12 @@ describeIslandState(counterStates, 'idle', island, (mounted) => {
673
674
  The props are the manifest's, so the picture `x shot --island` takes and the test are of one
674
675
  component. `mountIsland` stays the call for props no state declares. `As of 2026-10`.
675
676
 
677
+ A realtime island's page outlives its mount: the socket, the record store and the outbox sit on
678
+ `globalThis`, shared by every bundle. Between files (or cases that need a fresh page) call
679
+ `resetPageRealtime()` from `@ultimat3/realtime` in `afterAll` — it closes the socket the CHUNK's copy
680
+ of realtime built and drops every page global. Never `Reflect.deleteProperty(globalThis,
681
+ Symbol.for('ultimate.realtime'))`: that leaves the socket open. `As of 2026-11`.
682
+
676
683
  ## Testing an authenticator
677
684
 
678
685
  ```ts
@@ -846,7 +853,7 @@ A column whose value comes from another factory, built with the **same strategy*
846
853
 
847
854
  `ids.uuid()` and `ids.number()` come from the seeded generators; `index` starts at 1 for the first row.
848
855
 
849
- `factoriesFor(registry, seed?)` derives a factory per entity, each value read off the column's **declaration**, never its name (`As of 2026-11`, 30.0.0 entry 89): the declared default first, else an enumerated value, else what the kind offers (uuid, text, integer, boolean, timestamp, money as `{ minor, currency }`, …), and `null` only where the column is nullable. Every candidate goes through the column's own `$parse`, so a built row is one the table stores. A registry entry whose `$columns` are not `entity()` columns, or a column that refuses every generated value (a `json(t.object(…))` with required keys), throws `X_SEED_GRAPH_UNSATISFIED` at `build()`, naming the `defineFactory` to write. Source: `packages/testing/src/factory-registry.ts`, `seed-graph-values.ts`.
856
+ `factoriesFor(registry, seed?)` derives a factory per entity, each value read off the column's **declaration**, never its name (`As of 2026-11`, 30.0.0 entry 89): the declared default first, else an enumerated value, else what the kind offers (uuid, text, integer, boolean, timestamp, money as `{ minor, currency }`, …), and `null` only where the column is nullable. A text column held to a `matches(/…/)` invariant is also offered strings built from that pattern, and those come first; a `json(schema)` column is offered values its schema accepts (`As of 2026-11-07`). Every candidate goes through the column's own `$parse`, so a built row is one the table stores. A registry entry whose `$columns` are not `entity()` columns, or a column that refuses every generated value (a `json()` whose schema carries a `pattern` its sampler cannot meet), throws `X_SEED_GRAPH_UNSATISFIED` at `build()`, naming the `defineFactory` to write. Source: `packages/testing/src/factory-registry.ts`, `seed-graph-values.ts`.
850
857
 
851
858
  ### Persisting
852
859
 
@@ -889,8 +896,9 @@ await insertSeedGraph(graph, writer);
889
896
  |---|---|
890
897
  | order | parents before children, over the entities' own `$references()` |
891
898
  | tenancy | each child points at a parent of its **own** tenant; the entity a tenant column references holds one row per tenant, keyed by the tenant. A table with no tenant column whose references reach a tenant-owned one, and no `shared` reason, is seeded per tenant too, under its own tenant's parent — `row.tenant` is that parent's tenant |
892
- | a cycle | broken at a nullable key: inserted `null`, set by an entry of `graph.updates` once its target exists. A cycle with no nullable key is `X_SEED_GRAPH_UNSATISFIED` |
899
+ | a cycle | broken at a nullable key: inserted `null`, set by an entry of `graph.updates` once its target exists. The break prefers a table that takes updates — never an `appendOnly` one, whose key stays `null` when it is the only break. In `x exercise`, a deferred update the database refuses (a trigger no declaration carries) leaves the key `null`; an environment failure still fails. A cycle with no nullable key is `X_SEED_GRAPH_UNSATISFIED` |
893
900
  | values | read off the declaration, as `factoriesFor` reads them, and kept only if `$parse` accepts them |
901
+ | patterns and schemas | a `matches(/…/)` pattern adds strings drawn from the pattern itself: literals, classes, `.`, groups, `\|`, quantifiers, anchors. Lookaround, backreferences, `\b` and `\p{…}` add nothing, so the column's other candidates stand. A `json(schema)` column draws from its schema, as the contract step's `arbitrary` does. Both are seeded by the row, so the same seed gives the same bytes |
894
902
  | rules | every invariant, the primary key, unique columns and unique indexes are kept; a partial unique is treated as total. A unique column that runs out of values ends its table early — but a table-wide unique that runs out before every tenant holds a row is `X_SEED_GRAPH_UNSATISFIED` (`… runs out of values before tenant 2 of 2 holds a row`): that tenant's children would have no parent |
895
903
  | an unseeded target | a key — the tenant column included — whose target entity is not in the set is `X_SEED_GRAPH_UNSATISFIED` (`… which is not in the set seeded — pass it too`) |
896
904
  | soft delete | a soft-deleting table's `deletedAt` is `null` unless a factory builds it: seeded rows are live |
@@ -957,7 +965,8 @@ A legal input that crashes the handler is red in `contract`, the day the action
957
965
  | Runs | 16 (`EXERCISE_RUNS` from `@ultimat3/core`), `runs` from 1 to 64. Runs 0-3 sit on the IR's bounds (`minLength`, `maxLength`, `minimum`, `maximum`, `minItems`). The rest draw multibyte text, every enum and union member, optional keys on and off, `null` where nullable. A format value (`t.uuid`, `t.email`, …) is never varied |
958
966
  | Seed | `fnv1a(<action name>)`, never time, so a red run replays: `bun test <file> -t '<name>: handler survives generated input'` |
959
967
  | Rolled back | each run inside a memory transaction, always, and in it `withTransaction` when SQL can hold the writes: a SAVEPOINT in a test's open transaction, a root transaction once `setDbClient` installed a client. Both always roll back. The handler's SQL writes, its memory-store writes (`storeMode` `memory`, `tx` passed or not, since `memoryTransactor().run` is ambient), the outbox stage and the post-commit bust drop with it (`packages/action/src/tx-scope.ts` `inRolledBackScope`) |
960
- | **Not rolled back** | a write on a second client or `outsideTransaction`. A job enqueued with `outbox: false`. Storage puts. Mail and outbound fetch: the test preload seals both, so a real one is only sent where a test opted out. Rate-limit counters. An audit record a non-database sink accepted |
968
+ | **Not rolled back** | a write on a second client or `outsideTransaction`. A job enqueued with `outbox: false`. Storage puts. Mail and outbound fetch: the test preload seals both, so a real one is only sent where a test opted out. An audit record a non-database sink accepted |
969
+ | Rate-limit counters | spent in a scratch store of the exercise's own (`withScratchRateLimitStore`, `@ultimat3/http`), answering for the installed store's scope: the same file's next call has its whole declared limit |
961
970
  | Where it runs | `development` and `test` only. `staging` or `production` (`ULTIMATE_ENV`, else `NODE_ENV`) is `X_CONTRACT_EXERCISE_ENVIRONMENT` before the first run: generated input never reaches a deploy. `bun test` sets `NODE_ENV=test` |
962
971
  | Pass | the output parsed, or a coded refusal under 500 |
963
972
  | Defect, `X_CONTRACT_DEFECT` | an uncoded throw, `X_OUTPUT_INVALID`, or a coded 5xx (an undeclared `errors:` code is one). `meta.run`, `meta.input`, `meta.failure` |
@@ -984,11 +993,13 @@ shape, same unreached code. `As of 2026-11`.
984
993
  | Rows | one seed graph for the pass, a fresh pair of tenants per primitive in it — the first the actor's (A), the second the tenant proof's intruder (B); every table seeded off its declaration, parents first, same-tenant keys (`x db seed --generate`'s graph). A view and a `readOnly: '<why>'` table get no row; a NOT NULL key into a `readOnly` one is `X_SEED_GRAPH_UNSATISFIED` naming it ([Read-only tables](Entities-And-Migrations#read-only-tables)) |
985
994
  | Actor | the first of `x policy explain`'s actors the policy allows, in the primitive's tenant. None allowed is `refused`, by design |
986
995
  | Input | the override, then an id field whose stem names a seeded entity (`postId` → `posts`), then the schema's minimal draw. A wrong guess shows as `unreached` |
987
- | A job's org | the field(s) its `tenant(input)` reads — any name, nested, or left out of the draw — are set to the tenant it is enqueued in, found by reading them: A's for the control, B's for the tenant proof (B's own job, carrying A's ids). A `tenant` no input field moves (one fixed org) is `unreached`, `X_JOB_TENANT_MISMATCH`, its `fix:` the declaration: `tenant: (input) => input.orgId` |
996
+ | A job's org | the field(s) its `tenant(input)` reads — any name, nested, left out of the draw, or under a parent the draw left out (a scoped backfill's `payload.input.orgId`) — are set to the tenant it is enqueued in, found by reading them: A's for the control, B's for the tenant proof (B's own job, carrying A's ids). A `tenant` no input field moves (one fixed org) is `unreached`, `X_JOB_TENANT_MISMATCH`, its `fix:` the declaration: `tenant: (input) => input.orgId` |
997
+ | A job's failure | judged by the action rule: a coded refusal under 500 (an app's 409) is `unreached` under its own code, never a defect; an uncoded throw or a coded 5xx is `X_CONTRACT_DEFECT` |
988
998
  | Async input | an override's `input` may be `async`: it is awaited inside the call's request, as the call's actor, so it can mint what the action checks (a one-time token, a passkey assertion). `x drill`'s load skips an `async` input and says why |
989
999
  | Room | network sealed, mail captured, jobs in memory, clock frozen — from before the app loads and the scratch database boots, as `bun test`'s preload does, so the embedded Postgres never sees its clock jump. A call out is refused and counted, so the primitive is `unreached` |
990
1000
  | Cost | rows are primitives × tables; statements are not: the graph is written one `insertAll` per table, each tenant-proof snapshot is one statement, and a primitive reads only its own pair's rows of the graph |
991
1001
  | Overrides | `apps/web/exercise.ts`: `export const exercise = defineExercise({ publishPost: { actor, input }, sendInvoice: { off: '<why>' } })`. Each value is `.contract({ exercise })`'s option. An unknown name is `X_DECLARATION_UNKNOWN`; a blank `off` is `X_CONTRACT_EXERCISE_UNDECLARED` |
1002
+ | A handler that calls a vendor | `defineExercise({ … }, { setup: () => { mockFetch('https://api.vendor.test/*', handler); } })` — or `setup: () => cassette(import.meta.dir, 'exercise')`. Runs once in the exercise process, after the seal and before the first row; what it returns is disposed after the pass. A call a setup route answers is reached, not refused. `allowHost` in it is `X_EXERCISE_SETUP_UNSEALED` |
992
1003
  | Rows a generated value cannot satisfy | a cross-column `c.satisfies(…)` reads `X_SEED_GRAPH_UNSATISFIED`: `defineExercise({ … }, { factories: { bosses: defineFactory(bosses, { defaults: (i) => ({ … }) }) } })`, keyed by entity name, the graph's keys kept. A column that follows from a key or reference the graph picked: `derive: (row) => ({ scopeKey: … })` beside `build`/`reset`, reading `row.ownerOrgId`. A partial unique whose columns the factory builds is the database's to judge. An unknown name is `X_DECLARATION_UNKNOWN` |
993
1004
  | Report | `.x/exercise.json` and `--json`: `{ seed, counts, records[] }`, each record `{ kind, name, outcome, code?, reason?, statements, effects, ms, override?, fix?, tenant?, crash? }` — `fix` where no override reaches it. The fields before `tenant` are A's call. The human table's `tenant` and `crash` columns are the verdicts, `-` where none ran |
994
1005
  | Gate | the `exercise` step, after `job`, runs `x exercise --json` as a child and is red on its findings: `X_CONTRACT_EXERCISE_UNREACHED`, `X_TENANT_LEAK`, `X_TENANT_WRITE_CROSSED`, `X_JOB_CRASH_DIVERGED`, and — on a whole pass, not `x exercise <name>` — `X_ENTITY_TENANT_UNDECLARED`. `unreached` is never sampled or skipped: it is a finding whose `fix:` is the entry to paste |
package/wiki/Upgrading.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Upgrading
2
2
 
3
- **`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions. An app declares them at `^<version>`, as `x new` writes, and `bun.lock` pins the exact build.
3
+ **`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` package moves in lockstep — never mix versions. `x new` declares them at `^<version>`, so `bun update` takes a same-major fix before the first hop; `x upgrade` rewrites every pin it moves exact (`20.1.0`, no caret), on purpose — a caret is how `^19.0.0` beside `^19.4.0` came to be mixed, and a range is a silent upgrade. `bun.lock` pins the exact build either way.
4
4
 
5
5
  > **From v30, one major per 90 days.** The window, what semver covers, the support window and the security exception: [Stability](Stability). A removal now ships its `@deprecated` alias for at least one minor before a major takes it, so `x upgrade` across one major names no edit a minor did not already warn about. The 29 sections below stay as they are.
6
6
 
@@ -299,6 +299,7 @@ Only an app that relied on the old behaviour has anything to do:
299
299
  | What moved | Do |
300
300
  |---|---|
301
301
  | with `DATABASE_REPLICA_URL` set, a request that wrote sets `__Host-x-pos` and `x-ultimate-position` | nothing. A proxy with a cookie allowlist passes `__Host-x-pos`; stripped, the next read may land on a standby that has not replayed the write |
302
+ | a job attempt that completed and was acked leaves `ctx.signal` un-aborted (`As of 2026-11-07`); before, its timers' writes and enqueues were refused `X_ABORTED`, "was cancelled" | nothing. A body that read an aborted signal as "the run ended" reads the settle instead (`onSettled`); work that must outlive a FAILED attempt too runs in `runToCompletion(() => work)` from `@ultimat3/core` |
302
303
  | `onSettled`'s `ctx.signal` is live | a hook that treated an aborted signal as "the run ended" reads `settled.outcome` instead. It is aborted at `onSettledTimeout`, 30 s across every try by default: raise it on a hook that takes longer |
303
304
  | `publishEvent` inside a transaction commits and rolls back with it | nothing, unless a rolled-back event must still resume a run: publish it in `outsideTransaction(fn)` |
304
305
  | the `drift` step's `rollingSince` rail is off until set | optional: set `rollingSince` in `x.verify.json` to your newest applied migration id. `x doctor` reminds you |
@@ -3566,7 +3567,7 @@ bunx @ultimat3/cli@latest upgrade --json # an app below 30.0.0, whose own CLI
3566
3567
 
3567
3568
  A repository with pins and no `app.config.ts` hops too: no `x verify` can run, so it runs the `typecheck` and `test` scripts it declares, and `data.proof` (`verify`, `scripts`, `none`) says what `done` rests on.
3568
3569
 
3569
- Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the higher version> --json`), `X_UPGRADE_HOP_TOO_WIDE`, `X_UPGRADE_PIN_UNREADABLE`, `X_UPGRADE_REGISTRY_UNREACHABLE`, `X_UPGRADE_VERSION_UNPUBLISHED`, `X_UPGRADE_INSTALL_FAILED`. `data.bun.manual[]` lists what only you can move: a digest-pinned image, an expression. `data.guards.differs[]` offers `x g guard <name> --force`, and your guard is never touched. A hop below 30.0.0 reports `X_BUDGET_UNMEASURED` until `x build --target static --json` has run. Field by field: [CLI reference](CLI-Reference#x-upgrade).
3570
+ Refusals name their own fix: `X_UPGRADE_MIXED_PINS` (`x upgrade --to <the lowest pins' next major> --json`), `X_UPGRADE_HOP_TOO_WIDE`, `X_UPGRADE_PIN_UNREADABLE`, `X_UPGRADE_REGISTRY_UNREACHABLE`, `X_UPGRADE_VERSION_UNPUBLISHED`, `X_UPGRADE_INSTALL_FAILED`. `data.bun.manual[]` lists what only you can move: a digest-pinned image, an expression. `data.guards.differs[]` offers `x g guard <name> --force`, and your guard is never touched. A hop below 30.0.0 reports `X_BUDGET_UNMEASURED` until `x build --target static --json` has run. Field by field: [CLI reference](CLI-Reference#x-upgrade).
3570
3571
 
3571
3572
  ## Breaking-change classes and how each is detected
3572
3573