@ultimat3/admin 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/CLAUDE.md CHANGED
@@ -1,118 +1,110 @@
1
1
  # @ultimat3/admin — boundary
2
2
 
3
- Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli`, and only of `@ultimat3/admin/dev` — `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`) so `x dev` can **mount** `/_x`. The root barrel `@ultimat3/admin` has no importer.
3
+ Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli` — `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`). It imports `@ultimat3/admin/dev` to **mount** `/_x`, and the root barrel only DYNAMICALLY and only when an app declared an admin (`packages/cli/src/runtime-admin.ts`).
4
4
 
5
5
  Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev/index.ts`) = the `/_x` dev dashboard, `@ultimat3/admin` (`src/index.ts`) = the generated app admin (production, authz'd, AI-first). Keep them apart; nothing in `src/dev/` may be imported by an admin view, and the root barrel does not re-export the dev half — a host that mounts `/_x` (the CLI) must not load a Solid component to do it.
6
6
 
7
7
  ## Rules
8
8
 
9
9
  - **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
10
- - **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`, so an org-scoped or ownership rule can fire at all. It could not: every admin decision was evaluated with `actor.orgId === undefined` and `row === null`, so a role-only rule allowed and the coarse `admin:read` + `<entity>:read` pair was the only gate on a row — while `adminList`/`adminSearch` add no tenant predicate of their own. `adminDetail`, `adminUpdate` and `adminDestroy` load the row BEFORE the guard, the shape `packages/action/src/invoke.ts` uses; `undefined` means "not loaded" and is left off the subject, `null` means "looked and found none" and fails a row rule closed. Pinned in `policy-bridge.test.ts`, which asserts the SUBJECT a policy receives — `staticAuthz` is a grant-list stub and no suite driving it can see any of this.
11
- - **The MCP caller is the ambient actor for the whole call.** `mcp.ts` wraps `callAdminTool` in a child context (or a fresh root one over stdio, where there is no surrounding request), the same rule `@ultimat3/mcp`'s `app-tool.ts` states. `AdminApp.ctx()` builds a plain `CrudCtx` and touches nothing async-local, so everything deriving from `tryUseContext()` — entity's tenant guard, the query cache authority, the jit-preload store — read the TRANSPORT's actor: an agent token authorized as agent X while the repo reads ran as the cookie user's tenant, and over stdio `actorTenant` was `undefined` and `assertRowTenant` a no-op. Pinned in `mcp-context.test.ts`. **And the caller's TENANT rides both hops** — `resolveToken` mints the `Actor`, `adminActorOf` rebuilds the `AdminActor` from it, and each dropped `orgId`, so every `AdminAuthz` decision over `POST /mcp` was made with `actor.orgId === undefined` while the same app's UI path saw the real org. Pinned in `mcp-tenant.test.ts`, which asserts the org an authz decision RECEIVES, over `route.handle` — the catalog filter and the call both.
12
- - **A custom page's guard is composed, never written.** `pages.ts` turns a `pages:` entry into an `AdminRoute` carrying `[admin:read, …declared]`; `routes.ts` is the only thing that may hand a page component to a router and it hands the `guardedPage()` wrapper, with `permissions[0]` already in `defineRoute({ policy })`. `AdminPageProps.ctx` is required by the type so the wrapper cannot be bypassed by calling the component directly. An empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin` time — the one place an unauthenticated admin screen could have been born.
13
- - **One `AdminAction.name`, one handler.** The name is the MCP tool name (`admin.action.<name>`), the default label key, AND the key `callAdminTool` resolves a handler by — three addresses, one string. Two actions sharing it is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin` time (`admin.ts`, `mcp.test.ts`), not at the first agent call: `.find()` on a name dispatches to whichever resource came first, which is a call that SUCCEEDS against the wrong action and reports nothing. Refused at declaration rather than in `adminMcp()` because an app that renders the dashboard and never wires MCP has the same two broken label keys and the same ambiguous dispatch. The framework's own examples already qualify the name with the entity (`post.publish`) — the `fix:` line is that convention, made into the instruction. The same object attached through both `actions:` and `resources[e].actions` is one action, not two: identity, not name, is what "already seen" means.
14
- - **A host that serves an admin URL itself reads its gate, never restates it.** `adminRouteFor(app, path)` (`routes.ts`) is that read, and `AdminRouteConfig.policy` is what it hands over — the same object `config.policy` carries, non-optional so a caller need not prove it exists. Typing `policy: { permission: 'admin:read' }` into a page file beside a route table that already declares one for that URL is two declarations of one URL's authz; they agree until somebody edits one. The deployed demo shipped exactly that on five pages until 1.2.0, and its `apps/admin/app/admin/route-policy.test.ts` is the rule made executable: a quoted permission anywhere under `app/admin/**/page.tsx` fails the suite. An undeclared path is `X_ADMIN_PAGE_PATH_INVALID`, listing the paths that would have worked.
15
- - **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag.** `import type { RouteDescriptor }` beside the dynamic `import()` — the type is erased, so /_x still costs the production graph nothing, and a renamed descriptor field becomes a typecheck failure in `dev/data.ts` naming the field. The bag was defended here as tolerance and bought three panels that were wrong for every row and could not go red: `route['render']`, `route['budget']`, `route['revalidate']`, `job['idempotencyKey']` and `entity['drift']` are names no descriptor has ever published, so a read answered `undefined`, took the fallback, and shipped the fallback as a fact — every route `stream` with no budget and no tag, every job non-idempotent, every schema drift-free. `dev/published-keys.test.ts` is the runtime half a type cannot answer: it walks what the real registries EMIT, so a field the type declares and the projection never writes is a red test.
16
- - **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.
17
- - **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `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.
10
+ - **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`; `adminDetail`/`adminUpdate`/`adminDestroy` load the row BEFORE the guard. `undefined` = not loaded (left off the subject), `null` = looked and found none (a row rule fails closed). Pinned in `policy-bridge.test.ts`, which asserts the SUBJECT a policy receives.
11
+ - **The MCP caller is the ambient actor for the whole call, tenant included.** `mcp.ts` wraps `callAdminTool` in a child context (a fresh root one over stdio); `resolveToken` and `adminActorOf` both carry `orgId`. Pinned in `mcp-context.test.ts`, `mcp-tenant.test.ts`.
12
+ - **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
13
+ - **A screen's guard is composed, never written.** A resource screen asks `crud.ts` FIRST and renders what it answered — only it holds the row a row-level rule reads. Every other screen (a `pages:` entry, dashboard, jobs, audit, search) is built through `guardedScreen()` (`screen-frame.tsx`): `decideAll` over the route's permissions, the refusal audited (`auditRefusal`), answered 403. `pages.ts` composes `[admin:read, …declared]`; `AdminPageProps.ctx` is required by the type; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin`.
14
+ - **No screen ships a script.** Every route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form: `<Pagination hrefFor>`, `<DataTable sortHrefFor>`, scope tabs as links, the filter bar one GET form, a form posted at the URL that rendered it, an action posted at its ROW's URL (`_operation`). `form-decode.ts` is the one place posted strings become typed input, per widget — an empty box of a typed widget (money, number, instant, JSON) is `null`, never `''`.
15
+ - **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>` (`*` = none, not even the default; undeclarable), `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
16
+ - **One composition of predicates** (`list-scope.ts`): row scope (`rows`), then scope, then filters. Every read of a resource builds its `where` there, and every read of ONE row is `findRow` — never `repo.find` from a screen, `crud.ts` or `mcp.ts` — so a row outside `rows(actor)` is "not found" on list, search, detail, edit, action, lookup, labels and MCP alike (`row-scope.test.ts`). App-declared predicates (`scopes`, `rows`) pass `declaredFilters`: any column, never a sealed one.
17
+ - **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationsFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver. The picker is server-rendered: a `<select>` for a small target, else an id box + the target's lookup screen (`/admin/<resource>/lookup`, `screen-lookup.tsx`, `return` held to the admin's own paths).
18
+ - **A sealed column is never a filter, a scope or row-scope predicate, a label, a lookup key or a computed column's input** — `.sealed({ lookup: true })` included: a filter value rides a URL, and a URL is logged. `computedRow` drops sealed names from the row a column's `value` is handed.
19
+ - **The tenant column is the handle's, never an input** (`crud-input.ts`): not a form field, a list column or a filter; a create is stamped with the ACTOR's tenant and a patch never carries the column.
20
+ - **Computed columns are a closed renderer set** (`renderers.tsx`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`) or a component; `AdminComputedColumn` is typed over `AdminRow` so a resource stays covariant in `Row`.
21
+ - **A page seeks on the PAIR `(sort, id)`** (`keyset-seek.ts`): the rest of the boundary's class by id, then past it, then the NULLs (last ascending, first descending), so any number of ties and a nullable sort column are positions; the cursor carries `null`, never `''`. An instant's class is its MILLISECOND — a row's `Date` drops Postgres' microseconds — read whole by id. `repo-entity-keyset(.contract).test.ts` walks to the end and back.
22
+ - **A read by a mistyped id is a row that does not exist** — `repo-entity.ts`'s `find` answers `null` (the admin's 404); a write keeps the throw. An instant compared by the handle is a `Date`, never ISO text: the memory driver orders a string against a `Date` by its characters.
23
+ - **Every admin document is `noindex, nofollow`**, a refusal and a 404 included (`route-config.ts`).
24
+ - **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is touched only by the form (a password input, never a `value`) and `crud.ts` (`splitSecrets`: empty = unchanged). The audit diff is derived from that list, not from what a row enumerates. Pinned in `sealed-egress.test.ts`.
25
+ - **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package.
26
+ - **One `AdminAction.name`, one handler** — the MCP tool name, the default label key and `callAdminTool`'s lookup. A duplicate is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin`; identity, not name, is what "already seen" means.
27
+ - **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag** — `import type { RouteDescriptor }` beside the dynamic `import()`; `dev/published-keys.test.ts` walks what the registries EMIT. A route fact carries `mount` (`{ by, permissions }` or `null`).
28
+ - **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
29
+ - **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.
30
+ - **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
31
+ - **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives (`policy-bridge.test.ts`).
32
+ - **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, and calls `defineAdmin()` in `beforeAll`, NEVER at module scope — the leak guard keeps what a file's module scope registered for every later file, so one module-scope admin closed the permission set for `bun test packages/admin packages/query`.
18
33
  - **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.
19
34
  - **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.
20
- - **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.
21
- - **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".
22
- - **This catalog is the framework's one opt-OUT MCP surface, and that is deliberate.**
23
- `mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every
24
- tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at
25
- all, so opting in would list `admin.posts.delete` while hiding the action button beside it.
26
- `mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in
27
- `wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug.
28
- - **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe".** It blanks every opaque span in ONE left-to-right pass — `'…'`, `"…"`, `$tag$…$tag$` and both comment forms in a single alternation, because each form can contain another's opener — so `-- still typing` reads as an empty box. It used to feed a write-keyword scan in this file and that scan is gone; **do not cite it as a security property**. It once said an unterminated quote "leaves the rest visible — the guard fails closed": true of the scan it fed, meaningless now, and it never covered `$tag$` or slash-star, whose surviving character is `$` or `/`.
29
- - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider: a bare `catch` in `dev/panel-live.ts` reported an authz refusal and a dropped NATS connection from a *running* sync node as `dev.live.no-sync-node`, telling the reader to install a tier they already had. Everything else reaches `panelPayload`, which renders its code and its fix.
30
- - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — it used to build `/${entity}s/${id}`, which is English pluralisation by concatenation and drops `basePath`. The route table is `AdminApp`'s; a widget three layers down does not get to guess it.
31
- - **`assertReadOnly` answers a VERDICT carrying the runnable string, and the panel executes THAT.**
32
- `assertReadOnlyQuery` documents that what it returns is what the caller must execute — every
33
- check ran on a stripped form and the return is the reconciled one — and `panel-db.ts` discarded
34
- it and ran the textarea's bytes. Benign only for as long as `verbatim()` normalises nothing more
35
- than a trailing `;`, which is a promise no other file is keeping; `@ultimat3/mcp`'s own
36
- `dev-server.ts` honours the contract, and a `string | null` return here made it impossible to.
37
- `ReadOnlyVerdict` is exported from `@ultimat3/admin/dev` beside it.
38
- - **`decideAll([])` DENIES.** `permissions[length - 1] ?? ''` fell through to `allowed('')`, so a
39
- declared-but-empty gate opened for every actor, anonymous included, and named no permission at
40
- all. `visibleNav` hands an author's `item.permissions` straight to it, so `permissions: []` on a
41
- nav item was that gate. `pages.ts` already refuses an empty PAGE list at declaration
42
- (`X_ADMIN_PAGE_UNGUARDED`); this is the same rule at the seam every surface shares, which is
43
- where the ones that never pass through `defineAdmin` are decided. Reason
44
- `admin.policy.none-declared`.
45
- - **Two resources may not claim one `path:`** — `assertUniqueResourcePaths` in `admin.ts`, refused
46
- at `defineAdmin` with `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`). `adminRouteFor`
47
- resolves by `.find()`, so a duplicate produced EIGHT routes over FOUR paths and the second
48
- resource's four screens were unreachable, silently, with the dashboard rendering. Identical
49
- argument to the duplicate action NAME one line below it and to `pages.ts`'s shadow check — the
50
- same `taken` set, one step earlier. **A currently-booting app with a duplicate path now refuses
51
- at boot.**
52
- - **The audit diff is TOTAL over every value a row holds.** `same()` compared with
53
- `JSON.stringify`, which THROWS on a bigint — and `money()` puts one on the row
54
- (`widget-value.ts`), so every update of a money-bearing row raised, AFTER `repo.update()` had
55
- committed: the write landed, the caller got an uncoded `TypeError`, and the log stayed empty.
56
- `canonicalJson` from `@ultimat3/core`, the same answer `packages/manifest/src/diff-routes.ts`
57
- gives to the same question. `crud.test.ts`'s fixture entity carries a `money()` column and its
58
- repo CLONES on read, because two reads of one row are two objects and a shared reference
59
- short-circuits the comparison the diff exists to make.
60
- - **A repo that THROWS leaves a `failed` entry.** `AuditOutcome` declared the member and `crud.ts`
61
- emitted it in exactly one place — `invalid()`, for a validation issue — so a constraint
62
- violation, a statement that timed out after committing and a dropped connection each left nothing
63
- at all. `auditedWrite` wraps the three repo writes: try, record, re-throw UNCHANGED. A mutation
64
- cannot append BEFORE the call the way `search.ts` does for a read; that would record a write
65
- which may never have happened. The reason is a key (`admin.audit.write-failed`), never anything
66
- read off the thrown value.
67
- - **Nothing is read off a caught value in `action-gate.ts`, and the append runs first.** It built an
68
- `AdminDecision` whose `trace` was `String(error)` — and a `catch` binding is annotated by nobody,
69
- so `Object.create(null)` raised `TypeError: No default value` from inside the block that owed the
70
- auditor an entry: measured, ZERO entries and the caller received the TypeError. The decision
71
- object was DEAD anyway (only its `reason` was ever read; `append` takes no trace), so there is no
72
- destination for a rendered value and `renderThrowable` is not needed either.
73
- - **`decideAll`'s and `/_x`'s record indexing is `Object.hasOwn` / a `Map`, never a bare index.**
74
- `ADMIN_PERMISSION_SPEC[permission]` consulted the PROTOTYPE CHAIN, so a polluted
75
- `Object.prototype` gave any granted permission an `implies` the table never declared — and
76
- `expandPermissions` walks it. `panel-cache.ts`, `panel-routes.ts` and `panel-policy.ts` counted
77
- into plain objects, where `__proto__` reads a prototype (so `?? 0` never fires) and WRITES through
78
- the setter, dropping the row: the policy matrix reported a permission unreachable while an actor
79
- held it. `Map` + `Object.fromEntries`, which DEFINES each key.
80
- - **A `/_x` panel orders by CODE UNIT, never `localeCompare`.** `dev/panel-routes.ts` sorted its
81
- rows with it, and with no locale argument `localeCompare` answers from the runtime's ICU default
82
- locale and collation version — so the same route table reads in a different order on a
83
- developer's machine than in the container the same build runs in, and a screenshot of one does
84
- not match the other. `(a < b ? -1 : a > b ? 1 : 0)`, inlined: `@ultimat3/render`'s `byCodeUnit`
85
- is package-internal, and a comparator is not worth widening a barrel for.
86
- - **The locale picker reads `registeredLocales()`, not a bundled list.** It was
87
- `['en','es','de','fr','pt','ja']`, one line under a comment forbidding exactly that for IANA
88
- zones: an app registering `it` could not pick it, and an app with only `en` was offered five
89
- locales it renders `⟦key⟧` for. No fallback — an app with no catalog has no locale to offer, and
90
- inventing one is the admin declaring what the app did not.
91
- - **One read-only SQL guard, and it is `@ultimat3/mcp`'s — the whole verdict, with nothing held back.** `dev/panel-db.ts` calls `assertReadOnlyQuery` (tier 5 → tier 4, already a dependency) rather than keeping a second keyword scan: that guard also refuses a batch, a call into the `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families, `FOR UPDATE`, and a delimiter that never closes in all five forms (`'`, `E'`, `"`, `$tag$`, slash-star). A local unterminated-delimiter refusal lived here for one revision and was **deleted**: it tested for a surviving `'`/`"`, so it covered three of the five and called a dollar-quoted body "a quote" — one failure mode, two explanations, and a second detector for a property the guard below already tests. What stays local is the emptiness test and the **way out**: `@ultimat3/mcp` tells its caller to expose an action, which a developer at `/_x` cannot act on. The panel says "Fix the statement, or — if it is meant to write — run it with `x db psql --write`", conditional on purpose: `--write` grants writes, it does not close a delimiter, and it used to be printed as *the* fix for a syntax error.
92
- - **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row. `adminSearch` was the second, and it read rows out of EVERY readable entity: `AdminSearchResult.audit` now carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one. A resource skipped for having no text field or no repo is not an authz event and writes none.
35
+ - **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` — as do `batch.ts` (one row at a time) and `relations.ts` (label reads chunked at 200 ids, never cut). 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.
36
+ - **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
37
+ - **This catalog is the framework's one opt-OUT MCP surface, deliberately**: `mcp-tools.ts` does not call `isMcpExposed` — every tool is already gated on an admin permission. `mcp: { expose: false }` withdraws one. A *second* exception anywhere is the bug.
38
+ - **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
39
+ - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider — in `panel-live`, `panel-db`, `panel-timeline` and `panel-cache` alike (`dev/panel-degrade.test.ts`). Everything else reaches `panelPayload`, which renders its code and its fix.
40
+ - **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — the route table is `AdminApp`'s.
41
+ - **`panel-db.ts` executes the string `assertReadOnlyQuery` RETURNS**, never the textarea's bytes (`ReadOnlyVerdict`).
42
+ - **`decideAll([])` DENIES** (`admin.policy.none-declared`): an empty gate is a refusal for everyone, never a grant.
43
+ - **Two resources may not claim one `path:`** — `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`) at `defineAdmin`, every generated path included.
44
+ - **The audit diff is TOTAL over every value a row holds** — compared with core's `canonicalJson`, never `JSON.stringify` (a `money()` bigint throws there, after the write committed).
45
+ - **A write and its entry commit together** (`atomicallyAudited`, `crud-outcome.ts`): a CRUD write, an action's handler, `matching` and a queued batch's enqueues each run inside `audit.atomic` with their `allowed` entry, so an entry that cannot be written rolls the work back; ONE `failed` entry is appended after, outside, and the thrown value is re-thrown UNCHANGED with nothing read off it. A schema refusal and a missing row are `failed` too. `audit-atomic(.contract).test.ts`.
46
+ - **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
47
+ - **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
48
+ - **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
49
+ - **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
50
+ - **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
51
+ - **Every admin operation is audited, reads included.** `ListResult` carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`). `AdminSearchResult.audit` carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one; a resource skipped for no text field, no repo or a repo that THREW (`admin.search.skipped.failed`) is listed in `skipped`, never a 500 for the rest.
52
+ - **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
53
+ - **An action's `when(row)` is asked by the button AND by the call** (`actionApplies`, `action-gate.ts`): a row it excludes is `X_ADMIN_ACTION_NOT_APPLICABLE` (409 on a screen, the code over MCP), audited `admin.error.action-not-applicable`. The row it reads carries no sealed column. Every action on one row runs through `invokeRowAction` (`findRow` first).
54
+ - **An action with an `input` schema is a form, never a bare post**: its button links to `<row>?action=<name>`, the form posts `input.<field>` (`action-input.ts`, through `tryIntrospect`), the gate validates against the schema and a refusal is a 422 with issues on their fields. No schema: a confirm only.
55
+ - **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler; counts `done`/`refused`/`failed`/`queued`/`remaining`; one audit entry per row. Selection is checked ids or "all matching" — the list URL's own `listWhere`, keyset by id, `MAX_BATCH_ROWS` inline / `MAX_BATCH_QUEUED_ROWS` queued per request. `batch: { threshold }` queues one `admin.batch` job per chunk (`batch-queue.ts`, `batch-job.ts`) under a DERIVED `batchIdOf` (actor, action, rows, input — not the request id), so a retry dedupes; the worker runs it AS the operator. Zero JS: the bar is one form (`batch-bar.tsx`), row checkboxes join it by `form=`; a destructive or input-taking batch is a server round trip. ONE MCP tool per action — a batch action's tool takes `ids`.
56
+ - **Sections and form groups never hide a field** (`resource-layout.ts`): undeclared fields fall into a default group drawn last; a name that is not a drawn field, or claimed twice, is refused at `defineAdmin`. `on: 'create' | 'update'` is enforced on the write too (`onlyFor`, `crud-input.ts`).
57
+ - **A related list IS the related resource's list** (`related.ts`): a `hasMany` of the entity, read through `adminList` (its gate, its `rows`), filtered to this row; a resource the actor may not list is left out. One label read per target for the whole detail page.
58
+ - **The audit log is the store, a sink is a copy.** `AuditLog.entries()` is async, keyset (`before`), `changes: true` drops allowed reads; `atomic(run)` puts a write and its entry in one transaction (`auditedWrite`). `postgresAuditLog()` writes `x_admin_audit` (`audit-pg.ts`), skips allowed reads unless `reads: true`. `audit-pg.contract.test.ts` boots PGlite.
59
+ - **The jobs dashboard is every admin's, built from these pieces only** (`jobs/`): four resources — `x_jobs`, `x_job_queues`, `x_job_tasks`, `x_job_workers` — each with a `repo:` over `JobIntrospection` (`job-repo.ts`, `fleet-repo.ts`), scopes, a row scope and `job-actions.ts`'s `AdminAction`s, plus ONE screen (`overview.tsx`, the view `jobs`). `job:read` lists them (the resources' `permission` noun is `job`), `job:manage` runs every action. An actor with an `orgId` sees that org's job rows and none of the fleet. `jobs/boundary.test.ts` refuses a `fetch(`, a socket, an event stream or a hand-written table there; `/_x/jobs` draws `JobsOverview` over the dev queue (`dev/panel-jobs.ts` → `jobs/overview-tab.tsx`, dynamically).
60
+ - **A job-store predicate the store cannot answer is refused by name** (`job-where.ts`, `X_ADMIN_FILTER_INVALID`): one order (`createdAt desc`), equality on state/queue/name/tenant, an id PREFIX for the id box. `listScreen` answers a repo's refusal 400, as the URL's. "All matching" over jobs is `AdminAction.matching` onto the store's bulk verbs, never a walk.
61
+ - **A resource's permissions are named after its `permission` noun** (`resource.permission`, default the entity name) — every `permissionsForOperation` site reads it, never `resource.name`.
62
+ - **`matching` is "all matching" as ONE call** (`batch-matching.ts`): the list's `where`, gated by the batch's grant, confirmed as `<entity>:all matching`, audited once (`admin.batch.matching`). A checked selection never reaches it.
63
+ - **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
64
+ - **An action's input field is labelled `admin.input.<action>.<field>`** — never under `admin.action.<action>`, which is a leaf: a JSON catalog cannot hold both.
65
+ - **`x_admin_audit`'s DDL is the leaf `audit-schema.ts`** (`@ultimat3/admin/schema`): the boot applies it in every role; `serve-graph.test.ts` exempts exactly that file.
93
66
  - Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
94
67
  - **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
95
- - Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. MCP tool descriptions are literal English (protocol payload, not UI copy).
68
+ - Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. An action's is `actionLabelKey` (`action-label.ts`), never respelled. `AdminApp.catalogKeys()` (`catalog-keys.ts`) is every derived key — the CLI's `i18n` step audits exactly that list. MCP tool descriptions are literal English (protocol payload, not UI copy).
96
69
  - Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
97
- - **One owner of `globalThis.React`, and it is `@ultimat3/ui`'s.** `inert-jsx.ts` keeps the walkers (`nodesOf`, `shallowNodesOf`, `renderNodes`, `renderHtml`) and delegates install/restore to `probe`/`unprobe` from `@ultimat3/ui/jsx-probe` — tier 5 → tier 4, downward, no declared edge needed. It kept its own `depth`/`saved` pair until 2026-08-22 and two counters over one property restore in the wrong order: admin installs (saving the real binding), ui installs (saving ADMIN's factory), admin restores, ui restores admin's factory back over the top. `bun test` seats both packages in one process, so the interleave is reachable; `inert-jsx.test.ts` drives it in both orders.
98
- - Views are pure functions of props — no `createSignal`, no local state; the route owns it.
70
+ - **One owner of `globalThis.React` is `@ultimat3/ui`'s** (`probe`/`unprobe`); `inert-jsx.ts` keeps only the walkers. `inert-jsx.test.ts` drives both install orders.
71
+ - **A `/_x` panel orders by CODE UNIT, never `localeCompare`**; **the locale picker reads `registeredLocales()`**, never a bundled list.
72
+ - Views are pure functions of props — no `createSignal`, no local state, no event handler.
73
+ - **No test reaches a `.tsx` statically.** `routes.ts` reaches the screens, so a test does `await import('@ultimat3/render/server')` and then `await import('./routes')`: a static import compiles JSX before the loader exists and every later render dies with `React is not defined`.
99
74
 
