@eventmodelers/node-kit 0.0.11 → 0.0.12

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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/templates/.claude/skills/build-automation/SKILL.md +260 -0
  3. package/templates/.claude/skills/build-state-change/SKILL.md +329 -0
  4. package/templates/.claude/skills/build-state-view/SKILL.md +384 -0
  5. package/templates/.claude/skills/learn-eventmodelers-api/SKILL.md +609 -0
  6. package/templates/.claude/skills/load-slice/SKILL.md +69 -14
  7. package/templates/realtime-agent/src/index.js +11 -1
  8. package/templates/root/.env.example +22 -0
  9. package/templates/root/Claude.md +58 -0
  10. package/templates/root/agent.sh +15 -0
  11. package/templates/root/backend-prompt.md +139 -0
  12. package/templates/root/flyway.conf +17 -0
  13. package/templates/root/package.json +52 -0
  14. package/templates/root/ralph.sh +47 -26
  15. package/templates/root/server.ts +213 -0
  16. package/templates/root/setup-env.sh +55 -0
  17. package/templates/root/src/common/assertions.ts +6 -0
  18. package/templates/root/src/common/db.ts +32 -0
  19. package/templates/root/src/common/loadPostgresEventstore.ts +39 -0
  20. package/templates/root/src/common/parseEndpoint.ts +51 -0
  21. package/templates/root/src/common/processorDlq.ts +28 -0
  22. package/templates/root/src/common/realtimeBroadcast.ts +19 -0
  23. package/templates/root/src/common/replay.ts +16 -0
  24. package/templates/root/src/common/routes.ts +19 -0
  25. package/templates/root/src/common/testHelpers.ts +54 -0
  26. package/templates/root/src/slices/example/routes.ts +134 -0
  27. package/templates/root/src/supabase/LoginHandler.ts +36 -0
  28. package/templates/root/src/supabase/ProtectedPageProps.ts +21 -0
  29. package/templates/root/src/supabase/README.md +171 -0
  30. package/templates/root/src/supabase/api.ts +56 -0
  31. package/templates/root/src/supabase/component.ts +12 -0
  32. package/templates/root/src/supabase/requireOrgaAdmin.ts +32 -0
  33. package/templates/root/src/supabase/requireUser.ts +72 -0
  34. package/templates/root/src/supabase/serverProps.ts +25 -0
  35. package/templates/root/src/supabase/staticProps.ts +10 -0
  36. package/templates/root/src/swagger.ts +34 -0
  37. package/templates/root/src/util/assertions.ts +6 -0
  38. package/templates/root/src/util/hash.ts +9 -0
  39. package/templates/root/src/util/sanitize.ts +23 -0
  40. package/templates/root/supabase/config.toml +295 -0
  41. package/templates/root/supabase/migrations/V1__schema.sql.example +12 -0
  42. package/templates/root/supabase/seed.sql +1 -0
  43. package/templates/root/tsconfig.json +32 -0
  44. package/templates/root/vercel.json +8 -0
  45. package/templates/root/model.md +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventmodelers/node-kit",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Real-time Claude agent that reacts to slice:changed events on an Eventmodelers board",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,260 @@
