create-substrat 0.0.1 → 0.1.1
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 +214 -33
- package/package.json +4 -3
- 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 +386 -0
- package/template/AGENTS.md +117 -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
|
@@ -0,0 +1,386 @@
|
|
|
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, and
|
|
5
|
+
**land a design document they can review and approve before a line of code is written** — then
|
|
6
|
+
reshape the reference into it. Read the whole thing before starting — both the design gate
|
|
7
|
+
(Step 4) and the checkpoints (Step 7) are hard stops.
|
|
8
|
+
|
|
9
|
+
**The target is a reviewed design, not running code.** Steps 1–2 learn the domain and map it
|
|
10
|
+
onto what already exists; Step 3 writes a checked-in `DESIGN.md` in the user's own vocabulary;
|
|
11
|
+
Step 4 is a **hard stop** where the user reads and approves it. Only then does Step 5 reshape
|
|
12
|
+
the reference into their domain. The design gate (Step 4) is *upstream* of the two code
|
|
13
|
+
checkpoints (Step 7) — a user with zero Substrat knowledge gets to say "yes, that's the app I
|
|
14
|
+
want" before implementation, not after.
|
|
15
|
+
|
|
16
|
+
This project ships with a small **working reference vertical** — a bike-repair shop on
|
|
17
|
+
`engine-workorder` + `engine-invoicing`, green out of the box (`npm test`). It is your worked
|
|
18
|
+
example and your build starting point: once the design is approved you **reshape** it into the
|
|
19
|
+
user's domain rather than building from an empty directory. Work in the project root.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Step 1 — Interview
|
|
24
|
+
|
|
25
|
+
Ask, don't assume. **Three to five questions, conversational, one message.** You are learning
|
|
26
|
+
the *shape* of the domain — the answers become the design document (Step 3), so listen for
|
|
27
|
+
vocabulary, the cast, and who must be denied what, not just features. **Adapt depth to the
|
|
28
|
+
user**: someone who already knows their domain cold needs fewer, sharper questions; someone
|
|
29
|
+
thinking out loud needs you to draw the shape out. One flow, not branching tracks — read the
|
|
30
|
+
room and dial the teaching up or down.
|
|
31
|
+
|
|
32
|
+
1. **What are you building, and who uses it?** (the firm, the cast)
|
|
33
|
+
2. **What's the thing that moves through the system?** A job, a repair, an inspection, an
|
|
34
|
+
order, a case? What happens to it from start to finish?
|
|
35
|
+
3. **Who must be denied what?** The most important question and the one nobody expects.
|
|
36
|
+
Does a customer log in? Should a technician see pricing? This drives the whole
|
|
37
|
+
permission model, and it is what Substrat is *for*.
|
|
38
|
+
4. **Does money come out the other end?** Invoice, quote, receipt, nothing?
|
|
39
|
+
5. **Anything that must be signed off or checked before a step can happen?**
|
|
40
|
+
|
|
41
|
+
Don't ask about tech, hosting, or databases yet. If the user already described their app in
|
|
42
|
+
detail, skip to Step 2 and confirm your reading of it instead of re-asking.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Step 2 — The coverage map
|
|
47
|
+
|
|
48
|
+
**This is the most valuable thing you do, and the easiest to get wrong by being
|
|
49
|
+
flattering.** Work out what already exists and what the user is actually signing up to build.
|
|
50
|
+
Be specific and honest. This analysis is the analytical core of the design document (Step 3) —
|
|
51
|
+
sections 3 ("what already exists vs. what's yours") and 4 ("who is denied what") are this map,
|
|
52
|
+
written down.
|
|
53
|
+
|
|
54
|
+
**First, list the engines that actually exist. Do not trust any hard-coded list:**
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm search @substrat-run --json | grep -E '"name"|"description"'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Substrat publishes frequently, so any inventory in a doc goes stale between releases. A
|
|
61
|
+
missing engine does not fail loudly — it silently becomes Tier 3, and you hand the user an
|
|
62
|
+
estimate for work that already exists. Run the search, then read the `dist/index.d.ts` of
|
|
63
|
+
anything relevant.
|
|
64
|
+
|
|
65
|
+
Coverage has four tiers:
|
|
66
|
+
|
|
67
|
+
### Tier 0 — the kernel. Always. Free.
|
|
68
|
+
|
|
69
|
+
Every vertical gets this whether or not it uses a single engine:
|
|
70
|
+
|
|
71
|
+
- **Tenancy** — tenants and scopes, isolated at the database level. A scope is one
|
|
72
|
+
SQLite/DO database. Cross-tenant access is not a bug you avoid; there is no API for it.
|
|
73
|
+
- **Permissions** — roles, grants, entity-narrowed grants, and every decision carries a
|
|
74
|
+
proof path (why it was allowed).
|
|
75
|
+
- **Events + audit** — every mutation emits a kernel-stamped event. Origin fields (tenant,
|
|
76
|
+
scope, actor, time) are stamped by the kernel; your code cannot mislabel one.
|
|
77
|
+
- **Migrations** — journaled per module, applied lazily per scope.
|
|
78
|
+
|
|
79
|
+
This is usually *most of what the user would otherwise build badly*. Say so plainly.
|
|
80
|
+
|
|
81
|
+
### Tier 1 — engines you compose
|
|
82
|
+
|
|
83
|
+
Imported directly; their in-scope functions run in **your** transaction. Read each one's
|
|
84
|
+
`node_modules/@substrat-run/engine-*/dist/index.d.ts` for the real surface before composing
|
|
85
|
+
— in-scope functions, `PERM` keys, and types. Typical examples (verify with the search):
|
|
86
|
+
|
|
87
|
+
- **`engine-workorder`** — a job with a lifecycle that cannot skip states, plus time and
|
|
88
|
+
material reporting. Note there is **no `workorder/create` operation** — creation goes
|
|
89
|
+
through `createWorkOrder(ctx, …)`, an in-scope function, because the vertical must
|
|
90
|
+
price/label it first. The hole is deliberate: the engine owns the state machine, you own
|
|
91
|
+
vocabulary and pricing.
|
|
92
|
+
- **`engine-protocol`** — checklists/inspections with templates, responses, and signatures.
|
|
93
|
+
Contributes a guard predicate so you can declare an operation blocked until signed.
|
|
94
|
+
- **`engine-booking`** — reservations. Owns one invariant: concurrent allocations never
|
|
95
|
+
exceed capacity over any overlapping interval. Knows nothing about pricing, opening
|
|
96
|
+
hours, recurrence, or timezones — all vertical policy.
|
|
97
|
+
- **`engine-invites`** — how a person joins an org they are not in. Identifiers stored
|
|
98
|
+
hashed and never returned; an invitation confers nothing until accepted. Reach for it
|
|
99
|
+
before hand-rolling any invite flow.
|
|
100
|
+
|
|
101
|
+
### Tier 2 — engines you feed by event
|
|
102
|
+
|
|
103
|
+
**No import.** You emit; they consume. This is the star topology.
|
|
104
|
+
|
|
105
|
+
- **`engine-invoicing`** — invoice basis and lines, immutable after export. Consumes
|
|
106
|
+
`workorder.completed` and `commerce.order-placed`, so a vertical that imports zero engines
|
|
107
|
+
still gets invoicing by emitting an event. Its consumer find-or-creates the customer's
|
|
108
|
+
*open* basis and appends. It has **no tax/VAT concept** — say so before an EU user
|
|
109
|
+
discovers it.
|
|
110
|
+
|
|
111
|
+
### Tier 2b — connectors, for anything off-box
|
|
112
|
+
|
|
113
|
+
Module code may not touch the network (rule 2), so a third party is reached by a
|
|
114
|
+
**connector**: `host.registerConnector(id, eventType, handler, options)`, with retry
|
|
115
|
+
policy, timeout, dead letters, and per-connection state. The handler runs *outside* the
|
|
116
|
+
scope transaction. Delivery is at-least-once — key a dispatch ledger in connector state and
|
|
117
|
+
return early on redelivery. Granting door access twice is harmless; charging a card twice
|
|
118
|
+
is not.
|
|
119
|
+
|
|
120
|
+
### Tier 3 — yours
|
|
121
|
+
|
|
122
|
+
Vocabulary, price list, screens, roles, and any domain the engines don't own. If the user's
|
|
123
|
+
core noun isn't a job/inspection, this is most of the app — **a normal, supported outcome,
|
|
124
|
+
not a failure.**
|
|
125
|
+
|
|
126
|
+
### Deliver it like this
|
|
127
|
+
|
|
128
|
+
> Your bike shop: a repair is a **work order** — the engine owns its lifecycle, so it can't
|
|
129
|
+
> jump from booked to closed. Time and parts reporting: engine. The invoice at the end:
|
|
130
|
+
> invoicing, by event — you emit, it listens. **Yours:** bikes, customers, your price list,
|
|
131
|
+
> the pricing rule when a repair takes 20 minutes but you bill a minimum hour, and the
|
|
132
|
+
> screens. Tenancy, permissions, and the audit trail come from the kernel — including the
|
|
133
|
+
> part where a customer logs in and sees *only their own* bikes.
|
|
134
|
+
|
|
135
|
+
### The honest no
|
|
136
|
+
|
|
137
|
+
Substrat is the wrong tool for plenty. Say so — it's what makes the yes trustworthy. Bad
|
|
138
|
+
fits: single-tenant apps, content/marketing sites, pure CRUD with no permission story,
|
|
139
|
+
real-time collaborative editing, analytics workloads, anything where the hard part isn't
|
|
140
|
+
*who may do what to which record*. If it's a bad fit, say why, name a better tool, and
|
|
141
|
+
stop. Do not scaffold.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Step 3 — Write the design document
|
|
146
|
+
|
|
147
|
+
**This is the deliverable.** Everything before now was learning; this is where it lands
|
|
148
|
+
somewhere the user can hold. Write a **checked-in `DESIGN.md`** in the project root, in the
|
|
149
|
+
user's own vocabulary — no Substrat internals, no decision refs, no cross-references to
|
|
150
|
+
platform docs. Someone who has never heard of Substrat must be able to read it and recognise
|
|
151
|
+
their own business.
|
|
152
|
+
|
|
153
|
+
**Top line, verbatim** — the house marker for a pre-code design:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
Status: draft v0.1 · Last updated: <date> · For review before any code
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**The template** — the coverage map (Step 2) is sections 3–4, already done; the rest is the
|
|
160
|
+
interview written down. Two sections deliberately *preview* the code checkpoints of Step 7 in
|
|
161
|
+
plain language, so nothing there is a surprise:
|
|
162
|
+
|
|
163
|
+
1. **What we're building & who uses it** — the firm and the cast, one paragraph.
|
|
164
|
+
2. **The thing that moves through the system** — the core noun and its lifecycle
|
|
165
|
+
(the states it passes through, and which transitions must not be skippable).
|
|
166
|
+
3. **What already exists vs. what's yours** — the coverage map as tiers: the kernel
|
|
167
|
+
(free), the engines you compose, the connectors, and the Tier-3 vocabulary/pricing/
|
|
168
|
+
screens that are yours. If it's a **bad fit, this is where the honest no lands** — say
|
|
169
|
+
so and stop; do not write the rest.
|
|
170
|
+
4. **Who is denied what** — the load-bearing section, and a plain-language *preview of the
|
|
171
|
+
permission diff*: each role and what it can and cannot see. Make two answers impossible
|
|
172
|
+
to miss — **who can see the money, and who can see other customers' data.**
|
|
173
|
+
5. **Money & sign-off** — invoice / quote / receipt / none; anything gated on a signature
|
|
174
|
+
or a check before a step can happen.
|
|
175
|
+
6. **The cast, roles, and tenancy** — the named roles per persona (roles are the user's
|
|
176
|
+
vocabulary — name them for the persona: `workshop-admin`, not `role_1`). **Two tenants,
|
|
177
|
+
always** — the second exists to be attacked, which is how isolation gets proven rather
|
|
178
|
+
than claimed.
|
|
179
|
+
7. **The data we'll store** — the vertical's own tables and fields in plain terms. This
|
|
180
|
+
*previews the migration diff*; migrations are **append-only forever after first ship**,
|
|
181
|
+
so this is the cheap moment to get the shape right.
|
|
182
|
+
8. **The scenario the test will replay** — the happy path plus the denials that prove
|
|
183
|
+
isolation (wrong role denied, customer A sees theirs and customer B sees nothing, a
|
|
184
|
+
cross-tenant attacker gets nothing).
|
|
185
|
+
9. **Open decisions** — each with a **recommended default**, so the user chooses rather
|
|
186
|
+
than specifies:
|
|
187
|
+
- **Auth.** Local dev uses an `x-principal` header — a dev seam, not a login. Default to
|
|
188
|
+
it and note it must be replaced before anything real; offer to wire a real login (the
|
|
189
|
+
bike-shop reference shows the Better Auth pattern) now if they want it. Real auth gates
|
|
190
|
+
*exposing* the app, not *building* it.
|
|
191
|
+
- **Deploy or stay local.** Local-first is a legitimate endpoint; default to it.
|
|
192
|
+
10. **Out of scope / deferred** — what you are deliberately not building, so the review is
|
|
193
|
+
about a bounded thing.
|
|
194
|
+
|
|
195
|
+
End with a short **"Review questions for the human"** block (2–3 questions) — the things the
|
|
196
|
+
user must actively confirm, not rubber-stamp.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Step 4 — The design gate. STOP HERE.
|
|
201
|
+
|
|
202
|
+
**Present the design document and wait for approval. Do not reshape the reference, do not
|
|
203
|
+
write code.**
|
|
204
|
+
|
|
205
|
+
This is a *human* gate, and it is the whole point: it happens **before** any code, upstream of
|
|
206
|
+
the two implementation checkpoints in Step 7. Walk the user through section 4 ("who is denied
|
|
207
|
+
what") in **their own vocabulary** until they can answer, without your help: *who can see the
|
|
208
|
+
money, and who can see other customers' data?*
|
|
209
|
+
|
|
210
|
+
**A gate assumes a competent reviewer.** If the user cannot evaluate the permission preview,
|
|
211
|
+
say so rather than letting them wave it through — a design nobody understands is theater, and
|
|
212
|
+
reproduces exactly the failure Substrat exists to prevent. Iterate the document until they
|
|
213
|
+
can, and only then take explicit approval.
|
|
214
|
+
|
|
215
|
+
Approval of the design is what unlocks Step 5. Until you have it, you are still in design.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Step 5 — Reshape the reference
|
|
220
|
+
|
|
221
|
+
The design is approved. The scaffold already contains a working vertical in `src/` + `test/` —
|
|
222
|
+
the bike-repair shop. **Read it first** (it's your Callout: the real, green implementation of
|
|
223
|
+
every pattern this step describes), then reshape it into the user's domain from the approved
|
|
224
|
+
`DESIGN.md`:
|
|
225
|
+
|
|
226
|
+
- **Rename the vocabulary** — `shop_customers`/`shop_bikes` → the user's nouns, the `shop/*`
|
|
227
|
+
operation names, the roles, the price-list shape. If the user's core noun maps onto a work
|
|
228
|
+
order (a repair, a job, an inspection, a case), most of the structure carries over
|
|
229
|
+
unchanged and you are editing labels and the pricing rule.
|
|
230
|
+
- **Keep the load-bearing patterns** — the permission check as every operation's first line,
|
|
231
|
+
the pricing moment, the portal proof-walk, the two-tenant seed, the pinned-message
|
|
232
|
+
denials. These are what make it a Substrat vertical rather than a CRUD app; the reference
|
|
233
|
+
demonstrates each one working.
|
|
234
|
+
- **Drop what the domain doesn't need, add its own tables** for anything the engines don't
|
|
235
|
+
own. If the user's core noun *isn't* work-order-shaped, you may replace more of `src/` —
|
|
236
|
+
but the seed/server/test scaffolding and the layout still hold.
|
|
237
|
+
- **Re-run the gates as you go** (Step 6) — the reference is green, so any red is something
|
|
238
|
+
you just changed.
|
|
239
|
+
|
|
240
|
+
The dependencies are already wired in `package.json` (the `@substrat-run/*` packages, `hono`,
|
|
241
|
+
`better-sqlite3`; engines added as needed). If you compose a **different** engine, add it and
|
|
242
|
+
read its surface — the engines are self-describing:
|
|
243
|
+
`node_modules/@substrat-run/engine-*/dist/index.d.ts` is the reference; never guess at it.
|
|
244
|
+
|
|
245
|
+
**Do NOT add `zod` as a dependency, and never `import { z } from 'zod'`** (rule 10). Import
|
|
246
|
+
everything from contracts:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { z, entityRef, money, moduleManifest } from '@substrat-run/contracts';
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
**After install, the engines are self-describing — read them.** Do not guess at their
|
|
253
|
+
surface: `node_modules/@substrat-run/engine-*/dist/index.d.ts` is the reference.
|
|
254
|
+
|
|
255
|
+
### `src/manifest.ts`, `src/migrations.ts`, `src/module.ts`
|
|
256
|
+
|
|
257
|
+
Three separate files. `manifest.ts` holds the `PERM` consts + `moduleManifest.parse`,
|
|
258
|
+
`migrations.ts` exports the `SqlMigration[]`, `module.ts` imports both and holds only the
|
|
259
|
+
operations + the `ModuleRegistration`. Keep the split — the linter and tests expect it.
|
|
260
|
+
|
|
261
|
+
- `moduleManifest.parse({ … })` — id, version, `kernelContract: '^0.0.1'`, `permissions`
|
|
262
|
+
(key + human description; these feed the permission diff), `events` emits/consumes,
|
|
263
|
+
`attachmentTargets`, `entityRelations`, `entitlementKey`.
|
|
264
|
+
- **`entityRelations` must declare every edge you traverse** — your own (`bike → customer`)
|
|
265
|
+
and the ones the engine makes on your behalf (`workorder → bike`). The adapter rejects a
|
|
266
|
+
`ctx.link` for an undeclared edge. This is also what makes the portal proof-walk reach
|
|
267
|
+
the customer.
|
|
268
|
+
- Migrations: `SqlMigration[]`, tables prefixed `<vertical>_`, TEXT ids, ISO-8601 TEXT
|
|
269
|
+
timestamps, money/decimals as TEXT. **Append-only forever after first ship.**
|
|
270
|
+
- Operations: first line is always `assertAllowed(await ctx.check(PERM))`. Parse inputs
|
|
271
|
+
with Zod. `ctx.link(child, parent)` when creating related entities.
|
|
272
|
+
- **The pricing moment is the pattern to copy**: read the engine's reported lines with
|
|
273
|
+
`getReportedLines(ctx, orderId)` → apply the vertical's price list → call the engine's
|
|
274
|
+
`completeWorkOrder`. One transaction, invariants intact.
|
|
275
|
+
- Portal listing: iterate and `ctx.check(perm, entityRef)` **per entity** — a proof walk,
|
|
276
|
+
not UI filtering.
|
|
277
|
+
|
|
278
|
+
### `src/seed.ts`
|
|
279
|
+
|
|
280
|
+
`new SqliteScopeHost({ dir })`, then `registerModule` per engine + the vertical. The control
|
|
281
|
+
plane comes first and is audited:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
host.admin.createTenant(actor, { id: tenant, slug: 'acme', name: 'Acme' });
|
|
285
|
+
host.admin.grantEntitlement(actor, tenant, '<entitlementKey>'); // per module
|
|
286
|
+
await host.provisionScope(actor, { tenantId: tenant, scopeId: scope, jurisdiction: 'eu' });
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Define roles **per tenant** from the engines' `PERM` + your keys, assign them, create seed
|
|
290
|
+
entities via `stub.invoke` (**never raw SQL**), give portal principals entity-narrowed
|
|
291
|
+
grants. Make it idempotent.
|
|
292
|
+
|
|
293
|
+
### `test/scenario.test.ts`
|
|
294
|
+
|
|
295
|
+
Replay the domain scenario headlessly against a temp dir. **The denial assertions are the
|
|
296
|
+
whole point:**
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await expect(host.getScope(mallory, t2, s1)).rejects.toThrow(/unknown scope/); // wrong pair
|
|
300
|
+
const m = await host.getScope(mallory, t1, s1); // right pair, no tuples
|
|
301
|
+
await expect(m.invoke('workorder/list')).rejects.toThrow(/permission denied/);
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Cover: happy path → wrong-role denied → portal isolation (customer A sees theirs, B sees
|
|
305
|
+
nothing) → cross-tenant attacker denied → pricing exact to the öre → the state machine
|
|
306
|
+
refusing to skip. Never write a bare `.rejects.toThrow()` — pin the message, and pair every
|
|
307
|
+
closed-door assertion with a control proving a neighbouring door is still open.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Step 6 — Run it
|
|
312
|
+
|
|
313
|
+
Build confidence in this order, and **show the user the output of each**:
|
|
314
|
+
|
|
315
|
+
```sh
|
|
316
|
+
pnpm install
|
|
317
|
+
pnpm test # the scenario, including the denials
|
|
318
|
+
npx @substrat-run/boundary-lint # the layer rules
|
|
319
|
+
pnpm dev # API on :8871 (PORT=… WEB_PORT=… to move it)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Then **actually exercise it** — don't just report that the server started. A green scenario
|
|
323
|
+
test never touches `server.ts`, its routes, or the principal picker, so it can be green
|
|
324
|
+
while the app is broken. Drive the real flow with curl (create → assign → start → report →
|
|
325
|
+
complete) as two personas, switching `x-principal` to show a denial landing as a denial.
|
|
326
|
+
The moment the attack fails is the demo; make sure the user sees it.
|
|
327
|
+
|
|
328
|
+
If they want a UI, scaffold a minimal Vite + React app under `app/` with a principal picker
|
|
329
|
+
and typed wrappers over the routes. Ask first — it roughly doubles the work.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Step 7 — The two checkpoints. STOP HERE.
|
|
334
|
+
|
|
335
|
+
**You may never self-approve these. Present them and wait.** The design gate (Step 4) already
|
|
336
|
+
took the user's approval of *what* to build; these confirm that the code matches it.
|
|
337
|
+
|
|
338
|
+
1. **Migration diff** — every new `SqlMigration`, verbatim. Append-only forever once
|
|
339
|
+
shipped, so this is the last cheap moment to change your mind.
|
|
340
|
+
2. **Permission diff** — a table: key → description → which roles hold it → why.
|
|
341
|
+
|
|
342
|
+
| Key | Description | Roles |
|
|
343
|
+
|---|---|---|
|
|
344
|
+
| `repair:create` | Book a repair for a customer's bike | workshop-admin |
|
|
345
|
+
| `workorder:report` | Report time and materials | workshop-admin, mechanic |
|
|
346
|
+
| `bike:read-own` | See your own bikes (entity-narrowed) | portal-customer |
|
|
347
|
+
|
|
348
|
+
**A checkpoint assumes a competent reviewer.** If the user cannot evaluate the table, walk
|
|
349
|
+
them through it in their own vocabulary until they can answer: *who can now see the money,
|
|
350
|
+
and who can see other tenants' data?* A permission diff nobody understands is theater.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Step 8 — Deploy (optional)
|
|
355
|
+
|
|
356
|
+
Only if the user asks. Local-first is a legitimate stopping point.
|
|
357
|
+
|
|
358
|
+
Substrat runs on Cloudflare via `@substrat-run/adapter-cloudflare` (Durable Objects). A
|
|
359
|
+
vertical declares what it needs at runtime with a `substrat.runtimeNeeds` block in
|
|
360
|
+
`package.json` (stores, node-compat, build). The deploy path is the authenticated CLI, and
|
|
361
|
+
the author never holds a Cloudflare token:
|
|
362
|
+
|
|
363
|
+
- `substrat login` / `substrat whoami` — authenticate against the control plane.
|
|
364
|
+
- `substrat push` — push the vertical; the version auto-bumps. A **private** (tenant-owned)
|
|
365
|
+
vertical is admitted automatically; a **listed/shared** one waits for staff admission.
|
|
366
|
+
- `substrat promote <slug> --channel dev|staging|prod --version … [--ack-permissions]
|
|
367
|
+
[--ack-migrations]` — the owner promotes every channel, prod included, for their own
|
|
368
|
+
private vertical.
|
|
369
|
+
- `substrat hostnames bind <slug> --surface <s> [--domain <d>]` — mint a live hostname, or
|
|
370
|
+
record a custom domain pending DNS validation (`substrat hostnames verify`).
|
|
371
|
+
|
|
372
|
+
Updates deploy **in place** from one stable script — data carries forward, migrations run
|
|
373
|
+
against prod data, backout is a time-boxed PITR rewind.
|
|
374
|
+
|
|
375
|
+
Before deploying: the `x-principal` dev header **must** be gone. Shipping it is a
|
|
376
|
+
cross-tenant hole with a UI.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Step 9 — Leave the project competent
|
|
381
|
+
|
|
382
|
+
The next session — in any tool — starts cold. The scaffold already ships `AGENTS.md`,
|
|
383
|
+
`CLAUDE.md`, and the Cursor/opencode command stubs, so the rules and this flow survive. Your
|
|
384
|
+
job here is to make them *specific to this app*: append the vertical's own vocabulary, cast,
|
|
385
|
+
and roles to `AGENTS.md` (it's the file every tool reads), so the next session knows the
|
|
386
|
+
domain and not just the framework. Do this before the user comes back.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Building on Substrat — agent instructions
|
|
2
|
+
|
|
3
|
+
This project is a **Substrat vertical**: a multi-tenant business app built on the
|
|
4
|
+
Substrat kernel and its engines. This file is the always-on constitution — the rules
|
|
5
|
+
that hold no matter what you touch. It is read by every AI tool (Claude Code, Cursor,
|
|
6
|
+
opencode); do not duplicate it into tool-specific config.
|
|
7
|
+
|
|
8
|
+
The full build flow — interview, coverage map, a reviewed design document you approve
|
|
9
|
+
before any code, then reshape, run, and the checkpoints — is a **playbook**, not always-on
|
|
10
|
+
context. Invoke it when you start or extend a vertical:
|
|
11
|
+
|
|
12
|
+
- **Claude Code**: `/substrat`
|
|
13
|
+
- **Cursor / opencode**: the `new-vertical` command, or read [`.substrat/playbook.md`](.substrat/playbook.md)
|
|
14
|
+
|
|
15
|
+
Read the playbook before scaffolding. This file is what a session already mid-build
|
|
16
|
+
must never violate.
|
|
17
|
+
|
|
18
|
+
## The mental model
|
|
19
|
+
|
|
20
|
+
Three layers. You only own the third.
|
|
21
|
+
|
|
22
|
+
1. **Kernel — free, always.** Tenancy (one scope = one isolated database; there is no
|
|
23
|
+
cross-tenant API), permissions (roles, grants, and a proof path for every decision),
|
|
24
|
+
events + audit (every mutation emits a kernel-stamped event you cannot mislabel),
|
|
25
|
+
migrations (journaled per module, applied lazily per scope).
|
|
26
|
+
2. **Engines — compose or feed.** Headless, own invariants that cannot be violated
|
|
27
|
+
(state machines that can't skip states, append-only entries). You either **compose**
|
|
28
|
+
an engine (import it; its in-scope functions run in *your* transaction) or **feed** it
|
|
29
|
+
(emit a fat event; it consumes — no import). Engines never import each other. Read an
|
|
30
|
+
engine's real surface from `node_modules/@substrat-run/engine-*/dist/index.d.ts` —
|
|
31
|
+
never guess at it.
|
|
32
|
+
3. **Your vertical — everything a user touches.** Vocabulary, price list, extra fields,
|
|
33
|
+
roles, screens. If your core noun isn't something an engine already owns, this is most
|
|
34
|
+
of the app — a normal, supported outcome.
|
|
35
|
+
|
|
36
|
+
## Project layout
|
|
37
|
+
|
|
38
|
+
The linter and tests expect this shape. `manifest`/`migrations`/`module` are **module
|
|
39
|
+
code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
|
|
43
|
+
src/migrations.ts the SqlMigration[] ← module code
|
|
44
|
+
src/module.ts imports both; operations + registration ← module code
|
|
45
|
+
src/seed.ts host, tenants, roles, grants, seed world ← harness
|
|
46
|
+
src/server.ts thin wrapper, one route per operation ← harness
|
|
47
|
+
test/scenario.test.ts the scenario — including the denials
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## The rules (non-negotiable)
|
|
51
|
+
|
|
52
|
+
**Module code** = everything reachable from a `ModuleRegistration` (operations,
|
|
53
|
+
consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
|
|
54
|
+
|
|
55
|
+
1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, or
|
|
56
|
+
`node:*` in module code.
|
|
57
|
+
2. **No `fetch` / network in module code.** It would hold the scope's transaction open on
|
|
58
|
+
a third party. The sanctioned path is a **connector**: emit a fat event, register a
|
|
59
|
+
handler that runs outside the transaction. An integration is never impossible because
|
|
60
|
+
of this rule — it has an answer.
|
|
61
|
+
3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections);
|
|
62
|
+
writes forge the audit spine.
|
|
63
|
+
4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use
|
|
64
|
+
the engine's exported in-scope functions. This is the rule with no runtime equivalent:
|
|
65
|
+
the shortcut *works* and silently welds you to an engine's private schema forever. Need
|
|
66
|
+
extra data on an engine entity? Add **your own side table keyed by the engine's id** —
|
|
67
|
+
never a column upstream.
|
|
68
|
+
5. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
|
|
69
|
+
is the first line.
|
|
70
|
+
6. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
|
|
71
|
+
7. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
|
|
72
|
+
line wrong — that's design feedback, not a coding problem.
|
|
73
|
+
8. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
|
|
74
|
+
(`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
|
|
75
|
+
9. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
|
|
76
|
+
hand-roll a hash to dodge an import ban.
|
|
77
|
+
10. **Parse, don't trust.** Zod at every boundary — but import `z` from
|
|
78
|
+
`@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
|
|
79
|
+
copies or majors; composing a contracts schema into one built from a separate `zod`
|
|
80
|
+
fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
|
|
81
|
+
cause.
|
|
82
|
+
|
|
83
|
+
## Declare every link edge
|
|
84
|
+
|
|
85
|
+
`entityRelations` in the manifest must declare every edge you traverse — both your own
|
|
86
|
+
(`bike → customer`) and the ones an engine makes on your behalf (`workorder → bike`). The
|
|
87
|
+
adapter **rejects** a `ctx.link` for an undeclared edge, so a missing one fails loudly.
|
|
88
|
+
This is also what lets a portal permission-walk reach the owner.
|
|
89
|
+
|
|
90
|
+
## The gates — run them, believe them
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npm test # the scenario, including the denials
|
|
94
|
+
npx @substrat-run/boundary-lint # the layer rules (1–4)
|
|
95
|
+
npm run typecheck
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`boundary-lint` exits non-zero if it *couldn't do its job* (no module code found, no
|
|
99
|
+
engines resolvable) — a pass that checked nothing is worse than no linter. Never wave that
|
|
100
|
+
through; fix the setup until it can see your code.
|
|
101
|
+
|
|
102
|
+
A green scenario test does **not** mean the app works: the test calls operations directly
|
|
103
|
+
and never exercises `server.ts`, its routes, or the principal picker. Before calling a
|
|
104
|
+
vertical done, boot the server and drive the real flow over HTTP as two personas — one who
|
|
105
|
+
should succeed and one who should be denied — and confirm the denial arrives as a denial
|
|
106
|
+
(not a generic error).
|
|
107
|
+
|
|
108
|
+
## Two human checkpoints — you may never self-approve
|
|
109
|
+
|
|
110
|
+
Present these and stop:
|
|
111
|
+
|
|
112
|
+
1. **Migration diff** — every new `SqlMigration`, verbatim. Migrations are append-only
|
|
113
|
+
forever once shipped, so this is the last cheap moment to change your mind.
|
|
114
|
+
2. **Permission diff** — a table: key → description → which roles hold it → why. Walk the
|
|
115
|
+
reviewer through it in their own vocabulary until they can answer *who can now see the
|
|
116
|
+
money, and who can see other tenants' data?* A permission diff nobody understands is
|
|
117
|
+
theater — it reproduces the exact failure Substrat exists to prevent.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@AGENTS.md
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Claude Code reads CLAUDE.md, not AGENTS.md. This one-line @-import pulls the shared
|
|
5
|
+
constitution in verbatim, so there is a single source of truth every tool reads.
|
|
6
|
+
Add Claude-only notes below this line if you ever need them; keep the shared rules in
|
|
7
|
+
AGENTS.md so Cursor and opencode see them too.
|
|
8
|
+
-->
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { moduleManifest, permissionKey } from '@substrat-run/contracts';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The vertical's MANIFEST — the reviewable contract the kernel reads at
|
|
5
|
+
// registration. A minimal bike-repair shop composed onto two engines:
|
|
6
|
+
//
|
|
7
|
+
// engine-workorder — the repair's state machine and append-only lines
|
|
8
|
+
// engine-invoicing — turns a completed repair into an invoice basis, by EVENT
|
|
9
|
+
//
|
|
10
|
+
// This vertical owns only vocabulary (customers, bikes, a price list) and the
|
|
11
|
+
// PRICING MOMENT. Every invariant that matters — a repair can't skip states,
|
|
12
|
+
// a completed repair is immutable, every mutation emits an event — lives in the
|
|
13
|
+
// engine. Read CLAUDE.md / AGENTS.md before you touch it.
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/** The vertical's own permission keys (the engines declare their own). */
|
|
17
|
+
export const SHOP_PERM = {
|
|
18
|
+
customerManage: permissionKey.parse('customer:manage'),
|
|
19
|
+
bikeManage: permissionKey.parse('bike:manage'),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const bikeShopManifest = moduleManifest.parse({
|
|
23
|
+
id: 'bikeshop',
|
|
24
|
+
version: '0.0.1',
|
|
25
|
+
kernelContract: '^0.0.1',
|
|
26
|
+
permissions: [
|
|
27
|
+
{ key: 'customer:manage', description: 'Manage customers and the workshop price list' },
|
|
28
|
+
{ key: 'bike:manage', description: "Register and manage customers' bikes" },
|
|
29
|
+
],
|
|
30
|
+
// The vertical emits and consumes no events of its own: the invoicing engine
|
|
31
|
+
// consumes the WORKORDER engine's `workorder.completed` directly (star
|
|
32
|
+
// topology — engines cooperate by event, never by import).
|
|
33
|
+
events: { emits: [], consumes: [] },
|
|
34
|
+
migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' },
|
|
35
|
+
attachmentTargets: [
|
|
36
|
+
{ entityType: 'customer', readPermission: 'customer:manage' },
|
|
37
|
+
{ entityType: 'bike', readPermission: 'bike:manage' },
|
|
38
|
+
],
|
|
39
|
+
// The portal permission walk is workorder → bike → customer. The engine links
|
|
40
|
+
// workorder → <facility ref>; in this vertical that facility ref IS a bike, so
|
|
41
|
+
// the vertical declares BOTH of its own edges. An entity-narrowed
|
|
42
|
+
// `workorder:read` grant on a customer then resolves all the way up.
|
|
43
|
+
entityRelations: [
|
|
44
|
+
{ entityType: 'bike', parentType: 'customer' },
|
|
45
|
+
{ entityType: 'workorder', parentType: 'bike' },
|
|
46
|
+
],
|
|
47
|
+
entitlementKey: 'bikeshop',
|
|
48
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SqlMigration } from '@substrat-run/kernel';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The vertical's OWN tables, prefixed `shop_` so they can never collide with an
|
|
5
|
+
// engine's. Ids are TEXT (ULIDs), timestamps ISO-8601 TEXT, money/decimals TEXT
|
|
6
|
+
// — never a float, never a native DATE. Migrations are append-only and ordered:
|
|
7
|
+
// once a version has shipped, you add a new one, you never edit it.
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export const bikeShopMigrations: SqlMigration[] = [
|
|
11
|
+
{
|
|
12
|
+
version: '0001-init',
|
|
13
|
+
sql: `
|
|
14
|
+
CREATE TABLE shop_customers (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
number TEXT NOT NULL UNIQUE,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
phone TEXT,
|
|
19
|
+
created_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE shop_bikes (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
customer_id TEXT NOT NULL REFERENCES shop_customers(id),
|
|
24
|
+
label TEXT NOT NULL,
|
|
25
|
+
frame_no TEXT,
|
|
26
|
+
created_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE shop_price_list (
|
|
29
|
+
article TEXT PRIMARY KEY,
|
|
30
|
+
description TEXT NOT NULL,
|
|
31
|
+
unit TEXT NOT NULL,
|
|
32
|
+
price_amount TEXT NOT NULL,
|
|
33
|
+
currency TEXT NOT NULL DEFAULT 'SEK',
|
|
34
|
+
min_qty TEXT,
|
|
35
|
+
internal INTEGER NOT NULL DEFAULT 0
|
|
36
|
+
);
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
];
|