create-substrat 0.0.1 → 0.1.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.
package/index.js CHANGED
@@ -1,39 +1,216 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `npm create substrat` — placeholder.
3
+ * `npm create substrat <dir>` — scaffold a Substrat vertical.
4
4
  *
5
- * This reserves the entry point a user will guess. It deliberately does nothing
6
- * but point at what does exist: publishing a stub that pretends to scaffold
7
- * would be worse than publishing nothing.
5
+ * Copies the instruction layer (AGENTS.md, the playbook, and the per-tool command
6
+ * stubs for Claude Code / Cursor / opencode) into a new project, then generates the
7
+ * tooling configs that need the project name interpolated. The result is a project
8
+ * that installs and whose AI tools already know the rules and the build flow — the
9
+ * agent writes the vertical itself, guided by `.substrat/playbook.md`.
8
10
  *
9
- * Dependency-free and buildless on purpose — a placeholder that can break at
10
- * install time is a placeholder that damages the name it exists to protect.
11
+ * Dependency-free and buildless on purpose — node built-ins only, so it can never
12
+ * break at install time and damage the name it exists to protect.
11
13
  */
12
14
 
15
+ import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
16
+ import { basename, dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const TEMPLATE = join(HERE, 'template');
21
+
22
+ // Published today; Substrat is 0.x, so these are caret ranges on the current minor.
23
+ const SUBSTRAT = '^0.29.0';
24
+ // Engines version on their own line (0.3.x), independent of the kernel/contracts line.
25
+ const ENGINES = '^0.3.27';
26
+ const BOUNDARY_LINT = '^0.0.5';
27
+
13
28
  const DOCS = 'https://substrat.ahlstrand.es';
14
- const GUIDE = `${DOCS}/guide/getting-started`;
15
- const REPO = 'https://github.com/substrat-run/substrat';
16
-
17
- process.stdout.write(
18
- [
19
- '',
20
- ' Substrat — a hosted substrate for vertical business software.',
21
- '',
22
- ' The initializer is not released yet. This package reserves',
23
- ' `npm create substrat` for it.',
24
- '',
25
- ' To start a vertical today, follow the guide:',
26
- ` ${GUIDE}`,
27
- '',
28
- ' The packages are published and usable now:',
29
- ' pnpm add @substrat-run/kernel @substrat-run/contracts @substrat-run/adapter-sqlite zod',
30
- '',
31
- ` Docs: ${DOCS}`,
32
- ` Repo: ${REPO}`,
33
- '',
34
- ' Substrat is 0.x and interfaces change without notice until the first',
35
- ' vertical ships.',
36
- '',
37
- '',
38
- ].join('\n'),
39
- );
29
+
30
+ function fail(message) {
31
+ process.stderr.write(`\n create-substrat: ${message}\n\n`);
32
+ process.exit(1);
33
+ }
34
+
35
+ function usage() {
36
+ process.stdout.write(
37
+ [
38
+ '',
39
+ ' Scaffold a Substrat vertical.',
40
+ '',
41
+ ' npm create substrat <dir>',
42
+ ' npm create substrat . # scaffold into the current directory',
43
+ '',
44
+ ` Docs: ${DOCS}`,
45
+ '',
46
+ '',
47
+ ].join('\n'),
48
+ );
49
+ }
50
+
51
+ /** npm names: lowercase, url-safe, no leading dot/underscore. */
52
+ function toPackageName(dir) {
53
+ const name = basename(resolve(dir))
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9-~]+/g, '-')
56
+ .replace(/^[-_.]+|[-_.]+$/g, '');
57
+ return name || 'substrat-vertical';
58
+ }
59
+
60
+ function packageJson(name) {
61
+ return `${JSON.stringify(
62
+ {
63
+ name,
64
+ version: '0.0.0',
65
+ private: true,
66
+ type: 'module',
67
+ scripts: {
68
+ dev: 'tsx watch src/server.ts',
69
+ server: 'tsx src/server.ts',
70
+ test: 'vitest run',
71
+ typecheck: 'tsc --noEmit',
72
+ 'lint:boundaries': 'substrat-boundary-lint',
73
+ },
74
+ dependencies: {
75
+ '@substrat-run/kernel': SUBSTRAT,
76
+ '@substrat-run/contracts': SUBSTRAT,
77
+ '@substrat-run/adapter-sqlite': SUBSTRAT,
78
+ '@substrat-run/engine-workorder': ENGINES,
79
+ '@substrat-run/engine-invoicing': ENGINES,
80
+ hono: '^4.6.0',
81
+ '@hono/node-server': '^1.13.0',
82
+ 'better-sqlite3': '^12.0.0',
83
+ },
84
+ devDependencies: {
85
+ '@substrat-run/boundary-lint': BOUNDARY_LINT,
86
+ '@types/better-sqlite3': '^7.6.0',
87
+ concurrently: '^9.0.0',
88
+ tsx: '^4.19.0',
89
+ typescript: '^5.6.0',
90
+ vitest: '^3.0.0',
91
+ },
92
+ // Do NOT add `zod` here — import `z` from `@substrat-run/contracts` (AGENTS.md, rule 10).
93
+ pnpm: { onlyBuiltDependencies: ['better-sqlite3'] },
94
+ },
95
+ null,
96
+ 2,
97
+ )}\n`;
98
+ }
99
+
100
+ const TSCONFIG = `${JSON.stringify(
101
+ {
102
+ compilerOptions: {
103
+ target: 'ES2022',
104
+ module: 'NodeNext',
105
+ moduleResolution: 'NodeNext',
106
+ lib: ['ES2022'],
107
+ strict: true,
108
+ esModuleInterop: true,
109
+ skipLibCheck: true,
110
+ forceConsistentCasingInFileNames: true,
111
+ noEmit: true,
112
+ types: ['node'],
113
+ },
114
+ include: ['src', 'test'],
115
+ },
116
+ null,
117
+ 2,
118
+ )}\n`;
119
+
120
+ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
121
+
122
+ export default defineConfig({
123
+ test: {
124
+ include: ['test/**/*.test.ts'],
125
+ },
126
+ });
127
+ `;
128
+
129
+ const GITIGNORE = `node_modules/
130
+ dist/
131
+ *.sqlite
132
+ *.sqlite-*
133
+ *.db
134
+ .data/
135
+ .DS_Store
136
+ `;
137
+
138
+ function readme(name) {
139
+ return `# ${name}
140
+
141
+ A multi-tenant business app built on [Substrat](${DOCS}).
142
+
143
+ \`src/\` ships with a small **working reference vertical** (a bike-repair shop, green via
144
+ \`npm test\`) — your worked example and starting point. The build flow reshapes it into your
145
+ own domain; it is not meant to survive as-is.
146
+
147
+ ## Build it
148
+
149
+ Open this project in Claude Code, Cursor, or opencode and start the build flow:
150
+
151
+ - **Claude Code**: \`/substrat\`
152
+ - **Cursor / opencode**: run the \`new-vertical\` command
153
+
154
+ Both follow [\`.substrat/playbook.md\`](.substrat/playbook.md). The always-on rules the
155
+ agent must not violate live in [\`AGENTS.md\`](AGENTS.md).
156
+
157
+ ## Gates
158
+
159
+ \`\`\`sh
160
+ pnpm install
161
+ pnpm test # the scenario, including the denials
162
+ pnpm lint:boundaries # the layer rules (also: npx @substrat-run/boundary-lint)
163
+ pnpm typecheck
164
+ \`\`\`
165
+ `;
166
+ }
167
+
168
+ function main() {
169
+ const target = process.argv[2];
170
+ if (!target || target === '-h' || target === '--help') {
171
+ usage();
172
+ process.exit(target ? 0 : 1);
173
+ }
174
+
175
+ const dest = resolve(target);
176
+ if (existsSync(dest) && readdirSync(dest).some((f) => f === 'package.json')) {
177
+ fail(`${target} already looks like a project (package.json present). Refusing to overwrite.`);
178
+ }
179
+ if (!existsSync(TEMPLATE)) {
180
+ fail('template payload is missing from the package — please report this.');
181
+ }
182
+
183
+ mkdirSync(dest, { recursive: true });
184
+
185
+ // The static instruction layer: AGENTS.md, CLAUDE.md, .substrat/, and the per-tool stubs.
186
+ cpSync(TEMPLATE, dest, { recursive: true });
187
+
188
+ // Generated configs — these need the project name, so they aren't in the template.
189
+ const name = toPackageName(target);
190
+ writeFileSync(join(dest, 'package.json'), packageJson(name));
191
+ writeFileSync(join(dest, 'tsconfig.json'), TSCONFIG);
192
+ writeFileSync(join(dest, 'vitest.config.ts'), VITEST_CONFIG);
193
+ writeFileSync(join(dest, '.gitignore'), GITIGNORE);
194
+ writeFileSync(join(dest, 'README.md'), readme(name));
195
+
196
+ const where = target === '.' ? '' : ` cd ${target}\n`;
197
+ process.stdout.write(
198
+ [
199
+ '',
200
+ ` Scaffolded ${name}.`,
201
+ '',
202
+ ' The instruction layer is in place — Claude Code, Cursor, and opencode all',
203
+ ' read the same rules (AGENTS.md) and build flow (.substrat/playbook.md).',
204
+ '',
205
+ ' Next:',
206
+ where + ' pnpm install',
207
+ ' Then open the project in your AI editor and start the build flow:',
208
+ ' Claude Code: /substrat',
209
+ ' Cursor / opencode: the new-vertical command',
210
+ '',
211
+ '',
212
+ ].join('\n'),
213
+ );
214
+ }
215
+
216
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat`. Placeholder reserving the entry point; the initializer is not released yet.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -23,7 +23,8 @@
23
23
  "create-substrat": "./index.js"
24
24
  },
25
25
  "files": [
26
- "index.js"
26
+ "index.js",
27
+ "template"
27
28
  ],
28
29
  "engines": {
29
30
  "node": ">=20"
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: substrat
3
+ description: Build or extend a vertical on Substrat — interview, map the domain onto the engines, scaffold, run, and present the two human checkpoints. Use when asked to build, scaffold, or extend a multi-tenant business app / vertical / internal tool where tenancy, permissions, audit, or work-order-shaped workflows matter.
4
+ ---
5
+
6
+ # Build a vertical on Substrat
7
+
8
+ Read and follow [`.substrat/playbook.md`](../../../.substrat/playbook.md) in the project
9
+ root — the full flow (interview → coverage map → scaffold → run → checkpoints) lives there
10
+ so every tool reads one source of truth. The always-on rules are in
11
+ [`AGENTS.md`](../../../AGENTS.md); this skill is the invokable *flow* on top of them.
12
+
13
+ Do not skip the two human checkpoints in the playbook. You may never self-approve them.
@@ -0,0 +1,7 @@
1
+ # Build a Substrat vertical
2
+
3
+ Read and follow [`.substrat/playbook.md`](../../.substrat/playbook.md) — the full flow to
4
+ interview the user, map their domain onto the engines, scaffold, run, and present the two
5
+ human checkpoints. The always-on rules are in [`AGENTS.md`](../../AGENTS.md).
6
+
7
+ Do not skip the two human checkpoints. Never self-approve them.
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: How to build or extend this Substrat vertical — the interview → scaffold → run → checkpoints flow. Fetch when starting a new vertical, adding an operation, engine, migration, or permission, or wiring the server.
3
+ alwaysApply: false
4
+ ---
5
+
6
+ Follow the full playbook in [`.substrat/playbook.md`](/.substrat/playbook.md) before
7
+ scaffolding or extending the vertical. The always-on rules (module-code boundaries, the
8
+ gates, the two human checkpoints) live in [`AGENTS.md`](/AGENTS.md) and are always in
9
+ context — this rule is the on-demand *flow* on top of them.
10
+
11
+ Never self-approve the migration diff or the permission diff.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Build or extend a vertical on Substrat — interview, scaffold, run, checkpoints.
3
+ ---
4
+
5
+ Read and follow `.substrat/playbook.md` — the full flow to interview the user, map their
6
+ domain onto the engines, scaffold, run, and present the two human checkpoints. The
7
+ always-on rules are in `AGENTS.md`.
8
+
9
+ Do not skip the two human checkpoints. Never self-approve them.
@@ -0,0 +1,310 @@
1
+ # Playbook — build a vertical on Substrat
2
+
3
+ The always-on rules live in [`AGENTS.md`](../AGENTS.md); read them first. This playbook is
4
+ the **flow**: interview the user, tell them honestly how much of their app already exists,
5
+ then build and run the part that doesn't. Read the whole thing before starting — the
6
+ checkpoint in Step 6 is a hard stop.
7
+
8
+ This project ships with a small **working reference vertical** — a bike-repair shop on
9
+ `engine-workorder` + `engine-invoicing`, green out of the box (`npm test`). It is your
10
+ worked example and your starting point: you **reshape** it into the user's domain rather
11
+ than building from an empty directory. Work in the project root.
12
+
13
+ ---
14
+
15
+ ## Step 1 — Interview
16
+
17
+ Ask, don't assume. **Three to five questions, conversational, one message.** You are
18
+ learning the *shape* of the domain, not writing a spec.
19
+
20
+ 1. **What are you building, and who uses it?** (the firm, the cast)
21
+ 2. **What's the thing that moves through the system?** A job, a repair, an inspection, an
22
+ order, a case? What happens to it from start to finish?
23
+ 3. **Who must be denied what?** The most important question and the one nobody expects.
24
+ Does a customer log in? Should a technician see pricing? This drives the whole
25
+ permission model, and it is what Substrat is *for*.
26
+ 4. **Does money come out the other end?** Invoice, quote, receipt, nothing?
27
+ 5. **Anything that must be signed off or checked before a step can happen?**
28
+
29
+ Don't ask about tech, hosting, or databases yet. If the user already described their app in
30
+ detail, skip to Step 2 and confirm your reading of it instead of re-asking.
31
+
32
+ ---
33
+
34
+ ## Step 2 — The coverage map
35
+
36
+ **This is the most valuable thing you do, and the easiest to get wrong by being
37
+ flattering.** Tell the user what already exists and what they are actually signing up to
38
+ build. Be specific and honest.
39
+
40
+ **First, list the engines that actually exist. Do not trust any hard-coded list:**
41
+
42
+ ```sh
43
+ npm search @substrat-run --json | grep -E '"name"|"description"'
44
+ ```
45
+
46
+ Substrat publishes frequently, so any inventory in a doc goes stale between releases. A
47
+ missing engine does not fail loudly — it silently becomes Tier 3, and you hand the user an
48
+ estimate for work that already exists. Run the search, then read the `dist/index.d.ts` of
49
+ anything relevant.
50
+
51
+ Coverage has four tiers:
52
+
53
+ ### Tier 0 — the kernel. Always. Free.
54
+
55
+ Every vertical gets this whether or not it uses a single engine:
56
+
57
+ - **Tenancy** — tenants and scopes, isolated at the database level. A scope is one
58
+ SQLite/DO database. Cross-tenant access is not a bug you avoid; there is no API for it.
59
+ - **Permissions** — roles, grants, entity-narrowed grants, and every decision carries a
60
+ proof path (why it was allowed).
61
+ - **Events + audit** — every mutation emits a kernel-stamped event. Origin fields (tenant,
62
+ scope, actor, time) are stamped by the kernel; your code cannot mislabel one.
63
+ - **Migrations** — journaled per module, applied lazily per scope.
64
+
65
+ This is usually *most of what the user would otherwise build badly*. Say so plainly.
66
+
67
+ ### Tier 1 — engines you compose
68
+
69
+ Imported directly; their in-scope functions run in **your** transaction. Read each one's
70
+ `node_modules/@substrat-run/engine-*/dist/index.d.ts` for the real surface before composing
71
+ — in-scope functions, `PERM` keys, and types. Typical examples (verify with the search):
72
+
73
+ - **`engine-workorder`** — a job with a lifecycle that cannot skip states, plus time and
74
+ material reporting. Note there is **no `workorder/create` operation** — creation goes
75
+ through `createWorkOrder(ctx, …)`, an in-scope function, because the vertical must
76
+ price/label it first. The hole is deliberate: the engine owns the state machine, you own
77
+ vocabulary and pricing.
78
+ - **`engine-protocol`** — checklists/inspections with templates, responses, and signatures.
79
+ Contributes a guard predicate so you can declare an operation blocked until signed.
80
+ - **`engine-booking`** — reservations. Owns one invariant: concurrent allocations never
81
+ exceed capacity over any overlapping interval. Knows nothing about pricing, opening
82
+ hours, recurrence, or timezones — all vertical policy.
83
+ - **`engine-invites`** — how a person joins an org they are not in. Identifiers stored
84
+ hashed and never returned; an invitation confers nothing until accepted. Reach for it
85
+ before hand-rolling any invite flow.
86
+
87
+ ### Tier 2 — engines you feed by event
88
+
89
+ **No import.** You emit; they consume. This is the star topology.
90
+
91
+ - **`engine-invoicing`** — invoice basis and lines, immutable after export. Consumes
92
+ `workorder.completed` and `commerce.order-placed`, so a vertical that imports zero engines
93
+ still gets invoicing by emitting an event. Its consumer find-or-creates the customer's
94
+ *open* basis and appends. It has **no tax/VAT concept** — say so before an EU user
95
+ discovers it.
96
+
97
+ ### Tier 2b — connectors, for anything off-box
98
+
99
+ Module code may not touch the network (rule 2), so a third party is reached by a
100
+ **connector**: `host.registerConnector(id, eventType, handler, options)`, with retry
101
+ policy, timeout, dead letters, and per-connection state. The handler runs *outside* the
102
+ scope transaction. Delivery is at-least-once — key a dispatch ledger in connector state and
103
+ return early on redelivery. Granting door access twice is harmless; charging a card twice
104
+ is not.
105
+
106
+ ### Tier 3 — yours
107
+
108
+ Vocabulary, price list, screens, roles, and any domain the engines don't own. If the user's
109
+ core noun isn't a job/inspection, this is most of the app — **a normal, supported outcome,
110
+ not a failure.**
111
+
112
+ ### Deliver it like this
113
+
114
+ > Your bike shop: a repair is a **work order** — the engine owns its lifecycle, so it can't
115
+ > jump from booked to closed. Time and parts reporting: engine. The invoice at the end:
116
+ > invoicing, by event — you emit, it listens. **Yours:** bikes, customers, your price list,
117
+ > the pricing rule when a repair takes 20 minutes but you bill a minimum hour, and the
118
+ > screens. Tenancy, permissions, and the audit trail come from the kernel — including the
119
+ > part where a customer logs in and sees *only their own* bikes.
120
+
121
+ ### The honest no
122
+
123
+ Substrat is the wrong tool for plenty. Say so — it's what makes the yes trustworthy. Bad
124
+ fits: single-tenant apps, content/marketing sites, pure CRUD with no permission story,
125
+ real-time collaborative editing, analytics workloads, anything where the hard part isn't
126
+ *who may do what to which record*. If it's a bad fit, say why, name a better tool, and
127
+ stop. Do not scaffold.
128
+
129
+ ---
130
+
131
+ ## Step 3 — Decisions
132
+
133
+ Short. Recommend a default and move.
134
+
135
+ - **Auth.** Local dev uses an `x-principal` header — a dev seam, not a login. Offer to wire
136
+ a real login now if they want it; otherwise default to the dev header and say it **must**
137
+ be replaced before anything real. Real auth gates *exposing* the app, not *building* it.
138
+ - **The cast.** Confirm the personas and their roles (e.g. `office-admin`, `technician`,
139
+ `portal-customer`). Roles are the user's vocabulary — name them for the persona.
140
+ - **Two tenants, always.** Seed a second tenant that exists to be attacked. This is how the
141
+ isolation gets proven rather than claimed.
142
+
143
+ ---
144
+
145
+ ## Step 4 — Reshape the reference
146
+
147
+ The scaffold already contains a working vertical in `src/` + `test/` — the bike-repair shop.
148
+ **Read it first** (it's your Callout: the real, green implementation of every pattern this
149
+ step describes), then reshape it into the user's domain from the interview:
150
+
151
+ - **Rename the vocabulary** — `shop_customers`/`shop_bikes` → the user's nouns, the `shop/*`
152
+ operation names, the roles, the price-list shape. If the user's core noun maps onto a work
153
+ order (a repair, a job, an inspection, a case), most of the structure carries over
154
+ unchanged and you are editing labels and the pricing rule.
155
+ - **Keep the load-bearing patterns** — the permission check as every operation's first line,
156
+ the pricing moment, the portal proof-walk, the two-tenant seed, the pinned-message
157
+ denials. These are what make it a Substrat vertical rather than a CRUD app; the reference
158
+ demonstrates each one working.
159
+ - **Drop what the domain doesn't need, add its own tables** for anything the engines don't
160
+ own. If the user's core noun *isn't* work-order-shaped, you may replace more of `src/` —
161
+ but the seed/server/test scaffolding and the layout still hold.
162
+ - **Re-run the gates as you go** (Step 5) — the reference is green, so any red is something
163
+ you just changed.
164
+
165
+ The dependencies are already wired in `package.json` (the `@substrat-run/*` packages, `hono`,
166
+ `better-sqlite3`; engines added as needed). If you compose a **different** engine, add it and
167
+ read its surface — the engines are self-describing:
168
+ `node_modules/@substrat-run/engine-*/dist/index.d.ts` is the reference; never guess at it.
169
+
170
+ **Do NOT add `zod` as a dependency, and never `import { z } from 'zod'`** (rule 10). Import
171
+ everything from contracts:
172
+
173
+ ```ts
174
+ import { z, entityRef, money, moduleManifest } from '@substrat-run/contracts';
175
+ ```
176
+
177
+ **After install, the engines are self-describing — read them.** Do not guess at their
178
+ surface: `node_modules/@substrat-run/engine-*/dist/index.d.ts` is the reference.
179
+
180
+ ### `src/manifest.ts`, `src/migrations.ts`, `src/module.ts`
181
+
182
+ Three separate files. `manifest.ts` holds the `PERM` consts + `moduleManifest.parse`,
183
+ `migrations.ts` exports the `SqlMigration[]`, `module.ts` imports both and holds only the
184
+ operations + the `ModuleRegistration`. Keep the split — the linter and tests expect it.
185
+
186
+ - `moduleManifest.parse({ … })` — id, version, `kernelContract: '^0.0.1'`, `permissions`
187
+ (key + human description; these feed the permission diff), `events` emits/consumes,
188
+ `attachmentTargets`, `entityRelations`, `entitlementKey`.
189
+ - **`entityRelations` must declare every edge you traverse** — your own (`bike → customer`)
190
+ and the ones the engine makes on your behalf (`workorder → bike`). The adapter rejects a
191
+ `ctx.link` for an undeclared edge. This is also what makes the portal proof-walk reach
192
+ the customer.
193
+ - Migrations: `SqlMigration[]`, tables prefixed `<vertical>_`, TEXT ids, ISO-8601 TEXT
194
+ timestamps, money/decimals as TEXT. **Append-only forever after first ship.**
195
+ - Operations: first line is always `assertAllowed(await ctx.check(PERM))`. Parse inputs
196
+ with Zod. `ctx.link(child, parent)` when creating related entities.
197
+ - **The pricing moment is the pattern to copy**: read the engine's reported lines with
198
+ `getReportedLines(ctx, orderId)` → apply the vertical's price list → call the engine's
199
+ `completeWorkOrder`. One transaction, invariants intact.
200
+ - Portal listing: iterate and `ctx.check(perm, entityRef)` **per entity** — a proof walk,
201
+ not UI filtering.
202
+
203
+ ### `src/seed.ts`
204
+
205
+ `new SqliteScopeHost({ dir })`, then `registerModule` per engine + the vertical. The control
206
+ plane comes first and is audited:
207
+
208
+ ```ts
209
+ host.admin.createTenant(actor, { id: tenant, slug: 'acme', name: 'Acme' });
210
+ host.admin.grantEntitlement(actor, tenant, '<entitlementKey>'); // per module
211
+ await host.provisionScope(actor, { tenantId: tenant, scopeId: scope, jurisdiction: 'eu' });
212
+ ```
213
+
214
+ Define roles **per tenant** from the engines' `PERM` + your keys, assign them, create seed
215
+ entities via `stub.invoke` (**never raw SQL**), give portal principals entity-narrowed
216
+ grants. Make it idempotent.
217
+
218
+ ### `test/scenario.test.ts`
219
+
220
+ Replay the domain scenario headlessly against a temp dir. **The denial assertions are the
221
+ whole point:**
222
+
223
+ ```ts
224
+ await expect(host.getScope(mallory, t2, s1)).rejects.toThrow(/unknown scope/); // wrong pair
225
+ const m = await host.getScope(mallory, t1, s1); // right pair, no tuples
226
+ await expect(m.invoke('workorder/list')).rejects.toThrow(/permission denied/);
227
+ ```
228
+
229
+ Cover: happy path → wrong-role denied → portal isolation (customer A sees theirs, B sees
230
+ nothing) → cross-tenant attacker denied → pricing exact to the öre → the state machine
231
+ refusing to skip. Never write a bare `.rejects.toThrow()` — pin the message, and pair every
232
+ closed-door assertion with a control proving a neighbouring door is still open.
233
+
234
+ ---
235
+
236
+ ## Step 5 — Run it
237
+
238
+ Build confidence in this order, and **show the user the output of each**:
239
+
240
+ ```sh
241
+ pnpm install
242
+ pnpm test # the scenario, including the denials
243
+ npx @substrat-run/boundary-lint # the layer rules
244
+ pnpm dev # API on :8871 (PORT=… WEB_PORT=… to move it)
245
+ ```
246
+
247
+ Then **actually exercise it** — don't just report that the server started. A green scenario
248
+ test never touches `server.ts`, its routes, or the principal picker, so it can be green
249
+ while the app is broken. Drive the real flow with curl (create → assign → start → report →
250
+ complete) as two personas, switching `x-principal` to show a denial landing as a denial.
251
+ The moment the attack fails is the demo; make sure the user sees it.
252
+
253
+ If they want a UI, scaffold a minimal Vite + React app under `app/` with a principal picker
254
+ and typed wrappers over the routes. Ask first — it roughly doubles the work.
255
+
256
+ ---
257
+
258
+ ## Step 6 — The two checkpoints. STOP HERE.
259
+
260
+ **You may never self-approve these. Present them and wait.**
261
+
262
+ 1. **Migration diff** — every new `SqlMigration`, verbatim. Append-only forever once
263
+ shipped, so this is the last cheap moment to change your mind.
264
+ 2. **Permission diff** — a table: key → description → which roles hold it → why.
265
+
266
+ | Key | Description | Roles |
267
+ |---|---|---|
268
+ | `repair:create` | Book a repair for a customer's bike | workshop-admin |
269
+ | `workorder:report` | Report time and materials | workshop-admin, mechanic |
270
+ | `bike:read-own` | See your own bikes (entity-narrowed) | portal-customer |
271
+
272
+ **A checkpoint assumes a competent reviewer.** If the user cannot evaluate the table, walk
273
+ them through it in their own vocabulary until they can answer: *who can now see the money,
274
+ and who can see other tenants' data?* A permission diff nobody understands is theater.
275
+
276
+ ---
277
+
278
+ ## Step 7 — Deploy (optional)
279
+
280
+ Only if the user asks. Local-first is a legitimate stopping point.
281
+
282
+ Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects). A
283
+ vertical declares what it needs at runtime with a `substrat.runtimeNeeds` block in
284
+ `package.json` (stores, node-compat, build). The deploy path is the authenticated CLI, and
285
+ the author never holds a Cloudflare token:
286
+
287
+ - `substrat login` / `substrat whoami` — authenticate against the control plane.
288
+ - `substrat push` — push the vertical; the version auto-bumps. A **private** (tenant-owned)
289
+ vertical is admitted automatically; a **listed/shared** one waits for staff admission.
290
+ - `substrat promote <slug> --channel dev|staging|prod --version … [--ack-permissions]
291
+ [--ack-migrations]` — the owner promotes every channel, prod included, for their own
292
+ private vertical.
293
+ - `substrat hostnames bind <slug> --surface <s> [--domain <d>]` — mint a live hostname, or
294
+ record a custom domain pending DNS validation (`substrat hostnames verify`).
295
+
296
+ Updates deploy **in place** from one stable script — data carries forward, migrations run
297
+ against prod data, backout is a time-boxed PITR rewind.
298
+
299
+ Before deploying: the `x-principal` dev header **must** be gone. Shipping it is a
300
+ cross-tenant hole with a UI.
301
+
302
+ ---
303
+
304
+ ## Step 8 — Leave the project competent
305
+
306
+ The next session — in any tool — starts cold. The scaffold already ships `AGENTS.md`,
307
+ `CLAUDE.md`, and the Cursor/opencode command stubs, so the rules and this flow survive. Your
308
+ job here is to make them *specific to this app*: append the vertical's own vocabulary, cast,
309
+ and roles to `AGENTS.md` (it's the file every tool reads), so the next session knows the
310
+ domain and not just the framework. Do this before the user comes back.