1
+ ---
2
+ name: build-automation
3
+ description: Implements an emmett automation slice (reactor processor + command handler) from a slice.json definition
4
+ ---
5
+
6
+ # Build Automation Slice
7
+
8
+ > Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for which trigger event drives the automation and what command it fires.
9
+
10
+ ---
11
+
12
+ ## What an Automation Slice is
13
+
14
+ An automation slice reacts to events from the event store and fires a command in response. It replaces the old CRON + TODO-list pattern with an event-driven **reactor**.
15
+
16
+ Architecture:
17
+
18
+ ```
19
+ Event Store
20
+ │ (TriggerEvent emitted by another slice)
21
+
22
+ processor.ts ← reactor — listens, maps, fires command
23
+
24
+
25
+ {SliceName}Command.ts ← command handler (decide/evolve)
26
+
27
+
28
+ Event Store ← new events appended
29
+ ```
30
+
31
+ An automation slice is a **state-change slice with a reactor**. Always build the command handler first, then wire the processor.
32
+
33
+ ---
34
+
35
+ ## Step 1 — Read the slice.json
36
+
37
+ From the slice definition, extract:
38
+ - **sliceName** — the command being fired by the automation
39
+ - **context** — bounded context
40
+ - **processors[]** — each processor defines:
41
+ - `triggerEvent` — the event that starts the automation
42
+ - `command` — the command to fire
43
+ - `processorId` — unique kebab-case identifier
44
+ - **commands[]** — command data fields
45
+ - **events[]** — events emitted by the command
46
+
47
+ ---
48
+
49
+ ## Step 2 — Build the command handler
50
+
51
+ Follow the **build-state-change** skill to create:
52
+ - `{SliceName}Command.ts` (Command type, evolve, decide, handle{SliceName})
53
+ - `{SliceName}.test.ts` (DeciderSpecification tests)
54
+
55
+ **Do NOT create a `routes.ts`** for automations — the command is fired internally by the processor, not via HTTP.
56
+
57
+ Refer to the build-state-change skill for the full command handler structure.
58
+
59
+ ---
60
+
61
+ ## Step 3 — Ensure the trigger event type exists
62
+
63
+ The trigger event must be defined in `[Context]Events.ts`. If it belongs to a different context, import it from that context's events file.
64
+
65
+ If the trigger event is missing from the union type, add it:
66
+
67
+ ```typescript
68
+ // in {TriggerContext}Events.ts
69
+ export type {TriggerEventName} = Event<'{TriggerEventName}', {
70
+ id: string;
71
+ // fields the processor will use to construct the command
72
+ }, CommonMeta>;
73
+
74
+ export type {TriggerContext}Events = /* existing */ | {TriggerEventName};
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Step 4 — Create `processor.ts`
80
+
81
+ File: `src/slices/{context}/{SliceName}/processor.ts`
82
+
83
+ ```typescript
84
+ import {type {TriggerEventName}} from '../{TriggerContext}Events';
85
+ import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
86
+ import {PostgresEventStore, PostgreSQLEventStoreConsumer} from '@event-driven-io/emmett-postgresql';
87
+ import {storeDlqMessage} from '../../../common/processorDlq';
88
+ import {v4} from 'uuid';
89
+
90
+ const PROCESSOR_ID = '{unique-kebab-case-processor-id}';
91
+
92
+ let _consumer: PostgreSQLEventStoreConsumer<{TriggerEventName}> | null = null;
93
+
94
+ export const processor = {
95
+ start: async (eventStore: PostgresEventStore) => {
96
+ _consumer = eventStore.consumer<{TriggerEventName}>();
97
+
98
+ _consumer.reactor<{TriggerEventName}>({
99
+ processorId: PROCESSOR_ID,
100
+ processorInstanceId: v4(), // new UUID each restart — enables competing consumers
101
+ canHandle: ['{TriggerEventName}'],
102
+ lock: {
103
+ timeoutSeconds: 30,
104
+ acquisitionPolicy: {type: 'retry', retries: 60, minTimeout: 1000, maxTimeout: 2000},
105
+ },
106
+ eachMessage: async (message) => {
107
+ try {
108
+ console.log(`Processing ${message.type} for ${message.data.id}`);
109
+
110
+ const command: {SliceName}Command = {
111
+ type: '{SliceName}',
112
+ data: {
113
+ id: message.data.id,
114
+ // map fields from message.data to the command's data shape
115
+ },
116
+ metadata: {
117
+ correlation_id: message.data.id,
118
+ causation_id: message.metadata?.correlation_id,
119
+ },
120
+ };
121
+
122
+ await handle{SliceName}(message.data.id, command);
123
+ } catch (err) {
124
+ console.error(`${PROCESSOR_ID}: failed to process message`, message.data, err);
125
+ await storeDlqMessage(PROCESSOR_ID, message, err);
126
+ }
127
+ },
128
+ });
129
+
130
+ _consumer?.start().catch(err =>
131
+ console.error(`${PROCESSOR_ID} consumer error:`, err),
132
+ );
133
+ },
134
+
135
+ stop: async () => {
136
+ await _consumer?.stop();
137
+ },
138
+ };
139
+ ```
140
+
141
+ ### Key decisions when filling in the template
142
+
143
+ **`PROCESSOR_ID`** — unique kebab-case string identifying this processor across restarts. Use format: `{slicename}-automation` (e.g. `assign-user-to-organization-automation`). Never reuse IDs between processors.
144
+
145
+ **`processorInstanceId`** — `v4()` UUID generated at startup. A new UUID each time the server restarts allows multiple competing consumers to run safely in parallel.
146
+
147
+ **`canHandle`** — must list only the exact event type string(s) this reactor listens to.
148
+
149
+ **`lock`** — do not change the lock configuration unless there is a specific reason. The defaults provide safe at-least-once delivery with retry.
150
+
151
+ **Metadata mapping:**
152
+ - `correlation_id` in the command → pass the trigger message's `id` (aggregate identifier)
153
+ - `causation_id` in the command → pass the trigger message's `metadata?.correlation_id`
154
+
155
+ **DLQ** — always wrap `eachMessage` in try/catch and call `storeDlqMessage` on failure. Failed messages are not retried automatically; the DLQ record allows manual reprocessing.
156
+
157
+ ---
158
+
159
+ ## Step 5 — Register the processor in application startup
160
+
161
+ Find the app startup file (usually `src/index.ts` or `src/server.ts`) where other processors are started. Add:
162
+
163
+ ```typescript
164
+ import {processor as {SliceName}Processor} from './slices/{context}/{SliceName}/processor';
165
+
166
+ // during startup, after eventStore is initialized:
167
+ await {SliceName}Processor.start(eventStore);
168
+
169
+ // during shutdown:
170
+ await {SliceName}Processor.stop();
171
+ ```
172
+
173
+ The `eventStore` instance is the one returned by `findEventstore()` — reuse the shared instance, do not create a second one.
174
+
175
+ ---
176
+
177
+ ## Step 6 — Verify the event store bootstrap
178
+
179
+ The event store **must** call `schema.migrate()` before any processor starts. Check `src/common/loadPostgresEventstore.ts`:
180
+
181
+ ```typescript
182
+ export const findEventstore = async () => {
183
+ // ...
184
+ await eventStoreInstance.schema.migrate(); // ← must be present
185
+ return eventStoreInstance;
186
+ };
187
+ ```
188
+
189
+ If this line is missing, add it. Without it, the emmett schema functions (`emt_try_acquire_processor_lock` etc.) are not created and the reactor will fail to start.
190
+
191
+ ---
192
+
193
+ ## Processor patterns reference
194
+
195
+ ### Single trigger event → single command (standard)
196
+
197
+ ```typescript
198
+ eachMessage: async (message) => {
199
+ const command: MyCommand = {
200
+ type: 'MyCommand',
201
+ data: {id: message.data.id},
202
+ metadata: {
203
+ correlation_id: message.data.id,
204
+ causation_id: message.metadata?.correlation_id,
205
+ },
206
+ };
207
+ await handleMyCommand(message.data.id, command);
208
+ },
209
+ ```
210
+
211
+ ### Conditional processing (skip if condition not met)
212
+
213
+ ```typescript
214
+ eachMessage: async (message) => {
215
+ if (!message.data.someField) {
216
+ console.log(`Skipping — someField not set for ${message.data.id}`);
217
+ return;
218
+ }
219
+ // proceed normally
220
+ },
221
+ ```
222
+
223
+ ### Multiple commands from one trigger
224
+
225
+ ```typescript
226
+ eachMessage: async (message) => {
227
+ await handleFirstCommand(message.data.id, firstCommand);
228
+ await handleSecondCommand(message.data.id, secondCommand);
229
+ },
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Files to create / modify
235
+
236
+ ```
237
+ src/slices/{context}/{SliceName}/
238
+ ├── {SliceName}Command.ts ← command handler (decide/evolve/handle) — see build-state-change
239
+ ├── {SliceName}.test.ts ← DeciderSpecification tests — see build-state-change
240
+ └── processor.ts ← reactor (start/stop)
241
+
242
+ src/
243
+ └── index.ts (or server.ts) ← register processor.start() / processor.stop()
244
+
245
+ src/common/
246
+ └── loadPostgresEventstore.ts ← verify schema.migrate() is called
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Checklist
252
+
253
+ - [ ] `PROCESSOR_ID` is unique across all processors in the codebase (grep to verify)
254
+ - [ ] `processorInstanceId` uses `v4()` — not a hardcoded string
255
+ - [ ] `canHandle` matches the exact event type string from `{Context}Events.ts`
256
+ - [ ] `eachMessage` is wrapped in try/catch with `storeDlqMessage` fallback
257
+ - [ ] Processor registered in application startup (start + stop)
258
+ - [ ] `schema.migrate()` is called in `loadPostgresEventstore.ts`
259
+ - [ ] No `routes.ts` created (automations are not exposed via HTTP)
260
+ - [ ] Command handler tests cover idempotency (what happens if the command fires twice)
@@ -0,0 +1,329 @@
1
+ ---
2
+ name: build-state-change
3
+ description: Implements an emmett state-change slice (command handler, tests, route) from a slice.json definition
4
+ ---
5
+
6
+ # Build State Change Slice
7
+
8
+ > Before doing anything else, read the slice definition from `.slices/{Context}/{slicename}/slice.json`. This file is the **source of truth** for all fields, events, and metadata. Never invent fields not defined there.
9
+
10
+ ---
11
+
12
+ ## What a State Change Slice is
13
+
14
+ A state-change slice processes a command using event sourcing. It:
15
+ 1. Loads the current aggregate state by replaying past events (`evolve`)
16
+ 2. Validates the command against that state (`decide`)
17
+ 3. Returns new events if valid, throws if not
18
+
19
+ ---
20
+
21
+ ## Step 1 — Read the slice.json
22
+
23
+ From the slice definition, extract:
24
+ - **sliceName** — the slice title (becomes the Command name)
25
+ - **context** — the bounded context (used to find `[Context]Events.ts`)
26
+ - **commands[]** — list of commands with their data fields
27
+ - **events[]** — list of events emitted by each command
28
+ - **specifications[]** — test scenarios (given/when/then)
29
+
30
+ ---
31
+
32
+ ## Step 2 — Ensure the shared events union exists
33
+
34
+ Each context has one `[Context]Events.ts` file that exports a union of all event types.
35
+
36
+ File location: `src/slices/{context}/[Context]Events.ts` (or wherever the existing one lives — search for it).
37
+
38
+ ### Event type shape
39
+
40
+ ```typescript
41
+ import type {Event} from '@event-driven-io/emmett';
42
+
43
+ type CommonMeta = {
44
+ stream_name?: string;
45
+ userId?: string;
46
+ correlation_id?: string;
47
+ causation_id?: string;
48
+ };
49
+
50
+ export type {EventName} = Event<'{EventName}', {
51
+ // data fields from slice.json
52
+ }, CommonMeta>;
53
+
54
+ // Add the new event to the union
55
+ export type {Context}Events = /* existing events */ | {EventName};
56
+ ```
57
+
58
+ Add each new event type and update the union. Do NOT remove existing types.
59
+
60
+ ---
61
+
62
+ ## Step 3 — Create `{SliceName}Command.ts`
63
+
64
+ File: `src/slices/{context}/{SliceName}/{SliceName}Command.ts`
65
+
66
+ ### Full structure
67
+
68
+ ```typescript
69
+ import type {Command} from '@event-driven-io/emmett';
70
+ import {CommandHandler} from '@event-driven-io/emmett';
71
+ import {type {Context}Events} from '../{Context}Events';
72
+ import {findEventstore} from '../../../common/loadPostgresEventstore';
73
+
74
+ // 1. Command type — data fields come from slice.json commands[]
75
+ export type {SliceName}Command = Command<'{SliceName}', {
76
+ id: string;
77
+ // ... other data fields from the slice definition
78
+ }, {
79
+ correlation_id?: string;
80
+ causation_id?: string;
81
+ }>;
82
+
83
+ // 2. State — only fields needed for validation
84
+ export type {SliceName}State = {
85
+ // e.g. { processed: boolean } or { assignedIds: Set<string> }
86
+ // Use {} if no validation state is needed
87
+ };
88
+
89
+ export const {SliceName}InitialState = (): {SliceName}State => ({
90
+ // initial values
91
+ });
92
+
93
+ // 3. Evolve — pure function, updates state from past events
94
+ export const evolve = (
95
+ state: {SliceName}State,
96
+ event: {Context}Events,
97
+ ): {SliceName}State => {
98
+ const {type} = event;
99
+
100
+ switch (type) {
101
+ case '{EmittedEventName}':
102
+ return {...state, /* update field */};
103
+ default:
104
+ return state;
105
+ }
106
+ };
107
+
108
+ // 4. Decide — validates command, returns events or throws
109
+ export const decide = (
110
+ command: {SliceName}Command,
111
+ state: {SliceName}State,
112
+ ): {Context}Events[] => {
113
+ // idempotency / business rule check
114
+ if (state.processed) {
115
+ throw {code: 'already_processed', message: 'Already processed'};
116
+ }
117
+
118
+ return [{
119
+ type: '{EmittedEventName}',
120
+ data: {
121
+ id: command.data.id,
122
+ // ... map all fields from command.data per slice.json
123
+ },
124
+ metadata: {
125
+ correlation_id: command.metadata?.correlation_id,
126
+ causation_id: command.metadata?.causation_id,
127
+ userId: command.data.userId,
128
+ },
129
+ }];
130
+ };
131
+
132
+ // 5. CommandHandler + exported handle function
133
+ const {SliceName}CommandHandler = CommandHandler<{SliceName}State, {Context}Events>({
134
+ evolve,
135
+ initialState: {SliceName}InitialState,
136
+ });
137
+
138
+ export const handle{SliceName} = async (id: string, command: {SliceName}Command) => {
139
+ const eventStore = await findEventstore();
140
+ const result = await {SliceName}CommandHandler(
141
+ eventStore,
142
+ id,
143
+ (state: {SliceName}State) => decide(command, state),
144
+ );
145
+ return {
146
+ nextExpectedStreamVersion: result.nextExpectedStreamVersion,
147
+ lastEventGlobalPosition: result.lastEventGlobalPosition,
148
+ };
149
+ };
150
+ ```
151
+
152
+ ### State complexity guide
153
+
154
+ | Scenario | State shape |
155
+ |----------|-------------|
156
+ | Simple create-once | `{ created: boolean }` |
157
+ | Idempotency by user | `{ processedUserIds: Set<string> }` |
158
+ | Count validation | `{ count: number; limit: number }` |
159
+ | No validation needed | `{}` (empty object) |
160
+
161
+ ---
162
+
163
+ ## Step 4 — Create `{SliceName}.test.ts`
164
+
165
+ File: `src/slices/{context}/{SliceName}/{SliceName}.test.ts`
166
+
167
+ Use `DeciderSpecification` for unit tests. Derive test scenarios from `specifications[]` in the slice.json.
168
+
169
+ ```typescript
170
+ import {DeciderSpecification} from '@event-driven-io/emmett';
171
+ import {
172
+ {SliceName}Command,
173
+ {SliceName}InitialState,
174
+ decide,
175
+ evolve,
176
+ } from './{SliceName}Command';
177
+ import {describe, it} from 'node:test';
178
+
179
+ describe('{SliceName} Specification', () => {
180
+ const given = DeciderSpecification.for({
181
+ decide,
182
+ evolve,
183
+ initialState: {SliceName}InitialState,
184
+ });
185
+
186
+ it('spec: {SliceName} - creates event on empty stream', () => {
187
+ const command: {SliceName}Command = {
188
+ type: '{SliceName}',
189
+ data: {
190
+ id: 'test-id',
191
+ // ... test values
192
+ },
193
+ metadata: {},
194
+ };
195
+
196
+ given([])
197
+ .when(command)
198
+ .then([{
199
+ type: '{EmittedEventName}',
200
+ data: {
201
+ id: 'test-id',
202
+ // ... expected event data
203
+ },
204
+ metadata: {},
205
+ }]);
206
+ });
207
+
208
+ it('spec: {SliceName} - throws when already processed', () => {
209
+ const command: {SliceName}Command = {
210
+ type: '{SliceName}',
211
+ data: {id: 'test-id'},
212
+ metadata: {},
213
+ };
214
+
215
+ given([{
216
+ type: '{EmittedEventName}',
217
+ data: {id: 'test-id'},
218
+ metadata: {},
219
+ }])
220
+ .when(command)
221
+ .thenThrows();
222
+ });
223
+ });
224
+ ```
225
+
226
+ Add one test per specification in the slice.json. If the spec has no precondition events, use `given([])`.
227
+
228
+ ---
229
+
230
+ ## Step 5 — Create `routes.ts`
231
+
232
+ File: `src/slices/{context}/{SliceName}/routes.ts`
233
+
234
+ > **Concrete example**: `src/slices/example/routes.ts` — shows the full pattern with `requireUser`, `assertNotEmpty`, error mapping, and OpenAPI annotations. Read it before implementing.
235
+
236
+ ```typescript
237
+ import {Request, Response, Router} from 'express';
238
+ import {WebApiSetup} from '@event-driven-io/emmett-expressjs';
239
+ import {requireUser} from '../../../supabase/requireUser';
240
+ import {{SliceName}Command, handle{SliceName}} from './{SliceName}Command';
241
+
242
+ export const api = (): WebApiSetup => (router: Router): void => {
243
+
244
+ router.post('/api/{slicename}/:id', async (req: Request, res: Response) => {
245
+ const auth = await requireUser(req, res);
246
+ if (auth.error) return;
247
+
248
+ const id = req.params.id;
249
+ const correlationId = req.header('correlation_id') ?? id;
250
+
251
+ try {
252
+ const command: {SliceName}Command = {
253
+ type: '{SliceName}',
254
+ data: {
255
+ id,
256
+ // ... map from req.body
257
+ },
258
+ metadata: {
259
+ correlation_id: correlationId,
260
+ causation_id: id,
261
+ },
262
+ };
263
+
264
+ const result = await handle{SliceName}(id, command);
265
+
266
+ res.set('correlation_id', correlationId);
267
+ res.set('causation_id', id);
268
+
269
+ return res.status(201).json({
270
+ ok: true,
271
+ next_expected_stream_version: result.nextExpectedStreamVersion?.toString(),
272
+ last_event_global_position: result.lastEventGlobalPosition?.toString(),
273
+ });
274
+ } catch (err: any) {
275
+ const errorMessage = errorMapping(err?.code);
276
+ if (errorMessage) {
277
+ return res.status(409).json({error: errorMessage});
278
+ }
279
+ console.error(err);
280
+ return res.status(500).json({ok: false, error: 'Server error'});
281
+ }
282
+ });
283
+ };
284
+
285
+ const errorMapping = (code: string): string | null => {
286
+ switch (code) {
287
+ case 'already_processed': return 'This action has already been performed.';
288
+ // add other error codes from slice.json specifications
289
+ default: return null;
290
+ }
291
+ };
292
+ ```
293
+
294
+ ---
295
+
296
+ ## Step 6 — Wire up the route
297
+
298
+ Find the application's router registration (usually `src/index.ts` or `src/app.ts`) and add:
299
+
300
+ ```typescript
301
+ import {api as {SliceName}Api} from './slices/{context}/{SliceName}/routes';
302
+
303
+ // inside the router setup:
304
+ {SliceName}Api()(router);
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Key patterns
310
+
311
+ - **Metadata optional chaining**: always use `command.metadata?.correlation_id` (metadata may be absent in tests)
312
+ - **Throw with code**: `throw {code: 'snake_case_code', message: '...'}` — routes catch by `err?.code`
313
+ - **Idempotency in evolve**: track processed IDs in state, check in decide
314
+ - **Stream ID**: pass the aggregate ID as the first argument to `handle{SliceName}(id, command)` — the stream is `{context}-{id}`
315
+ - **No side effects in evolve**: evolve must be a pure function; all side effects go in decide or the route
316
+
317
+ ---
318
+
319
+ ## Files to create
320
+
321
+ ```
322
+ src/slices/{context}/{SliceName}/
323
+ ├── {SliceName}Command.ts ← command handler (decide/evolve/handle)
324
+ ├── {SliceName}.test.ts ← DeciderSpecification tests
325
+ └── routes.ts ← Express POST endpoint
326
+
327
+ src/slices/{context}/
328
+ └── {Context}Events.ts ← add new event types here (update union)
329
+ ```