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 +209 -32
- package/package.json +3 -2
- package/template/.claude/skills/substrat/SKILL.md +13 -0
- package/template/.cursor/commands/new-vertical.md +7 -0
- package/template/.cursor/rules/substrat.mdc +11 -0
- package/template/.opencode/command/new-vertical.md +9 -0
- package/template/.substrat/playbook.md +310 -0
- package/template/AGENTS.md +116 -0
- package/template/CLAUDE.md +8 -0
- package/template/src/manifest.ts +48 -0
- package/template/src/migrations.ts +39 -0
- package/template/src/module.ts +297 -0
- package/template/src/seed.ts +265 -0
- package/template/src/server.ts +145 -0
- package/template/test/scenario.test.ts +249 -0
package/index.js
CHANGED
|
@@ -1,39 +1,216 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `npm create substrat
|
|
3
|
+
* `npm create substrat <dir>` — scaffold a Substrat vertical.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 —
|
|
10
|
-
* install time
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
process.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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.
|