Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,49 @@ 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:<name>:<parsed-input fingerprint>:<sorted tag keys>` 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.

```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/<kebab>` 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).

- **`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.

- **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.
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/architecture/01-package-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`, `<head>` 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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/03-request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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); `<job>.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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/06-data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/idea/05-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/action/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion packages/action/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
},
Expand Down
7 changes: 5 additions & 2 deletions packages/action/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
},
};
Expand Down
Loading