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
54 changes: 53 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ a later slice appends its group below the last one. `As of 2026-10` slices 01–
rest of tier 1 — `money`, `cache`, `seo`, `storage` — with what they changed in `http`, `render`
and `cli`; then slice 04, complete — tier 2's `entity`, `policy` and `http`. A browser-launcher
repair in `testing` rode along with it. Slice 05 is `auth`: **a deployment with MFA-enrolled
users has an operator step — the first `auth` entry under Changed.**
users has an operator step — the first `auth` entry under Changed.** Slice 06 has begun: `query`,
with `entity`'s comparison rule, `mcp` and `admin`.

### Added

Expand Down Expand Up @@ -105,6 +106,14 @@ Tier 5 — testing (slice 05).
- **testing:** the types `CdpTimeoutObservation`, `CdpTimeoutReading` and `CdpTargetGone` — see
Changed.

Tier 2 — entity. Tier 3 — query (slice 06).

- **entity:** `compareByKind` and `sameValueOfKind` — how Postgres compares two values of one
column, by the column's declared kind.
- **query:** `kindsOf(entity)` and the `KindOf` type — see Changed. `readAnswer` and the
`QueryToolAnswer` type — the one "first row or `X_NOT_FOUND`" rule the route, the tool and the
served MCP tool share.

Tier 5 — cli.

- **cli:** `Finding` carries an optional `meta` — the structured facts behind `cause`, for a
Expand Down Expand Up @@ -482,6 +491,37 @@ Tier 5 — testing (slice 05).
(`framesArrived`, `lastFrames`), the frames dropped as unparseable (`framesDropped` — they were
dropped silently) and the navigations since. Code and title unchanged.

Tier 3 — query.

- **BREAKING — `compareValues` is removed, and `compareRows`, `matchesFilter` and `isAfterKey`
require a `KindOf`.** Pass `kindsOf(shape.entity)`. Values compare by the column's declared kind
in the live matcher, `from()` and the seek fallback, so a live query ordered on a `bigint()` or
`decimal()` column patches rows where the database returns them. A relation no entity declares
compares digits as text.
- **BREAKING — a declared `.limit()` is the size of the listing on every page.** `.page()` and
`?_first=` no longer replace it, and a cursor cannot walk past it: the page after the last is
empty with `nextCursor: null`. Drop the `.limit()` from a read meant to be paged to the end. A
limited read's cursor carries the rows served so far, so a cursor minted before the upgrade on
a limited read answers `X_CURSOR_INVALID` once.
- **query:** `./client` is 11,012 B minified for the browser, was 10,899; the cap is 11,264.
`As of 2026-10-02`, as stated in the package's own notes — not re-measured here.

Tier 3 — query. Tier 4 — mcp.

- **BREAKING — a `single: true` read answers one row through its MCP tool**, or `X_NOT_FOUND` —
from `tool().read()` and from the served `tools/call`. Its `outputSchema` is the row, was
`{ rows }`. An agent or client that read `.rows[0]` reads the object. `tool().read()` is typed by
the declaration: a list read still answers `readonly object[]`, and only a single read's type
changed.

Tier 5 — admin.

- **BREAKING — importing `@ultimat3/admin` no longer declares `admin:*`.** The import used to
close the app's permission set as a side effect; `defineAdmin()` declares them now, and the
`adminPermissions` export is removed. An app with its own closed permission set that writes
`can('admin:read')` before `defineAdmin()` runs adds `...ADMIN_PERMISSIONS` to its
`definePermissions([...])`.

Tier 5 — cli.

- **cli:** `X_VERIFY_STEP_TIMEOUT` names what was running. On expiry the step's test workers are
Expand Down Expand Up @@ -692,6 +732,18 @@ Tier 5 — testing (slice 05). Reference app.
test's page read `navigator.onLine === false` in 9 of 15 runs, as measured by the fix's author.
The stale-build e2e awaits the update banner as an event, not a 5 s budget.

Tier 2 — entity. Tier 3 — query (slice 06).

- **entity:** one comparison rule, `numericOrder`, agreeing with Postgres 17. In the memory driver
`where` and `order` compare decimal kinds by value: `where({ total: 10 })` on a `bigint()`
column, `'2.5'` against a stored `'2.50'`, and `'-0.00'` against `'0'` now match. `uuid`
operands order case-insensitively, and a `number` / `bigint` pair compares numerically.
Invariants and the driver no longer disagree on an operand written `1e21`.
- **query:** `search()` refusals are `X_INPUT_INVALID` (400), were `X_INVARIANT` (500): a blank
`q`, a cursor, a window that would cut rows.
- **query:** the typed read client sends a `Date` input as its ISO instant. A required array input
the request omits reads `[]` instead of a 400, so `{ tags: [] }` arrives.

