Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ temp/

# Generated site output
site/dist/

# CLI test fixtures (written and removed per run; a crashed run leaves them)
packages/cli/.app-fixture/
packages/cli/.dev-fixture/
packages/cli/.mcp-fixture/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ A package may import from strictly lower tiers. Never sideways within a tier, ne
| 4 | `render`, `pwa`, `mcp`, `ai`, `manifest`, `mail` |
| 5 | `ui`, `admin`, `testing`, `cli` |

Declared sideways edges, each earning its line: `admin → ui`, `realtime → query`, `create-ultimate → cli`.
Declared sideways edges, each earning its line: `admin → ui`, `realtime → query`, `cli → admin`, `create-ultimate → cli`.

**`db` is tier 1, decided 2026-08.** It imports `core` and nothing else, so tier 1 is the lowest its real imports allow — and that is what lets `entity` (tier 2) hold its own Postgres driver (`postgresDriver()`) instead of exiling it to a tier-3 package. Two things would have been wrong: a second package owning `Driver`'s only production implementation (two places to look for "where rows live"), and `database()` callers importing the seam from one package and the driver from another. Same shape as `auth → db`.

Expand Down
2 changes: 2 additions & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions docs/architecture/01-package-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tier 5 ui, admin, testing, cli (may import tier 0-4)
| `schema` → `core` | needs `UltimateError` for parse failures. `core` imports nothing. |
| `admin` → `ui` | the admin dashboard *is* the ui kit, composed. Inverting it ships every widget through props. |
| `realtime` → `query` | tier 3 is one feature: a live query is a query plus a subscription. Splitting duplicates the SQL shape. |
| `cli` → `admin` | `x dev` **mounts** `/_x`; it does not reimplement it. The panels are a tier-5 product, and the alternative is a second dev dashboard inside the CLI. |
| `create-ultimate` → `cli` | a published shim whose whole job is `x new`. The alternative is a second copy of the templates. |
| everything else | none. Siblings share **types only**, declared in the lowest tier that needs them. |

Expand Down
2 changes: 1 addition & 1 deletion packages/admin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Tier 5. May import tiers 0–4. Nothing imports this.

