@ultimat3/cli 30.3.0 → 30.5.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.
@@ -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