## 23.0.0 - 2026-10-02

**23.0.0: platform readiness for big systems**
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/21-client-data-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ the first realtime hook. Records answered earlier wait in core's pending buffer.
| a new principal | `rescope()` clears every record |

**`@ultimat3/query/client` is a second import path, deliberately.** It is the browser entry of
`queryClient`, like `@ultimat3/entity/record`: 10,899 B against 23,997 B through the barrel
(`As of 2026-10-01`, `bun build --target=browser --minify`, per `packages/query/CLAUDE.md`). The barrel's extra cost
`queryClient`, like `@ultimat3/entity/record`: 11,012 B against 25,237 B through the barrel
(`As of 2026-10-02`, `bun build --target=browser --minify`, per `packages/query/CLAUDE.md`). The barrel's extra cost
is its anchored registry, which a browser never needs. `browser-transport` refuses the barrel in
browser code (`X_BROWSER_SERVER_BARREL`), so the second path is enforced, not optional.

Expand Down
20 changes: 20 additions & 0 deletions docs/history/entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -1343,3 +1343,23 @@ and `ENTITY_BORROWED_ERROR_CODES`; `ENTITY_ERROR_CODES` is now exactly the codes
The record modules still import `entity-error.ts` and never `errors.ts`
(`record-bundle.test.ts`): the rule stands on its own, since `errors.ts` is where a future
`@ultimat3/db` import would land.

## 2026-10-02 — one comparison rule, checked against Postgres 17, and exported

`compareByKind` and `sameValueOfKind` (`memory-match.ts`) are exported: `@ultimat3/query` deleted
its own `typeof`-based comparator and calls these. Running one `(kind, left, right)` table through
both and through a real server found this package's rule wrong on nine rows, all fixed here:

| Pair | Before | Postgres |
|---|---|---|
| `bigint` `'10'` = `10` / `10n`; `numeric` `'2.50'` = `'2.5'` / `2.5`; `'0.1'` = `'0.10'`; `'-0.00'` = `'0'` | not equal (`===`) | equal |
| `uuid` order, upper against lower case | by the text as written | by value |
| a `number` beside a `bigint`, no declared kind | as text (`'2' > '10'`) | numerically |

There is ONE numeric comparison, `numericOrder` (`numeric-compare.ts`): `compareByKind` used to
call core's `compareDecimalText` directly while invariants went through `numericOrder`, two paths
that differed on an operand written `1e21`. Order, equality and an invariant's `gte`/`eq` now ask
the same function, which reads either side as plain digits. So `where({ total: 10 })` on a
`bigint()` column matches the row holding `'10'` in the memory driver, as it always did in
Postgres. `compare-parity.test.ts` runs the rows against both drivers; emitted DDL did not move
(`ddl-pin.test.ts`).
21 changes: 21 additions & 0 deletions docs/history/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,3 +691,24 @@ it is addressed by the index its id was found at, so a projected query still rem
The rule generalises what `assertSeekable` already applies to a cursor: **a sort key has to be
readable on the row.** A live query whose rows omit one still works — it re-reads instead of
patching — which is correct and slower, and the fix is to project the key.

## 2026-10-02 — `compareValues` is deleted; the `DECLARED_GAP` is closed

Everything above that names `compareValues`, `same`, the number/bigint "family" and the
`DECLARED_GAP` in `shape-order.test.ts` describes the tree before this date. This package no longer
has a comparator: `compareRows`, `matchesFilter` and `isAfterKey` take a `KindOf`
(`kindsOf(shape.entity)`, `column-kinds.ts`) and call `@ultimat3/entity`'s `compareByKind` /
`sameValueOfKind`. The gap closed the way the note said it had to — a kind reaching the comparison —
but through the relation the shape already names, not through an `OrderKey` carrying one.

Two comparators had answered four inputs differently (a `bigint()` and a `decimal()` row, a `uuid`
in two cases, a `Date` against its ISO text), so a live window patched rows into positions the
database never returned. Writing the parity table (`compare-parity-fixture.ts`) against Postgres 17
then showed entity's own rule wrong on nine rows — decimal equality across spellings, `uuid` order
across case, a `number` beside a `bigint` — and those were fixed in entity in the same change
(`docs/history/entity.md`). A relation no entity declares has no kinds; its digits are text, which
is what Postgres answers for a `text` column.

