From 0a19b71e512a96f6e16ca719160bb4bb6852843a Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 13 Aug 2026 13:58:05 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat(query):=20serve=20reads=20over=20HTTP?= =?UTF-8?q?=20=E2=80=94=20the=20route=20client()=20already=20fetched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `toQueryRoute()` in `packages/query/src/http.ts`: `GET /_x/query/`, mirroring `@ultimat3/action`'s `toRoute`. The search string is coerced at the boundary and validated by `runQuery` — one parser, so a bad input is the read's own `X_INPUT_INVALID`. `meta.input` stays absent (the body stage would validate it against a body a GET has none of), `no-store` (the URL names no actor), `enforcedBy: 'handler'` (the read holds the one policy evaluation). - `runQuery` gains a schema-erased overload so the projection maps `listQueries()` without a cast. Same read path, only the row type is gone. - `apiRoutes()` in `@ultimat3/cli` is now the one composition of the app's HTTP API; `x dev` and `serve.ts` both mount it, so a read cannot answer in one and 404 in the other. - `X_INPUT_INVALID` is a 400, not the unmapped 500 that blamed the server for a caller's typo and paged the on-call for it. - 18 tests over the real pipeline, 14 verified red across 6 mutations. Co-Authored-By: Claude --- CHANGELOG.md | 14 ++ packages/cli/CLAUDE.md | 9 + packages/cli/src/api-routes.test.ts | 60 +++++++ packages/cli/src/api-routes.ts | 16 ++ packages/cli/src/cmd-dev.ts | 6 +- packages/cli/src/index.ts | 2 + packages/cli/src/serve.ts | 4 +- packages/http/src/error-map.test.ts | 7 + packages/http/src/error-map.ts | 5 + packages/query/CLAUDE.md | 22 ++- packages/query/README.md | 7 + packages/query/package.json | 1 + packages/query/src/http.test.ts | 245 ++++++++++++++++++++++++++++ packages/query/src/http.ts | 72 ++++++++ packages/query/src/index.ts | 2 + packages/query/src/read.ts | 36 ++-- packages/query/tsconfig.json | 3 + wiki/Error-Codes.md | 2 +- wiki/Queries-And-Live-Queries.md | 13 ++ 19 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/api-routes.test.ts create mode 100644 packages/cli/src/api-routes.ts create mode 100644 packages/query/src/http.test.ts create mode 100644 packages/query/src/http.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1557ad2ef..5c5911db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -250,6 +250,20 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ### Fixed +- **`query.client()` now reaches a route. Reads are served over HTTP, in `x dev` and in a container alike.** `@ultimat3/query` derived `/_x/query/` in `naming.ts`, `client.ts` fetched exactly that URL, and nothing anywhere built or mounted a route for it — so every typed read compiled, shipped, and 404'd, while the README and the wiki documented the projection as shipped. + + `toQueryRoute(target)` in `packages/query/src/http.ts` is that projection, mirroring `@ultimat3/action`'s `toRoute`: + + ```ts + GET /_x/query/live-feed?orgId=… // the URL liveFeed.client({ baseUrl }) already derived + ``` + + Three decisions it makes, each for a reason a read has and a write does not. The search string is **coerced** at the boundary (`@ultimat3/schema`'s `coerceQuery`, the one HTTP-boundary decoder) and **validated** by `runQuery` — one parser, so a bad `orgId` is the read's own `X_INPUT_INVALID` with the line that prints its schema, never the pipeline's `X_BODY_INVALID`. `meta.input` is therefore absent: the pipeline validates it against a *body*, and a GET has none. The answer is `no-store` — the URL names no actor while the rows are scoped to one — and `enforcedBy: 'handler'`, because `runQuery` is the read's one policy evaluation and it holds the parsed input; an authz stage deciding first would be a second authz system deciding from raw strings. + + `@ultimat3/cli`'s new `apiRoutes()` is what mounts it, and it is now the **one** composition of the app's HTTP API — both `x dev` and `serve.ts` mount it, so a read cannot answer in one and 404 in the other. `@ultimat3/query` gains a dependency on `@ultimat3/http` (tier 3 → tier 2, downward). + +- **`X_INPUT_INVALID` is a 400 over HTTP, not a 500.** The code had no row in `error-map.ts`, so it took the 500 default on every surface that throws it — an action route and now a query read alike. A caller's typo'd uuid was answered as a server fault *and* reported to the error monitor by the `error-map` stage, which pages the on-call for someone else's mistake. 400 is also what the published OpenAPI operation has always promised for it. + - **`LruCache.clear()` now resets `hits`/`misses`/`evictions` along with entries and bytes.** `stats()` after a `clear()` used to keep reporting whatever the cache had accumulated before the clear — a fresh cache with stale lifetime counters. `clear()` is a reset, and `stats()` now reads as one. - **A drain that times out no longer leaks its waiter.** `waitForIdle()` pushed a closure onto `idleWaiters` and relied on the eventual `beginWork()` completion to remove it; a drain that gave up at the deadline resolved its own promise but left that closure in the array forever, to be invoked (harmlessly, but pointlessly) whenever in-flight work eventually finished. The timeout branch now removes its own waiter. `@ultimat3/core` exports a test-only `idleWaiterCount()` so this stays provable. diff --git a/packages/cli/CLAUDE.md b/packages/cli/CLAUDE.md index 76048b5c0..fc4ca4a06 100644 --- a/packages/cli/CLAUDE.md +++ b/packages/cli/CLAUDE.md @@ -191,6 +191,7 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests. | File | Job | |---|---| +| `api-routes.ts` | the app's API over HTTP: every registered action AND every registered query | | `dev-services.ts` | resolve which service each binding points at — embedded or external | | `dev-queue.ts` | the db + queue pair alone, and the one place that takes `db()` and `jobDriver()` back | | `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) | @@ -212,6 +213,14 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests. | `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` | | `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller | +`api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts` +and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus +`listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()` +shipped deriving `/_x/query/` against a route neither file mounted — a typed read that +compiled everywhere and 404'd everywhere — and a surface that answers in `x dev` and not in the +container is the same failure one release later. It reads the registries at call time, never at +import: importing the app IS the registration, and it happens after this module loads. + `startWeb` warns when the route table declares `auth: 'required'` and the app configured no authenticator: `hooks.authenticate` is the only place an actor can come from, so such a process boots clean, reports healthy, and refuses every valid session. A warning and not a throw, because diff --git a/packages/cli/src/api-routes.test.ts b/packages/cli/src/api-routes.test.ts new file mode 100644 index 000000000..a2fc70f02 --- /dev/null +++ b/packages/cli/src/api-routes.test.ts @@ -0,0 +1,60 @@ +// One table, both halves. `x dev` and a container mount `apiRoutes()` and nothing else, so a +// surface that answers in one and 404s in the other has to break this test first. + +import { afterEach, expect, test } from 'bun:test'; +import { action, registerAction, resetRegistry as resetActions } from '@ultimat3/action'; +import { allow } from '@ultimat3/policy'; +import { from, query, registerQuery, resetRegistry as resetQueries } from '@ultimat3/query'; +import { t } from '@ultimat3/schema'; +import { apiRoutes } from './api-routes'; + +const Input = t.object({ orgId: t.uuid }); + +afterEach(() => { + resetActions(); + resetQueries(); +}); + +function registerBoth(): void { + registerAction( + 'publishPost', + action({ + input: Input, + output: t.object({ ok: t.boolean }), + policy: allow(), + handle: () => ({ ok: true }), + }), + ); + registerQuery( + 'orgFeed', + query({ + input: Input, + policy: allow(), + sql: ({ orgId }) => from('posts', []).where({ orgId }), + }), + ); +} + +test('an app with no primitives contributes no routes', () => { + expect(apiRoutes()).toEqual([]); +}); + +test('the read half is mounted beside the write half', () => { + registerBoth(); + const routes = apiRoutes(); + + // The write half was never the gap; the read half is the one `query.client()` fetches and + // nothing served, so a typed call site compiled everywhere and 404'd everywhere. + expect(routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + 'POST /api/posts/publish', + 'GET /_x/query/org-feed', + ]); +}); + +test('reads it at call time, because importing the app IS the registration', () => { + // This module is imported long before `loadApp` runs. A table captured at import would be the + // empty one above, on every boot. + expect(apiRoutes()).toEqual([]); + registerBoth(); + expect(apiRoutes()).toHaveLength(2); +}); diff --git a/packages/cli/src/api-routes.ts b/packages/cli/src/api-routes.ts new file mode 100644 index 000000000..87ecc9ea3 --- /dev/null +++ b/packages/cli/src/api-routes.ts @@ -0,0 +1,16 @@ +// The app's API over HTTP, composed once: the write half `@ultimat3/action` projects and the read +// half `@ultimat3/query` projects. `x dev` and `serve.ts` both mount THIS rather than each listing +// the registries themselves — two lists is how `query.client()` shipped deriving `/_x/query/` +// against a route neither file mounted, compiling everywhere and 404ing everywhere. + +import { listActions, toRoute } from '@ultimat3/action'; +import type { Route } from '@ultimat3/http'; +import { listQueries, toQueryRoute } from '@ultimat3/query'; + +/** + * Whatever loading the app's modules put in the two registries, as routes. Read at call time, + * never at import: importing the app IS the registration, and it happens after this module loads. + */ +export function apiRoutes(): readonly Route[] { + return [...listActions().map(toRoute), ...listQueries().map(toQueryRoute)]; +} diff --git a/packages/cli/src/cmd-dev.ts b/packages/cli/src/cmd-dev.ts index f338af6cc..230041667 100644 --- a/packages/cli/src/cmd-dev.ts +++ b/packages/cli/src/cmd-dev.ts @@ -6,7 +6,6 @@ import { watch } from 'node:fs'; import { join } from 'node:path'; -import { listActions, toRoute } from '@ultimat3/action'; import { devShellStyle } from '@ultimat3/admin/dev'; import type { Role } from '@ultimat3/core'; import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core'; @@ -15,6 +14,7 @@ import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http'; import { asCtx } from '@ultimat3/http'; import type { Manifest } from '@ultimat3/manifest'; import { MANIFEST_FILENAME } from '@ultimat3/manifest'; +import { apiRoutes } from './api-routes'; import { loadSignInPath } from './app-auth'; import { loadApp } from './app-load'; import { appManifest } from './app-manifest'; @@ -157,7 +157,9 @@ export async function startDev(options: StartDevOptions): Promise { const routes: readonly Route[] = [ ...devDashboardRoutes(dashboard), - ...listActions().map(toRoute), + // The same API table the container serves: a read that answers here and 404s in production + // is exactly the drift one composition exists to prevent. + ...apiRoutes(), // The image pipeline's only HTTP surface: the icons the web manifest declares, and the // variants every `srcset` promises. Mounted before the app's own routes so a page route can // never shadow `/icons` or `/media`. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8e7b71d29..9bfd4e3a2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,8 @@ // on these, and a barrel that re-exports everything would make every internal a compatibility // promise. +/** The app's own API over HTTP — the one table `x dev` and a container both mount. */ +export { apiRoutes } from './api-routes'; export type { BoundaryCode, SourceFile } from './app-boundaries'; export { appImportGraph, diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index e2d19633c..adb626ffb 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -4,7 +4,6 @@ // `dev: true`. The only production-shaped decisions live here: which role, which port, and the // fact that a container must bind every interface. -import { listActions, toRoute } from '@ultimat3/action'; import type { Role } from '@ultimat3/core'; import { configureErrorReporting, @@ -15,6 +14,7 @@ import { } from '@ultimat3/core'; import { type MigrationReport, migrate } from '@ultimat3/db'; import type { Route } from '@ultimat3/http'; +import { apiRoutes } from './api-routes'; import { loadSignInPath } from './app-auth'; import { loadApp } from './app-load'; import { appManifest } from './app-manifest'; @@ -186,7 +186,7 @@ export async function serveApp(options: ServeOptions): Promise { // own logs, everything below it is a served request, a claimed job or a routed frame. configureReporting(options.env, buildId); const routes: readonly Route[] = [ - ...listActions().map(toRoute), + ...apiRoutes(), ...assetRoutes({ root: options.root, storage: runtime.storage }), ...storageRoutes({ storage: runtime.storage }), ...appRoutes({ buildId }), diff --git a/packages/http/src/error-map.test.ts b/packages/http/src/error-map.test.ts index 9c94f14e2..4d95739a5 100644 --- a/packages/http/src/error-map.test.ts +++ b/packages/http/src/error-map.test.ts @@ -34,6 +34,13 @@ describe('error -> status', () => { expect(statusFor('X_PATH_INVALID')).toBe(400); }); + // The input a primitive declared and the caller got wrong. Unmapped it was a 500, so every + // action route and every query read answered a typo'd uuid by blaming the server — and the + // `error-map` stage reports 5xx, so the on-call heard about it too. + test('input the caller got wrong blames the caller, not the server', () => { + expect(statusFor('X_INPUT_INVALID')).toBe(400); + }); + test('codes owned by other packages are mapped here, not there', () => { expect(statusFor('X_NOT_FOUND')).toBe(404); expect(statusFor('X_INVARIANT_VIOLATED')).toBe(422); diff --git a/packages/http/src/error-map.ts b/packages/http/src/error-map.ts index f1f5ed078..c8be16783 100644 --- a/packages/http/src/error-map.ts +++ b/packages/http/src/error-map.ts @@ -30,6 +30,11 @@ export const ERROR_STATUS: Readonly> = { // and declaring a status the framework already owns. 500 is the honest answer to either. X_NO_REQUEST: 500, X_ERROR_STATUS_INVALID: 500, + // @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema + // the primitive declared. 400 because that is what the published OpenAPI operation promises for + // it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke, + // and the `error-map` stage reported the caller's mistake to the on-call monitor. + X_INPUT_INVALID: 400, // @ultimat3/entity X_NOT_FOUND: 404, X_ENTITY_DUPLICATE: 409, diff --git a/packages/query/CLAUDE.md b/packages/query/CLAUDE.md index 8ba39269a..c0b318ee6 100644 --- a/packages/query/CLAUDE.md +++ b/packages/query/CLAUDE.md @@ -4,7 +4,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. ## Boundary -- May import: `core`, `schema` (t0), `cache`, `i18n`, `time` (t1), `entity`, `policy` (t2). +- May import: `core`, `schema` (t0), `cache`, `i18n`, `time` (t1), `entity`, `policy`, `http` (t2). - Never import: `action`, `jobs`, `realtime` (sideways), or any tier 4-5 package. - Reads only. A query that writes is an `action` in the wrong file. @@ -15,6 +15,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. | `query.ts` | the primitive: `query()`, `describeQuery`, `queryHash`; the package's front door for the read path | | `read.ts` | **the one read path** (`runQuery`, `sourceFor`) + the private declaration store `sql` lives in | | `facade.ts` | the fluent surface — binds each projection to the query, re-implements none | +| `http.ts` | route projection (`GET /_x/query/`, `enforcedBy: 'handler'`) | | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` | | `client.ts` | typed read client (browser-safe: no server imports) | | `naming.ts` | export name → `/_x/query/` + snake_case tool name. Pure string math | @@ -48,6 +49,25 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. the author said so. - `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action` is the same tier, so its naming is ported here, never imported. +- **`toQueryRoute` is the other half of `client()`, and the two derive the same URL from the same + `naming.ts`.** The client shipped fetching `/_x/query/` while nothing built a route for + it, so every typed read compiled and 404'd; a projection whose only consumer is a URL string is + the failure this pairing exists to prevent. Named for the primitive rather than `toRoute`, + because a host mounts it beside `@ultimat3/action`'s — the same reason the tool projection here + is `toQueryTool`. +- **The route coerces, `runQuery` validates, and only the first belongs to the wire.** A search + string is characters, so the boundary decodes it with `@ultimat3/schema`'s `coerceQuery` — the + one HTTP-boundary decoder, which never invents data and hands on what it cannot convert. + Validating there as well (`request.query(schema)`) would be the second parser: the same read + would answer `X_BODY_INVALID` where every other surface answers `X_INPUT_INVALID` and prints + its schema. For the same reason `meta.input` stays **absent** — the pipeline's body stage + validates it against a body, and a GET has none, so declaring it fails every read on nothing. +- **A read is `no-store`, and its policy is `enforcedBy: 'handler'`.** The URL names no actor + while the answer is scoped to one, so `public` would hand one reader's rows to the next caller + of that URL; and `runQuery` is the read's one evaluation, deciding from the parsed input, so an + authz stage deciding first would be a second authz system holding raw strings — and would + demand an `authorize` hook to decide at all. `http.test.ts` drives both over the real pipeline + with no hook wired and counts the evaluations: exactly one. - `registry.ts` announces `registerQueries` in core's registrar table at import. That is how `defineApi({ queries })` in `@ultimat3/action` registers a read without importing this package sideways. Never remove the announcement: `defineApi` would then throw `X_REGISTRAR_MISSING`. diff --git a/packages/query/README.md b/packages/query/README.md index b36534dc8..1b12f64a4 100644 --- a/packages/query/README.md +++ b/packages/query/README.md @@ -33,6 +33,12 @@ Every projection is a method on the query itself. A query has no `.def`. | `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways | | `liveFeed.describe()` | the manifest row | +The route on the other end of that client is `toQueryRoute(liveFeed)`, and `x dev` and a +container both mount it for every registered read — the framework's job, not the app's. The +search string is coerced at the wire and validated by the read's own schema, so a bad `orgId` +is the query's `X_INPUT_INVALID` and a 400; the answer is `no-store`, because the URL names no +actor while the rows are scoped to one. + The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing that can build a source and there is nowhere for a second authz path to hide. Something that @@ -47,6 +53,7 @@ merely looks like a query (`kind: 'query'`, no declaration) is `X_QUERY_FOREIGN` | `facade.ts` | binds each projection to the query; re-implements none of them | | `mcp-tool.ts` | the MCP read descriptor | | `client.ts` | the typed read client (browser-safe) | +| `http.ts` | the route projection — `GET /_x/query/`, the URL the client derives | | `naming.ts` | export name → wire path + tool name | | `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to | | `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) | diff --git a/packages/query/package.json b/packages/query/package.json index 54861e09f..027cac161 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -32,6 +32,7 @@ "dependencies": { "@ultimat3/cache": "1.2.0", "@ultimat3/core": "1.2.0", + "@ultimat3/http": "1.2.0", "@ultimat3/policy": "1.2.0", "@ultimat3/schema": "1.2.0" } diff --git a/packages/query/src/http.test.ts b/packages/query/src/http.test.ts new file mode 100644 index 000000000..3d0dd9743 --- /dev/null +++ b/packages/query/src/http.test.ts @@ -0,0 +1,245 @@ +// The route projection, proven over the real pipeline rather than by calling `route.handler`: +// a read is a GET whose search string IS its input, its policy is evaluated once and inside the +// handler, and the URL it answers on is the one `client()` derives with no server import. + +import { describe, expect, test } from 'bun:test'; +import type { Actor } from '@ultimat3/core'; +import { userActor } from '@ultimat3/core'; +import { createServer } from '@ultimat3/http'; +import { allow, can } from '@ultimat3/policy'; +import { t } from '@ultimat3/schema'; +import type { FetchLike } from './client'; +import { toQueryRoute } from './http'; +import type { AnyQuery, Query } from './query'; +import { query } from './query'; +import { from } from './source'; + +interface Post { + readonly id: string; + readonly orgId: string; + readonly rank: number; +} + +const ORG = '00000000-0000-4000-8000-000000000001'; +const OTHER = '00000000-0000-4000-8000-000000000002'; +const Input = t.object({ orgId: t.uuid, first: t.number.optional() }); + +const posts: readonly Post[] = [ + { id: 'a', orgId: ORG, rank: 1 }, + { id: 'b', orgId: OTHER, rank: 2 }, + { id: 'c', orgId: ORG, rank: 3 }, +]; + +const reader = (id: string): Actor => ({ ...userActor({ id }), permissions: ['feed:read'] }); + +/** + * One query per test, so the evaluation counter is its own. `named` stands in for registration: + * a projection needs the name, nothing more. + */ +function feed(evaluations: { count: number }) { + return query({ + input: Input, + policy: can('feed:read', () => { + evaluations.count += 1; + return true; + }), + cache: { tags: [{ entity: 'posts' }] }, + mcp: { expose: true, description: 'The org feed' }, + sql: ({ orgId, first }) => + from('posts', posts) + .where({ orgId }) + .orderBy('rank') + .limit(first ?? 50), + }).named('orgFeed'); +} + +function serve(target: AnyQuery, actor: Actor | null) { + return createServer({ + routes: [toQueryRoute(target)], + // No `authorize` hook, on purpose: a query route must not need one. Wiring a second opinion + // is how a host grows a second authz system, and the pipeline 403s any route that declares a + // policy without one unless `enforcedBy: 'handler'` stands the stage down. + hooks: { authenticate: () => actor }, + }); +} + +type Server = ReturnType; + +const read = (server: Server, search = `?orgId=${ORG}`): Promise => + server.fetch(new Request(`http://dev.test/_x/query/org-feed${search}`, { method: 'GET' })); + +describe('the route a query projects', () => { + test('is a GET on the path client() derives, and declares who enforces it', () => { + const evaluations = { count: 0 }; + const route = toQueryRoute(feed(evaluations)); + + expect(route.method).toBe('GET'); + expect(route.path).toBe('/_x/query/org-feed'); + expect(route.meta.name).toBe('orgFeed'); + // Still named — dropping it would read as "this read is unguarded" in `x routes`. + expect(route.meta.policy).toBe('feed:read'); + expect(route.meta.enforcedBy).toBe('handler'); + expect(route.meta.description).toBe('The org feed'); + expect(evaluations.count).toBe(0); + }); + + test('declares no input schema, because the pipeline would validate it against a body', () => { + // A GET has no body, so `meta.input` would fail every read on an absent one before the + // handler ran. The schema is applied by `runQuery` instead — which the coercion test proves. + expect(toQueryRoute(feed({ count: 0 })).meta.input).toBeUndefined(); + }); + + test('is never cached by a shared cache, and carries the tags a purge names', () => { + const meta = toQueryRoute(feed({ count: 0 })).meta; + + // The URL names no actor while the answer is scoped to one, so `public` would hand one + // reader's rows to the next caller of that URL. + expect(meta.cache?.mode).toBe('no-store'); + expect(meta.cache?.tags).toEqual(['posts']); + expect(meta.tags).toEqual(['query']); + }); + + test('is public only when the policy is allow()', () => { + const guarded = toQueryRoute(feed({ count: 0 })); + const open = toQueryRoute( + query({ input: Input, policy: allow(), sql: () => from('posts', posts) }).named( + 'publicFeed', + ), + ); + + expect(guarded.meta.auth).toBe('required'); + expect(open.meta.auth).toBe('public'); + }); +}); + +describe('a query read over the pipeline', () => { + test('answers the rows as json, with the policy evaluated exactly once', async () => { + const evaluations = { count: 0 }; + const response = await read(serve(feed(evaluations), reader('u1'))); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + { id: 'a', orgId: ORG, rank: 1 }, + { id: 'c', orgId: ORG, rank: 3 }, + ]); + // Twice would mean the pipeline's authz stage decided too — a second authz system, deciding + // the same policy from the raw search string this one parsed. + expect(evaluations.count).toBe(1); + }); + + test('decodes the search string against the query’s own schema', async () => { + const evaluations = { count: 0 }; + // `first` arrives as the characters "1". Without the boundary's coercion the read fails + // validation on its own limit, which is the whole reason a GET projection decodes first. + const response = await read(serve(feed(evaluations), reader('u1')), `?orgId=${ORG}&first=1`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([{ id: 'a', orgId: ORG, rank: 1 }]); + }); + + test('an input the schema refuses is the query’s own error, not the body stage’s', async () => { + const response = await read(serve(feed({ count: 0 }), reader('u1')), '?orgId=not-a-uuid'); + const body = (await response.json()) as { code?: string; fix?: string }; + + expect(response.status).toBe(400); + // `X_BODY_INVALID` here would mean a second parser ran: the same read answers + // `X_INPUT_INVALID` on every other surface, with the line that prints the schema. + expect(body.code).toBe('X_INPUT_INVALID'); + expect(body.fix).toContain('x queries describe orgFeed'); + }); + + test('a caller without the permission is denied by that same evaluation', async () => { + const evaluations = { count: 0 }; + const guarded = query({ + input: Input, + policy: can('feed:read', () => { + evaluations.count += 1; + return false; + }), + sql: ({ orgId }) => from('posts', posts).where({ orgId }), + }).named('orgFeed'); + const response = await read(serve(guarded, reader('u1'))); + const body = (await response.json()) as { code?: string }; + + expect(response.status).toBe(403); + expect(body.code).toBe('X_FORBIDDEN'); + expect(evaluations.count).toBe(1); + }); + + test('an anonymous caller is refused before the policy is ever reached', async () => { + const evaluations = { count: 0 }; + const response = await read(serve(feed(evaluations), null)); + + // The `auth` stage answers this one, from `meta.auth`. Authentication, not authorization — + // which is why the count stays at zero. + expect(response.status).toBe(401); + expect(evaluations.count).toBe(0); + }); + + test('a write to a read’s path is refused by method, not answered', async () => { + const server = serve(feed({ count: 0 }), reader('u1')); + const response = await server.fetch( + new Request(`http://dev.test/_x/query/org-feed?orgId=${ORG}`, { method: 'POST' }), + ); + + expect(response.status).toBe(405); + }); + + test('a failure that is not a framework error reaches the server’s error boundary', async () => { + const broken = query({ + input: Input, + policy: allow(), + sql: () => { + throw new TypeError('sql is not a function'); + }, + }).named('brokenFeed'); + const server = createServer({ routes: [toQueryRoute(broken)] }); + const response = await server.fetch( + new Request(`http://dev.test/_x/query/broken-feed?orgId=${ORG}`), + ); + const body = (await response.json()) as { code?: string }; + + // Rethrown rather than dressed as a read failure: a bug belongs to the boundary that reports + // it, and `problem()` would have given it a 4xx and a fix line nobody can run. + expect(response.status).toBe(500); + expect(body.code).toBe('X_INTERNAL'); + }); + + test('an unregistered query cannot be mounted at all', () => { + const anonymous = query({ input: Input, policy: allow(), sql: () => from('posts', posts) }); + + expect(() => toQueryRoute(anonymous)).toThrow('X_QUERY_UNREGISTERED'); + }); +}); + +/** + * The whole promise in one test: `liveFeed.client({ baseUrl })` derives its URL from the export + * name with no server import, and the route the same name projects is what answers it. This is + * what shipped broken — a typed call site that compiled everywhere and 404'd everywhere. + */ +describe('the typed client against the route', () => { + test('reads its own rows end to end', async () => { + const target = feed({ count: 0 }) as Query; + const server = serve(target, reader('u1')); + const fetchLike: FetchLike = (input, init) => server.fetch(new Request(input, init)); + + const rows = await target.client({ baseUrl: 'http://dev.test', fetch: fetchLike })({ + orgId: ORG, + }); + + expect(rows).toEqual([ + { id: 'a', orgId: ORG, rank: 1 }, + { id: 'c', orgId: ORG, rank: 3 }, + ]); + }); + + test('a denial arrives as the server’s own problem document', async () => { + const target = feed({ count: 0 }) as Query; + const server = serve(target, null); + const fetchLike: FetchLike = (input, init) => server.fetch(new Request(input, init)); + const call = target.client({ baseUrl: 'http://dev.test', fetch: fetchLike }); + + // Not `X_RPC_FAILED`: the server already said what broke, and the client re-throws it verbatim. + expect(call({ orgId: ORG })).rejects.toThrow('X_UNAUTHENTICATED'); + }); +}); diff --git a/packages/query/src/http.ts b/packages/query/src/http.ts new file mode 100644 index 000000000..aff6360a4 --- /dev/null +++ b/packages/query/src/http.ts @@ -0,0 +1,72 @@ +/** + * Projection: a query becomes `GET /_x/query/` — the URL `client.ts` already + * derives and fetches. The search string is the input, decoded at the wire and judged + * by `runQuery`, so the endpoint cannot drift from the MCP tool, the live window or a + * direct server call, and cannot acquire a second authz path while doing it. + */ + +import { isUltimateError } from '@ultimat3/core'; +import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http'; +import { json, problem } from '@ultimat3/http'; +import { coerceQuery } from '@ultimat3/schema'; +import { derivePath } from './naming'; +import { policyCapability } from './policy-gate'; +import type { AnyQuery } from './query'; +import { queryName, runQuery } from './read'; +import { tagKeys } from './tags'; + +/** + * `liveFeed` -> `GET /_x/query/live-feed`. Named for the primitive rather than spelled + * `toRoute`, because a host mounts this beside `@ultimat3/action`'s and an alias at the + * import site is a name the reader has to hold — the same reason the tool projection + * here is `toQueryTool`. + */ +export function toQueryRoute(target: AnyQuery): Route { + const name = queryName(target); + + const handler = async (request: UltimateRequest): Promise => { + try { + // Coerced, then validated — two different jobs, and only the first one belongs to a + // wire. A search string is characters, so `t.number` and `t.boolean` need the HTTP + // boundary's decode (`coerceQuery` never invents data: what it cannot convert it + // hands on untouched). VALIDATING here — `request.query(schema)` — would be the + // second parser: the same read would answer `X_BODY_INVALID` where every other + // surface answers `X_INPUT_INVALID` with the line that prints its schema. `runQuery` + // is the one that decides, exactly as it does for a direct server call. + const input = coerceQuery(target.input, request.queryRaw()); + return json(await runQuery(target, input, { surface: 'http' })); + } catch (error) { + // Framework errors carry their own code, status and fix line; anything else is + // a bug and belongs to the server's error boundary, not to this route. + if (isUltimateError(error)) return problem(error); + throw error; + } + }; + + const meta: RouteMeta = { + name, + // `allow(...)` is the only way a read is public, and saying so explicitly is what + // keeps "forgot the policy" from ever looking like "meant to be readable". + auth: target.policy.kind === 'allow' ? 'public' : 'required', + policy: policyCapability(target.policy), + // `runQuery` is this route's one evaluation and it decides from the PARSED input the + // rule reads (`ownsOrg(actor, input.orgId)`); the stage would decide the same policy + // from raw strings, and would need an `authorize` hook wired to decide at all. + enforcedBy: 'handler', + // `input` stays absent, deliberately: the pipeline's body stage validates `meta.input` + // against the BODY, and a GET has none — declaring it would fail every read on an + // absent body before the handler ran. The schema is not skipped, it is applied in the + // handler, by the same `runQuery` every other surface goes through. + + // A read is answered per actor — the policy decided for this caller, and `sql` may + // scope the rows to them — while the URL names no actor at all. `public` would hand + // one actor's rows to the next caller of that URL, so a read is `no-store` and a + // shared cache is something a CDN in front of the app configures knowingly. The tags + // ride along so a purge can still name the read the tier keys by. + cache: { mode: 'no-store', tags: tagKeys(target.cache?.tags ?? []) }, + tags: ['query'], + ...(target.mcp?.description === undefined ? {} : { description: target.mcp.description }), + }; + + return { method: 'GET', path: derivePath(name), handler, meta }; +} diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index ead84b9ba..0a9eccc47 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -40,6 +40,8 @@ export { QueryRequestFailedError, QueryUnregisteredError, } from './errors'; +/** The HTTP projection: `GET /_x/query/`, the URL `client()` derives. */ +export { toQueryRoute } from './http'; export type { LiveCursor, LiveQuery, ResumeMode, ResumePlan, ToLiveOptions } from './live'; export { advanceCursor, liveEpoch, planResume, seekOf, toLiveQuery } from './live'; export type { ChangeEvent, ChangeOp, Patch } from './matcher'; diff --git a/packages/query/src/read.ts b/packages/query/src/read.ts index d5de6c8c3..b97b83439 100644 --- a/packages/query/src/read.ts +++ b/packages/query/src/read.ts @@ -57,8 +57,23 @@ export function queryName(target: AnyQuery): string { export function runQuery( target: Query, raw: unknown, + options?: QueryOptions, +): Promise; +/** + * The same read from a schema-erased handle. The route projection maps `listQueries()`, + * which knows only `AnyQuery` — an overload rather than a second function, so what is + * gone is the row TYPE and never the parse, the policy or the memo. + */ +export function runQuery( + target: AnyQuery, + raw: unknown, + options?: QueryOptions, +): Promise; +export function runQuery( + target: AnyQuery, + raw: unknown, options: QueryOptions = {}, -): Promise { +): Promise { return asActor(options, (ctx) => readRows(target, raw, ctx, options)); } @@ -90,30 +105,29 @@ function asActor(options: QueryOptions, run: (ctx: Ctx) => Promise): Promi : runWithContext(base, () => withChildContext(patch, inChild)); } -async function readRows( - target: Query, +async function readRows( + target: AnyQuery, raw: unknown, ctx: Ctx, options: QueryOptions, -): Promise { +): Promise { const def = defOf(target); const name = queryName(target); const source = await buildSource(target, raw, ctx, options); const read = (): Promise => withSpan(`query.${name}`, () => source.execute()); - // The source came from this query's own `sql()`, so its rows are TRow throughout. + // The source came from this query's own `sql()`, so its rows are TRow throughout — + // which is what the typed overload above states, and this body never has to assert. const key = cacheKeyFor(name, raw, def.cache?.tags ?? []); // `fresh` is the caller saying no cache may answer this one — the memo included, a memo being // a cache whose lifetime is the request. It still *publishes* into the memo: this read is the // newest answer the request has, so the next plain read of the key joins it rather than the // entry a write earlier in the request already moved past. - if (options.fresh === true) return (await readFresh(ctx, key, read)) as readonly TRow[]; + if (options.fresh === true) return await readFresh(ctx, key, read); // `cache:` buys the tier, never the memo: a read asked twice in one request is one execution // whether or not its author opted into caching. - const rows = - def.cache === undefined - ? await readOnce(ctx, key, read) - : await readThrough(ctx, key, def.cache.ttlMs ?? null, read); - return rows as readonly TRow[]; + return def.cache === undefined + ? await readOnce(ctx, key, read) + : await readThrough(ctx, key, def.cache.ttlMs ?? null, read); } async function buildSource( diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json index 4a8ea7b8c..c3c170ccf 100644 --- a/packages/query/tsconfig.json +++ b/packages/query/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../core" }, + { + "path": "../http" + }, { "path": "../policy" }, diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index caa379f80..f9bec8e0d 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -179,7 +179,7 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | Code | Means | Typical cause | Fix | |---|---|---|---| | `X_ACTION_DUPLICATE` | two actions registered under one name | duplicate export names across features | rename one; names are global. `x actions list --json` | -| `X_INPUT_INVALID` | input failed the action's schema | wrong shape from a client or an agent | `x actions describe --json` | +| `X_INPUT_INVALID` | input failed the primitive's schema — an action's, and a query's too | wrong shape from a client or an agent; over HTTP it is a **400**, never a 500 | `x actions describe --json` (`x queries describe` for a read) | | `X_OUTPUT_INVALID` | the handler returned a value its `output` schema rejects | the handler drifted from the declared output | `x actions describe --json`, then fix the handler or the schema | | `X_ACTION_FOREIGN` | a value that is not an action was projected as one | a hand-rolled object with `kind: 'action'`, or an action from a duplicated copy of `@ultimat3/action` | declare it as `export const name = action({ input, output, policy, handle })` | | `X_ACTION_UNREGISTERED` | an action was projected before it was registered, so it has no name | `.tool()` / `.client()` / `.job()` / `.openapi()` on an action `registerActions()` never named | `registerActions(await import('./actions'))` at boot, before mounting routes | diff --git a/wiki/Queries-And-Live-Queries.md b/wiki/Queries-And-Live-Queries.md index fe93f35c6..b4d383722 100644 --- a/wiki/Queries-And-Live-Queries.md +++ b/wiki/Queries-And-Live-Queries.md @@ -60,6 +60,19 @@ Every projection is a method on the query — `liveFeed.tool()`, never `toQueryT | Cache entry | tags from `sql` | key includes actor tenant + policy scope; see [Caching and invalidation](Caching-And-Invalidation) | | MCP read tool | `input` + `policy` + name | one read tool per query, identical authz. See [MCP and AI](MCP-And-AI) | +### The HTTP GET, precisely + +Mounted for every registered query by `x dev` and by a container, from one composition — nothing to wire in the app. + +| Fact | Rule | +|---|---| +| Method + path | `GET /_x/query/`, the URL `.client()` derives with no server import | +| Input | the search string, coerced at the boundary (`t.number` from `"12"`) then validated by the query's own schema. Repeated keys are an array, and keys are sorted so one input is one URL | +| Bad input | **400** `X_INPUT_INVALID`, with `x queries describe --json` as the fix — the same code and line every other surface of that read answers | +| Authz | evaluated once, inside the read, from the parsed input. `auth: 'public'` only for `allow()` — anything else is `required`, and an anonymous caller is 401 before the policy is reached | +| Caching | `no-store`. The URL names no actor while the rows are scoped to one, so a shared cache is something a CDN in front of the app configures knowingly. The read's own `cache:` tags ride along for a purge | +| Failures | `application/problem+json` carrying the code, cause and fix. A non-framework throw is the server's 500, never dressed as a read failure | + ## Owns / never | Aspect | Rule | From f615cd5f5c429caf76f371c3f8a5b8ae7638b829 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 13 Aug 2026 14:14:59 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(realtime):=20liveHookFor=20=E2=80=94?= =?UTF-8?q?=20the=20typed=20client=20hook=20the=20wiki=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `liveHookFor(liveFeed)` binds one declared `query({ live: true })` to one named hook, so `useLiveFeed({ orgId })` is real: the query's input in, its row type out, a wrong key a compile error in the component - binds `useLive` rather than re-implementing it — one subscribe path, with the name and types already bound; `LiveQuerySource` names `Query` structurally so a hook stays browser code with no read path in the bundle - the query's name is read per call, never at bind time: a module-level binding runs at import, `registerQueries()` stamps the name at boot - new `X_QUERY_NOT_SUBSCRIBABLE` refuses a non-live query where the binding is written; `LiveRows` widens to `object` so a query's row type survives - type claims pinned in `type-pins.ts`, which `tsc` reads and a `.test.ts` cannot Co-Authored-By: Claude --- CHANGELOG.md | 13 ++ framework.manifest.json | 7 +- packages/realtime/CLAUDE.md | 11 ++ packages/realtime/README.md | 21 +++ packages/realtime/src/errors.test.ts | 7 +- packages/realtime/src/errors.ts | 19 ++ packages/realtime/src/hooks.ts | 6 +- packages/realtime/src/index.ts | 7 + packages/realtime/src/query-hook.test.ts | 221 +++++++++++++++++++++++ packages/realtime/src/query-hook.ts | 56 ++++++ packages/realtime/src/type-pins.ts | 57 ++++++ wiki/Error-Codes.md | 1 + wiki/Queries-And-Live-Queries.md | 28 ++- 13 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 packages/realtime/src/query-hook.test.ts create mode 100644 packages/realtime/src/query-hook.ts create mode 100644 packages/realtime/src/type-pins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5911db2..62df3b3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,19 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major `@ultimat3/cli`'s new `apiRoutes()` is what mounts it, and it is now the **one** composition of the app's HTTP API — both `x dev` and `serve.ts` mount it, so a read cannot answer in one and 404 in the other. `@ultimat3/query` gains a dependency on `@ultimat3/http` (tier 3 → tier 2, downward). +- **`useLiveFeed({ orgId })` exists. The typed client hook the wiki has always documented is a projection you can bind.** [Queries and live queries](https://github.com/developerz-ai/ultimate/wiki/Queries-And-Live-Queries) listed a typed client hook among a query's five projections and showed exactly that line; no `useLiveFeed`, `useQuery` or `createLiveQuery` existed in any package. What shipped was `useLive(query, input)` — untyped on both sides, because its `query` parameter is anything carrying a `name`, so neither the input nor the row type could come off the declaration. + + `liveHookFor` (`@ultimat3/realtime`) is the missing projection: one declared `query({ live: true })` bound to one named hook, in one line, with no generated file. + + ```ts + export const useLiveFeed = liveHookFor(liveFeed); // app/feed/hooks.ts + + const feed = useLiveFeed({ orgId: actor.orgId }); // feed()[0].title typechecks + useLiveFeed({ orgIdd: actor.orgId }); // does not compile + ``` + + It **binds** `useLive` rather than re-implementing it — one subscribe path, given the query's name and types, because two of those is two places a subscription can be opened wrong. `LiveQuerySource` names `Query`'s shape structurally instead of importing `@ultimat3/query` as a value: a hook is browser code, and a value import would carry the server's read path into the bundle. The name is read **per call**, never captured at bind time, because a module-level binding runs at import and `registerQueries()` stamps the name at boot — later. Binding a query with no `live: true` is the new `X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written rather than at the first render: a read that never patches has no subscription for a hook to hold, and the non-live read from a component is `query.client({ baseUrl })` over the route above. `LiveRows`'s row parameter widens from the wire's `Row` to `object` so a query's own row type survives; the four type claims are pinned in `packages/realtime/src/type-pins.ts`, which `tsc` checks and a `.test.ts` could not. + - **`X_INPUT_INVALID` is a 400 over HTTP, not a 500.** The code had no row in `error-map.ts`, so it took the 500 default on every surface that throws it — an action route and now a query read alike. A caller's typo'd uuid was answered as a server fault *and* reported to the error monitor by the `error-map` stage, which pages the on-call for someone else's mistake. 400 is also what the published OpenAPI operation has always promised for it. - **`LruCache.clear()` now resets `hits`/`misses`/`evictions` along with entries and bytes.** `stats()` after a `clear()` used to keep reporting whatever the cache had accumulated before the clear — a fresh cache with stale lifetime counters. `clear()` is a reset, and `stats()` now reads as one. diff --git a/framework.manifest.json b/framework.manifest.json index 421da2784..544216524 100644 --- a/framework.manifest.json +++ b/framework.manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "buildId": "86b874b65c2afb2c2dc5316afe31434c911c56aa3e0be7d4fd37d5aa57ca0159", + "buildId": "2958671cff28c4e656cf944e6f1f0f9a3cba5e037054302fe5484651e9c85d18", "tiers": { "0": [ "core", @@ -1150,6 +1150,11 @@ "owner": "query", "at": "packages/query/src/errors.ts" }, + { + "code": "X_QUERY_NOT_SUBSCRIBABLE", + "owner": "realtime", + "at": "packages/realtime/src/errors.ts" + }, { "code": "X_QUERY_POLICY_MISSING", "owner": "query", diff --git a/packages/realtime/CLAUDE.md b/packages/realtime/CLAUDE.md index 20a650655..e7508d4b1 100644 --- a/packages/realtime/CLAUDE.md +++ b/packages/realtime/CLAUDE.md @@ -59,6 +59,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t `@ultimat3/action` `Mutator` assigns with no cast — a function-typed property would not. - `useLive`'s thunk input is read once, at subscribe time. There is no reactive runtime here to re-run it, and pretending otherwise would be a silently stale subscription. +- `liveHookFor(query)` is the typed projection the wiki promises as `useLiveFeed({ orgId })`. It + **binds** `useLive` — it never re-implements a subscribe path, because two of those is two places + a subscription can be opened wrong. It names `Query`'s shape structurally (`LiveQuerySource`) + rather than importing `@ultimat3/query` as a value: a hook is browser code, and a value import + would pull the server's read path into the bundle. +- The query's name is read **per call**, never captured at bind time. `export const useLiveFeed = + liveHookFor(liveFeed)` runs at import; `registerQueries()` stamps the name later, at boot. +- Type claims about the hook go in `type-pins.ts`, never in a `.test.ts` — `tsconfig.json` excludes + test files, so `tsc -b` never reads one and an assertion written there can never fail. - Deny by default on topics. No guard = `X_TOPIC_FORBIDDEN`. - Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`clock.now()` is a `Date`; use `monotonic()` for durations). @@ -79,6 +88,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t | `local-store.ts` / `offline-queue.ts` / `rebase.ts` | tier 3 | | `client.ts` / `sync-node.ts` | the two halves | | `hooks.ts` | the ambient client seam + the four component hooks — the only file an app imports | +| `query-hook.ts` | the typed projection: one declared query bound to one named hook | +| `type-pins.ts` | compile-time assertions `tsc` checks — the hook's input type, its row type, the `Query` seam | | `policy-gate.ts` | the only authz seam | | `live-definition.ts` | the only bridge from a declared `query({ live: true })` to a registrable definition — and `policy-gate.ts`'s only caller | | `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam | diff --git a/packages/realtime/README.md b/packages/realtime/README.md index cea7b07f2..a5dce7fcc 100644 --- a/packages/realtime/README.md +++ b/packages/realtime/README.md @@ -58,6 +58,7 @@ frame handler is unchanged between rungs. | wire | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` | | halves | `LiveClient` (client), `createSyncNode` / `listenSyncNode` (`sync` role) | | hooks | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` | +| the typed projection | `liveHookFor` — one query bound to one named hook | ## The four hooks @@ -83,6 +84,26 @@ const queue = useMutationQueue(); // .pending .fai Tier 2 has no queue, so `pending` is `0` there — stated, not guessed. +### The typed one: `liveHookFor` + +`useLive(query, input)` takes any object carrying a `name`, so it cannot type either side. +`liveHookFor` binds one declared `query({ live: true })` to one named hook and carries both types +through — the query's `input` in, its row type out. It is not a second subscribe path: it *is* +`useLive`, with the name and the types already bound. + +```ts +export const useLiveFeed = liveHookFor(liveFeed); // app/feed/hooks.ts — one line, no codegen + +const feed = useLiveFeed({ orgId: actor.orgId }); // feed()[0].title typechecks +useLiveFeed({ orgIdd: actor.orgId }); // does not compile +``` + +The query's name is read **per call**, never at bind time: `registerQueries()` stamps it at boot, +after a module-level binding has already run. Binding a query with no `live: true` is +`X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written — a read that never patches has no +subscription to hold, and the non-live read from a component is `query.client({ baseUrl })`. +`type-pins.ts` fails the build if the hook ever widens either type. + Authz goes through `@ultimat3/query`'s `guard`, which is the only contact with `@ultimat3/policy`. One authz system, never two: `policy` is evaluated **once per subscriber**, never once per query. Two actors on one live query get two different result sets, and a row that leaves an actor's policy diff --git a/packages/realtime/src/errors.test.ts b/packages/realtime/src/errors.test.ts index c423c6861..6357388d5 100644 --- a/packages/realtime/src/errors.test.ts +++ b/packages/realtime/src/errors.test.ts @@ -29,7 +29,12 @@ const ORIGINAL_MEMBERS = [ ]; /** Codes added since the refactor. A shipped code is forever, so this list only ever grows. */ -const ADDED_SINCE = ['X_LIVE_CLIENT_MISSING', 'X_REPLICATOR_SLOT_HELD', 'X_LIVE_ROW_UNIDENTIFIED']; +const ADDED_SINCE = [ + 'X_LIVE_CLIENT_MISSING', + 'X_REPLICATOR_SLOT_HELD', + 'X_LIVE_ROW_UNIDENTIFIED', + 'X_QUERY_NOT_SUBSCRIBABLE', +]; /** Widened once: these lists are compared against plain strings, not against the literal union. */ const EVERY_CODE: readonly string[] = REALTIME_ERROR_CODES; diff --git a/packages/realtime/src/errors.ts b/packages/realtime/src/errors.ts index e275b8695..ae425d452 100644 --- a/packages/realtime/src/errors.ts +++ b/packages/realtime/src/errors.ts @@ -17,6 +17,7 @@ export const REALTIME_OWNED_ERROR_CODES = [ 'X_REPLICATOR_SLOT_HELD', 'X_LIVE_CLIENT_MISSING', 'X_LIVE_ROW_UNIDENTIFIED', + 'X_QUERY_NOT_SUBSCRIBABLE', ] as const; /** @@ -76,6 +77,7 @@ export const REALTIME_ERROR_TITLES: Readonly' : `"${args.name}"`} is not declared live: true, so it has no subscription for a hook to read`, + fix: 'add live: true to the query declaration, or read it once through query.client({ baseUrl }) — wiki/Queries-And-Live-Queries.md', + }); + } +} + /** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */ export class NotImplementedError extends RealtimeError { constructor(args: { what: string; fix: string }) { diff --git a/packages/realtime/src/hooks.ts b/packages/realtime/src/hooks.ts index 7b7af3c0c..fea720465 100644 --- a/packages/realtime/src/hooks.ts +++ b/packages/realtime/src/hooks.ts @@ -55,8 +55,12 @@ export type LiveInput = JsonValue | (() => JsonValue); /** * A callable result set: `feed()` are the rows, `feed.state()` / `feed.cursor()` / * `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it. + * + * `R` is only constrained to `object`: on the wire every row is a `Row`, but a hook bound to a + * declared query (`query-hook.ts`) answers in that query's own row type, which is whatever its + * `sql` returns. The three members hanging off the accessor are the same for every `R`. */ -export type LiveRows = (() => readonly R[]) & Omit, 'rows'>; +export type LiveRows = (() => readonly R[]) & Omit; /** * Subscribe to a live query. `query` is anything carrying a `name`, which a `@ultimat3/query` diff --git a/packages/realtime/src/index.ts b/packages/realtime/src/index.ts index 9d0d3f73b..69d4542b4 100644 --- a/packages/realtime/src/index.ts +++ b/packages/realtime/src/index.ts @@ -75,6 +75,7 @@ export { LiveRowUnidentifiedError, NotImplementedError, ProtocolVersionError, + QueryNotSubscribableError, REALTIME_ERROR_CODES, REALTIME_ERROR_TITLES, RealtimeError, @@ -232,6 +233,12 @@ export { PresenceRegistry, presenceFrame, } from './presence'; +/** The typed projection: one query bound to one named hook, `useLiveFeed({ orgId })`. */ +export { + type LiveQueryHook, + type LiveQuerySource, + liveHookFor, +} from './query-hook'; export { type ConflictStrategy, type CustomMerge, diff --git a/packages/realtime/src/query-hook.test.ts b/packages/realtime/src/query-hook.test.ts new file mode 100644 index 000000000..54bdab3aa --- /dev/null +++ b/packages/realtime/src/query-hook.test.ts @@ -0,0 +1,221 @@ +// The hook the docs promise: `useLiveFeed({ orgId })`, bound from a real declared +// `query({ live: true })` — so these cases go through `@ultimat3/query` rather than a stand-in. +// The binding's *type* claims live in `type-pins.ts`, not here: `tsconfig.json` excludes test +// files, so a type-level assertion written in this file could never fail. + +import { beforeEach, describe, expect, test } from 'bun:test'; +import { UltimateError } from '@ultimat3/core'; +import { from, type QueryPolicy, query, registerQuery, resetRegistry, t } from '@ultimat3/query'; +import { type ClientSocket, LiveClient, type SignalFactory } from './client'; +import { makeCursor } from './cursor'; +import { clearLiveClient, setLiveClient } from './hooks'; +import { liveHookFor } from './query-hook'; +import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol'; + +/** + * Deliberately *not* `Row`-shaped: a query's row type is whatever its `sql` returns, and the hook + * has to carry that one through. A row type constrained to the wire's `JsonObject` would make this + * declaration — an ordinary interface with no index signature — unbindable. + */ +interface PostRow { + readonly id: string; + readonly title: string; + readonly likes: number; +} + +const ROWS: readonly PostRow[] = [ + { id: 'p1', title: 'first', likes: 0 }, + { id: 'p2', title: 'second', likes: 3 }, +]; + +/** Structural, as everywhere else here: authz reaches `@ultimat3/policy` only through `guard`. */ +const anyone: QueryPolicy = { + kind: 'allow', + label: 'post:read', + permissions: [], + children: [], + run: () => ({ allowed: true }), +}; + +function declareFeed(options: { live: boolean }) { + return query({ + input: t.object({ orgId: t.string }), + policy: anyone, + ...(options.live ? { live: true } : {}), + sql: ({ orgId }) => + from('posts', async () => ROWS) + .where({ orgId }) + .orderBy('id') + .limit(50), + }); +} + +const signal: SignalFactory = (initial: T) => { + let value = initial; + return [ + () => value, + (next: T) => { + value = next; + }, + ]; +}; + +class FakeSocket implements ClientSocket { + readonly sent: string[] = []; + #open: (() => void) | null = null; + #message: ((data: string) => void) | null = null; + + send(data: string): void { + this.sent.push(data); + } + close(): void {} + onOpen(handler: () => void): void { + this.#open = handler; + } + onMessage(handler: (data: string) => void): void { + this.#message = handler; + } + onClose(): void {} + + open(): void { + this.#open?.(); + } + deliver(frame: Frame): void { + this.#message?.(encode(frame)); + } + frames(): readonly Frame[] { + return this.sent.map((data) => decode(data)); + } +} + +function connected(): { readonly socket: FakeSocket; readonly client: LiveClient } { + const socket = new FakeSocket(); + const client = new LiveClient({ signal, connect: () => socket, buildId: 'build-1' }); + client.connect(); + socket.open(); + setLiveClient(client); + return { socket, client }; +} + +/** The subscribe frame for a query, so a case reads the sid the client minted rather than one it picked. */ +function subscribeFrame(socket: FakeSocket): Extract | undefined { + for (const frame of socket.frames()) { + if (frame.type === 'subscribe' && frame.target.kind === 'query') return frame; + } + return undefined; +} + +/** Asserting on the code, not the message: the code is the part that is stable forever. */ +function codeOf(run: () => unknown): string { + try { + run(); + } catch (error) { + return error instanceof UltimateError ? error.code : `not an UltimateError: ${String(error)}`; + } + return 'nothing was thrown'; +} + +beforeEach(() => { + clearLiveClient(); + resetRegistry(); +}); + +describe('liveHookFor', () => { + test('binds one query to one named hook, and the hook subscribes under that name', () => { + const liveFeed = registerQuery('liveFeed', declareFeed({ live: true })); + const useLiveFeed = liveHookFor(liveFeed); + const { socket } = connected(); + + const feed = useLiveFeed({ orgId: 'o1' }); + + const frame = subscribeFrame(socket); + expect(frame?.op).toBe('add'); + expect(frame?.target).toMatchObject({ kind: 'query', qid: 'liveFeed', input: { orgId: 'o1' } }); + expect(feed()).toEqual([]); + }); + + test('reads the name at call time, so a binding written above registration still subscribes', () => { + // The order an app actually runs in: `export const useLiveFeed = liveHookFor(liveFeed)` is a + // module-level binding, and `registerQueries()` stamps the name later, at boot. + const declared = declareFeed({ live: true }); + const useLiveFeed = liveHookFor(declared); + expect(declared.name).toBe(''); + + registerQuery('liveFeed', declared); + const { socket } = connected(); + useLiveFeed({ orgId: 'o1' }); + + expect(subscribeFrame(socket)?.target).toMatchObject({ qid: 'liveFeed' }); + }); + + test('the snapshot lands on the accessor in the query’s own row type', () => { + const liveFeed = registerQuery('liveFeed', declareFeed({ live: true })); + const useLiveFeed = liveHookFor(liveFeed); + const { socket } = connected(); + const feed = useLiveFeed({ orgId: 'o1' }); + const sid = subscribeFrame(socket)?.sid ?? ''; + + socket.deliver({ + type: 'snapshot', + v: PROTOCOL_VERSION, + sid, + rows: [{ id: 'p1', title: 'first', likes: 0 }], + cursor: makeCursor('liveFeed', '1', [{ id: 'p1' }], 1_000), + }); + + // `title` typechecks because the row type came off the declaration — that is the assertion. + expect(feed()[0]?.title).toBe('first'); + expect(feed.state()).toBe('live'); + expect(feed.cursor()?.qid).toBe('liveFeed'); + }); + + test('a thunk input is read once, at subscribe time — never again', () => { + const liveFeed = registerQuery('liveFeed', declareFeed({ live: true })); + const useLiveFeed = liveHookFor(liveFeed); + connected(); + let reads = 0; + + const feed = useLiveFeed(() => { + reads += 1; + return { orgId: 'o1' }; + }); + feed(); + feed(); + + expect(reads).toBe(1); + }); + + test('unsubscribe is the caller’s, and it drops the subscription it opened', () => { + const liveFeed = registerQuery('liveFeed', declareFeed({ live: true })); + const { socket } = connected(); + + const feed = liveHookFor(liveFeed)({ orgId: 'o1' }); + const sid = subscribeFrame(socket)?.sid ?? ''; + feed.unsubscribe(); + + const dropped = socket + .frames() + .find((frame) => frame.type === 'subscribe' && frame.op === 'drop'); + expect(dropped?.type === 'subscribe' && dropped.sid).toBe(sid); + }); + + test('a query that is not live: true is refused where the binding is written', () => { + const plain = registerQuery('orgPosts', declareFeed({ live: false })); + expect(codeOf(() => liveHookFor(plain))).toBe('X_QUERY_NOT_SUBSCRIBABLE'); + }); + + test('the refusal names the query before registration has stamped one', () => { + try { + liveHookFor(declareFeed({ live: false })); + expect.unreachable('expected the binding to be refused'); + } catch (error) { + expect(error instanceof UltimateError && error.cause).toContain(''); + } + }); + + test('a bound hook called with no client registered is X_LIVE_CLIENT_MISSING, not a default', () => { + const liveFeed = registerQuery('liveFeed', declareFeed({ live: true })); + const useLiveFeed = liveHookFor(liveFeed); + expect(codeOf(() => useLiveFeed({ orgId: 'o1' }))).toBe('X_LIVE_CLIENT_MISSING'); + }); +}); diff --git a/packages/realtime/src/query-hook.ts b/packages/realtime/src/query-hook.ts new file mode 100644 index 000000000..764459474 --- /dev/null +++ b/packages/realtime/src/query-hook.ts @@ -0,0 +1,56 @@ +// The typed projection of a query into the hook a component calls: `liveHookFor(liveFeed)` is +// `useLiveFeed`, and `useLiveFeed({ orgId })` carries that query's own input and row types. It +// binds `useLive` rather than re-implementing it — one subscribe path, given the query's name. + +import { QueryNotSubscribableError } from './errors'; +import { type LiveInput, type LiveRows, useLive } from './hooks'; + +/** + * What the hook needs from a `@ultimat3/query` `Query`: the name it subscribes under, the declared + * `live:` flag, and the call signature both types are read off. Named structurally rather than + * imported, the way `hooks.ts` names a mutator — a hook is browser code, and a value import of + * `@ultimat3/query` would carry the server's read path into the bundle. + * + * `options` is `never` because this side never passes one; a `Query`, whose second parameter is + * optional and wider, still assigns. + */ +export interface LiveQuerySource { + (input: TInput, options?: never): Promise; + readonly name: string; + readonly isLive: boolean; +} + +/** + * The bound hook. Input is the query's own — a wrong key is a compile error in the component, not + * a subscription that returns nothing. A thunk is read **once**, at subscribe time, exactly as + * `useLive`'s is: there is no reactive runtime here to re-run it, so new input means a new + * subscription. + */ +export type LiveQueryHook = ( + input: TInput | (() => TInput), +) => LiveRows; + +/** + * Bind one live query to one hook: `export const useLiveFeed = liveHookFor(liveFeed)`, then + * `useLiveFeed({ orgId })` in a component. Nothing is generated and nothing is fetched by hand — + * the types come off the query declaration and the rows off its subscription. + * + * Binding a query that is not `live: true` throws here, at module load, rather than handing back a + * hook that could only ever return an empty set. + */ +export function liveHookFor( + query: LiveQuerySource, +): LiveQueryHook { + if (!query.isLive) throw new QueryNotSubscribableError({ name: query.name }); + // The query object is handed through, never its `name` read now: `registerQueries()` stamps that + // at boot, and this binding runs at import — earlier. `useLive` reads it per subscription. + return (input) => { + // Both assertions are the one wire seam: the input is about to be serialised into a subscribe + // frame, and the rows come back off that subscription. `unknown` in between rather than a + // direct cast, exactly as `query.client()` hops through it at the same seam. + const rows: unknown = useLive(query, input as LiveInput); + // Erased at the wire seam, the way `query.client()` erases its own: the rows arriving on this + // subscription are this query's by construction, because the server built them from its `sql`. + return rows as LiveRows; + }; +} diff --git a/packages/realtime/src/type-pins.ts b/packages/realtime/src/type-pins.ts new file mode 100644 index 000000000..138d2a413 --- /dev/null +++ b/packages/realtime/src/type-pins.ts @@ -0,0 +1,57 @@ +// Compile-time pins for the typed query hook. Source, not a `.test.ts`, on purpose: +// `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a +// type-level assertion written there can never fail. This module emits nothing and exports +// nothing anybody imports — a regression is a build error, the only enforcement that counts +// (axiom 3). What it protects is the whole reason `liveHookFor` exists: `useLiveFeed({ orgId })` +// carrying the query's own input and row types. Lose that and the hook still runs — it just +// stops catching the typo that makes a subscription match nothing. + +import type { Query } from '@ultimat3/query'; +import type { LiveQueryHook, LiveQuerySource } from './query-hook'; + +/** Fails to compile when `T` is anything but `true`. The whole mechanism. */ +type Assert = T; + +type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +/** The input type a `Query` accepts, read off its call signature rather than its schema. */ +type InputOf = Q extends (input: infer I, options?: never) => unknown ? I : never; + +interface FeedInput { + readonly orgId: string; +} + +interface FeedRow { + readonly id: string; + readonly title: string; +} + +type FeedHook = LiveQueryHook; + +/** The hook takes the query's own input, as a value or as the thunk `useLive` reads once. */ +export type _HookInputIsTheQueryInput = Assert< + Equals[0], FeedInput | (() => FeedInput)> +>; + +/** …and answers in the query's own row type, not the wire's `Row`. */ +export type _HookRowsAreTheQueryRows = Assert< + Equals>, readonly FeedRow[]> +>; + +/** + * A wrong key is refused. Written as a negative because that is the failure being pinned: a hook + * whose input widened to `JsonValue` would still compile everywhere and silently accept `orgIdd`. + */ +export type _WrongInputKeyIsRefused = Assert< + [{ readonly orgIdd: string }] extends [Parameters[0]] ? false : true +>; + +/** + * The seam itself: a declared `@ultimat3/query` `Query` assigns to the structural shape + * `liveHookFor` binds. Named structurally rather than imported as a value, so this stays the one + * place a change to `Query` — losing `isLive`, ceasing to be callable — fails, instead of every + * component call site in every app. + */ +export type _DeclaredQueryBindsToTheHook = Assert< + [Query] extends [LiveQuerySource, Record>] ? true : false +>; diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index f9bec8e0d..16ce1a6f6 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -223,6 +223,7 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | `X_REPLICATION_PROTOCOL` | the WAL stream cannot be decoded | a server or `pgoutput` version this build does not speak, or a proxy on the replication port | `x doctor db` — point the URL at postgres itself, on a server ≥ 14 | | `X_REPLICATOR_SLOT_HELD` | another replicator already owns this database | `replicator` scaled to more than one replica, or a rolling deploy started the new replicator before the old one exited — nothing is wrong with this process, the database already has its one replicator | scale the replicator to 1 per database: `kubectl scale deploy/replicator --replicas=1` | | `X_LIVE_CLIENT_MISSING` | a realtime hook ran with no `LiveClient` registered | `useLive` / `useConnection` / `useMutation` / `useMutationQueue` on a page whose entry never registered one | `setLiveClient(new LiveClient({ signal: createSignal, connect, buildId }))` in the app entry, above the first render | +| `X_QUERY_NOT_SUBSCRIBABLE` | a hook was bound to a query that is not declared live | `liveHookFor(orgPosts)` where `orgPosts` has no `live: true` — there is no subscription for the hook to read, so it could only ever answer an empty set | add `live: true` to the query declaration, or read it once through `query.client({ baseUrl })` | | `X_LIVE_ROW_UNIDENTIFIED` | a live query returned a row with no id | a `live: true` read whose projection selects columns but not the primary key — patches, cursors and the local store all address a row by `id` | select the primary key in that query's `sql()`, or drop `live: true` from it | ## Cache diff --git a/wiki/Queries-And-Live-Queries.md b/wiki/Queries-And-Live-Queries.md index b4d383722..97bebdb42 100644 --- a/wiki/Queries-And-Live-Queries.md +++ b/wiki/Queries-And-Live-Queries.md @@ -55,11 +55,37 @@ Every projection is a method on the query — `liveFeed.tool()`, never `toQueryT | Projection | Derived from | Shape | |---|---|---| | HTTP GET | name + `input` | `GET /_x/query/live-feed?orgId=…`, errors as `UltimateError` JSON | -| Typed client hook | `input` + `sql` return type | `const feed = useLiveFeed({ orgId })` in `app/` — no fetch, no codegen step | +| Typed client hook | `input` + `sql` return type | `const feed = useLiveFeed({ orgId })` in `app/` — no fetch, no codegen step. Bound once with `liveHookFor`, below | | Live subscription | `live: true` | WS frames `{qid, op, row, lsn}` patched into a Solid signal | | Cache entry | tags from `sql` | key includes actor tenant + policy scope; see [Caching and invalidation](Caching-And-Invalidation) | | MCP read tool | `input` + `policy` + name | one read tool per query, identical authz. See [MCP and AI](MCP-And-AI) | +### The typed client hook, precisely + +`liveHookFor` (`@ultimat3/realtime`) binds one live query to one named hook. The binding is a line in `app/`, not a generated file — the types come off the declaration, so a wrong input key is a compile error in the component. + +```ts +// app/feed/hooks.ts +import { liveHookFor } from '@ultimat3/realtime'; +import { liveFeed } from '../posts/live'; + +export const useLiveFeed = liveHookFor(liveFeed); +``` + +```tsx +const feed = useLiveFeed({ orgId: actor.orgId }); // feed(), feed.state(), feed.cursor(), feed.unsubscribe() +``` + +| Fact | Rule | +|---|---| +| Types | the query's own `input` and row type. `useLiveFeed({ orgIdd })` does not compile, and `feed()[0].titel` does not compile | +| Transport | the subscription, not a fetch — the hook *is* `useLive` with the query's name and types already bound. One subscribe path, never two | +| Input | a value or a thunk, read **once**, at subscribe time. There is no reactive runtime at tier 3 to re-run it; new input is a new subscription | +| Naming | read per call, never at bind time — `registerQueries()` stamps the name at boot, after the module-level binding has already run | +| Lifetime | the caller owns `unsubscribe()`. This layer does not know what a mount is | +| A query without `live: true` | `X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written. A non-live read from a component is `query.client({ baseUrl })` over the HTTP GET below — a read that never patches has no subscription for a hook to hold | +| No registered client | `X_LIVE_CLIENT_MISSING`. One `setLiveClient` per app, in the entry, above the first render | + ### The HTTP GET, precisely Mounted for every registered query by `x dev` and by a container, from one composition — nothing to wire in the app. From 1a96f355f5627adfa1386a4f2c1629320a0cd06e Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 13 Aug 2026 14:28:56 -0500 Subject: [PATCH 3/5] fix(core): one predicate decides MCP exposure everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isMcpExposed in @ultimat3/core is the single answer to "did this primitive opt into being an MCP tool?" — action, query, mcp, ai and manifest all ask it, and core is the only tier all five reach - describeAction's manifest fact (?? true) and toOpenApiOperation's x-ultimate.mcpTool (!== false) fail-opened, so an action with no mcp block was published as a tool by both contract artifacts and refused by every surface that could serve one — and a first honest expose: false then read as a withdrawn capability the gate calls breaking - diffManifest reads both sides through the predicate: a manifest parsed off disk with an absent or non-boolean expose is un-exposed, not a third state - @ultimat3/admin's own catalog keeps the opposite default on purpose (every tool already gated on an admin permission, CRUD tools carry no mcp block) and now says so in code, in core and in the wiki - packages/cli/src/mcp-exposure-pin.test.ts pins every reader to one answer; 6 of the new tests verified red across 4 mutations Co-Authored-By: Claude --- CHANGELOG.md | 14 +++ packages/action/CLAUDE.md | 6 + packages/action/src/action.ts | 7 +- packages/action/src/http.ts | 7 +- packages/action/src/mcp-tool.test.ts | 31 +++++- packages/action/src/mcp-tool.ts | 6 +- packages/admin/CLAUDE.md | 6 + packages/admin/src/mcp-tools.ts | 6 + packages/ai/CLAUDE.md | 4 +- packages/ai/src/tools.test.ts | 127 ++++++++++++++++++++++ packages/ai/src/tools.ts | 11 +- packages/cli/src/mcp-exposure-pin.test.ts | 87 +++++++++++++++ packages/core/CLAUDE.md | 9 ++ packages/core/src/index.ts | 2 + packages/core/src/mcp-exposure.test.ts | 34 ++++++ packages/core/src/mcp-exposure.ts | 32 ++++++ packages/manifest/CLAUDE.md | 4 + packages/manifest/src/diff.test.ts | 23 ++++ packages/manifest/src/diff.ts | 13 ++- packages/mcp/CLAUDE.md | 4 + packages/mcp/src/from-action.ts | 9 +- packages/mcp/src/projectable.ts | 9 +- packages/query/CLAUDE.md | 3 +- packages/query/src/mcp-tool.ts | 8 +- wiki/Actions.md | 2 +- wiki/Admin-Dashboard.md | 4 +- 26 files changed, 441 insertions(+), 27 deletions(-) create mode 100644 packages/ai/src/tools.test.ts create mode 100644 packages/cli/src/mcp-exposure-pin.test.ts create mode 100644 packages/core/src/mcp-exposure.test.ts create mode 100644 packages/core/src/mcp-exposure.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62df3b3ad..059e81eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -250,6 +250,20 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ### Fixed +- **MCP exposure has one answer, `isMcpExposed`, and the contract stops publishing tools nothing serves.** Six readers across five packages decided `mcp: { expose }` three ways: `=== true` where a tool is actually built (`@ultimat3/action`'s `toMcpTools`, `@ultimat3/query`'s, `@ultimat3/mcp`'s two projections), `!== false` in the OpenAPI operation's `x-ultimate.mcpTool`, and `?? true` in `describeAction`'s manifest fact. So an action with **no `mcp` block at all** was published as a tool by `x.manifest.json` and by `openapi.json`, and refused by every surface an agent could call — and the first honest `mcp: { expose: false }` an author wrote then read as a *withdrawn* capability, which `x verify`'s contract diff classifies as breaking and demands a major version bump for. + + `isMcpExposed(declared)` in `@ultimat3/core` is now the single predicate. Core owns it because the readers span tiers 3–5 and this is the only tier all of them reach — the same reason `timingSafeEqual` lives there. Opt-in is unchanged and unchanged everywhere: an absent block, an omitted `expose` and a literal `false` are one answer. + + ```jsonc + // an action declaring no mcp block, in openapi.json + "x-ultimate": { "mcpTool": "publish_post" } // before — a tool no catalog listed + "x-ultimate": { "mcpTool": null } // after + ``` + + `diffManifest` reads both sides through the same predicate, so a manifest parsed off disk whose `mcp.expose` is absent or non-boolean reads as un-exposed rather than as a third state. **Upgrading:** run `x manifest` once — an app whose actions never declared `mcp.expose` will see those facts flip `true → false`, which is the correction, not a withdrawal; re-commit the regenerated file before the next `x verify`. + + `@ultimat3/admin`'s own catalog keeps the opposite default on purpose and says so in code: every tool there is already gated on an admin permission and its CRUD tools carry no `mcp` block at all, so opt-in would list `admin.posts.delete` while hiding the action button beside it. That exception is the only one, and `packages/cli/src/mcp-exposure-pin.test.ts` pins every other reader to one answer. + - **`query.client()` now reaches a route. Reads are served over HTTP, in `x dev` and in a container alike.** `@ultimat3/query` derived `/_x/query/` in `naming.ts`, `client.ts` fetched exactly that URL, and nothing anywhere built or mounted a route for it — so every typed read compiled, shipped, and 404'd, while the README and the wiki documented the projection as shipped. `toQueryRoute(target)` in `packages/query/src/http.ts` is that projection, mirroring `@ultimat3/action`'s `toRoute`: diff --git a/packages/action/CLAUDE.md b/packages/action/CLAUDE.md index 37edb01bb..825011624 100644 --- a/packages/action/CLAUDE.md +++ b/packages/action/CLAUDE.md @@ -43,6 +43,12 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. drives a row-level action over the real pipeline and counts the evaluations: exactly one. - An action has no `.def`. Inside the package read it with `defOf(target)`; outside, read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`. +- **MCP exposure is read through `isMcpExposed` from `@ultimat3/core`, in all three places.** + `toMcpTools` builds the tool, `describeAction` publishes the manifest fact and + `toOpenApiOperation` publishes `x-ultimate.mcpTool` — the last two fail-opened (`?? true`, + `!== false`) until 2026-08, so an action with no `mcp` block was advertised as a tool by both + contract artifacts and refused by the only surface that could serve one. A contract that + disagrees with the runtime is worse than no contract; never spell the check inline again. - App code reaches a projection through the action (`publishPost.tool()`), never through `.def` and never by importing the projection function. `facade.ts` is where a new method is bound; the projection itself keeps living in its own file. diff --git a/packages/action/src/action.ts b/packages/action/src/action.ts index c1d81142d..0ec539f0f 100644 --- a/packages/action/src/action.ts +++ b/packages/action/src/action.ts @@ -6,6 +6,7 @@ import type { CacheTag } from '@ultimat3/cache'; import type { Actor, Ctx } from '@ultimat3/core'; +import { isMcpExposed } from '@ultimat3/core'; import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema'; import type { ClientMethod, ClientOptions } from './client'; import type { ContractTest, ContractTestOptions } from './contract-test'; @@ -286,7 +287,11 @@ export function describeAction(target: AnyAction): ActionDescriptor { invalidates: tagKeys(def.cache?.invalidates ?? []), idempotent: def.idempotent === true, mcp: { - expose: mcp?.expose ?? true, + // `isMcpExposed`, not `?? true`: this fact is what the manifest publishes and what the + // contract diff classifies, so it has to be the answer the tool projection actually gives. + // Fail-open here published every action as a tool no surface would ever serve, which made + // a first honest `expose: false` read as a withdrawn capability and demand a major bump. + expose: isMcpExposed(mcp), tool: toToolName(name), description: mcp?.description ?? null, }, diff --git a/packages/action/src/http.ts b/packages/action/src/http.ts index a3abb7355..242634873 100644 --- a/packages/action/src/http.ts +++ b/packages/action/src/http.ts @@ -5,7 +5,7 @@ * there is no way to mount an action without them. */ -import { isUltimateError } from '@ultimat3/core'; +import { isMcpExposed, isUltimateError } from '@ultimat3/core'; import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http'; import { json, problem, redirect, takeRedirect } from '@ultimat3/http'; import type { ActionRateLimit, AnyAction } from './action'; @@ -127,7 +127,10 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation { capability: policyCapability(def.policy), idempotent, invalidates: tagKeys(def.cache?.invalidates ?? []), - mcpTool: def.mcp?.expose === false ? null : toToolName(name), + // The tool name an agent would call, or `null` when there is no tool. `!== false` here + // advertised one for every action, so an agent reading the spec asked for a tool the MCP + // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. + mcpTool: isMcpExposed(def.mcp) ? toToolName(name) : null, rateLimit: rateLimitMeta(def.rateLimit), }, }; diff --git a/packages/action/src/mcp-tool.test.ts b/packages/action/src/mcp-tool.test.ts index 544d49dca..d90a4cdc4 100644 --- a/packages/action/src/mcp-tool.test.ts +++ b/packages/action/src/mcp-tool.test.ts @@ -3,8 +3,8 @@ import { createContext, runWithContext, userActor } from '@ultimat3/core'; import { createRequestContext, defineHttpConfig, UltimateRequest } from '@ultimat3/http'; import { can } from '@ultimat3/policy'; import { t } from '@ultimat3/schema'; -import { action } from './action'; -import { toRoute } from './http'; +import { action, describeAction } from './action'; +import { toOpenApiOperation, toRoute } from './http'; import { isExposed, toMcpTool, toMcpTools } from './mcp-tool'; const Input = t.object({ postId: t.uuid }); @@ -114,4 +114,31 @@ describe('exposure is opt-in', () => { 'publishPost', ]); }); + + // The two readers that fail-opened until 2026-08. Both are CONTRACT surfaces — the manifest + // fact `x verify` diffs, and the OpenAPI operation an agent reads — so publishing a tool the + // catalog refuses to serve is worse than not publishing one at all. + test('describeAction reports the exposure the tool projection actually gives', () => { + expect(describeAction(declaring()).mcp.expose).toBe(false); + expect(describeAction(declaring({ expose: false })).mcp.expose).toBe(false); + expect(describeAction(declaring({ expose: true })).mcp.expose).toBe(true); + }); + + test('the OpenAPI operation names a tool only when one exists', () => { + expect(toOpenApiOperation(declaring())['x-ultimate']['mcpTool']).toBe(null); + expect(toOpenApiOperation(declaring({ expose: false }))['x-ultimate']['mcpTool']).toBe(null); + expect(toOpenApiOperation(declaring({ expose: true }))['x-ultimate']['mcpTool']).toBe( + 'publish_post', + ); + }); + + test('every reader of one declaration agrees, whatever it says', () => { + for (const mcp of [undefined, { expose: false }, { expose: true }] as const) { + const target = declaring(mcp); + const exposed = isExposed(target); + expect(describeAction(target).mcp.expose).toBe(exposed); + expect(toOpenApiOperation(target)['x-ultimate']['mcpTool'] !== null).toBe(exposed); + expect(toMcpTools([target]).length > 0).toBe(exposed); + } + }); }); diff --git a/packages/action/src/mcp-tool.ts b/packages/action/src/mcp-tool.ts index 491ff4faf..cb6db1b8c 100644 --- a/packages/action/src/mcp-tool.ts +++ b/packages/action/src/mcp-tool.ts @@ -4,6 +4,7 @@ * endpoint and cannot acquire a second authz path. One authz system, never two. */ import type { Ctx } from '@ultimat3/core'; +import { isMcpExposed } from '@ultimat3/core'; import type { AnyAction } from './action'; import { actionName, defOf, invoke } from './invoke'; import { type JsonSchemaObject, mcpSchemaOf, sortSchema } from './json-schema'; @@ -56,10 +57,11 @@ export function toMcpTool(target: AnyAction): McpToolDescriptor { * It read `!== false` until 2026-08, which made writing an action silently hand every agent a * new write capability — and disagreed with `@ultimat3/mcp`'s `exposedPrimitives`, the projection * that actually builds a catalog. Two functions answering "is this a tool?" differently is the - * ambiguity axiom 1 rejects, so the fail-closed one wins. + * ambiguity axiom 1 rejects, so the fail-closed one wins — and `isMcpExposed` from + * `@ultimat3/core` is now the single answer every reader in the framework asks. */ export function isExposed(target: AnyAction): boolean { - return target.mcp?.expose === true; + return isMcpExposed(target.mcp); } /** Deterministic order — the tool list is part of the agent-visible contract. */ diff --git a/packages/admin/CLAUDE.md b/packages/admin/CLAUDE.md index f6376c245..62d99768d 100644 --- a/packages/admin/CLAUDE.md +++ b/packages/admin/CLAUDE.md @@ -12,6 +12,12 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev - **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value. - **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (tier 1, downward, and the only thing this package imports from it). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1. - **The timeline panel renders the detector's verdicts, it does not re-derive them.** `repeatedSql` (`dev/panel-timeline.ts`) is a measurement over the trace this panel recorded — every SQL text seen twice. `nPlusOne` is the verdict: `x dev`'s statement ledger, read through `DevSources.statementLoops()` and scoped to the selected request, in the ledger's own order. A second count here would be blind to the `expectedQueryLoop` above and to statement attribution (`members.findById`, not raw SQL text), and would disagree with the `fix:` an author actually pastes. No detector wired → `null`, never `[]`: "nobody counted" is not "this request was clean". +- **This catalog is the framework's one opt-OUT MCP surface, and that is deliberate.** + `mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every + tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at + all, so opting in would list `admin.posts.delete` while hiding the action button beside it. + `mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in + `wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug. - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever. - **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek. diff --git a/packages/admin/src/mcp-tools.ts b/packages/admin/src/mcp-tools.ts index 2cfbc0891..a134405c1 100644 --- a/packages/admin/src/mcp-tools.ts +++ b/packages/admin/src/mcp-tools.ts @@ -127,6 +127,11 @@ function toolGates(app: AdminApp): readonly ToolGate[] { out.push({ tool, gate: (ctx) => decideOperation(resource, op, ctx) }); } for (const action of resource.actions) { + // The one surface in the framework that does NOT call `isMcpExposed`, on purpose: every + // tool in this catalog is already gated on an admin permission, and the CRUD tools above + // carry no `mcp` block at all — so opt-in here would list `admin.posts.delete` and hide + // the button next to it. `expose: false` withdraws one. Stated in `wiki/Admin-Dashboard.md` + // and in core's `mcp-exposure.ts`; nothing else may grow a second default. if (action.mcp?.expose === false) continue; out.push({ tool: { @@ -161,6 +166,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] { }); for (const action of app.globalActions) { + // Same opt-out default as a resource action above, for the same reason. if (action.mcp?.expose === false) continue; out.push({ tool: { diff --git a/packages/ai/CLAUDE.md b/packages/ai/CLAUDE.md index 3bc8fb5c2..b3bf089f6 100644 --- a/packages/ai/CLAUDE.md +++ b/packages/ai/CLAUDE.md @@ -7,7 +7,9 @@ Declared today: `action` (the primitive `llm()` returns), `cache` (semantic cach `db` (pgvector), `money`, `policy`, `schema`, `time`. `mcp` is the same tier, so the LLM-tool projection is restated structurally in `tools.ts` -rather than imported. Same contract, two wire formats. +rather than imported. Same contract, two wire formats — and the same *decision*: `toLlmTools` and +`runLlmToolCall` ask `isMcpExposed` from `@ultimat3/core` (tier 0, reachable by both), never a +local `=== true`. An in-app agent and an external one must be offered exactly the same tools. ## Owns diff --git a/packages/ai/src/tools.test.ts b/packages/ai/src/tools.test.ts new file mode 100644 index 000000000..d2c25ab72 --- /dev/null +++ b/packages/ai/src/tools.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import { agentActor } from '@ultimat3/core'; +import type { ProjectableAction } from './tools'; +import { runLlmToolCall, toLlmTool, toLlmTools } from './tools'; + +const actor = agentActor({ id: 'agent-1' }); + +const projectable = ( + name: string, + mcp?: ProjectableAction['mcp'], + run?: ProjectableAction['run'], +): ProjectableAction => ({ + name, + ...(mcp === undefined ? {} : { mcp }), + inputJsonSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + run: run ?? (async ({ input }) => ({ ran: name, input })), +}); + +describe('toLlmTool', () => { + test('the mcp description wins, then the primitive description, then a derived line', () => { + expect(toLlmTool(projectable('publishPost', { expose: true, description: 'Publish' }))).toEqual( + { + name: 'publishPost', + description: 'Publish', + input_schema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + strict: true, + }, + ); + expect(toLlmTool({ ...projectable('publishPost'), description: 'From the primitive' })).toEqual( + expect.objectContaining({ description: 'From the primitive' }), + ); + expect(toLlmTool(projectable('publishPost')).description).toBe('Run the "publishPost" action.'); + }); + + test('an action with no input schema still projects a callable, empty object schema', () => { + const tool = toLlmTool({ name: 'ping', run: async () => 'pong' }); + expect(tool.input_schema).toEqual({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + expect(tool.strict).toBe(true); + }); +}); + +// The gateway offers the same tools MCP does, because both ask `isMcpExposed`. A second, looser +// rule here would hand an in-app agent a capability the external one is refused. +describe('exposure is opt-in, the same opt-in MCP reads', () => { + test('only a literal expose: true reaches the model, in stable name order', () => { + const tools = toLlmTools([ + projectable('publishPost', { expose: true }), + projectable('deleteOrg'), + projectable('archivePost', { expose: false }), + projectable('inviteMember', { expose: true }), + ]); + expect(tools.map((tool) => tool.name)).toEqual(['inviteMember', 'publishPost']); + }); + + test('an un-exposed action is unknown to a tool call, even by exact name', async () => { + let ran = false; + const actions = [ + projectable('deleteOrg', undefined, async () => { + ran = true; + return 'gone'; + }), + ]; + const result = await runLlmToolCall( + actions, + { id: 'call-1', name: 'deleteOrg', input: { id: 'o1' } }, + actor, + ); + expect(ran).toBe(false); + expect(result).toEqual({ + toolUseId: 'call-1', + content: 'unknown tool: deleteOrg', + isError: true, + }); + }); + + test('an exposed action runs and its output comes back as JSON', async () => { + const result = await runLlmToolCall( + [projectable('publishPost', { expose: true })], + { id: 'call-2', name: 'publishPost', input: { id: 'p1' } }, + actor, + ); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content)).toEqual({ ran: 'publishPost', input: { id: 'p1' } }); + }); +}); + +describe('runLlmToolCall renders a failure the model can act on', () => { + test('a framework error keeps its code, cause and fix', async () => { + const denied = projectable('publishPost', { expose: true }, async () => { + throw Object.assign(new Error('denied'), { + code: 'X_FORBIDDEN', + cause: 'post:publish is not held', + fix: 'grant post:publish', + }); + }); + const result = await runLlmToolCall( + [denied], + { id: 'call-3', name: 'publishPost', input: { id: 'p1' } }, + actor, + ); + expect(result).toEqual({ + toolUseId: 'call-3', + content: 'X_FORBIDDEN: post:publish is not held (fix: grant post:publish)', + isError: true, + }); + }); + + test('a foreign throw is reported without inventing a code', async () => { + const boom = projectable('publishPost', { expose: true }, async () => { + throw new Error('kaboom'); + }); + const result = await runLlmToolCall( + [boom], + { id: 'call-4', name: 'publishPost', input: { id: 'p1' } }, + actor, + ); + expect(result).toEqual({ toolUseId: 'call-4', content: 'tool failed', isError: true }); + }); +}); diff --git a/packages/ai/src/tools.ts b/packages/ai/src/tools.ts index bd28ab9e7..16eb63472 100644 --- a/packages/ai/src/tools.ts +++ b/packages/ai/src/tools.ts @@ -10,6 +10,7 @@ // boundary error. Both packages describe the same structural contract. import type { Actor } from '@ultimat3/core'; +import { isMcpExposed } from '@ultimat3/core'; /** The JSON Schema subset the framework emits for tool arguments. */ export interface JsonSchema { @@ -76,10 +77,14 @@ export function toLlmTool(action: ProjectableAction): LlmTool { }; } -/** Every exposed action as a tool definition, in stable name order. */ +/** + * Every exposed action as a tool definition, in stable name order. The gateway and MCP ask + * `isMcpExposed` — @ultimat3/core's one predicate — so an in-app agent and an external one are + * offered exactly the same tools. + */ export function toLlmTools(actions: readonly ProjectableAction[]): readonly LlmTool[] { return actions - .filter((a) => a.mcp?.expose === true) + .filter((a) => isMcpExposed(a.mcp)) .map(toLlmTool) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } @@ -93,7 +98,7 @@ export async function runLlmToolCall( call: LlmToolCall, actor: Actor, ): Promise { - const action = actions.find((a) => a.name === call.name && a.mcp?.expose === true); + const action = actions.find((a) => a.name === call.name && isMcpExposed(a.mcp)); if (action === undefined) { return { toolUseId: call.id, content: `unknown tool: ${call.name}`, isError: true }; } diff --git a/packages/cli/src/mcp-exposure-pin.test.ts b/packages/cli/src/mcp-exposure-pin.test.ts new file mode 100644 index 000000000..d596f929d --- /dev/null +++ b/packages/cli/src/mcp-exposure-pin.test.ts @@ -0,0 +1,87 @@ +// One declaration, six readers, one answer. `mcp: { expose }` is read by `@ultimat3/action` +// (the tool, the manifest fact, the OpenAPI hint), `@ultimat3/query`, `@ultimat3/mcp` and +// `@ultimat3/ai` — packages on three tiers that cannot import each other, so no one of them can +// prove the others agree. `@ultimat3/cli` is tier 5 and may import all of them, which is why the +// pin lives here, exactly like `schema-error-codes-pin.test.ts`. +// +// Until 2026-08 they did NOT agree: two readers fail-opened (`?? true`, `!== false`) and four +// fail-closed, so an action with no `mcp` block was published as a tool by the contract and +// refused by every surface that could serve one. `isMcpExposed` in `@ultimat3/core` is now the +// single predicate; this file is what makes "single" checkable. + +import { describe, expect, test } from 'bun:test'; +import { action, describeAction, toMcpTools, toOpenApiOperation } from '@ultimat3/action'; +import { toLlmTools } from '@ultimat3/ai'; +import { isMcpExposed } from '@ultimat3/core'; +import { asProjectable, isExposed as isMcpToolExposed, toolsFrom } from '@ultimat3/mcp'; +import { can } from '@ultimat3/policy'; +import { from, query, toQueryTools } from '@ultimat3/query'; +import { t } from '@ultimat3/schema'; + +const Input = t.object({ id: t.uuid }); +const Output = t.object({ ok: t.boolean }); + +/** Every declaration an author can write, including the one they can only write by silence. */ +const DECLARATIONS = [ + { label: 'no mcp block', mcp: undefined, exposed: false }, + { label: 'expose: false', mcp: { expose: false }, exposed: false }, + { label: 'expose: true', mcp: { expose: true }, exposed: true }, +] as const; + +const actionDeclaring = (mcp?: { readonly expose: boolean }) => + action({ + input: Input, + output: Output, + policy: can('post:publish'), + ...(mcp === undefined ? {} : { mcp }), + handle: () => ({ ok: true }), + }).named('publishPost'); + +const queryDeclaring = (mcp?: { readonly expose: boolean }) => + query({ + input: Input, + policy: can('feed:read'), + ...(mcp === undefined ? {} : { mcp }), + sql: ({ id }) => from<{ id: string }>('posts', [{ id }]).where({ id }).orderBy('id'), + }).named('orgFeed'); + +describe('one predicate decides MCP exposure', () => { + for (const { label, mcp, exposed } of DECLARATIONS) { + test(`an action declaring ${label} is exposed=${exposed} on every surface`, () => { + const target = actionDeclaring(mcp); + expect(isMcpExposed(mcp)).toBe(exposed); + + // @ultimat3/action — the tool, the manifest fact, the OpenAPI operation. + expect(toMcpTools([target]).length > 0).toBe(exposed); + expect(describeAction(target).mcp.expose).toBe(exposed); + expect(toOpenApiOperation(target)['x-ultimate']['mcpTool'] !== null).toBe(exposed); + + // @ultimat3/mcp — the projection an app's own MCP surface serves. + const primitive = asProjectable(target); + expect(isMcpToolExposed(primitive)).toBe(exposed); + expect(toolsFrom([primitive]).length > 0).toBe(exposed); + + // @ultimat3/ai — the same catalog in the Anthropic tool wire format. + expect( + toLlmTools([ + { name: 'publishPost', ...(mcp === undefined ? {} : { mcp }), run: primitive.run }, + ]).length > 0, + ).toBe(exposed); + }); + + test(`a query declaring ${label} is exposed=${exposed} on every surface`, () => { + const target = queryDeclaring(mcp); + expect(toQueryTools([target]).length > 0).toBe(exposed); + const primitive = asProjectable(target); + expect(isMcpToolExposed(primitive)).toBe(exposed); + expect(toolsFrom([primitive]).length > 0).toBe(exposed); + }); + } + + test('an action and a query saying the same thing get the same answer', () => { + for (const { mcp, exposed } of DECLARATIONS) { + expect(toMcpTools([actionDeclaring(mcp)]).length > 0).toBe(exposed); + expect(toQueryTools([queryDeclaring(mcp)]).length > 0).toBe(exposed); + } + }); +}); diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 95e91d055..35e6ac645 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -66,6 +66,15 @@ the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may l `@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives here rather than in either package copying the other's file. +`mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is +the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query` +(t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the +only tier all of them reach. Three spellings of the same question shipped before it (`=== true`, +`!== false`, `?? true`), which published tools in `openapi.json` and `x.manifest.json` that no +surface would serve. Never add a second reader: `@ultimat3/cli`'s `mcp-exposure-pin.test.ts` is +what makes "one predicate" checkable, since no single package below tier 5 can. The one deliberate +exception is `@ultimat3/admin`'s own catalog, which is opt-OUT and says why in `mcp-tools.ts`. + Metrics mirror tracing exactly — `metrics.ts` is to `telemetry.ts` what a counter is to a span: always on, no-op exporter by default, driver on the wire. `runtime-metrics.ts` is the only place that names a series the deploy chart reads (`http_requests_total`, `connections`, `queue_depth`); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 863044982..5a2827ecd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -272,6 +272,8 @@ export { redactKeys, setLoggerContextFields, } from './logger'; +export type { McpExposureDeclaration } from './mcp-exposure'; +export { isMcpExposed } from './mcp-exposure'; export type { Counter, Gauge, diff --git a/packages/core/src/mcp-exposure.test.ts b/packages/core/src/mcp-exposure.test.ts new file mode 100644 index 000000000..49bd5313a --- /dev/null +++ b/packages/core/src/mcp-exposure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { isMcpExposed } from './mcp-exposure'; + +describe('isMcpExposed', () => { + test('a literal true is the only opt-in', () => { + expect(isMcpExposed({ expose: true })).toBe(true); + }); + + test('an absent block exposes nothing', () => { + expect(isMcpExposed(undefined)).toBe(false); + }); + + test('a declared block with no expose exposes nothing', () => { + expect(isMcpExposed({})).toBe(false); + }); + + test('an explicit false exposes nothing', () => { + expect(isMcpExposed({ expose: false })).toBe(false); + }); + + test('a truthy non-boolean is not an opt-in', () => { + // JSON reaches this from a manifest file, where nothing checked the type first: `1` or + // `'true'` must read as "the author did not say true", never as consent. + const parsed = JSON.parse('{"expose":1}') as { expose?: boolean }; + expect(isMcpExposed(parsed)).toBe(false); + const asString = JSON.parse('{"expose":"true"}') as { expose?: boolean }; + expect(isMcpExposed(asString)).toBe(false); + }); + + test('reads a richer declaration structurally', () => { + const actionMcp = { expose: true, description: 'Publish a draft post', visibleTo: ['admin'] }; + expect(isMcpExposed(actionMcp)).toBe(true); + }); +}); diff --git a/packages/core/src/mcp-exposure.ts b/packages/core/src/mcp-exposure.ts new file mode 100644 index 000000000..f1520123f --- /dev/null +++ b/packages/core/src/mcp-exposure.ts @@ -0,0 +1,32 @@ +// The one answer to "did this primitive opt into being an MCP tool?" — a literal `expose: true`. +// +// Six readers across five packages decided it three ways until 2026-08: `=== true` where a tool is +// actually built, `!== false` in the OpenAPI hint and `?? true` in the manifest fact. So an action +// with no `mcp` block was published as a tool by the contract and refused by every surface that +// could have called one. Core owns the predicate because those readers span tiers 3-5 — `action`, +// `query`, `mcp`, `ai`, `manifest` — and this is the only tier all of them reach, the same reason +// `timing-safe-equal.ts` lives here. + +/** + * The `mcp` block, read structurally. Each package keeps its own richer declaration — + * `ActionMcp` carries `visibleTo`, `@ultimat3/mcp`'s `McpExposure` carries `name` — and hands it + * here; restating the one field they share binds this to none of them. + */ +export interface McpExposureDeclaration { + readonly expose?: boolean | undefined; +} + +/** + * Opt-in, never opt-out: silence exposes nothing. An absent block, an omitted `expose` and a + * literal `false` are one answer, because a tool the author never asked for is a capability + * handed to every agent that can reach the surface — and writing an action is not a request to + * hand one out. + * + * The one deliberate exception is `@ultimat3/admin`'s OWN catalog, whose every tool is already + * gated on an admin permission and whose CRUD tools carry no `mcp` block at all; there + * `expose: false` withdraws a tool. That surface says so in `mcp-tools.ts` and in + * `wiki/Admin-Dashboard.md`. Nothing else may grow a second default. + */ +export function isMcpExposed(declared: McpExposureDeclaration | undefined): boolean { + return declared?.expose === true; +} diff --git a/packages/manifest/CLAUDE.md b/packages/manifest/CLAUDE.md index 74e4f13ba..7d96b3329 100644 --- a/packages/manifest/CLAUDE.md +++ b/packages/manifest/CLAUDE.md @@ -29,6 +29,10 @@ by the CLI, not imported. - `agents-md.ts` **never writes**. Generated prose lowers agent task success; facts go in `x.manifest.json` and conventions stay human-authored. - A new manifest field ⇒ bump `MANIFEST_VERSION` and add a `diff.ts` rule for it. +- `diff.ts` reads `mcp.expose` through `isMcpExposed` from `@ultimat3/core`, on **both** sides. + `before` is a file parsed off disk, so an older or hand-trimmed manifest can carry an absent or + non-boolean value that `!==` would classify from; and the fact `sources.ts` publishes has to be + the answer `toMcpTools` gives, or the gate demands a major bump for a tool that never existed. ## Commands diff --git a/packages/manifest/src/diff.test.ts b/packages/manifest/src/diff.test.ts index b99840c2b..5152cfb66 100644 --- a/packages/manifest/src/diff.test.ts +++ b/packages/manifest/src/diff.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import type { ManifestSources } from './build'; import { buildManifest } from './build'; import { diffManifest } from './diff'; +import type { Manifest } from './schema'; import { verifyContract } from './verify'; const action = (name: string, policy: string, expose = true) => ({ @@ -94,6 +95,28 @@ describe('classification', () => { ); }); + // `before` is a file read off disk, so nothing checked its types first. An older or + // hand-trimmed manifest whose `expose` is absent must read as "not exposed" — the same answer + // `isMcpExposed` gives every other surface — rather than as a third state `!==` calls a change. + test('a manifest that omits mcp.expose reads as un-exposed, not as a change', () => { + const unexposed = withActions([ + action('publishPost', 'post:publish', false), + action('archivePost', 'post:archive', false), + ]); + const parsed = JSON.parse(JSON.stringify(unexposed)) as Manifest; + for (const fact of parsed.actions) { + delete (fact.mcp as { expose?: boolean }).expose; + } + + expect(diffManifest(parsed, unexposed).changes.map((c) => c.path)).not.toContain( + 'actions.publishPost.mcp.expose', + ); + // And opting in from that same file is still the additive change it really is. + expect(diffManifest(parsed, buildManifest(base)).additive.map((c) => c.path)).toContain( + 'actions.publishPost.mcp.expose', + ); + }); + test('a nullable column added is additive; a NOT NULL column added is breaking', () => { const before = buildManifest(base); const columns = base.entities?.[0]?.columns ?? []; diff --git a/packages/manifest/src/diff.ts b/packages/manifest/src/diff.ts index ba0b6fc88..5c737c04c 100644 --- a/packages/manifest/src/diff.ts +++ b/packages/manifest/src/diff.ts @@ -9,6 +9,7 @@ // `x verify` fails on a breaking change without a major version bump. Additive and internal // changes never fail, which is what makes the gate credible enough to leave on. +import { isMcpExposed } from '@ultimat3/core'; import { canonical } from './build'; import type { ActionFact, JobFact, Manifest, QueryFact, RouteFact } from './schema'; @@ -91,12 +92,18 @@ function diffActions( detail: `policy ${action.policy ?? 'none'} -> ${next.policy ?? 'none'}`, }); } - if (action.mcp.expose !== next.mcp.expose) { + // Through `isMcpExposed`, not the raw field: `before` is a file parsed from disk, so an + // older or hand-trimmed manifest can carry an absent, `null` or non-boolean `expose` that + // `!==` would read as a change and classify from. One predicate, the same one the tool + // projection asks, is what makes this verdict match what the surface actually serves. + const exposed = isMcpExposed(action.mcp); + const nextExposed = isMcpExposed(next.mcp); + if (exposed !== nextExposed) { // Widening the surface is additive; withdrawing a tool an agent depends on is not. changes.push({ - kind: next.mcp.expose ? 'additive' : 'breaking', + kind: nextExposed ? 'additive' : 'breaking', path: `${path}.mcp.expose`, - detail: `mcp exposure ${String(action.mcp.expose)} -> ${String(next.mcp.expose)}`, + detail: `mcp exposure ${String(exposed)} -> ${String(nextExposed)}`, }); } if (canonical(action.cacheInvalidates) !== canonical(next.cacheInvalidates)) { diff --git a/packages/mcp/CLAUDE.md b/packages/mcp/CLAUDE.md index 532edadd0..e38767bbc 100644 --- a/packages/mcp/CLAUDE.md +++ b/packages/mcp/CLAUDE.md @@ -59,6 +59,10 @@ import. The CLI wires it. actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never weaken them. A gate can only refuse what a declaration can reach, so a new gate needs a test in BOTH: the registry half passes while the declaration surface silently drops the field. +- **`isExposed` and `exposureOf` both delegate to `isMcpExposed` in `@ultimat3/core`.** That is the + framework's one answer to "did this opt in?", shared with `action`, `query`, `ai` and `manifest` + — five packages that cannot import each other, which is how three spellings of the check shipped + and why the pin lives in `@ultimat3/cli`. Never spell `=== true` inline here again. - Exposure is declared at the primitive, never in `defineAppMcp`. A primitive NAMED in `actions:`/`queries:` without `mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot — a written-out list is a request, so filtering it would ship a catalog missing a tool its diff --git a/packages/mcp/src/from-action.ts b/packages/mcp/src/from-action.ts index 7ea570c18..ee596c5e2 100644 --- a/packages/mcp/src/from-action.ts +++ b/packages/mcp/src/from-action.ts @@ -13,6 +13,7 @@ // JSON Schema); this file owns the execution half. import type { Actor } from '@ultimat3/core'; +import { isMcpExposed } from '@ultimat3/core'; import { McpToolUndeclaredError } from './errors'; import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry'; import { jsonResult } from './registry'; @@ -56,9 +57,13 @@ export interface ProjectablePrimitive { run(args: { input: unknown; actor: Actor }): Promise; } -/** True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing. */ +/** + * True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing. + * `isMcpExposed` from `@ultimat3/core` is the one predicate — the manifest fact, the OpenAPI + * hint and both tool projections all ask it, so none of them can answer differently. + */ export function isExposed(primitive: ProjectablePrimitive): boolean { - return primitive.mcp?.expose === true; + return isMcpExposed(primitive.mcp); } /** diff --git a/packages/mcp/src/projectable.ts b/packages/mcp/src/projectable.ts index 97670f1ac..699d111b1 100644 --- a/packages/mcp/src/projectable.ts +++ b/packages/mcp/src/projectable.ts @@ -5,7 +5,7 @@ import type { AnyAction } from '@ultimat3/action'; import { actionName, invoke, isAction } from '@ultimat3/action'; -import { withChildContext } from '@ultimat3/core'; +import { isMcpExposed, withChildContext } from '@ultimat3/core'; import type { AnyQuery } from '@ultimat3/query'; import { isQuery, queryName, sourceFor } from '@ultimat3/query'; import type { McpExposure, ProjectablePrimitive } from './from-action'; @@ -73,8 +73,9 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive { /** * An action and a query declare MCP exposure with the same fields, so one typed path reads - * both. Narrow on purpose: only a literal `expose: true` counts, so nothing is exposed by - * accident — an undeclared `mcp` block yields no exposure at all. + * both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal + * `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields + * no exposure at all. * * `visibleTo` travels with it, and must: it is OUTCOME 1's only declaration surface for a * projected primitive. Dropping it here — which this function did until 2026-08 — left @@ -85,7 +86,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive { function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined { if (declared === undefined) return undefined; return { - expose: declared.expose === true, + expose: isMcpExposed(declared), ...(declared.description === undefined ? {} : { description: declared.description }), ...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }), }; diff --git a/packages/query/CLAUDE.md b/packages/query/CLAUDE.md index c0b318ee6..7933b2169 100644 --- a/packages/query/CLAUDE.md +++ b/packages/query/CLAUDE.md @@ -46,7 +46,8 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. - `isLive` is the declared boolean, `live()` is the subscription. Never name one after the other. `QueryDescriptor.live` keeps its name — `@ultimat3/manifest` and `@ultimat3/admin` read it. - `mcp` is opt-in (`expose: true`), exactly as it is for an action: rows reach an agent only when - the author said so. + the author said so. `isExposed` here delegates to `isMcpExposed` in `@ultimat3/core` — the one + predicate every reader in the framework asks — rather than spelling `=== true` a second time. - `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action` is the same tier, so its naming is ported here, never imported. - **`toQueryRoute` is the other half of `client()`, and the two derive the same URL from the same diff --git a/packages/query/src/mcp-tool.ts b/packages/query/src/mcp-tool.ts index 570b8c4bd..bd039f241 100644 --- a/packages/query/src/mcp-tool.ts +++ b/packages/query/src/mcp-tool.ts @@ -5,6 +5,7 @@ */ import type { Actor, Ctx } from '@ultimat3/core'; +import { isMcpExposed } from '@ultimat3/core'; import type { JsonSchema } from '@ultimat3/schema'; import { toMcpInputSchema } from '@ultimat3/schema'; import { toToolName } from './naming'; @@ -57,11 +58,12 @@ export function toQueryTool(target: AnyQuery): QueryToolDescriptor { } /** - * Opt-in, unlike an action's tool: a read hands rows to an agent, so silence - * exposes nothing. `mcp: { expose: true }` is the whole opt-in. + * Opt-in: a read hands rows to an agent, so silence exposes nothing. `mcp: { expose: true }` is + * the whole opt-in — `isMcpExposed` from `@ultimat3/core` is the framework's one answer, and an + * action's tool is now decided by the same call rather than by a second, looser rule. */ export function isExposed(target: AnyQuery): boolean { - return target.mcp?.expose === true; + return isMcpExposed(target.mcp); } /** Deterministic order — the tool list is part of the agent-visible contract. */ diff --git a/wiki/Actions.md b/wiki/Actions.md index acbb1c558..3f4ff97a9 100644 --- a/wiki/Actions.md +++ b/wiki/Actions.md @@ -27,7 +27,7 @@ Declared in `api/` or a feature's `actions.ts`. Named export, never default. The | `output` | Standard Schema | yes | the response contract; drives the typed client return type and the OpenAPI response | | `policy` | `Policy` from `can(...)` | yes | the one authz decision, evaluated on every surface. Omitting it is a build error | | `cache.invalidates` | `readonly CacheTag[]` | no | tags dropped from every cache tier after `handle` settles; unknown tag = compile error | -| `mcp.expose` | `boolean` | no (opt-in) | only a literal `true` makes the action a tool; silence exposes nothing. Listing an un-exposed action in `defineAppMcp` is `X_MCP_TOOL_UNDECLARED` at boot | +| `mcp.expose` | `boolean` | no (opt-in) | only a literal `true` makes the action a tool; silence exposes nothing. One predicate answers it everywhere — the tool, the LLM tool list, `describe().mcp.expose`, and `openapi.json`'s `x-ultimate.mcpTool` (which is `null` when there is no tool). Listing an un-exposed action in `defineAppMcp` is `X_MCP_TOOL_UNDECLARED` at boot | | `mcp.description` | `string` | no | the tool description an agent reads, and the OpenAPI `summary`. Contract text, so it stays outside `t()` — `openapi.json` must not depend on a locale. Write it for a stranger | | `mcp.visibleTo` | `readonly string[]` | no | roles that may see the projected tool; a caller whose role is not named gets ToolNotFound, never Forbidden — the policy still decides every call | | `rateLimit` | `{ limit: number; windowMs: number }` | no | per-actor limit enforced at the HTTP and MCP edges | diff --git a/wiki/Admin-Dashboard.md b/wiki/Admin-Dashboard.md index 02d990338..40bb01a89 100644 --- a/wiki/Admin-Dashboard.md +++ b/wiki/Admin-Dashboard.md @@ -63,7 +63,7 @@ mcp: { expose: true, description: 'Publish a draft post' }, | Tool list is generated | adding a feature adds a capability; deleting one removes it | | Projected tools declare no MCP scope | adding one would be a second gate in front of the only gate that matters | | Ships on | day-one agent access to operations, not a v3 roadmap item | -| Exposure | two surfaces, two defaults. Inside the admin's own catalog a registered action becomes a tool unless it sets `mcp: { expose: false }`. Projecting an `action` into an **app** MCP surface with `defineAppMcp` is opt-in: only `mcp: { expose: true }` makes a tool, and naming an un-exposed primitive there is `X_MCP_TOOL_UNDECLARED` at boot ([Actions](Actions)) | +| Exposure | two surfaces, two defaults — and this catalog is the **only** exception in the framework. Inside the admin's own catalog a registered action becomes a tool unless it sets `mcp: { expose: false }`, because every tool here is already gated on an admin permission and the CRUD tools carry no `mcp` block at all. Everywhere else — the app MCP surface (`defineAppMcp`), the LLM tool list, `openapi.json`'s `x-ultimate.mcpTool` and `x.manifest.json` — one predicate decides, and it is opt-in: only `mcp: { expose: true }` makes a tool, and naming an un-exposed primitive in `defineAppMcp` is `X_MCP_TOOL_UNDECLARED` at boot ([Actions](Actions)) | 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). @@ -126,7 +126,7 @@ See [Deployment](Deployment). - Read-only SQL, capped. No write console. - Keyset pagination only. - Every write emits an audit diff with `sensitive` fields redacted. -- MCP runs with the human's own permissions. Projecting an `action` into an app MCP surface is opt-in — `mcp: { expose: true }`; inside the admin's own catalog a registered action opts out with `mcp: { expose: false }`. +- MCP runs with the human's own permissions. Projecting an `action` into an app MCP surface is opt-in — `mcp: { expose: true }`, the one rule every other surface reads too; inside the admin's own catalog, and nowhere else, a registered action opts *out* with `mcp: { expose: false }`. - Extend by adding routes to `apps/admin/`, never by forking `@ultimat3/admin`. Source: [`packages/admin/src`](https://github.com/developerz-ai/ultimate/blob/main/packages/admin/src) From 447e73e95a1ce7722adc706df563bc9f146ba4a1 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 13 Aug 2026 14:38:08 -0500 Subject: [PATCH 4/5] test(cli): make the mcp-exposure pin selectable by its own named command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bun.lock: regenerate — packages/query/package.json gained @ultimat3/http as a dependency in the prior task but the lockfile (and node_modules symlink) was never rerun, so any fresh install typechecked query's http.ts against a missing module - mcp-exposure-pin.test.ts: lowercase the describe title so `bun test -t 'mcp expos'` — the exact command the plan names as this predicate's proof — actually selects these tests; -t is a case-sensitive substring match and "MCP exposure" never matched it. Same fix class as n-plus-one-detector.test.ts's `n1` shorthand. - verified: query route (packages/query/src/http.ts) mounts via apiRoutes() in both x dev and serve.ts, and is proven end-to-end in http.test.ts; api-routes.test.ts pins both halves mounted together Co-Authored-By: Claude --- bun.lock | 1 + packages/cli/src/mcp-exposure-pin.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index df12d2d2e..bb94c84f8 100644 --- a/bun.lock +++ b/bun.lock @@ -410,6 +410,7 @@ "dependencies": { "@ultimat3/cache": "1.2.0", "@ultimat3/core": "1.2.0", + "@ultimat3/http": "1.2.0", "@ultimat3/policy": "1.2.0", "@ultimat3/schema": "1.2.0", }, diff --git a/packages/cli/src/mcp-exposure-pin.test.ts b/packages/cli/src/mcp-exposure-pin.test.ts index d596f929d..8a2ad7bfb 100644 --- a/packages/cli/src/mcp-exposure-pin.test.ts +++ b/packages/cli/src/mcp-exposure-pin.test.ts @@ -45,7 +45,10 @@ const queryDeclaring = (mcp?: { readonly expose: boolean }) => sql: ({ id }) => from<{ id: string }>('posts', [{ id }]).where({ id }).orderBy('id'), }).named('orgFeed'); -describe('one predicate decides MCP exposure', () => { +// Lowercase on purpose — `bun test -t 'mcp expos'` is a substring match against the full test +// name, and a capitalized "MCP" would not have matched. Same lesson as `n-plus-one-detector.test.ts`'s +// `n1` describe prefix: pick the describe text so the command an author would actually type works. +describe('one predicate decides mcp exposure', () => { for (const { label, mcp, exposed } of DECLARATIONS) { test(`an action declaring ${label} is exposed=${exposed} on every surface`, () => { const target = actionDeclaring(mcp); From 5167178dfa6c7b60aeaf20726b00ebe8d5fef57f Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 13 Aug 2026 14:59:57 -0500 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20address=20PR=20#69=20review=20?= =?UTF-8?q?=E2=80=94=20cache-key=20claim,=20headers,=20test=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs: the read cache key never carried the actor; six pages said it did. cacheKeyFor is name + parsed-input fingerprint + sorted tag keys, so the tenant reaches the key through the input. Corrected across wiki/ and docs/architecture/; docs/idea/05-caching.md now names the gap instead of claiming the scoped key shipped. - ai: tools.test.ts throws the real PolicyError from `forbidden()` instead of a duck-typed `{code, cause, fix}` lookalike; the foreign-throw case names its error class so what it simulates is legible. - core/cli: file headers back inside the 1-4 line rule; the rationale they carried moved to the JSDoc that already held the rest of it. - wiki: X_INPUT_INVALID and X_RPC_FAILED fixes print a runnable command — `x queries describe --json`, not a bare `x queries describe`. - query README: dated the route contract `As of 2026-08`. Co-Authored-By: Claude --- CHANGELOG.md | 2 ++ docs/architecture/01-package-map.md | 2 +- docs/architecture/03-request-lifecycle.md | 2 +- docs/architecture/06-data-layer.md | 2 +- docs/idea/05-caching.md | 6 ++++-- packages/ai/src/tools.test.ts | 22 +++++++++++++++------- packages/cli/src/mcp-exposure-pin.test.ts | 14 ++++---------- packages/core/src/mcp-exposure.ts | 14 +++++++------- packages/query/README.md | 10 +++++----- wiki/Caching-And-Invalidation.md | 4 ++-- wiki/Entities-And-Migrations.md | 2 +- wiki/Error-Codes.md | 4 ++-- wiki/Queries-And-Live-Queries.md | 2 +- 13 files changed, 46 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 059e81eb1..a05654af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -250,6 +250,8 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ### Fixed +- **Docs: a read's cache key does not include the actor, and six pages said it did.** "Cache keys always include the actor's tenant and policy scope, so a cache hit can never leak across tenants" was a guarantee an app could have designed around. `cacheKeyFor` in `@ultimat3/query` is `query:::` and has never carried the actor. The rule that actually holds: the tenant reaches the key through the read's **input**, so `feed({ orgId })` is one entry per org, and a `cache:` read whose answer varies by actor for one input must not declare `cache:` — tier 1, the request memo, is keyed by `Ctx` identity and already separates it. Policy still runs on every read before a tier is consulted; it decides whether *this* caller may ask, not which rows the entry holds. Corrected in `wiki/Caching-And-Invalidation.md`, `wiki/Queries-And-Live-Queries.md`, `wiki/Entities-And-Migrations.md`, `docs/architecture/01-package-map.md`, `03-request-lifecycle.md` and `06-data-layer.md`; `docs/idea/05-caching.md` keeps the scoped key as the design intent and now names the gap rather than claiming it shipped. No code changed. + - **MCP exposure has one answer, `isMcpExposed`, and the contract stops publishing tools nothing serves.** Six readers across five packages decided `mcp: { expose }` three ways: `=== true` where a tool is actually built (`@ultimat3/action`'s `toMcpTools`, `@ultimat3/query`'s, `@ultimat3/mcp`'s two projections), `!== false` in the OpenAPI operation's `x-ultimate.mcpTool`, and `?? true` in `describeAction`'s manifest fact. So an action with **no `mcp` block at all** was published as a tool by `x.manifest.json` and by `openapi.json`, and refused by every surface an agent could call — and the first honest `mcp: { expose: false }` an author wrote then read as a *withdrawn* capability, which `x verify`'s contract diff classifies as breaking and demands a major version bump for. `isMcpExposed(declared)` in `@ultimat3/core` is now the single predicate. Core owns it because the readers span tiers 3–5 and this is the only tier all of them reach — the same reason `timingSafeEqual` lives there. Opt-in is unchanged and unchanged everywhere: an absent block, an omitted `expose` and a literal `false` are one answer. diff --git a/docs/architecture/01-package-map.md b/docs/architecture/01-package-map.md index 273dbdf51..e15e70377 100644 --- a/docs/architecture/01-package-map.md +++ b/docs/architecture/01-package-map.md @@ -48,7 +48,7 @@ Decided **2026-08**, when the Postgres entity driver needed a home. `db` imports | `i18n` | 1 | translator, catalog flattening, locale negotiation, loud misses | `t()`, catalog format, `⟦key⟧` rendering, plural selection via CLDR | read a request object; format money | | `money` | 1 | integer minor units with an attached currency | `Money`, arithmetic, `allocate`, ISO exponent table, `Intl` formatting | floats; cross-currency arithmetic; a bare number as a total | | `time` | 1 | UTC instants, zone math, cron, durations | `Instant`, `ZonedFormat`, cron parse/next, duration parse (`'3d'`) | format without an explicit IANA `timeZone` | -| `cache` | 1 | four cache tiers behind one tagged invalidation graph | tag algebra, key derivation from actor scope, tier drivers | hold business logic; accept a hand-built key | +| `cache` | 1 | four cache tiers behind one tagged invalidation graph | tag algebra, tag serialization, tier drivers | hold business logic; accept a hand-built key | | `seo` | 1 | metadata, typed JSON-LD, sitemap/robots/feeds, SEO checks | `ld.*`, `` model, sitemap splitting, the SEO check set | render pages; fetch data | | `db` | 1 | Postgres access, transactions, migrations, drift detection | `sql` binding, `DbClient`, `withTransaction`, the migration ledger, branch/introspect | import `entity` — an entity snapshot arrives as a parameter, never as an import | | `storage` | 1 | named disks over `Bun.file` and `Bun.s3` | disk registry, safe keys, signed URLs, sniffed uploads | let a call site name a driver instead of a disk | diff --git a/docs/architecture/03-request-lifecycle.md b/docs/architecture/03-request-lifecycle.md index e3704280f..cfda9554f 100644 --- a/docs/architecture/03-request-lifecycle.md +++ b/docs/architecture/03-request-lifecycle.md @@ -19,7 +19,7 @@ Order is fixed. Each row states why it cannot move earlier or later. | 9 | `body-parse` | bounded read, content-type dispatch, size cap | after rate limiting; before validation | | 10 | `input-validate` | `input` schema parse → typed `input`; failure → `X_INPUT_INVALID` with the field path | authz reads validated input | | 11 | `authz` | `policy.evaluate({ actor, input, tenant })` → allow or `X_FORBIDDEN` | **after validation, because policies read validated input.** `ownsPost(actor, input.postId)` on an unparsed body is how type confusion becomes privilege escalation | -| 12 | `cache-lookup` | queries only: tier 1 → 2 → 3, key includes actor scope | **after authz** — a cache hit that precedes a policy check is a cross-tenant leak with a fast path | +| 12 | `cache-lookup` | queries only: tier 1 → 2 → 3, key is name + parsed-input fingerprint + tags | **after authz** — a cache hit that precedes a policy check is a cross-tenant leak with a fast path. The actor is not in the key, so a `cache:` read carries its tenant in the input | | 13 | `handler` | `handle({ input, ctx })` inside a DB transaction (mutations); `.enqueue` joins that transaction | the only user code in the pipeline | | 14 | `commit` | commit; then release outbox rows and fan out `cache.invalidates` | invalidation *enqueued* in the tx, *executed* after commit: a rolled-back write never purges | | 15 | `output-validate` | `output` schema parse — always in dev/test, sampled in prod | catches contract drift at the source, not in a client | diff --git a/docs/architecture/06-data-layer.md b/docs/architecture/06-data-layer.md index 86dbe301d..0dcb71539 100644 --- a/docs/architecture/06-data-layer.md +++ b/docs/architecture/06-data-layer.md @@ -129,7 +129,7 @@ Defense in depth, because a single missed `WHERE` is a data breach. | 3. Write guard | insert/update stamps the tenant from context, refuses a mismatching literal | `X_TENANT_MISMATCH` | | 4. Read guard | a returned row whose tenant ≠ context tenant is a bug, not a filter miss | `X_TENANT_MISMATCH`, logged with the query hash | | 5. Postgres RLS | optional, opt-in per entity; policy uses a session variable set on checkout | last line of defense for raw SQL and admin sessions | -| 6. Cache keys | derived from actor scope, never hand-built | a cache hit cannot cross tenants ([`../idea/05-caching.md`](../idea/05-caching.md)) | +| 6. Cache keys | query name + parsed-input fingerprint + tags, never hand-built. `As of 2026-08` the actor is **not** a part, so the tenant must be in the read's input | a `cache:` read scoped by actor rather than by input is a cross-tenant hit ([`../idea/05-caching.md`](../idea/05-caching.md)) | | 7. Live queries | subject includes the tenant; policy re-checked per delivered row | [`07-realtime-internals.md`](./07-realtime-internals.md) | ## Cursor pagination only diff --git a/docs/idea/05-caching.md b/docs/idea/05-caching.md index 77e9367cd..b3c08cf6e 100644 --- a/docs/idea/05-caching.md +++ b/docs/idea/05-caching.md @@ -11,7 +11,9 @@ Four tiers, one invalidation graph. You declare what a write touches; the framew | 3 | **shared** (`Bun.redis`) | cross-instance, TTL + tag sets | ~1ms | shared query results, rendered fragments, session-adjacent data | | 4 | **CDN / HTTP headers** | client + edge | `Cache-Control`, `ETag`, `stale-while-revalidate` | static + ISR pages, images, assets | -Every tier is read in order 1 → 2 → 3 → origin. A tier is never consulted for a request whose `policy` has not already passed — **cache keys always include the actor's tenant and policy scope**, so a cache hit can never leak across tenants. +Every tier is read in order 1 → 2 → 3 → origin. A tier is never consulted for a request whose `policy` has not already passed. + +**Cache keys should carry the actor's tenant and policy scope**, so that a cache hit cannot leak across tenants. `As of 2026-08` they do **not**: `cacheKeyFor` in `packages/query/src/cache.ts` is the query name, a fingerprint of the parsed input, and the read's sorted tag keys. The tenant therefore reaches the key only through the input — which is the shape every scoped read already has (`feed({ orgId })`) — and a `cache:` read whose answer varies by actor for one input is a cross-tenant hit. Tier 1 is keyed by `Ctx` identity and is not affected. Closing this means the key derives the scope itself; until then the rule is the one the wiki states. Tier 2 is optional per entry (`local: false` for large or per-tenant-unbounded values). Tier 3 is required for any entry an ISR page depends on, because regeneration happens on a different instance than the write. @@ -95,7 +97,7 @@ The bug is never "the cache is wrong". The bug is that invalidation is a *decisi | Stale page after publish | forgot one `revalidate` call | tags are declared on the route, resolved from the graph | | Purging too much | uncertainty → `flushAll` | narrow `tag.post.id(x)` is the ergonomic default | | Tier drift | Redis purged, CDN not | one fanout, all tiers | -| Leak across tenants | hand-built cache key missing the tenant | keys are framework-generated from actor scope | +| Leak across tenants | hand-built cache key missing the tenant | keys are framework-generated — `As of 2026-08` from the query name, the parsed input and the tags, not yet from actor scope, so the read must take the tenant as input | This is [wrap, don't reinvent](./00-thesis.md#wrap-dont-reinvent) applied to eviction: the tiers are `Bun.redis` and standard CDN headers, and what the wrapper deletes is the decision about which of them to clear. A wrapper that only renamed `DEL` would have earned nothing. diff --git a/packages/ai/src/tools.test.ts b/packages/ai/src/tools.test.ts index d2c25ab72..9c029d6f8 100644 --- a/packages/ai/src/tools.test.ts +++ b/packages/ai/src/tools.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { agentActor } from '@ultimat3/core'; +import { forbidden } from '@ultimat3/policy'; import type { ProjectableAction } from './tools'; import { runLlmToolCall, toLlmTool, toLlmTools } from './tools'; @@ -93,13 +94,11 @@ describe('exposure is opt-in, the same opt-in MCP reads', () => { }); describe('runLlmToolCall renders a failure the model can act on', () => { + // The real denial an action's policy gate throws, not a hand-rolled lookalike: a duck-typed + // `{ code, cause, fix }` would keep passing after `PolicyError` stopped carrying one of them. test('a framework error keeps its code, cause and fix', async () => { const denied = projectable('publishPost', { expose: true }, async () => { - throw Object.assign(new Error('denied'), { - code: 'X_FORBIDDEN', - cause: 'post:publish is not held', - fix: 'grant post:publish', - }); + throw forbidden('post:publish', 'actor lacks post:publish'); }); const result = await runLlmToolCall( [denied], @@ -108,14 +107,23 @@ describe('runLlmToolCall renders a failure the model can act on', () => { ); expect(result).toEqual({ toolUseId: 'call-3', - content: 'X_FORBIDDEN: post:publish is not held (fix: grant post:publish)', + content: + 'X_FORBIDDEN: post:publish denied: actor lacks post:publish ' + + '(fix: x policy explain post:publish --json # shows which clause decided and why)', isError: true, }); }); + // A throw from outside the framework — an SDK, a driver — is what this branch exists for, so + // the test has to raise one. It carries no `code`, which is the whole subject: an + // `UltimateError` here would exercise the branch above instead. + class ThirdPartySdkError extends Error { + override readonly name = 'ThirdPartySdkError'; + } + test('a foreign throw is reported without inventing a code', async () => { const boom = projectable('publishPost', { expose: true }, async () => { - throw new Error('kaboom'); + throw new ThirdPartySdkError('kaboom'); }); const result = await runLlmToolCall( [boom], diff --git a/packages/cli/src/mcp-exposure-pin.test.ts b/packages/cli/src/mcp-exposure-pin.test.ts index 8a2ad7bfb..a0b2c1581 100644 --- a/packages/cli/src/mcp-exposure-pin.test.ts +++ b/packages/cli/src/mcp-exposure-pin.test.ts @@ -1,13 +1,7 @@ -// One declaration, six readers, one answer. `mcp: { expose }` is read by `@ultimat3/action` -// (the tool, the manifest fact, the OpenAPI hint), `@ultimat3/query`, `@ultimat3/mcp` and -// `@ultimat3/ai` — packages on three tiers that cannot import each other, so no one of them can -// prove the others agree. `@ultimat3/cli` is tier 5 and may import all of them, which is why the -// pin lives here, exactly like `schema-error-codes-pin.test.ts`. -// -// Until 2026-08 they did NOT agree: two readers fail-opened (`?? true`, `!== false`) and four -// fail-closed, so an action with no `mcp` block was published as a tool by the contract and -// refused by every surface that could serve one. `isMcpExposed` in `@ultimat3/core` is now the -// single predicate; this file is what makes "single" checkable. +// One declaration, six readers, one answer: the pin that makes "one predicate" checkable. +// `mcp: { expose }` is read across `action`, `query`, `mcp` and `ai` — three tiers that cannot +// import each other, so no one of them can prove the others agree. `@ultimat3/cli` is tier 5 and +// may import all of them, which is why the pin lives here, like `schema-error-codes-pin.test.ts`. import { describe, expect, test } from 'bun:test'; import { action, describeAction, toMcpTools, toOpenApiOperation } from '@ultimat3/action'; diff --git a/packages/core/src/mcp-exposure.ts b/packages/core/src/mcp-exposure.ts index f1520123f..6e4764c71 100644 --- a/packages/core/src/mcp-exposure.ts +++ b/packages/core/src/mcp-exposure.ts @@ -1,11 +1,6 @@ // The one answer to "did this primitive opt into being an MCP tool?" — a literal `expose: true`. -// -// Six readers across five packages decided it three ways until 2026-08: `=== true` where a tool is -// actually built, `!== false` in the OpenAPI hint and `?? true` in the manifest fact. So an action -// with no `mcp` block was published as a tool by the contract and refused by every surface that -// could have called one. Core owns the predicate because those readers span tiers 3-5 — `action`, -// `query`, `mcp`, `ai`, `manifest` — and this is the only tier all of them reach, the same reason -// `timing-safe-equal.ts` lives here. +// Core owns it because its readers span tiers 3-5 — `action`, `query`, `mcp`, `ai`, `manifest` — +// and this is the only tier all of them reach, the same reason `timing-safe-equal.ts` lives here. /** * The `mcp` block, read structurally. Each package keeps its own richer declaration — @@ -22,6 +17,11 @@ export interface McpExposureDeclaration { * handed to every agent that can reach the surface — and writing an action is not a request to * hand one out. * + * Six readers decided it three ways until 2026-08: `=== true` where a tool is actually built, + * `!== false` in the OpenAPI hint and `?? true` in the manifest fact. So an action with no `mcp` + * block was published as a tool by the contract and refused by every surface that could have + * called one. + * * The one deliberate exception is `@ultimat3/admin`'s OWN catalog, whose every tool is already * gated on an admin permission and whose CRUD tools carry no `mcp` block at all; there * `expose: false` withdraws a tool. That surface says so in `mcp-tools.ts` and in diff --git a/packages/query/README.md b/packages/query/README.md index 1b12f64a4..502f9702f 100644 --- a/packages/query/README.md +++ b/packages/query/README.md @@ -33,11 +33,11 @@ Every projection is a method on the query itself. A query has no `.def`. | `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways | | `liveFeed.describe()` | the manifest row | -The route on the other end of that client is `toQueryRoute(liveFeed)`, and `x dev` and a -container both mount it for every registered read — the framework's job, not the app's. The -search string is coerced at the wire and validated by the read's own schema, so a bad `orgId` -is the query's `X_INPUT_INVALID` and a 400; the answer is `no-store`, because the URL names no -actor while the rows are scoped to one. +The route on the other end of that client is `toQueryRoute(liveFeed)`, and — `As of 2026-08` — +`x dev` and a container both mount it for every registered read, the framework's job, not the +app's. The search string is coerced at the wire and validated by the read's own schema, so a bad +`orgId` is the query's `X_INPUT_INVALID` and a 400; the answer is `no-store`, because the URL +names no actor while the rows are scoped to one. The declaration is lifted too: `.input`, `.policy`, `.cache`, `.mcp`, `.isLive`. `sql` is not among them — it lives in a private store inside `read.ts`, so `sourceFor` is the only thing diff --git a/wiki/Caching-And-Invalidation.md b/wiki/Caching-And-Invalidation.md index b5b293641..ea998af0a 100644 --- a/wiki/Caching-And-Invalidation.md +++ b/wiki/Caching-And-Invalidation.md @@ -15,7 +15,7 @@ v1.1.0 `As of 2026-08`. Stable API — semver from here ([Upgrading](Upgrading)) Read order is **1 → 2 → 3 → origin**. A tier is never consulted for a request whose `policy` has not already passed. -**Cache keys always include the actor's tenant and policy scope**, so a cache hit can never leak across tenants. Keys are framework-generated from the query name, its parsed input, and the resolved actor scope. +**A cache key is framework-generated, never hand-built.** `As of 2026-08` it is the query name, a fingerprint of the parsed input, and the read's sorted tag keys — `cacheKeyFor` in `@ultimat3/query`. The actor is **not** one of its parts, so what separates one tenant's entry from another's is the **input**: `feed({ orgId })` is one key per org. Policy still runs on every read before a tier is consulted, but it decides whether *this* caller may ask — not which rows the entry holds. So a read whose answer differs by actor for the same input must not declare `cache:`; tier 1 is keyed by `Ctx` identity and already separates it. | Tier | Opt-out / requirement | |---|---| @@ -96,7 +96,7 @@ The bug is never "the cache is wrong". The bug is that invalidation is a *decisi | Stale page after publish | forgot one `revalidate` call | tags are declared on the route, resolved from the graph | | Purging too much | uncertainty → `flushAll` | narrow `tag.post.id(x)` is the ergonomic default | | Tier drift | Redis purged, CDN not | one fanout, all tiers | -| Leak across tenants | hand-built cache key missing the tenant | keys are framework-generated from actor scope | +| Leak across tenants | hand-built cache key missing the tenant | keys are framework-generated from the query name, its parsed input and its tags — the tenant reaches the key through the input, which is why a read scoped by actor rather than by input must not declare `cache:` | | Stale forever | a query whose tables no tag covers | the tag rule — **not yet a gate**: `X_CACHE_UNTAGGED_QUERY` is reserved `As of 2026-08` and nothing raises it, so a cached query no tag covers is cached and never invalidated ([Error codes → Reserved codes](Error-Codes#reserved-codes)) | | Silent typo | `invalidates: [tag.pots]` | `X_CACHE_TAG_UNKNOWN`, or a compile error against the generated registry | diff --git a/wiki/Entities-And-Migrations.md b/wiki/Entities-And-Migrations.md index a84459908..36ce86d33 100644 --- a/wiki/Entities-And-Migrations.md +++ b/wiki/Entities-And-Migrations.md @@ -125,7 +125,7 @@ Removing the key is a real option, not a hedge: inference (steps 2–4) still ap | Consequence | Detail | |---|---| | Repo methods | inject the tenant predicate from `ctx.actor.orgId`; a raw `db.*` call in a service is a boundary error | -| Cache keys | framework-generated and include the actor's tenant + policy scope, so a hit can never cross tenants | +| Cache keys | framework-generated from the query name, its parsed input and its tags — never hand-built. The tenant reaches the key through the **input**, so a `cache:` read must take it there ([Caching and invalidation](Caching-And-Invalidation)) | | Live queries | the tenant predicate is part of the matcher, not a post-filter | | Vector search | tenant + policy filters applied **in SQL**, so similarity search cannot leak across tenants | diff --git a/wiki/Error-Codes.md b/wiki/Error-Codes.md index 16ce1a6f6..ddaab4d01 100644 --- a/wiki/Error-Codes.md +++ b/wiki/Error-Codes.md @@ -179,13 +179,13 @@ A denial is `X_FORBIDDEN`, above — `@ultimat3/policy` owns it and every surfac | Code | Means | Typical cause | Fix | |---|---|---|---| | `X_ACTION_DUPLICATE` | two actions registered under one name | duplicate export names across features | rename one; names are global. `x actions list --json` | -| `X_INPUT_INVALID` | input failed the primitive's schema — an action's, and a query's too | wrong shape from a client or an agent; over HTTP it is a **400**, never a 500 | `x actions describe --json` (`x queries describe` for a read) | +| `X_INPUT_INVALID` | input failed the primitive's schema — an action's, and a query's too | wrong shape from a client or an agent; over HTTP it is a **400**, never a 500 | `x actions describe --json` (`x queries describe --json` for a read) | | `X_OUTPUT_INVALID` | the handler returned a value its `output` schema rejects | the handler drifted from the declared output | `x actions describe --json`, then fix the handler or the schema | | `X_ACTION_FOREIGN` | a value that is not an action was projected as one | a hand-rolled object with `kind: 'action'`, or an action from a duplicated copy of `@ultimat3/action` | declare it as `export const name = action({ input, output, policy, handle })` | | `X_ACTION_UNREGISTERED` | an action was projected before it was registered, so it has no name | `.tool()` / `.client()` / `.job()` / `.openapi()` on an action `registerActions()` never named | `registerActions(await import('./actions'))` at boot, before mounting routes | | `X_IDEMPOTENCY_CONFLICT` | idempotency key reused with a different payload, or still in flight | a retried request mutated its body | send a fresh `Idempotency-Key` for a different payload; otherwise retry after the first settles | | `X_CONTRACT_DRIFT` | the published contract changed | input/output shape moved without a version bump | give new inputs a `.default()`, or bump the package version | -| `X_RPC_FAILED` | the typed client could not reach the action or the query | gateway, network, or a non-JSON response | check the gateway, then `x actions describe --json` (`x queries describe` for a read) | +| `X_RPC_FAILED` | the typed client could not reach the action or the query | gateway, network, or a non-JSON response | check the gateway, then `x actions describe --json` (`x queries describe --json` for a read) | ## Queries and live queries diff --git a/wiki/Queries-And-Live-Queries.md b/wiki/Queries-And-Live-Queries.md index 97bebdb42..5138934b3 100644 --- a/wiki/Queries-And-Live-Queries.md +++ b/wiki/Queries-And-Live-Queries.md @@ -57,7 +57,7 @@ Every projection is a method on the query — `liveFeed.tool()`, never `toQueryT | HTTP GET | name + `input` | `GET /_x/query/live-feed?orgId=…`, errors as `UltimateError` JSON | | Typed client hook | `input` + `sql` return type | `const feed = useLiveFeed({ orgId })` in `app/` — no fetch, no codegen step. Bound once with `liveHookFor`, below | | Live subscription | `live: true` | WS frames `{qid, op, row, lsn}` patched into a Solid signal | -| Cache entry | tags from `sql` | key includes actor tenant + policy scope; see [Caching and invalidation](Caching-And-Invalidation) | +| Cache entry | tags from `sql` | key is the query name + a fingerprint of the parsed input + the sorted tag keys — the actor is not in it; see [Caching and invalidation](Caching-And-Invalidation) | | MCP read tool | `input` + `policy` + name | one read tool per query, identical authz. See [MCP and AI](MCP-And-AI) | ### The typed client hook, precisely