100
75
  ## Layout
101
76
 
102
77
  | File | Owns |
103
78
  |---|---|
104
- | `admin.ts` | `defineAdmin` → resources, nav, route table, audit, authz |
105
- | `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the wrapper that decides before it renders |
106
- | `routes.ts` | the only `defineRoute` caller: mode per view, `policy` composed from the route's permissions |
79
+ | `admin.ts` / `mounts.ts` / `actor.ts` | `defineAdmin` → resources, repos, nav, route table, audit, authz / the registry a host mounts from / the default actor |
80
+ | `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the refusal a screen renders and audits |
81
+ | `route-config.ts` / `routes.ts` / `screens.ts` | the render bridge: `defineRoute`, `registerMountedRoutes` / route → screen, `adminRouteMatch` / view → screen |
82
+ | `screen-frame.tsx` / `screen-resource.tsx` / `screen-lookup.tsx` / `screen-home.tsx` / `screen-system.tsx` | request/response shape, shell, `guardedScreen` (a body's refused URL is a 400), `widgetContextOf` / list·detail·create·edit and their writes / the picker / dashboard / audit·search |
83
+ | `list-request.ts` / `list-filters.ts` / `list-scope.ts` | URL ⇄ list request / operators per shape + the validator / row scope → scope → filters, `findRow`, `scopeCounts` |
84
+ | `list.tsx` / `list-filter-bar.tsx` / `list-scope-tabs.tsx` / `renderers.tsx` | the list view / one GET form / tabs as links / computed cells |
85
+ | `resource-list.ts` / `relations.ts` / `describe.ts` | `scopes`·`rows`·`columns` normalised / labels + lookup, one read per target / the admin as manifest data |
86
+ | `repo-entity.ts` / `keyset-seek.ts` / `form-decode.ts` / `row-version.ts` | typed handle → `AdminRepo` / the `(sort, id)` seek / posted form → typed input / an edit's version |
107
87
  | `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
108
88
  | `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
109
- | `crud.ts` / `action-gate.ts` | policy → confirmation → validation → repo → audit |
89
+ | `crud.ts` / `crud-input.ts` / `crud-outcome.ts` / `action-gate.ts` / `row-scope-write.ts` | policy → confirmation → validation → repo → audit / secrets, tenant, sides, diff / the entry of a write that did not happen / a write's result vs `rows` |
90
+ | `action-row.ts` / `action-input.ts` / `action-form.tsx` / `screen-action.tsx` | one action on one row / input schema ⇄ form fields / the action's form / row-action and batch writes |
91
+ | `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
92
+ | `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
93
+ | `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
110
94
  | `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
95
+ | `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
96
+ | `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
97
+ | `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
111
98
  | `dev/server.ts` + `dev/panel-*.ts` | `/_x` mount guard and one panel per file |
112
99
 
100
+ Why each rule is shaped the way it is: [`docs/history/admin.md`](../../docs/history/admin.md).
101
+
113
102
  ## Commands
114
103
 
104
+ From the repo root — the seal-key preload is the root's, so `bun test --filter` from this
105
+ directory fails on the first sealed column:
106
+
115
107
  ```bash
116
- bun test --filter @ultimat3/admin
117
- bun run --filter @ultimat3/admin typecheck
108
+ bun test packages/admin
109
+ bunx tsc --noEmit -p packages/admin/tsconfig.json
118
110
  ```