The same change made a declared `.limit()` bound the listing on every page (a limited read's cursor
carries the rows served), turned `search()`'s three refusals into `X_INPUT_INVALID`, and made a
`single: true` read answer one row or `X_NOT_FOUND` through the MCP tool.
10 changes: 9 additions & 1 deletion docs/plans/2026/10/02/101-deep-dive-gaps-bugs/status.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ notes: >-
page-outbox.ts:104, :153-155, :262). Settle whether that is at-least-once as designed. The
'like taken offline' e2e is satisfied by the optimistic count before the server confirms.
For the cli PR: nothing checks that x auth seal-mfa has run except x doctor when someone runs
it - a boot-time warning with the unsealed count would enforce the operator step. Section A of slice 15 holds 21 owner decisions; five were first asked on
it - a boot-time warning with the unsealed count would enforce the operator step.
PR 5 merged as #622 (f8fa90bf). PR 6 found that entity's own comparison rule - the one the plan
told query to adopt - disagreed with Postgres 17 on 9 rows (decimal equality by string identity,
uuid order as written); entity's memory driver, invariants and query's matcher now share one rule,
numericOrder, proven on memory, PGlite and Postgres 17.
For the admin PR (slice 11): 22 admin test files call defineAdmin() at module scope
(describe.test.ts:38, routes.test.ts:42, ...), so `bun test ./packages/admin ./packages/query`
in one process still fails 165 query tests in that order. The source is fixed in PR 6 (importing
admin declares nothing); move those calls into beforeAll. Section A of slice 15 holds 21 owner decisions; five were first asked on
2026-09-28. Unaudited areas are listed at the end of each findings file.
last_updated: 2026-10-02
1 change: 1 addition & 0 deletions packages/admin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
- **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `hasMeta` was dropped because `defineRoute()` refuses a route with no `meta`, so `missingMeta` was a defect list with no member it could hold; `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key — a guarantee shown where the question is asked. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
- **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describeRoutes()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
- **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`): a package sheet nobody claims rides both surface bundles, and a static `site/` document carried the dashboard's CSS. `stylesheet-scope.test.ts`.
- **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives; a module-scope `definePermissions` here closes the permission set for every module in the process (`policy-bridge.test.ts`). `adminPermissions` is gone — read `ADMIN_PERMISSIONS`.
- **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `route-config.ts` the only one calling `defineRoute`/`registerMountedRoutes`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, because `definePermissions` writes a process-global registry and `bun test` seats several files in one process — `dev/data.test.ts` relied on the empty-registry-means-permissive fallback and went red the first time it shared a process with anything that imports this package.
- **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
- **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.
Expand Down
1 change: 0 additions & 1 deletion packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,6 @@ export {
ruleFor,
} from './permissions';
export {
adminPermissions,
declareAdminPermissions,
type PolicyAuthzInput,
policyAuthz,
Expand Down
52 changes: 30 additions & 22 deletions packages/admin/src/policy-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// The one global-state property `policy-bridge.ts` rests on: its module-scope
// `definePermissions(ADMIN_PERMISSIONS)` ADDS admin's four names to the process-global permission
// registry and never replaces what an app declared. Both tracked apps depend on it in both
// directions — `dummy/social-media-clone/apps/admin/app/admin/policy.ts` declares its own set on
// top of this one and says so in a comment.
// The one global-state property `policy-bridge.ts` rests on: IMPORTING it declares nothing, and
// `declareAdminPermissions()` — called by `defineAdmin()` — ADDS admin's four names to the
// process-global permission registry without replacing what an app declared. Until 2026-10 the
// import itself registered them, which closed the permission set for every module sharing the
// process: an app importing the admin for a type, and every test file after an admin one.
//
// And what the bridge HANDS a policy, which lives here for the same reason: this is the ONE file
// allowed to load `policy-bridge` in this process. A second one would import it first, the module
Expand Down Expand Up @@ -82,21 +82,30 @@ afterAll(() => {
restoreRoles(ambientRoles, ambientRoleSites);
});

describe('the module-scope permission registration', () => {
// Imported dynamically, inside the test: a static import would register admin's four names in
// every process that merely loads this file, which is the hazard being pinned.
test('registering by import alone is what lets an app never declare admin:*', async () => {
const { adminPermissions } = await import('./policy-bridge');
describe('the admin permission registration', () => {
test('importing the bridge declares nothing — an empty set stays permissive', async () => {
clearPermissions();
// A FRESH evaluation, by a specifier no earlier file can have loaded: a module evaluates once
// per process, so a plain `import('./policy-bridge')` after another suite imported it runs no
// module scope at all and this test could not fail.
const fresh: string = `./policy-bridge.ts?fresh=${String(Date.now())}`;
await import(fresh);

expect(adminPermissions.all).toEqual([...ADMIN_PERMISSIONS]);
for (const permission of ADMIN_PERMISSIONS) {
expect(knownPermissions()).toContain(permission);
}
expect(knownPermissions()).toEqual([]);
});

test('declareAdminPermissions is the explicit call, and it declares admin:* with what it is handed', async () => {
clearPermissions();
const { declareAdminPermissions } = await import('./policy-bridge');
declareAdminPermissions(['posts:read', 'not a permission']);

expect([...knownPermissions()].sort()).toEqual([...ADMIN_PERMISSIONS, 'posts:read'].sort());
});

test("an app's own set survives it — declaration adds, it never replaces", async () => {
definePermissions(['post:read', 'post:publish']);
await import('./policy-bridge');
const { declareAdminPermissions } = await import('./policy-bridge');
declareAdminPermissions([]);

// If `definePermissions` ever replaced instead of merging, one of these two sets would be gone
// and every `can()` in the losing half would throw X_PERMISSION_UNKNOWN at declaration time —
Expand All @@ -109,14 +118,13 @@ describe('the module-scope permission registration', () => {

/**
* The bridge is imported DYNAMICALLY and every registration below is undone in the `afterAll`
* above. `definePermissions` writes a process-global registry whose EMPTY state is permissive, and
* merely importing `policy-bridge` registers admin's four names — doing either at this file's
* module scope makes every later FILE in the run declare `can('post:read')` into
* `X_PERMISSION_UNKNOWN`. Measured: 247 failures across `action`, `query`, `mcp` and `ai`.
* above. `definePermissions` writes a process-global registry whose EMPTY state is permissive —
* declaring at this file's module scope makes every later FILE in the run declare
* `can('post:read')` into `X_PERMISSION_UNKNOWN`. Measured: 247 failures across `action`, `query`,
* `mcp` and `ai`.
*
* `ADMIN_PERMISSIONS` is declared here as well as by the import, because `definePermissions` merges
* and a module evaluates once per process: a re-run of this hook in a process where something
* already cleared them would otherwise fail at `definePolicy('admin:read')`.
* `ADMIN_PERMISSIONS` is declared here because no `defineAdmin()` runs in this file, and
* `definePolicy('admin:read')` below needs the name.
*/
let authz: AdminAuthz;

Expand Down
18 changes: 10 additions & 8 deletions packages/admin/src/policy-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ import {
} from './authz';
import { ADMIN_PERMISSIONS } from './permissions';

/** The admin's own permission set, registered with the policy layer at import time. */
export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);

/**
* The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
* mapping lives here because this file is the only one allowed to speak to the policy layer.
Expand Down Expand Up @@ -62,13 +59,18 @@ function readDecision(permission: string, result: unknown): AdminDecision {
const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);

/**
* Declare the permissions an admin DERIVES — `<entity>:read|write|delete`, a page's, an action's —
* so `can()` knows them. Granting them stays the app's: a role map that names none of these
* refuses every screen. Idempotent, and this file's because it is the one that speaks to
* `@ultimat3/policy`.
* Declare the admin's OWN permissions (`ADMIN_PERMISSIONS`) and the ones a mount DERIVES —
* `<entity>:read|write|delete`, a page's, an action's — so `can()` knows them. Granting them stays
* the app's: a role map that names none of these refuses every screen. Idempotent, and this file's
* because it is the one that speaks to `@ultimat3/policy`.
*
* Called by `defineAdmin()`, never at module scope. The permission registry is permissive while
* EMPTY and closed once it holds one name, so registering `admin:*` on import closed the set for
* every module that merely shared a process with this package — an app that imported the admin
* for a type, and every test file that ran after an admin one.
*/
export function declareAdminPermissions(permissions: readonly string[]): void {
definePermissions(permissions.filter(isPermission));
definePermissions([...ADMIN_PERMISSIONS, ...permissions.filter(isPermission)]);
}

const evaluated = (
Expand Down
Loading
Loading