# SOP — AI App Builder (`ai-builder`)

> **Status:** Design v1 (research + decisions locked). The scaffolder package
> implements this document.
> **Source projects studied:** `../korm-js` (`@dreamtree-org/korm-js`),
> `../dreamtree-ui` (`@dreamtree-org/twreact-ui`), `../billing` (`butic-v2-server`).
> **Scope:** apps that **don't need SEO** → client-rendered SPA (Vite + React) +
> Express API. No SSR / Next.js.

---

## 0. What this is

`ai-builder` is an **AI-native CLI** with three jobs. It (1) **scaffolds** a
complete, working full-stack app on disk pre-wired to the three layers, then gets
out of the way; (2) **`init`s** an app-building skill pack + MCP wiring into any
project; (3) is itself an **MCP server** (`ai-builder mcp`). It does **not** ship a
runtime framework and does **not** own the app after generation. **Aim: vibe coding
with minimal tokens, world-class consistent output, and a proper SDLC** — an agent
fetches the rules/contract/catalog live instead of re-reading large files.

```
ai-builder my-app                    # scaffold
  → Express + Vite/React skeleton
  → data layer  = @dreamtree-org/korm-js   (JSON-contract ORM)
  → UI layer    = @dreamtree-org/twreact-ui (compose only, never duplicate)
  → multi-tenant by default (platform DB + per-tenant DB)
  → RBAC: platform superadmin + tenant-admin, module-driven
  → CSS-variable theming (runtime-swappable, DB-loadable)
  → korm-js + twreact-ui AI skills + MCP servers wired in

ai-builder init --ai <provider>      # install the app-building skill pack + wire the ai-builder MCP
ai-builder mcp                       # run the MCP server (stdio): build_rules, data_contract,
                                     #   list_modules, describe_module, get_schema, get_registry, scaffold_plan
```

---

## 1. The three-layer architecture

| Layer | Package | Responsibility | Contract |
|---|---|---|---|
| **Data** | `@dreamtree-org/korm-js` | All DB access, multi-engine (MySQL/PG/SQLite) | `processRequest({action, where, data, with, ...}, Model)` |
| **Design** | `@dreamtree-org/twreact-ui` | All UI — React + Tailwind, dark-mode, tree-shakeable | `import { Button, Table, ... } from '@dreamtree-org/twreact-ui'` |
| **App** | *(generated by `ai-builder`)* | Composition: Express server, tenant routing, RBAC, pages | `POST /api/crud/:Model` → KORM |

**Pattern recognised across both libraries (the "AI-native trio"):** each ships
(1) a `bin` CLI with `init --ai <provider>`, (2) an MCP server whose catalog is
**derived from the source of truth** (korm: live schema; ui: `src/index.js` +
`doc/*.md`), (3) a hand-kept skill doc. The generated app **installs all three
layers' skills + MCP** so any AI assistant working in the app already knows the
data contract and the component catalog. **`ai-builder` now follows the same
pattern** (§10): `init --ai <provider>` installs its app-building skill pack and
`ai-builder mcp` serves the module catalog + build rules + scaffold plan — the
builder's own contribution to the trio.

---

## 2. Hard rules (the SOP every generated app obeys)

1. **All data access goes through KORM.** Routes call
   `req.context.korm.processRequest(body, Model, options)`. No hand-built SQL,
   no scattered `axios` — the frontend speaks the same contract via an `@api`
   registry aimed at `/api/crud/:Model`.
2. **Per-request tenant instance, never the singleton.** Use
   `req.context.korm` / `req.context.db` (set by `tenantResolver`). Module-level
   instances are for boot/CLI only. Mixing them leaks tenant data.
3. **Compose `twreact-ui`; never duplicate a component.** See §8. A missing
   component is a **GitHub issue against the twreact-ui repo**, not a local
   re-implementation.
4. **No raw colors.** All theme color flows through the CSS-variable token layer
   (§7). No `style={{ color: '#hex' }}`.
5. **Models own business logic; routes stay thin.** Validation, derived fields,
   side effects live in `models/*.model.js` hooks.
6. **Schema is generated, not hand-authored.** `schema/sync.json` is the source
   of truth; change it via the schema workflow (§6), not by editing JSON by hand.
7. **One module registry drives the app** (§4) — permissions, nav, routes, and
   the model→module map all derive from it. Don't author those four things
   independently.
8. **Never assume — ask with clickable options.** (Inherited from all three repos'
   `user_interaction_rules`.)
9. **Don't commit secrets.** `.env` is gitignored; the generated repo ships
   `.env.example` only.
10. **Every generated app is an installable PWA, responsive across all screen
    sizes, with a mobile-native feel.** Phone → tablet → laptop → desktop →
    ultrawide. No fixed-width desktop-only layouts. See §13.
11. **Depend on the published packages only.** No relative/local paths to
    `korm-js` or `twreact-ui`. AI skills come from
    `npx @dreamtree-org/<pkg> init --ai <provider>`.

---

## 3. Generated app anatomy

