create-substrat 0.0.0 → 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.
@@ -0,0 +1,116 @@
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, scaffold, run, checkpoints — is a
9
+ **playbook**, not always-on context. Invoke it when you start or extend a vertical:
10
+
11
+ - **Claude Code**: `/substrat`
12
+ - **Cursor / opencode**: the `new-vertical` command, or read [`.substrat/playbook.md`](.substrat/playbook.md)
13
+
14
+ Read the playbook before scaffolding. This file is what a session already mid-build
15
+ must never violate.
16
+
17
+ ## The mental model
18
+
19
+ Three layers. You only own the third.
20
+
21
+ 1. **Kernel — free, always.** Tenancy (one scope = one isolated database; there is no
22
+ cross-tenant API), permissions (roles, grants, and a proof path for every decision),
23
+ events + audit (every mutation emits a kernel-stamped event you cannot mislabel),
24
+ migrations (journaled per module, applied lazily per scope).
25
+ 2. **Engines — compose or feed.** Headless, own invariants that cannot be violated
26
+ (state machines that can't skip states, append-only entries). You either **compose**
27
+ an engine (import it; its in-scope functions run in *your* transaction) or **feed** it
28
+ (emit a fat event; it consumes — no import). Engines never import each other. Read an
29
+ engine's real surface from `node_modules/@substrat-run/engine-*/dist/index.d.ts` —
30
+ never guess at it.
31
+ 3. **Your vertical — everything a user touches.** Vocabulary, price list, extra fields,
32
+ roles, screens. If your core noun isn't something an engine already owns, this is most
33
+ of the app — a normal, supported outcome.
34
+
35
+ ## Project layout
36
+
37
+ The linter and tests expect this shape. `manifest`/`migrations`/`module` are **module
38
+ code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
39
+
40
+ ```
41
+ src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
42
+ src/migrations.ts the SqlMigration[] ← module code
43
+ src/module.ts imports both; operations + registration ← module code
44
+ src/seed.ts host, tenants, roles, grants, seed world ← harness
45
+ src/server.ts thin wrapper, one route per operation ← harness
46
+ test/scenario.test.ts the scenario — including the denials
47
+ ```
48
+
49
+ ## The rules (non-negotiable)
50
+
51
+ **Module code** = everything reachable from a `ModuleRegistration` (operations,
52
+ consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
53
+
54
+ 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, or
55
+ `node:*` in module code.
56
+ 2. **No `fetch` / network in module code.** It would hold the scope's transaction open on
57
+ a third party. The sanctioned path is a **connector**: emit a fat event, register a
58
+ handler that runs outside the transaction. An integration is never impossible because
59
+ of this rule — it has an answer.
60
+ 3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections);
61
+ writes forge the audit spine.
62
+ 4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use
63
+ the engine's exported in-scope functions. This is the rule with no runtime equivalent:
64
+ the shortcut *works* and silently welds you to an engine's private schema forever. Need
65
+ extra data on an engine entity? Add **your own side table keyed by the engine's id** —
66
+ never a column upstream.
67
+ 5. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
68
+ is the first line.
69
+ 6. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
70
+ 7. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
71
+ line wrong — that's design feedback, not a coding problem.
72
+ 8. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
73
+ (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
74
+ 9. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
75
+ hand-roll a hash to dodge an import ban.
76
+ 10. **Parse, don't trust.** Zod at every boundary — but import `z` from
77
+ `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
78
+ copies or majors; composing a contracts schema into one built from a separate `zod`
79
+ fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
80
+ cause.
81
+
82
+ ## Declare every link edge
83
+
84
+ `entityRelations` in the manifest must declare every edge you traverse — both your own
85
+ (`bike → customer`) and the ones an engine makes on your behalf (`workorder → bike`). The
86
+ adapter **rejects** a `ctx.link` for an undeclared edge, so a missing one fails loudly.
87
+ This is also what lets a portal permission-walk reach the owner.
88
+
89
+ ## The gates — run them, believe them
90
+
91
+ ```sh
92
+ npm test # the scenario, including the denials
93
+ npx @substrat-run/boundary-lint # the layer rules (1–4)
94
+ npm run typecheck
95
+ ```
96
+
97
+ `boundary-lint` exits non-zero if it *couldn't do its job* (no module code found, no
98
+ engines resolvable) — a pass that checked nothing is worse than no linter. Never wave that
99
+ through; fix the setup until it can see your code.
100
+
101
+ A green scenario test does **not** mean the app works: the test calls operations directly
102
+ and never exercises `server.ts`, its routes, or the principal picker. Before calling a
103
+ vertical done, boot the server and drive the real flow over HTTP as two personas — one who
104
+ should succeed and one who should be denied — and confirm the denial arrives as a denial
105
+ (not a generic error).
106
+
107
+ ## Two human checkpoints — you may never self-approve
108
+
109
+ Present these and stop:
110
+
111
+ 1. **Migration diff** — every new `SqlMigration`, verbatim. Migrations are append-only
112
+ forever once shipped, so this is the last cheap moment to change your mind.
113
+ 2. **Permission diff** — a table: key → description → which roles hold it → why. Walk the
114
+ reviewer through it in their own vocabulary until they can answer *who can now see the
115
+ money, and who can see other tenants' data?* A permission diff nobody understands is
116
+ 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
+ ];
@@ -0,0 +1,297 @@
1
+ import {
2
+ addDecimal,
3
+ compareDecimal,
4
+ moneyOf,
5
+ mulMoney,
6
+ z,
7
+ type EntityRef,
8
+ type Money,
9
+ } from '@substrat-run/contracts';
10
+ import {
11
+ assertAllowed,
12
+ ulid,
13
+ type ModuleRegistration,
14
+ type OperationHandler,
15
+ } from '@substrat-run/kernel';
16
+ import {
17
+ closeWorkOrder,
18
+ completeWorkOrder,
19
+ createWorkOrder,
20
+ getReportedLines,
21
+ listOrders,
22
+ PERM as WO,
23
+ type BillableLine,
24
+ type WorkOrder,
25
+ } from '@substrat-run/engine-workorder';
26
+ import { bikeShopManifest, SHOP_PERM } from './manifest.js';
27
+ import { bikeShopMigrations } from './migrations.js';
28
+
29
+ // ============================================================================
30
+ // The bike-shop operations. Each is either:
31
+ // - a thin custodian of the vertical's own tables (customers, bikes, prices),
32
+ // OR
33
+ // - a COMPOSITION that wraps an engine's in-scope function inside the same
34
+ // transaction and adds the vertical's policy (the pricing moment).
35
+ //
36
+ // Every operation's FIRST line is the permission check. Data access is
37
+ // `ctx.sql` only. No `fetch`, no `node:*`, no other engine's tables.
38
+ // ============================================================================
39
+
40
+ export interface CustomerRow {
41
+ id: string;
42
+ number: string;
43
+ name: string;
44
+ phone: string | null;
45
+ created_at: string;
46
+ }
47
+
48
+ export interface BikeRow {
49
+ id: string;
50
+ customer_id: string;
51
+ label: string;
52
+ frame_no: string | null;
53
+ created_at: string;
54
+ }
55
+
56
+ export interface PriceRow {
57
+ article: string;
58
+ description: string;
59
+ unit: string;
60
+ price_amount: string;
61
+ currency: string;
62
+ min_qty: string | null;
63
+ internal: number;
64
+ }
65
+
66
+ const createCustomerOp: OperationHandler<
67
+ { number: string; name: string; phone?: string },
68
+ CustomerRow
69
+ > = async (ctx, input) => {
70
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
71
+ const id = ulid();
72
+ ctx.sql.exec(
73
+ `INSERT INTO shop_customers (id, number, name, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
74
+ [id, input.number, input.name, input.phone ?? null, new Date().toISOString()],
75
+ );
76
+ return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
77
+ };
78
+
79
+ const listCustomersOp: OperationHandler<undefined, (CustomerRow & { bikes: BikeRow[] })[]> = async (
80
+ ctx,
81
+ ) => {
82
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
83
+ const customers = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers ORDER BY number');
84
+ return customers.map((c) => ({
85
+ ...c,
86
+ bikes: ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE customer_id = ? ORDER BY label', [
87
+ c.id,
88
+ ]),
89
+ }));
90
+ };
91
+
92
+ const registerBikeOp: OperationHandler<
93
+ { customerId: string; label: string; frameNo?: string },
94
+ BikeRow
95
+ > = async (ctx, input) => {
96
+ assertAllowed(await ctx.check(SHOP_PERM.bikeManage));
97
+ const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
98
+ input.customerId,
99
+ ])[0];
100
+ if (!customer) throw new Error(`customer not found: ${input.customerId}`);
101
+ const id = ulid();
102
+ ctx.sql.exec(
103
+ `INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
104
+ [id, customer.id, input.label, input.frameNo ?? null, new Date().toISOString()],
105
+ );
106
+ // Record the bike → customer edge the manifest declared, so the portal walk
107
+ // (workorder → bike → customer) can resolve an entity-narrowed grant.
108
+ ctx.link({ entityType: 'bike', entityId: id }, { entityType: 'customer', entityId: customer.id });
109
+ return ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [id])[0]!;
110
+ };
111
+
112
+ const upsertPriceOp: OperationHandler<
113
+ {
114
+ article: string;
115
+ description: string;
116
+ unit: string;
117
+ priceAmount: string;
118
+ currency?: string;
119
+ minQty?: string;
120
+ internal?: boolean;
121
+ },
122
+ PriceRow
123
+ > = async (ctx, input) => {
124
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
125
+ ctx.sql.exec(
126
+ `INSERT OR REPLACE INTO shop_price_list
127
+ (article, description, unit, price_amount, currency, min_qty, internal)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
129
+ [
130
+ input.article,
131
+ input.description,
132
+ input.unit,
133
+ input.priceAmount,
134
+ input.currency ?? 'SEK',
135
+ input.minQty ?? null,
136
+ input.internal ? 1 : 0,
137
+ ],
138
+ );
139
+ return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list WHERE article = ?', [
140
+ input.article,
141
+ ])[0]!;
142
+ };
143
+
144
+ const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
145
+ assertAllowed(await ctx.check(SHOP_PERM.customerManage));
146
+ return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list ORDER BY article');
147
+ };
148
+
149
+ /**
150
+ * Open a repair: a vertical operation that composes the engine's `createWorkOrder`.
151
+ * The vertical resolves its own vocabulary (a bike, its owner) into the engine's
152
+ * `facility`/`customer` refs; the engine owns the number, the state, the event.
153
+ */
154
+ const createRepairOp: OperationHandler<
155
+ { bikeId: string; kind: string; title: string; description?: string },
156
+ WorkOrder
157
+ > = async (ctx, input) => {
158
+ assertAllowed(await ctx.check(WO.create));
159
+ const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
160
+ if (!bike) throw new Error(`bike not found: ${input.bikeId}`);
161
+ return createWorkOrder(ctx, {
162
+ facility: { entityType: 'bike', entityId: bike.id },
163
+ customer: { entityType: 'customer', entityId: bike.customer_id },
164
+ kind: input.kind,
165
+ title: input.title,
166
+ ...(input.description !== undefined ? { description: input.description } : {}),
167
+ });
168
+ };
169
+
170
+ /**
171
+ * THE PRICING MOMENT — the whole reason a vertical exists. Read the engine's
172
+ * reported time and material, price them against the vertical's OWN price list
173
+ * (labor bills at least its minimum quantity; internal articles are dropped),
174
+ * then hand the priced lines back to the engine's `completeWorkOrder`. One
175
+ * transaction: the engine's invariant stays intact and pricing is 100% vertical.
176
+ * The engine's `workorder.completed` event carries these lines, and the
177
+ * invoicing engine consumes it — no import between the two.
178
+ */
179
+ const completeRepairOp: OperationHandler<
180
+ { orderId: string },
181
+ { order: WorkOrder; billable: BillableLine[]; total: Money }
182
+ > = async (ctx, input) => {
183
+ assertAllowed(await ctx.check(WO.complete));
184
+ const reported = getReportedLines(ctx, input.orderId);
185
+ const prices = new Map<string, PriceRow>(
186
+ ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list').map((p) => [p.article, p]),
187
+ );
188
+
189
+ const billable: BillableLine[] = [];
190
+
191
+ // Labor: sum reported hours, then bill at least the minimum quantity.
192
+ const laborPrice = prices.get('labor');
193
+ const reportedHours = reported.time.reduce((sum, t) => addDecimal(sum, t.hours), '0');
194
+ if (laborPrice && compareDecimal(reportedHours, '0') > 0) {
195
+ const minQty = laborPrice.min_qty ?? '0';
196
+ const qty = compareDecimal(reportedHours, minQty) >= 0 ? reportedHours : minQty;
197
+ const unitPrice = moneyOf(laborPrice.price_amount, laborPrice.currency);
198
+ billable.push({
199
+ article: 'labor',
200
+ description: laborPrice.description,
201
+ qty,
202
+ unit: laborPrice.unit,
203
+ unitPrice,
204
+ lineTotal: mulMoney(qty, unitPrice),
205
+ sourceType: 'time',
206
+ sourceId: input.orderId,
207
+ });
208
+ }
209
+
210
+ // Parts: one billable line per reported material; internal articles dropped.
211
+ for (const m of reported.material) {
212
+ const price = prices.get(m.article);
213
+ if (!price) throw new Error(`no price for article: ${m.article}`);
214
+ if (price.internal) continue;
215
+ const unitPrice = moneyOf(price.price_amount, price.currency);
216
+ billable.push({
217
+ article: m.article,
218
+ description: price.description,
219
+ qty: m.qty,
220
+ unit: price.unit,
221
+ unitPrice,
222
+ lineTotal: mulMoney(m.qty, unitPrice),
223
+ sourceType: 'material',
224
+ sourceId: m.id,
225
+ });
226
+ }
227
+
228
+ const result = completeWorkOrder(ctx, { orderId: input.orderId, billable });
229
+ return { order: result.order, billable, total: result.total };
230
+ };
231
+
232
+ /**
233
+ * Pickup: hand the bike back (completed → closed). A thin composition of the
234
+ * engine's in-scope `closeWorkOrder`; the vertical owns the vocabulary
235
+ * ("pickup"), the engine owns the transition.
236
+ */
237
+ const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (ctx, input) => {
238
+ assertAllowed(await ctx.check(WO.close));
239
+ return closeWorkOrder(ctx, { orderId: input.orderId });
240
+ };
241
+
242
+ /**
243
+ * The customer PORTAL listing. A proof walk: no blanket `workorder:read`, one
244
+ * PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
245
+ * on their own customer record, so the walk workorder → bike → customer lets
246
+ * them through for their own repairs and no one else's.
247
+ */
248
+ const portalRepairsOp: OperationHandler<undefined, WorkOrder[]> = async (ctx) => {
249
+ const visible: WorkOrder[] = [];
250
+ for (const order of listOrders(ctx)) {
251
+ const decision = await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id });
252
+ if (decision.allowed) visible.push(order);
253
+ }
254
+ return visible;
255
+ };
256
+
257
+ const timelineInput = z.object({
258
+ entityType: z.string().min(1),
259
+ entityId: z.string().min(1),
260
+ });
261
+
262
+ /**
263
+ * An entity's event timeline, read straight off the spine (a read of `_substrat_*`
264
+ * for a projection is allowed; writing it is not). Gated by a per-entity
265
+ * `workorder:read` check, so it obeys the same walk as the portal.
266
+ */
267
+ const timelineOp: OperationHandler<
268
+ z.infer<typeof timelineInput>,
269
+ { type: string; occurred_at: string; actor: string }[]
270
+ > = async (ctx, rawInput) => {
271
+ const entity: EntityRef = timelineInput.parse(rawInput);
272
+ assertAllowed(await ctx.check(WO.read, entity));
273
+ // Append order is authoritative — rowid, not ULID (ids minted in the same
274
+ // millisecond are not mutually ordered).
275
+ return ctx.sql.query(
276
+ `SELECT type, occurred_at, actor FROM _substrat_outbox
277
+ WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`,
278
+ [entity.entityType, entity.entityId],
279
+ );
280
+ };
281
+
282
+ export const bikeShopModule: ModuleRegistration = {
283
+ manifest: bikeShopManifest,
284
+ migrations: bikeShopMigrations,
285
+ operations: {
286
+ 'shop/create-customer': createCustomerOp as never,
287
+ 'shop/list-customers': listCustomersOp as never,
288
+ 'shop/register-bike': registerBikeOp as never,
289
+ 'shop/upsert-price': upsertPriceOp as never,
290
+ 'shop/price-list': priceListOp as never,
291
+ 'shop/create-repair': createRepairOp as never,
292
+ 'shop/complete-repair': completeRepairOp as never,
293
+ 'shop/close-repair': closeRepairOp as never,
294
+ 'shop/portal-repairs': portalRepairsOp as never,
295
+ 'shop/timeline': timelineOp as never,
296
+ },
297
+ };