Two products, one package: `src/dev/*` = the `/_x` dev dashboard (dev only), `src/*` = the generated app admin (production, authz'd, AI-first). Keep them apart; nothing in `src/dev/` may be imported by an admin view.
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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Rules

Expand Down
8 changes: 6 additions & 2 deletions packages/admin/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# @ultimat3/admin 🛠️

**Two dashboards live here. They are not the same thing.**
**Two dashboards live here. They are not the same thing — and they have two doors.**

| | `/_x` — framework dev dashboard | `admin` — generated app admin |
|---|---|---|
| Import | `@ultimat3/admin/dev` | `@ultimat3/admin` |
| Audience | you, debugging the framework | your operators, and their agents |
| Environment | development **only** — mounting it with `env=production` or `role=production` throws `X_DEV_DASHBOARD_IN_PROD` | production |
| Authz | none: it is your own machine | the app's policies, one decision per surface |
Expand All @@ -27,13 +28,16 @@ One panel per file. Each kills one question, and each is available as `--json`
| `manifest` | emitted `x.manifest.json` diffed against the committed one |

```ts
import { devDashboard, defaultDevSources } from '@ultimat3/admin';
import { devDashboard, defaultDevSources } from '@ultimat3/admin/dev';

const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
const response = await dev.handle(request); // null when the path is not /_x
await dev.json('jobs'); // the same payload `x dev --panel jobs --json` prints
```

The root barrel does not re-export any of this: `x dev` mounts `/_x` without pulling a Solid
component tree into the process, and an admin view cannot reach a dev panel by accident.

## The generated admin

One call, a working CRUD admin: columns from the entity's columns, filters from indexed columns, validation from the entity's schema, labels from i18n keys.
Expand Down
3 changes: 2 additions & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"provenance": true
},
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./dev": "./src/dev/index.ts"
},
"files": [
"src",
Expand Down
49 changes: 49 additions & 0 deletions packages/admin/src/dev/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The `/_x` dev dashboard's own door, reached as `@ultimat3/admin/dev`.
//
// Separate from the package root on purpose. `src/dev/*` is a development tool and `src/*` is
// production app-admin code built on Solid components — one entry point for both would mean the
// CLI, whose only job here is to MOUNT `/_x`, pulls a component tree into every `x dev` process.
// Two products, two doors: this is the boundary `dev/data.ts`'s dynamic imports already assume.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export { type DevSourceOptions, defaultDevSources, staticDevSources } from './data';
export type {
CacheEdgeFact,
ColumnFact,
DevSources,
DriftFact,
InvalidationFact,
JobDefFact,
JobRunFact,
JobStepFact,
LiveQueryFact,
LiveSubscriberFact,
MailFact,
ManifestFact,
PolicyFact,
QueueFact,
RequestTrace,
RouteFact,
SpanKind,
SqlResult,
TableFact,
TaskFact,
TimelineSpan,
} from './facts';
export { type DevPanel, type PanelPayload, panelPayload } from './panel';
export { type CachePanelData, cachePanel } from './panel-cache';
export { assertReadOnly, type DbPanelData, dbPanel } from './panel-db';
export { type JobsPanelData, jobsPanel } from './panel-jobs';
export { type LivePanelData, livePanel } from './panel-live';
export { type MailPanelData, mailPanel } from './panel-mail';
export { type ManifestPanelData, manifestPanel } from './panel-manifest';
export { type PolicyPanelData, policyPanel } from './panel-policy';
export { type RoutesPanelData, routesPanel } from './panel-routes';
export { type TimelinePanelData, timelinePanel } from './panel-timeline';
export {
assertDevOnly,
DEV_BASE_PATH,
DEV_PANELS,
type DevDashboard,
type DevDashboardOptions,
devDashboard,
} from './server';
44 changes: 2 additions & 42 deletions packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,48 +77,8 @@ export {
permissionsForOperation,
} from './crud';
export { AdminDetail, type AdminDetailProps } from './detail';
export { type DevSourceOptions, defaultDevSources, staticDevSources } from './dev/data';
export type {
CacheEdgeFact,
ColumnFact,
DevSources,
DriftFact,
InvalidationFact,
JobDefFact,
JobRunFact,
JobStepFact,
LiveQueryFact,
LiveSubscriberFact,
MailFact,
ManifestFact,
PolicyFact,
QueueFact,
RequestTrace,
RouteFact,
SpanKind,
SqlResult,
TableFact,
TaskFact,
TimelineSpan,
} from './dev/facts';
export { type DevPanel, type PanelPayload, panelPayload } from './dev/panel';
export { type CachePanelData, cachePanel } from './dev/panel-cache';
export { assertReadOnly, type DbPanelData, dbPanel } from './dev/panel-db';
export { type JobsPanelData, jobsPanel } from './dev/panel-jobs';
export { type LivePanelData, livePanel } from './dev/panel-live';
export { type MailPanelData, mailPanel } from './dev/panel-mail';
export { type ManifestPanelData, manifestPanel } from './dev/panel-manifest';
export { type PolicyPanelData, policyPanel } from './dev/panel-policy';
export { type RoutesPanelData, routesPanel } from './dev/panel-routes';
export { type TimelinePanelData, timelinePanel } from './dev/panel-timeline';
export {
assertDevOnly,
DEV_BASE_PATH,
DEV_PANELS,
type DevDashboard,
type DevDashboardOptions,
devDashboard,
} from './dev/server';
// The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
// host that only mounts the dev panels never loads a production admin component.
export {
ADMIN_ERROR_CODES,
AdminEntityUnknownError,
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,36 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
| Subprocesses | only through `exec.ts`, so a test can inject a fake `Runner` |
| Templates | `templates/*.ts` return strings; no fixture files on disk |
| Strings | `messages.ts` flat catalog, missing key renders `⟦key⟧` |
| Facts | load the app (`app-load.ts`), then project it — never parse source for primitives |

Every fact the CLI reports comes from a framework package: the manifest from
`@ultimat3/manifest`, `openapi.json` from `@ultimat3/action`, the route table from
`@ultimat3/render`, budget units from `@ultimat3/render`, the `/_x` panels from
`@ultimat3/admin`, the MCP tool catalog from `@ultimat3/mcp`. A check that reimplements one
of those here is the bug, not the fix.

`cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`): `x dev` **mounts** the
dashboard, it never grows a second one. The CLI's only contribution is the facts no registry
holds — a SQL runner, the committed manifest, the process's own services — supplied as
`defaultDevSources({ hooks })`.

## `x dev` boots the app; it does not simulate one

| File | Job |
|---|---|
| `dev-services.ts` | resolve which service each binding points at — embedded or external |
| `dev-runtime.ts` | start them and install the ambient accessors (`db()`, `jobDriver()`, storage, transport) |
| `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function |
| `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
| `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
| `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
| `cmd-dev.ts` | boot order, mounting `/_x`, the file watcher |
| `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
| `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |

The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list here. A dev-only
driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the
only thing dev changes is which driver is behind an interface.

Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.

Expand Down
16 changes: 13 additions & 3 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The `x` binary. One char during dev, one command per job, `--json` on every one
| `x verify` | **the gate** | 15 named steps, each with pass/fail + duration |
| `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
| `x db gen\|migrate\|reset\|studio\|branch` | everything DB | `branch` = copy-on-write clone + preview URL |
| `x mcp serve` | MCP over stdio or HTTP | read tools unrestricted in dev |
| `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
| `x doctor` | environment, ports, drift, PWA prerequisites | every finding carries a fix command |
| `x deploy` | container deploy plan | compose or helm; zero platform primitives |
| `x manifest` / `x routes` | generated facts | `x.manifest.json`, `openapi.json`, route table |
Expand Down Expand Up @@ -54,11 +54,21 @@ There is no `--only` and no `--skip`; the exit code is non-zero if any step fail
| `registry.ts` | the one command list |
| `cmd-*.ts` | one command group each |
| `templates/` | scaffolding as typed string modules, not copied fixtures |
| `surfaces.ts` | app import boundaries (site→app, shared leaf, route→DB) |
| `app-load.ts` | import an app's modules so the framework registries hold it |
| `app-manifest.ts` | `x.manifest.json`, projected by `@ultimat3/manifest` |
| `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
| `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
| `dev-*.ts` | what `x dev` boots: services, runtime, routes, hooks, roles, the `/_x` mount |
| `mcp-host.ts` | the shell-side half of `@ultimat3/mcp`'s dev server — db, tests, logs, verify |
| `verify-step.ts` | the step shape, the step names, the host-check hook |
| `verify-tests.ts` | one `bun test` invocation per test type |
| `workspace-checks.ts` | file-size ceiling and package contract files |
| `drift.ts` `openapi.ts` `budgets.ts` `manifest-scan.ts` | the checks `x verify` composes |
| `drift.ts` `budgets.ts` | the checks `x verify` composes |

The CLI describes an app by **loading** it, never by parsing it: `action()`, `entity()`,
`job()` and `defineRoute()` register themselves, and `x manifest`, `x routes` and `x verify`
read the same tables the running server reads. There is no second definition of a primitive,
no second OpenAPI builder and no second surface-boundary walk anywhere in this package.

## Generated file layout

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,21 @@
},
"dependencies": {
"@ultimat3/action": "0.0.1",
"@ultimat3/admin": "0.0.1",
"@ultimat3/core": "0.0.1",
"@ultimat3/db": "0.0.1",
"@ultimat3/entity": "0.0.1",
"@ultimat3/http": "0.0.1",
"@ultimat3/i18n": "0.0.1",
"@ultimat3/jobs": "0.0.1",
"@ultimat3/mail": "0.0.1",
"@ultimat3/manifest": "0.0.1",
"@ultimat3/mcp": "0.0.1",
"@ultimat3/policy": "0.0.1",
"@ultimat3/query": "0.0.1",
"@ultimat3/realtime": "0.0.1",
"@ultimat3/render": "0.0.1",
"@ultimat3/storage": "0.0.1",
"@ultimat3/testing": "0.0.1"
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { describe, expect, test } from 'bun:test';
import type { SourceFile } from './surfaces';
import { checkSurfaceRules, surfaceOf } from './surfaces';
import type { SourceFile } from './app-boundaries';
import { checkImportRules, resolveSpecifier } from './app-boundaries';

const file = (path: string, source: string): SourceFile => ({ path, source });

describe('unit · app boundaries', () => {
test('site/ importing app/ is a build error, not a warning', () => {
const findings = checkSurfaceRules([
const findings = checkImportRules([
file('apps/web/site/pricing/page.tsx', "import { Chart } from '../../app/charts';"),
]);
expect(findings.map((finding) => finding.code)).toEqual(['X_BOUNDARY_SITE_TO_APP']);
Expand All @@ -15,49 +15,74 @@ describe('unit · app boundaries', () => {
});

test('shared/ is a leaf: it may not import a surface', () => {
const findings = checkSurfaceRules([
const findings = checkImportRules([
file('apps/web/shared/ui/button.tsx', "import { useSession } from '../../app/session';"),
]);
expect(findings[0]?.code).toBe('X_BOUNDARY_SHARED_LEAF');
});

test('app/ may import api/ types but not api/ values', () => {
const typeOnly = checkSurfaceRules([
const typeOnly = checkImportRules([
file('apps/web/app/orders/page.tsx', "import type { Order } from '../../api/orders';"),
]);
expect(typeOnly).toEqual([]);
const runtime = checkSurfaceRules([
const runtime = checkImportRules([
file('apps/web/app/orders/page.tsx', "import { placeOrder } from '../../api/orders';"),
]);
expect(runtime[0]?.code).toBe('X_BOUNDARY_APP_TO_API');
});

test('a route may not touch the database', () => {
const findings = checkSurfaceRules([
const findings = checkImportRules([
file('apps/web/app/orders/page.tsx', "import { db } from '@acme/db';"),
]);
expect(findings[0]?.code).toBe('X_BOUNDARY_ROUTE_TO_DB');
expect(findings[0]?.fix).toContain('x g query');
});

test('a service may not know about HTTP', () => {
const findings = checkSurfaceRules([
const findings = checkImportRules([
file('apps/web/app/orders/service.ts', "import { request } from '@ultimat3/http';"),
]);
expect(findings[0]?.code).toBe('X_BOUNDARY_SERVICE_TO_HTTP');
});

test('a legal import graph produces no findings', () => {
expect(
checkSurfaceRules([
checkImportRules([
file('apps/web/site/page.tsx', "import { Button } from '../shared/ui/button';"),
file('apps/web/app/orders/repo.ts', "import { db } from '@acme/db';"),
]),
).toEqual([]);
});

test('surfaceOf reads the surface out of a path', () => {
expect(surfaceOf('apps/web/site/page.tsx')).toBe('site');
expect(surfaceOf('packages/domain/src/index.ts')).toBe('unknown');
// The upgrade over the CLI's own former check: the import that costs you is two hops away
// from the file anyone reviewed, and a direct-imports-only walk finds nothing.
test('a transitive site/ -> shared/ -> app/ chain is caught, and the chain is named', () => {
const findings = checkImportRules([
file('apps/web/site/pricing.tsx', "import { Price } from '../shared/price';"),
file('apps/web/shared/price.ts', "import { rate } from '../app/rates';"),
file('apps/web/app/rates.ts', 'export const rate = 1;'),
]);
const codes = findings.map((finding) => finding.code);
expect(codes).toContain('X_BOUNDARY_SITE_TO_APP');
expect(codes).toContain('X_BOUNDARY_SHARED_LEAF');
const chained = findings.find((finding) => finding.code === 'X_BOUNDARY_SITE_TO_APP');
expect(chained?.cause).toContain('apps/web/site/pricing.tsx');
expect(chained?.cause).toContain('apps/web/app/rates.ts');
expect(chained?.at).toBe('apps/web/shared/price.ts');
});

test('a specifier resolves onto the graph key it names, extension and all', () => {
const keys = new Set(['apps/web/shared/price.ts', 'apps/web/shared/ui/index.tsx']);
expect(resolveSpecifier('apps/web/site/page.tsx', '../shared/price', keys)).toBe(
'apps/web/shared/price.ts',
);
expect(resolveSpecifier('apps/web/site/page.tsx', '../shared/ui', keys)).toBe(
'apps/web/shared/ui/index.tsx',
);
expect(resolveSpecifier('apps/web/site/page.tsx', '@ultimat3/http', keys)).toBe(
'@ultimat3/http',
);
});
});
Loading
Loading