```
my-app/
├── server.js                     # Express app, middleware stack, /api/crud/:Model
├── middleware/
│   └── tenantResolver.js         # domain → per-tenant Knex + KORM (cached)
├── config/
│   ├── database.js               # platform/master Knex connection
│   └── korm.js                   # KORM init + setSchema
├── modules.config.js             # ★ THE MODULE REGISTRY (single source of truth)
├── models/
│   ├── BaseModel.js              # THE auth gate: validate() runs on every KORM request (HTTP/MCP/CLI) + before*/after*/on*Action hooks
│   ├── BaseTenantModel.js        # + tenant/branch scoping + instance-grant row filtering
│   └── *.model.js                # e.g. User.model.js, ApiToken.model.js
├── schema/
│   ├── sync.json                 # schema source of truth (generated)
│   └── lastCommittedSync.txt
├── commands/
│   ├── schema-generate.js        # introspect DB → schema.json
│   ├── sync-db.js                # sync.json → DB (create/alter) + seed
│   ├── seed-db.js
│   └── seed-rbac.js              # seed permissions from modules.config.js
├── utils/                        # auth.js (JWT), rbac.js (effective set + can()), apiToken.js
├── client/                       # Vite + React SPA (installable PWA, responsive)
│   ├── vite.config.js            # @ aliases, /api proxy, vite-plugin-pwa
│   ├── index.html                # viewport-fit=cover, theme-color, apple icons
│   ├── public/manifest.webmanifest + icons/  # standalone, maskable  ← PWA
│   └── src/
│       ├── main.jsx              # <StoreProvider><ThemeProvider><App/>
│       ├── App.jsx               # router: public vs protected routes
│       ├── api/index.js          # @api registry → /api/crud/:Model
│       ├── theme/applyTheme.js   # set CSS vars from Settings/DB  ← NEW
│       ├── rbac/
│       │   ├── Can.jsx           # <Can module="billing" action="update">  ← NEW
│       │   ├── useCan.js         # hook  ← NEW
│       │   └── RequireAuth.jsx   # route guard (module-derived)
│       ├── layouts/DefaultLayout.jsx   # twreact-ui Navbar + Sidebar + main
│       └── pages/                # composed from twreact-ui ONLY
├── tests/                        # USER-JOURNEYS.md + unit/ (Vitest) + e2e/ (Playwright)
├── tracking/                     # ★ the app's own SSOT trio — agent memory (§16)
│   ├── plan.md                   #   active work + the Resume-here block
│   ├── big-picture.md            #   architecture, seeded from the registry
│   ├── backlog.md                #   bugs / quality / ideas
│   └── plans/                    #   archived completed plans (created on first archive)
├── .github/workflows/ci.yml      # unit + e2e gate on push/PR
├── .claude/skills/github-workflow/ # gh skill: gap→issue, branch→PR→merge→deploy
├── .claude/skills/github-projects/ # gh skill: board-driven planning + cross-agent comms
├── .claude/skills/image-assets/    # generate PNG icons/favicon/hero/logo on demand
├── CLAUDE.md                     # pointer map: where every answer lives (never rules)
│                                 # ── below: written by `--ai <provider>` at scaffold (default claude) ──
├── .claude/skills/dreamtree-app-builder/ # the build skill pack
├── .mcp.json                     # ai-builder MCP server (+ korm / twreact-ui once their init runs)
│                                 # other providers instead get .cursor/rules/, GEMINI.md,
│                                 # AGENTS.md or .github/copilot-instructions.md — the last
│                                 # three inside an <!-- ai-builder:start/end --> block
├── .env.example
├── .gitignore
├── Dockerfile / docker-compose.yml
└── package.json
```

> **Planned, not yet generated (roadmap iter 7+).** The cross-tenant *platform
> plane* (`platform/`, `commands/provision-tenant.js`) and a standalone
> `middleware/requirePermission.js` route guard are target design (see §5.1), not
> in the emitted template today. RBAC is currently enforced in **one place** —
> `BaseModel.validate()`, which KORM runs on every request (HTTP/MCP/CLI) — so a
> separate auth middleware is unnecessary for the single-tenant default.

---

## 4. The module registry — single source of truth

A module is a feature area (Billing, Inventory, CRM…). One file enumerates every
module; the scaffolder + seeders derive everything else from it.

```js
// modules.config.js
module.exports = {
  billing: {
    key: 'billing',
    label: 'Billing',
    icon: 'receipt',                                  // → twreact-ui Sidebar icon
    models: ['Bill', 'Estimate', 'Payment', 'Passbook'],  // → backend model→module map
    routes: ['/bills', '/bills/:id', '/estimates'],   // → route guard + nav
    actions: ['view', 'create', 'update', 'delete', 'export', 'approve'],
    // models that support ROW-LEVEL grants (who can touch WHICH row), keyed by id|slug:
    instanceScoped: [{ model: 'Passbook', identifier: 'slug', actions: ['view', 'use', 'update'] }],
  },
  inventory: { key: 'inventory', label: 'Inventory', icon: 'package',
    models: ['Product', 'Category', 'Stock'],
    routes: ['/products', '/categories'],
    actions: ['view', 'create', 'update', 'delete'] },
  // ...
};
```

**Derived automatically:**
- **Permission seed** = `module × action` rows (`seed-rbac.js`).
- **Backend model→module map** → each model's `this.module` + the CRUD verb let `BaseModel.validate()` resolve any `/api/crud/:Model` request to its `(module, action)`.
- **Frontend sidebar/nav** (twreact-ui `Sidebar`) and **route guards** (`RequireAuth` / `<Can>`).
- **Tenant-admin permission picker UI** (only shows modules the tenant has enabled).

This kills the three weaknesses found in billing: the free-text `artifacts`
field, the `can_*_model` name-string convention, and the dual (backend-name vs
frontend-route) source of truth.

---

## 5. RBAC design

### 5.1 Two control planes

```
PLATFORM DB (metadata / master)                 TENANT DB (one per tenant)
────────────────────────────────                ──────────────────────────────
tenants(id,name,domain,db_creds🔒,plan_id)      modules(id,key,name)   ← enabled set
modules(id,key,name,actions[],routes[])         permissions(id,module_key,action,scope)
plans(id,name) / plan_modules                   roles(id,name,weight,branch_id,is_system)
tenant_modules(tenant_id,module_id,enabled) ─┐  role_permissions(role_id,permission_id)
platform_users(superadmin / support)         │  user_roles(user_id,role_id)
platform_roles                               │  users(...)
                                             │  user_permissions(user_id,permission_id,
   superadmin toggles modules per tenant ────┘                    effect ENUM('allow','deny'))
```

> **Implementation status.** The generated app ships the **tenant-DB plane** today
> (roles, permissions, user/role grants, instance grants — all enforced by
> `BaseModel.validate`). The **platform plane** above (cross-tenant superadmin,
> `tenant_modules` toggling, `provision-tenant`) is target design on the roadmap
> (iter 7+); single-tenant apps don't need it.

- **Superadmin = a platform role** in the platform DB (not a tenant-DB string
  match like billing's `role_name.includes('superadmin')`). Creates tenants,
  toggles `tenant_modules`, seeds tenant default roles, can impersonate.
- **Tenant-admin = a tenant-DB system role** (`is_system=1`) auto-granted every
  action for the tenant's **enabled** modules; manages org roles/users/permissions
  but **cannot grant a permission for a module the tenant doesn't have enabled**.

### 5.2 Permission grain — `(module, action, scope)`

- `module`: FK to the module registry (e.g. `billing`).
- `action`: `view | create | update | delete | export | approve | …` (per-module list).
- `scope`: `own | branch | tenant | instance` — how wide the rows are. `branch`
  reuses billing's `branch_id` scoping in `BaseTenantModel`; `tenant` = all rows;
  `own` = rows created by the user; **`instance` = only specific rows the subject
  has been granted** (§5.5).

Effective permissions = `union(roles' permissions) − user denies + user grants`
(deny wins). Kept: `weight` hierarchy (an operator can't assign a role/permission
heavier than their own max).

### 5.3 Unified enforcement (one set, both ends)

- **Backend** — `BaseModel.validate()` (KORM runs it on every `processRequest`,
  so HTTP, MCP, and CLI share one gate): `/api/crud/:Model` → the model's `module`
  → CRUD verb → `action` → check effective set + scope. **One FK-backed check**,
  no prefix-regex, no separate route middleware.
- **Frontend** — login returns the effective `(module, action, scope)` set;
  `<Can module="x" action="y">`, `useCan()`, and `RequireAuth` (routes derived
  from enabled modules) all read that one set.

### 5.4 Auth/session (kept from billing, hardened)

JWT in an **httpOnly cookie**, verified per-request in `BaseModel.validate()` →
hydrates the user's effective permission set into `req.context.authUser`.

### 5.5 Instance-level (row-level) grants — "who can use WHICH row"

Some resources need access decided **per record**, not per module — e.g. *which
passbook may a user use* within the tenant. Modelled as relationship-based grants
(ReBAC-lite) layered on top of the `(module, action, scope)` capability check.

**Which models opt in:** declared in the registry's `instanceScoped` (§4). Only
those models are row-filtered; everything else stays at module/branch scope.

**`resource_grants` table (tenant DB):**
```
resource_grants(
  id,
  subject_type ENUM('user','role'),   -- grants target USERS by default;
  subject_id,                          --   'role' kept for forward-compat (off by default)
  resource_type      VARCHAR,          -- model key, e.g. 'passbook'
  resource_id        BIGINT NULL,      -- specific row by id …
  resource_slug      VARCHAR NULL,     -- … or by slug
  action             VARCHAR,          -- 'use' | 'update' | '*' (all module actions)
  effect             ENUM('allow','deny'),  -- deny wins
  branch_id          BIGINT,           -- stays inside tenant/branch scope
  created_by, created_at
)
```

**Enforcement (rides on KORM's `where` operators):**
- **list** on an instance-scoped model → `BaseTenantModel.beforeList` injects the
  granted id set into the request `where` using KORM's IN operator:
  `where[identifier] = "[]7,12,…"` (the user's allowed ids/slugs). Denies subtract.
  → the user simply *can't see* rows they weren't granted.
- **show / update / use / delete** on a specific instance → check a grant exists
  for that `resource_id`/`resource_slug` + action; else `403`.
- **Bypass:** a holder of `scope=tenant` on that `(module, action)` — and the
  tenant-admin — see all rows (instance filter is skipped). `own` and `branch`
  scopes still apply their filters.

**Default semantics:** instance-scoped models are **default-deny** — a user with
the module's `use` permission still sees no passbooks until granted specific ones
(this is exactly the "who can use which passbook" intent). Non-instance-scoped
models are unaffected.

**Delegation:** who may create grants for a resource is itself a permission
(`action: 'share'` or `'grant'` on the module), so a passbook owner/manager — not
only the tenant-admin — can grant peers access to *their* passbooks.

**Frontend:** `<Can module="billing" action="use" resourceType="passbook"
resourceId={pb.id}>`; `useCan('billing','use',{ resourceType:'passbook', id })`.
The permission payload carries the user's instance-grant set (or the list view
relies on the server-side `where` filter, so the UI naturally only renders
granted rows).

### 5.6 Seeding

`seed-rbac.js` reads `modules.config.js` → upserts `permissions` (`module×action`)
→ creates the `tenant-admin` system role with all enabled-module permissions →
optional starter roles. Idempotent (keyed on `module+action`).

---

## 6. Schema workflow (from billing, kept)

```
models/*.model.js  ──describe──►  schema/sync.json  ──db:sync──►  database (create/alter) + seed
                                        ▲
                          db:schema-generate (introspect existing DB)
```
- `npm run db:schema-generate` → introspect DB → `schema/schema.json` (+ optional `.model.js` / merge into `sync.json`).
- `npm run db:sync` → `sync.json` → create/alter tables + seed empty tables.
- `config/korm.js` loads `sync.json` via `korm.setSchema()` at boot.

---

## 7. Theming — CSS-variable layer (upgrade over twreact-ui)

twreact-ui today uses **hardcoded Tailwind tokens, no CSS variables** → rebrand =
edit `tailwind.config` + rebuild. Generated apps add a **runtime-swappable CSS
variable layer** so brand (and per-tenant) colors change with no rebuild and can
load from the DB.

**Approach — a numeric `50…950` primary scale backed by CSS variables.** twreact-ui
renders against Tailwind shade classes (`bg-primary-600`, `text-primary-700`, …), so
the token layer mirrors that scale rather than the `hsl(var(--x) / <alpha-value>)`
channel pattern. (The alpha-value pattern needs every var to hold *raw* HSL channels,
which doesn't compose with a shipped library that already emits shade classes — that
mismatch is why the locked design uses a full numeric scale.) Only the **primary**
scale is variable-backed (it's what rebrands); the semantic palettes
(secondary / success / warning / error) stay **static hex** in `tailwind.config.js`
so twreact-ui keeps rendering correctly. App-chrome surfaces are variables too,
with a dark override.

```css
/* client/src/theme/tokens.css — only the primary scale + chrome are variable-backed */
:root {
  --color-primary-50:  #eff6ff;                 /* 100–500: static blue defaults */
  --color-primary-600: hsl(__BRAND_PRIMARY__);  /* ← the chosen brand color */
  --color-primary-950: #172554;                 /* 700–950: static defaults */
  --radius: 0.5rem;
  --app-bg: #f8fafc; --app-surface: #ffffff; --app-border: #e2e8f0; --app-text: #0f172a;
}
[data-theme="dark"] { --app-bg: #0b1220; --app-surface: #111a2e; /* …chrome only… */ }
```
```js
// tailwind.config.js — map the primary scale to the CSS vars
colors: {
  primary: { 50: 'var(--color-primary-50)', /* …600: 'var(--color-primary-600)'… */ 950: 'var(--color-primary-950)' },
  // secondary / success / warning / error: static hex palettes (rarely rebranded)
}
```
- The scaffolder substitutes `__BRAND_PRIMARY__` with HSL channels (e.g. `220 90% 56%`)
  from the brand prompt; it lands inside `hsl(...)` at the `600` step.
- `client/src/theme/applyTheme.js` reads a **`Setting`** row (per-tenant brand) at
  load and overrides any `--color-primary-*` var on `:root` — non-devs change brand from an admin UI.
- Dark mode keeps twreact-ui's `data-theme` + `useTheme` (localStorage); the chrome
  surface vars carry the dark palette.
- **Upstream note:** propose this CSS-variable layer back to twreact-ui so the
  library and the generated apps converge (raise as an enhancement issue — §8).

---

## 8. UI sourcing policy — compose, don't duplicate

**Rule:** the app's `client/src/pages/**` may only **compose** exports from
`@dreamtree-org/twreact-ui` (plus layout glue). It must not define its own
buttons, inputs, tables, dialogs, etc.

**Workflow when a component is needed:**
1. **Discover** — query the twreact-ui MCP catalog (`list_components` /
   `get_component` / `search_components`) or `ai-skills/dreamtree-ui.md` for an
   existing component or a composition of primitives that satisfies the need.
2. **Found** → compose it (`className`-extend via `cn`, pass `...rest`).
3. **Not found / insufficient** → **raise a GitHub issue** against the twreact-ui
   repo via the **`github-workflow` skill** (`.claude/skills/github-workflow/`,
   scaffolded into every app). Do **not** inline a local component.
   - Repo is read dynamically from the installed package's `package.json`
     `repository` field (don't hard-code the slug).
   - `gh issue create -R <twreact-ui-repo> --title "[component] <Name>" --body <template>`
   - Issue template: use case, proposed props/variants/sizes, a11y needs, a
     minimal usage example, and the app + page that needs it.
   - The same skill then drives **picking up** an issue → branch
     (`<type>/<issue#>-<slug>`) → tests → PR (`Closes #n`) → CI → merge → deploy.
4. **Unblock** — until the component ships, mark the spot with a documented
   `TODO(twreact-ui#<issue>)` placeholder; never let a one-off component harden
   into the app.

**Component props come from the installed package, never from memory.**
`@dreamtree-org/twreact-ui` ships `dist/ai-schema.json` — the per-component prop
contract (types, required, defaults, enum values) — alongside its
`ai-skills/dreamtree-ui.md`. The MCP `layer_docs` tool reads it out of this app's
own `node_modules`, so the answer is correct for the version actually installed
rather than for whatever an ai-builder snapshot once recorded. A prop that isn't
in that schema **does not exist at this version**: that is a twreact-ui issue
(step 3 above), not something to invent, and never a reason to re-implement the
component locally.

**Enforcement idea (optional):** a generated lint rule / CI check that fails if
`client/src/` declares a styled primitive outside an allow-listed glue boundary,
nudging contributors back to twreact-ui + the issue workflow.

**Images are content, not components.** PWA icons, favicons, logos, hero/illustration
and empty-state art are **generated assets** (PNG/SVG under `client/public/`), produced
on demand via the AI provider's image generation — see the **`image-assets` skill**
(`.claude/skills/image-assets/`, scaffolded into every app). This is orthogonal to the
compose-only rule: generate imagery freely; never hand-build interactive UI.

**Design-intelligence skill is reference material, not a styling license.**
`ai-builder init` also installs the third-party `ui-ux-pro-max` skill (color
palettes, font pairings, layout patterns — via `npx -y ui-ux-pro-max-cli init`,
see §10.1) by default. It may inform which brand hue / font pairing to substitute
into the existing swappable tokens (§7). It must **not** be used to justify
introducing new styled primitives, alternate component aesthetics (glassmorphism,
neumorphism, etc.), or anything outside the compose-twreact-ui rule above.

---

## 9. Multi-tenancy (default, from billing)

- `tenantResolver` resolves the tenant from the request **domain**
  (`x-forwarded-host` / hostname) → looks up the platform DB `tenants` table →
  builds a per-tenant Knex + KORM → caches (TTL) → attaches to `req.context`.
- Per-tenant DB credentials live (encrypted) in the platform `tenants` table;
  an encrypted cookie can short-circuit the lookup (billing's `DKEU` pattern).
- Single-tenant is a future flag, but **multi-tenant is the default**.

---

## 10. The ai-builder package itself

- **Form:** zero-runtime-dep CLI (Node built-ins only), mirroring korm-js/twreact-ui
  CLI style. Single `bin` (`ai-builder`); `npx @dreamtree-org/ai-builder`.
- **Commands (reserved subcommands win over an app name):**
  - `ai-builder <app-name>` — scaffold (default path).
  - `ai-builder init --ai <provider>` — install the app-building skill pack + wire
    the MCP server into an existing project (§10.1).
  - `ai-builder mcp` — run the MCP server over stdio (§10.2).
  - `ai-builder update` (alias `upgrade`) — report running-vs-latest version and
    re-sync the installed skill pack + MCP wiring to the running package (§10.3).
- **Templating:** ship a `templates/` tree; copy + token-substitute
  (`__APP_NAME__`, selected modules, tenancy, DB client). Keep templates as real,
  lint-clean files (not string blobs).
- **Scaffold-time prompts/flags:** app name, DB client (`mysql|pg|sqlite`),
  starter modules, brand color, platform-DB connection. (Multi-tenant + the RBAC
  model are fixed defaults per the locked decisions.)
- **Scaffold installs our own AI surface in-process.** After writing the template
  tree, `scaffold` calls `installProviders` directly to emit the skill pack + MCP
  config into the new app (`--ai <provider|none>`, default `claude`). It is a local
  file write with no network, so it can't hang the critical path — and it removes
  the incoherence of an app that shipped three `.claude/skills/` but not the one
  that teaches how to build it. Scaffold **never spawns `npx`**.
- **Post-generate:** install deps, run `db:sync` + `seed-rbac`, then install the
  **layer** AI skills from the published packages — `npx @dreamtree-org/korm-js
  init --ai <provider>` and `npx @dreamtree-org/twreact-ui init --ai <provider>`
  (both covered by `npm run setup`). **No relative/local paths to `korm-js` or
  `twreact-ui` anywhere** — generated apps depend only on the published package
  names; the libraries' own CLIs install their skill blocks.
- **Shared internals:** `src/registry.js` is the single npm-lookup path for the
  whole family (https → `npm view` → offline floor, one 6h cache, both transports
  gated by `AI_BUILDER_NO_UPDATE_CHECK`). `src/project.js` is read-only
  introspection of a generated app on disk (never writes, never networks, never
  reads `.env`), feeding `src/projectState.js` and `src/layerDocs.js`.
- **Build/publish:** follow the repo pattern — `files` allow-list + `bin` + never
  publish manually. Releasing is tag-driven: add the entry to `CHANGELOG.md`,
  commit, then `npm run release:patch|minor|major` (that is `npm version` +
  `git push --follow-tags`). The `v*` tag fires `.github/workflows/release.yml`,
  which gates on **e2e** (scaffold → install → build → boot) and the smoke test,
  publishes, and cuts a GitHub release whose notes are the matching
  `CHANGELOG.md` section. `npm publish` is never run by hand. (No `--provenance`:
  npm only accepts provenance from a **public** source repository.)

### 10.1 `init --ai <provider>` — skill pack + MCP wiring

Run in an existing project. For each provider (claude|cursor|copilot|gemini|openai,
or `all`) it writes the skill body (`templates/skills/app-builder.md`) to the
provider's conventional location and merges the `ai-builder` MCP server into that
provider's MCP config:

| provider | skill file | MCP config (key) |
|---|---|---|
| claude | `.claude/skills/dreamtree-app-builder/SKILL.md` | `.mcp.json` (`mcpServers`) |
| cursor | `.cursor/rules/dreamtree-app-builder.mdc` | `.cursor/mcp.json` (`mcpServers`) |
| copilot | `.github/copilot-instructions.md` | `.vscode/mcp.json` (`servers`) |
| gemini | `GEMINI.md` | `.gemini/settings.json` (`mcpServers`) |
| openai | `AGENTS.md` | (Codex global `~/.codex/config.toml` — printed as guidance) |

The MCP entry is `{ command: "npx", args: ["-y", "@dreamtree-org/ai-builder", "mcp"] }`.
`--no-mcp` installs the skill only. Config merges are non-destructive (parse → set
one key → write back).

**Owned files vs shared files.** `.claude/skills/…/SKILL.md` and
`.cursor/rules/*.mdc` are wholly ours: preserved unless `--force`, then rewritten.
The other three (`AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`) are
shared conventions the **user** also writes in, so they are never written whole.
`writeManagedBlock` owns only the span between `<!-- ai-builder:start -->` and
`<!-- ai-builder:end -->`, in this order:

1. file absent → write the block;
2. markers present → replace **only** that span (idempotent; `--force` is inert);
3. no markers but the file is a legacy (≤0.6.0) whole-file install, detected by the
   version stamp in its first 200 chars → take it over, bringing it under markers;
4. otherwise → **append**. User content is never truncated.

This closes a real data-loss path: `update` passes `force: true`, which previously
overwrote a hand-written `AGENTS.md` wholesale (BUG-003).

Detection follows the same asymmetry: `update` considers a provider "installed
here" when an owned file exists, but for a shared file only when it carries our
marker or version stamp — a project can have its own `AGENTS.md` without ever
having run `init`, and that is not an invitation to write into it.

**`init` never writes `tracking/`.** Installing a skill pack must not add documents
to someone's repo. `scaffold` seeds the tracking trio; in a non-scaffolded project
the agent creates `tracking/plan.md` itself from the `plan_template` tool (§16).

**Design-intelligence skill (default-on).** After the skill+MCP steps, each
provider also gets the third-party `ui-ux-pro-max` design skill installed via
`npx -y ui-ux-pro-max-cli init --ai <platform>` (provider key mapped to that
CLI's platform id — `openai` → `codex`). This is a new pattern for `init.js`:
the first time it shells out to an external process rather than only writing
files, so every `ai-builder init` now executes third-party code by default —
an intentional, accepted trade-off (§12), not a buried side effect. It's
best-effort with a 60s timeout: a failure or hang (offline, registry error)
logs a warning and never aborts the primary install. Opt out with
`--no-design-skill` (also available on
`ai-builder update`, which re-syncs it alongside the layer skills). See §8 for
the guardrail on how this skill's guidance may be used.

### 10.2 `ai-builder mcp` — zero-dependency MCP server

Raw JSON-RPC 2.0 over stdio (newline-delimited; no `@modelcontextprotocol/sdk`, per
the zero-runtime-dep rule). Implements `initialize` / `tools/list` / `tools/call` /
`ping`. **Every tool is read-only — the server never writes to a user's project.**
`plan_template` returns text; the assistant writes the file with its own tools.
That boundary is what makes it safe to run the server anywhere.

Eleven tools, in three groups:

| Group | Tools | Source of truth |
|---|---|---|
| **Project-aware** (reads the app on disk) | `project_state`, `plan_template` | `src/project.js` — `modules.config.js`, `schema/sync.json`, `tracking/plan.md`, git |
| **Layer-aware** (reads `node_modules`) | `layer_docs`, `check_version` | the installed korm-js / twreact-ui packages + `src/registry.js` |
| **Static** (our catalog + SOP digests) | `list_modules`, `describe_module`, `get_schema`, `get_registry`, `data_contract`, `build_rules`, `scaffold_plan` | `moduleCatalog.js` + `knowledge.js` |

- **`project_state`** is the orientation primitive: one call replaces ~10 file
  reads and returns app/modules/models/tables, layer versions, plan status
  including the Resume-here block, git, and a computed **`nextAction`** — a
  computed imperative rather than a rule the model must re-derive. It also
  returns `evidence` (how the project root was decided) so a wrong cwd is
  *visible* instead of silently producing answers about the wrong project.
  Degradation is a *result*, never an error: outside a dreamtree app it returns
  `isDreamtreeApp: false` plus `hints[]`.
- **`layer_docs`** serves the layer package's own shipped docs at the version this
  project installed (§8, §12). Discovery-first: if a layer renames `ai-skills/`,
  it degrades to listing what the package does ship rather than reporting nothing.
- **`check_version`** now reports the whole family (declared vs installed vs
  latest) plus the exact commands. Its top-level `{current, latest, isOutdated}`
  shape is unchanged, so skill packs installed by older versions keep working.
  `null` means *unknown* (offline) — never "up to date".

### 10.3 `ai-builder update` — stay current

The skill pack `init` writes is a **static snapshot** of `templates/skills/app-builder.md`
at install time; a later publish never touches it, and `npx -y … mcp` may run a
cached old version. `update` (alias `upgrade`) closes that gap with two safe steps:

1. **Report** running-vs-latest. `src/version.js` owns the policy; `src/registry.js`
   owns the lookup — https to the registry first, `npm view` as a fallback (so a
   private registry / proxy / auth in `.npmrc` still resolves), the built-in floor
   last, all behind one 6h tmpdir cache. Failure is silent (offline never blocks).
   It prints the package-upgrade command (`npm i -g …@latest` for global installs;
   `npx …@latest` always pulls latest) — it **never runs `npm install` itself**.
2. **Report family drift** for this project: what korm-js / twreact-ui are
   *declared* vs *installed* vs *published*, with the `npm i <pkg>@latest` to run.
   A stale layer install is stale API knowledge, so this matters as much as our
   own version.
3. **Re-sync** the installed skill pack + MCP wiring in the project to the **running**
   version. Installed skill files carry a version stamp
   (`<!-- ai-builder skill pack · vX.Y.Z … -->`); `update` detects the providers
   already present (or honors `--ai`), rewrites the stale skill — whole-file for
   files we own, block-merge for shared ones (§10.1) — and re-merges the MCP entry.
   `--check` reports only; `--dry-run` shows the plan.
4. **Refresh the sibling layer skills** (korm-js, twreact-ui) by running each
   package's own `npx …@latest init --ai <provider>` for the same providers (each
   owns its skill block; `@latest` defeats the npx cache). Best-effort: a failure
   for one layer/provider warns + prints the manual command, never aborts. Skip with
   `--no-layers`; `--dry-run` / `AI_BUILDER_NO_UPDATE_CHECK` plan without spawning.

`scaffold` and `init` also print a **non-blocking nudge** (same cached check) when a
newer version exists. `AI_BUILDER_NO_UPDATE_CHECK=1` disables all network checks
(air-gapped/CI). The AI surface mirrors this: the `check_version` MCP tool + a
"check once per session" instruction in the skill body.

---

## 11. Open items (need the live DB to confirm — non-blocking)

The design was recovered from **source** (authoritative for shapes). To confirm
real data, allowlist `152.57.3.154` on the `butic_v2` MySQL host and run
`node app_builder/_peek.cjs` (read-only). Specifically validates:
- the real `permissions.artifacts` JSON shape + the live permission-name vocabulary,
- how many roles/permissions exist and their `weight` distribution,
- whether `web_projects` (platform/tenant registry) carries per-tenant module flags today.

---

## 12. Decisions locked (this revision)

| Decision | Choice |
|---|---|
| Builder form | AI-native CLI (`ai-builder`): scaffold + `init --ai` skill/MCP installer + `mcp` server (§10) |
| AI surfaces | `init --ai <provider>` installs the app-building skill pack + wires the MCP; `ai-builder mcp` serves compact build rules/contract/catalog/scaffold-plan (zero-dep JSON-RPC over stdio) |
| Staying current | `ai-builder update` (alias `upgrade`): version-stamped skill pack, zero-dep cached registry check (`AI_BUILDER_NO_UPDATE_CHECK` opt-out), re-syncs skill/MCP to the running version but never auto-`npm install`; non-blocking nudge on scaffold/init; `check_version` MCP tool + per-session skill check (§10.3) |
| Agent working memory | `tracking/plan.md` in the generated app (§16). Resume block delimited by HTML markers, not headings; `updated` is file mtime, never a written field; rewritten at every task transition, not at session end; archived to `tracking/plans/` on completion |
| MCP write boundary | The MCP server is **read-only**, permanently. `plan_template` returns text; the agent writes files with its own tools |
| Shared provider files | `AGENTS.md` / `GEMINI.md` / `.github/copilot-instructions.md` are managed-block merged, never overwritten (§10.1) — user content is never truncated |
| Version lookups | All npm lookups go through `src/registry.js`: https → `npm view` → floor, one cache, both transports offline-gated |
| Skill body | **Generated** from `src/knowledge.js` by token substitution. Hand-copying a rule into `templates/skills/app-builder.md` is a defect, and smoke asserts parity + a token budget |
| Layer API facts | Read from the **installed** package (`ai-skills/`, `dist/ai-schema.json`, `index.d.ts`) via `layer_docs`. ai-builder never maintains a snapshot of another package's API |
| Scaffold AI install | `scaffold` installs our own skill + MCP config in-process (`--ai <provider\|none>`, default `claude`); it never spawns `npx` |
| Tenancy | Multi-tenant by default (platform DB + per-tenant DB) |
| Theming | CSS-variable layer, runtime-swappable, DB-loadable |
| Superadmin plane | Platform DB, cross-tenant |
| Permission grain | `(module, action, scope)`, scope ∈ own/branch/tenant/instance |
| Row-level access | `resource_grants` (ReBAC) on registry-declared `instanceScoped` models; default-deny; list-filtered via KORM `where` IN |
| User overrides | Yes — `user_permissions` allow/deny (deny wins) |
| UI sourcing | Compose twreact-ui only; missing → GitHub issue; no duplication |
| Rendering | SPA (Vite + React); no SSR/SEO |
| Client form factor | Installable **PWA**, mobile-native feel, responsive at every breakpoint (phone→ultrawide) |
| Image assets | Generated on demand (PWA icons/favicon/logo/hero/empty-state) via the `image-assets` skill + the provider's image gen; content, not components |
| Library deps | Published packages only; AI skills via `npx … init --ai` (no local paths) |
| External access | Per-tenant **API tokens** (`api_tokens` in the tenant DB → structurally tenant-scoped), RBAC-scoped (role or `["module:action"]`), **never admin**, hashed at rest, rotatable |
| Design reference skill | `ui-ux-pro-max` auto-installed by `ai-builder init`/`update` (default-on, `--no-design-skill` to opt out); reference/ideation only — compose-twreact-ui rule (§8) still governs generated code |

---

## 13. PWA & responsive design (all screen sizes) — Phase 4

Every generated SPA is an **installable PWA** that feels native on mobile and
scales cleanly to ultrawide. Implementation plan for the frontend phase:

**PWA (installable + app-shell, network-first for data):**
- `vite-plugin-pwa` (Workbox under the hood) generates the service worker +
  precaches the app shell; **network-first** for `/api/**` (tenant data isn't
  meaningfully cacheable offline — the value is install + instant shell + native
  feel, with a graceful offline fallback screen).
- `manifest.webmanifest`: `display: standalone`, `theme_color`/`background_color`
  **bound to the brand CSS variable**, name/short_name from `__APP_NAME__`,
  maskable + standard icons (generated from one source icon).
- `index.html`: `<meta name="viewport" content="width=device-width,
  initial-scale=1, viewport-fit=cover">`, `theme-color`, apple-touch icons,
  `apple-mobile-web-app-capable`.

**Mobile-native feel:**
- Respect safe-area insets (`env(safe-area-inset-*)`) for notches/home bars.
- Momentum scroll, no tap-highlight flash, `touch-action` tuned; honor
  `prefers-reduced-motion`.
- App-like navigation: **bottom tab bar** on phones, **drawer** for secondary nav,
  **persistent sidebar** on ≥ lg — all composed from `twreact-ui`
  (`Sidebar`/`Navbar`/etc.); if a needed primitive (e.g. `BottomNav`,
  `Drawer`) is missing, **file a twreact-ui issue** (SOP §8), don't inline it.

**Responsive at every breakpoint (Tailwind):**
- `DefaultLayout` is mobile-first: single-column + bottom nav by default,
  progressively enhances at `sm/md/lg/xl/2xl` to sidebar + multi-column.
- Content uses fluid grids/containers; tables degrade to cards on narrow screens;
  max-width caps + centered gutters keep ultrawide readable.
- Verify across phone / tablet / laptop / desktop / ultrawide before a page ships.

**Exit criterion (Phase 4):** generated app passes a Lighthouse PWA "installable"
check, renders without horizontal scroll from 320px to 2560px, and shows the
bottom-nav→sidebar transition at the `lg` breakpoint.

---

## 14. Per-tenant API tokens (external access)

External systems (and external users) access a tenant's data through the **same
door** (`POST /api/crud/:Model`) using an API token — never a human session.

**Structurally tenant-scoped.** Tokens live in the **tenant DB** (`api_tokens`
table). A token presented on tenant B's domain is looked up in tenant B's DB and
simply isn't there → `401`. A token can **never** reach data outside its tenant —
the isolation is the database boundary, not a runtime check that could be missed.

**RBAC-scoped, never admin.** A token carries either a `role_id` (it inherits that
role's `(module, action)` permissions) or an explicit `scopes` JSON array
(`["billing:view", …]`). `resolveApiToken` returns `isAdmin: false` always —
tokens get only what they're granted, default least-privilege.

**Lifecycle (`models/ApiToken.model.js`, gated by `core:manage_tokens`):**
- `issue` → generates `tnt_<random>`, stores only its **sha256 hash** + a display
  prefix, returns the plaintext **once**.
- `rotate` → new secret, old hash overwritten → old token instantly dead.
- `revoke` → sets `revoked_at`.
- reads strip `token_hash`.

**Auth path.** `BaseModel.validate` accepts `X-API-Key:` or
`Authorization: Bearer tnt_…`; resolves the token against the request's tenant DB;
otherwise falls back to the JWT (human) path. One enforcement point, both principals.

**Verified (Phase 3/4 e2e):** issue → scoped read `200` → out-of-scope `403` →
cross-module `403` → bogus `401` → token can't self-escalate `403` → rotate → old
`401` / new `200`.
```

---

## 15. Development workflow — worktrees, branching, test gates

Codified in the **`github-workflow` skill** (shipped in every generated app and in
this repo). The standards:

### 15.1 Git worktrees (isolated, parallel development)
Multiple agents/humans routinely work one repo at once; a shared working tree means
one's uncommitted files leak into another's `git status` and can be swept into the
wrong commit. **One worktree per task** — own directory + branch over the shared `.git`:
```bash
git worktree add -b <type>/<issue#>-<slug> ../<repo>-worktrees/<slug> main
cd ../<repo>-worktrees/<slug> && npm install   # node_modules is NOT shared
# … work, commit, push, PR …
git worktree remove ../<repo>-worktrees/<slug> # after the PR merges
```
**Never `git add -A` / `git add .`** — stage explicitly so a co-worker's in-progress
files can't be committed.

### 15.2 Branching standards (trunk-based)
| Rule | Standard |
| --- | --- |
| Base | always off `main` |
| Name | `<type>/<issue#>-<slug>` — type ∈ `feat`·`fix`·`chore`·`docs`·`refactor`·`test`·`perf` |
| Scope | one branch per issue; short-lived; sync from `main` often |
| Commits | Conventional Commits referencing the issue — `feat: passbook list (#42)` |
| main | never commit directly; merge via squash PR only |

### 15.3 Test gates — which test at which step
| Step | Run | Gate |
| --- | --- | --- |
| While coding (every change / pre-commit) | `npm run test:unit` — Vitest backend + client (fast) | green before commit |
| Before opening a PR | `npm test` + `npm run test:e2e` (after `npm run e2e:setup` + `npx playwright install`) | green before PR |
| On PR / push (CI) | `.github/workflows/ci.yml` → unit **and** e2e | red CI blocks merge |
| Before deploy | CI green on `main` | deploy only from green `main` |

- **Unit (Vitest)** covers pure logic: rbac/auth/token utils, client permission helpers,
  components. **E2E (Playwright)** covers the `tests/USER-JOURNEYS.md` catalog.
- Every **bug fix** ships a regression test; every **user-facing** change adds/updates
  a journey + its spec.

### 15.4 The full cycle
```
issue → worktree+branch → code (unit green locally) → e2e before PR → push
   → PR (Closes #n) → CI (unit+e2e) green → squash-merge → remove worktree → deploy from main
```

That is the **git** cycle. The **agent** cycle that runs inside each of those
steps — orient, plan, build, verify, record — is §16.

---

## 16. Agent session protocol & the plan document

### 16.1 The problem

An agent session can end at any moment — context exhausted, a tool cut off, the
user closing the terminal — usually mid-task and without warning. Everything the
agent worked out (why this model, which approach was rejected, what was half-done)
lives only in its context, so it dies with the session. The next session starts
from zero: it re-reads the same ten files, re-derives the same architecture, and
frequently re-does or contradicts work already committed.

Nothing in the generated app addressed this. The scaffolder itself has
`tracking/big-picture.md` + `tracking/backlog.md` and a hard rule keeping them in
lockstep, but shipped none of that downstream.

### 16.2 The loop

```
ORIENT → PLAN → BUILD → VERIFY → RECORD
```

| Step | Trigger | Action | Artifact |
|---|---|---|---|
| **ORIENT** | session start, before reading any file | MCP `project_state`; obey its `nextAction` | — |
| **PLAN** | before the first code edit | `plan_template` → write `tracking/plan.md`, or read the existing Resume block | `tracking/plan.md` |
| **BUILD** | — | the add-a-feature path (§4, §6); facts from MCP tools + `layer_docs` | code |
| **VERIFY** | after each task | the gate matching the change (§15.3) | verification log row |
| **RECORD** | at **every task transition** | rewrite the Resume block | `tracking/plan.md` |

The two ends are what matter. ORIENT is cheap enough that the compliant path is
also the lazy path — one `project_state` call genuinely beats reading
`package.json`, `modules.config.js`, `schema/sync.json`, three `node_modules`
manifests and `git log`. RECORD is mid-flight rather than end-of-session,
because there is no reliable end-of-session hook to write to.

### 16.3 The documents

The generated app gets the same SSOT trio the scaffolder uses on itself, plus a
`CLAUDE.md` that is a **pointer map only** — no rule is restated there, so nothing
in it can go stale.

| File | Answers | Lifetime |
|---|---|---|
| `tracking/plan.md` | *Where did I stop, and what is the next concrete action?* | one feature, then archived |
| `tracking/big-picture.md` | *What is this app, architecturally?* (seeded from the registry at scaffold) | the app's life |
| `tracking/backlog.md` | *What is broken or deferred?* | the app's life |
| `tracking/plans/` | completed plans, dated + slugged | history |

### 16.4 The plan contract

The **Resume block** is the load-bearing part. It is delimited by
`<!-- resume:start -->` / `<!-- resume:end -->` rather than by a heading, so an
agent rewording the heading cannot break resumption, and it is capped at ~12
lines so rewriting it at every task transition stays affordable. It answers, in
order: *doing now* (task id) · *next* (one command or file+change) · *touched* ·
*verify* (the exact command) · *watch out* (the trap the next session would fall
into).

Supporting rules, in full, are `PLAN_PROTOCOL` in `src/knowledge.js` — served by
`plan_template` and substituted into the skill, so there is one copy. The load-
bearing ones: exactly **one** task may be `doing`; Decisions and the verification
log are **append-only**; `blocked` requires stating what unblocks it; completion
means archive + fold-forward into big-picture/backlog **in the same change**.

Freshness is derived, never declared: `updated` is the file's **mtime**, so there
is no metadata field to keep in sync and it cannot lie. `project_state` surfaces
`stale` (>14 days) and `bloated` (>200 lines) and puts the remedy in `nextAction`.

### 16.5 Plan vs. board

Both exist; they answer different questions and must not mirror each other.

| | `tracking/plan.md` | GitHub Projects board (§ `github-projects` skill) |
|---|---|---|
| Audience | the agent, next session | humans + other agents |
| Grain | sub-task, file-level, "next command" | feature/card, column-level |
| Lifetime | one feature, then archived | the project's life |
| Needs | nothing (in-repo, offline) | `gh` + auth + network |
| Truth about | *how far through the work I am* | *who owns what, what is queued* |

**The board says what and who; the plan says where I stopped.** The only permitted
link is the `Board card` row in the plan header.

### 16.6 Version-accurate layer knowledge

The other half of "understand the project" is understanding the libraries it
actually installed. korm-js ships `ai-skills/korm-js.md` + `index.d.ts`;
twreact-ui ships `ai-skills/dreamtree-ui.md` + `dist/ai-schema.json`. `layer_docs`
reads these out of the app's own `node_modules` (§10.2), and `check_version`
reports declared vs installed vs published across the family.

The design rule behind both: **ai-builder never keeps a snapshot of another
package's API.** A snapshot is stale the moment that package publishes; reading
`node_modules` is correct by construction. Resolution is discovery-first, so a
layer renaming its doc folder degrades the tool rather than breaking it.
