@eventmodelers/cli 1.0.45 → 1.0.47

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 (123) hide show
  1. package/README.md +5 -2
  2. package/cli.js +53 -1
  3. package/package.json +2 -2
  4. package/shared/build-kit/lib/checks/README.md +59 -0
  5. package/shared/build-kit/lib/ralph.js +110 -28
  6. package/shared/build-kit/lib/util/find-slice.cjs +59 -0
  7. package/shared/build-kit/ralph-claude.js +6 -2
  8. package/shared/skills/learn-eventmodelers-api/SKILL.md +52 -46
  9. package/shared/skills/request-feedback/SKILL.md +7 -5
  10. package/stacks/axon/templates/build-kit/CLAUDE.md +1 -1
  11. package/stacks/blank/templates/build-kit/CLAUDE.md +1 -1
  12. package/stacks/blank/templates/build-kit/lib/backend-prompt.md +106 -102
  13. package/stacks/blank/templates/build-kit/lib/prompt.md +102 -106
  14. package/stacks/cratis-csharp/templates/build-kit/CLAUDE.md +9 -0
  15. package/stacks/kurrent/templates/.claude/skills/build-automation/SKILL.md +422 -0
  16. package/stacks/kurrent/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  17. package/stacks/kurrent/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +65 -0
  18. package/stacks/kurrent/templates/.claude/skills/build-state-change/SKILL.md +418 -0
  19. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +41 -0
  20. package/stacks/kurrent/templates/.claude/skills/build-state-change/references/integration-test-patterns.md +64 -0
  21. package/stacks/kurrent/templates/.claude/skills/build-state-view/SKILL.md +391 -0
  22. package/stacks/kurrent/templates/build-kit/CLAUDE.md +124 -0
  23. package/stacks/kurrent/templates/build-kit/lib/AGENT.md +73 -0
  24. package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +169 -0
  25. package/stacks/kurrent/templates/build-kit/lib/prompt.md +128 -0
  26. package/stacks/kurrent/templates/root/README.md +46 -0
  27. package/stacks/kurrent/templates/root/docker-compose.yml +45 -0
  28. package/stacks/kurrent/templates/root/mvnw +259 -0
  29. package/stacks/kurrent/templates/root/mvnw.cmd +149 -0
  30. package/stacks/kurrent/templates/root/pom.xml +152 -0
  31. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  32. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/common/EventStore.java +91 -0
  33. package/stacks/kurrent/templates/root/src/main/java/com/example/quickstart/config/KurrentConfiguration.java +42 -0
  34. package/stacks/kurrent/templates/root/src/main/resources/application.properties +14 -0
  35. package/stacks/kurrent/templates/root/src/main/resources/static/index.html +11 -0
  36. package/stacks/modeling-kit/templates/.claude/skills/add-next-slice/SKILL.md +2 -2
  37. package/stacks/modeling-kit/templates/.claude/skills/attributes/SKILL.md +0 -1
  38. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/SKILL.md +1 -1
  39. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-checking-completeness/references/examples.md +2 -2
  40. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -2
  41. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-identifying-outputs/SKILL.md +1 -1
  42. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-interview-protocol/SKILL.md +1 -1
  43. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-orchestrating-event-modeling/SKILL.md +4 -4
  44. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-storyboarding-events/SKILL.md +1 -1
  45. package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-validating-event-models/SKILL.md +1 -1
  46. package/stacks/modeling-kit/templates/.claude/skills/examples/SKILL.md +1 -1
  47. package/stacks/modeling-kit/templates/.claude/skills/handle-comment/SKILL.md +13 -4
  48. package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +2 -2
  49. package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +5 -5
  50. package/stacks/modeling-kit/templates/kit/AGENTS.md +1 -1
  51. package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -2
  52. package/stacks/node/templates/build-kit/CLAUDE.md +23 -1
  53. package/stacks/node/templates/build-kit/lib/check-commit-scope.cjs +123 -0
  54. package/stacks/node/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  55. package/stacks/node/templates/build-kit/lib/checks/10-slice-scope.cjs +29 -0
  56. package/stacks/node/templates/build-kit/lib/checks/20-append-only-migrations.cjs +20 -0
  57. package/stacks/node/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  58. package/stacks/node/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  59. package/stacks/node/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  60. package/stacks/node/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  61. package/stacks/node/templates/root/.githooks/pre-commit +11 -0
  62. package/stacks/node/templates/root/package.json +2 -1
  63. package/stacks/node/templates/root/setup-env.sh +7 -1
  64. package/stacks/opencqrs/templates/.claude/skills/build-automation/SKILL.md +434 -0
  65. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +19 -0
  66. package/stacks/opencqrs/templates/.claude/skills/build-automation/references/idempotent-dispatch-patterns.md +62 -0
  67. package/stacks/opencqrs/templates/.claude/skills/build-state-change/SKILL.md +413 -0
  68. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +46 -0
  69. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/rest-api-patterns.md +145 -0
  70. package/stacks/opencqrs/templates/.claude/skills/build-state-change/references/test-fixture-patterns.md +85 -0
  71. package/stacks/opencqrs/templates/.claude/skills/build-state-view/SKILL.md +354 -0
  72. package/stacks/opencqrs/templates/build-kit/CLAUDE.md +103 -0
  73. package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +58 -0
  74. package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +169 -0
  75. package/stacks/opencqrs/templates/build-kit/lib/prompt.md +128 -0
  76. package/stacks/opencqrs/templates/root/README.md +42 -0
  77. package/stacks/opencqrs/templates/root/docker-compose.yml +37 -0
  78. package/stacks/opencqrs/templates/root/mvnw +259 -0
  79. package/stacks/opencqrs/templates/root/mvnw.cmd +149 -0
  80. package/stacks/opencqrs/templates/root/pom.xml +139 -0
  81. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/QuickstartApplication.java +12 -0
  82. package/stacks/opencqrs/templates/root/src/main/java/com/example/quickstart/config/CqrsConfiguration.java +74 -0
  83. package/stacks/opencqrs/templates/root/src/main/resources/application.properties +23 -0
  84. package/stacks/opencqrs/templates/root/src/main/resources/schema.sql +19 -0
  85. package/stacks/opencqrs/templates/root/src/main/resources/static/index.html +11 -0
  86. package/stacks/supabase/templates/build-kit/CLAUDE.md +26 -1
  87. package/stacks/supabase/templates/build-kit/lib/check-commit-scope.cjs +126 -0
  88. package/stacks/supabase/templates/build-kit/lib/checks/00-blocked-paths.cjs +28 -0
  89. package/stacks/supabase/templates/build-kit/lib/checks/10-slice-scope.cjs +34 -0
  90. package/stacks/supabase/templates/build-kit/lib/checks/20-append-only-migrations.cjs +21 -0
  91. package/stacks/supabase/templates/build-kit/lib/checks/30-test-file-present.cjs +44 -0
  92. package/stacks/supabase/templates/build-kit/lib/checks/40-no-invented-fields.cjs +92 -0
  93. package/stacks/supabase/templates/build-kit/lib/checks/50-spec-coverage.cjs +50 -0
  94. package/stacks/supabase/templates/build-kit/lib/checks/90-tsc-build.cjs +22 -0
  95. package/stacks/supabase/templates/root/.githooks/pre-commit +11 -0
  96. package/stacks/supabase/templates/root/package.json +2 -1
  97. package/stacks/supabase/templates/root/setup-env.sh +7 -1
  98. package/stacks/umadb/templates/.claude/skills/build-automation/SKILL.md +313 -0
  99. package/stacks/umadb/templates/.claude/skills/build-automation/references/feature-flag-patterns.md +42 -0
  100. package/stacks/umadb/templates/.claude/skills/build-state-change/SKILL.md +376 -0
  101. package/stacks/umadb/templates/.claude/skills/build-state-change/references/feature-flag-patterns.md +42 -0
  102. package/stacks/umadb/templates/.claude/skills/build-state-change/references/umadb-query-patterns.md +78 -0
  103. package/stacks/umadb/templates/.claude/skills/build-state-view/SKILL.md +338 -0
  104. package/stacks/umadb/templates/build-kit/CLAUDE.md +94 -0
  105. package/stacks/umadb/templates/build-kit/lib/AGENT.md +47 -0
  106. package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +169 -0
  107. package/stacks/umadb/templates/build-kit/lib/prompt.md +128 -0
  108. package/stacks/umadb/templates/root/.mvn/wrapper/maven-wrapper.properties +19 -0
  109. package/stacks/umadb/templates/root/README.md +48 -0
  110. package/stacks/umadb/templates/root/docker-compose.yml +29 -0
  111. package/stacks/umadb/templates/root/mvnw +259 -0
  112. package/stacks/umadb/templates/root/mvnw.cmd +149 -0
  113. package/stacks/umadb/templates/root/pom.xml +151 -0
  114. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/QuickstartApplication.java +12 -0
  115. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/config/UmaDbConfig.java +39 -0
  116. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/DecisionModelLoader.java +76 -0
  117. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventCodec.java +40 -0
  118. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/EventDispatcher.java +100 -0
  119. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/OptimisticConcurrencyException.java +14 -0
  120. package/stacks/umadb/templates/root/src/main/java/io/umadb/quickstart/eventstore/SliceEventListener.java +23 -0
  121. package/stacks/umadb/templates/root/src/main/resources/application.properties +17 -0
  122. package/stacks/umadb/templates/root/src/test/java/io/umadb/quickstart/testsupport/InMemoryUmaDbClient.java +135 -0
  123. package/stacks/umadb/templates/root/src/test/resources/application.properties +8 -0
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ // supabase/migrations/V{n}__*.sql may only be ADDED, never modified or deleted —
4
+ // migrations are append-only; a fix belongs in a new migration, not an edit to
5
+ // an old one.
6
+
7
+ const MIGRATION_PATTERN = /^supabase\/migrations\/V\d+__.*\.sql$/;
8
+
9
+ module.exports = {
10
+ name: 'append-only-migrations',
11
+ run(ctx) {
12
+ const violations = [];
13
+ for (const { status, path: p } of ctx.changes) {
14
+ if (!MIGRATION_PATTERN.test(p)) continue;
15
+ if (status !== 'A') {
16
+ violations.push({ path: p, reason: 'existing migrations are append-only; add a new V{n} file instead of editing this one' });
17
+ }
18
+ }
19
+ return violations;
20
+ },
21
+ };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ // A slice commit that adds/changes a command handler, projection, or processor
4
+ // must also include a *.test.ts for that slice — catches the agent skipping the
5
+ // matching build skill's test step (build-state-change Step 4 / build-state-view
6
+ // Step 5 / build-automation's DeciderSpecification tests).
7
+
8
+ const { execSync } = require('child_process');
9
+
10
+ const IMPLEMENTATION_FILE = /^src\/slices\/([^/]+)\/([^/]+)\/(?:[A-Za-z0-9]+Command|[A-Za-z0-9]+Projection|processor(?:-[A-Za-z0-9]+)?)\.ts$/;
11
+
12
+ module.exports = {
13
+ name: 'test-file-present',
14
+ run(ctx) {
15
+ const sliceDirs = new Set();
16
+ for (const { path: p } of ctx.changes) {
17
+ const m = IMPLEMENTATION_FILE.exec(p);
18
+ if (m) sliceDirs.add(`src/slices/${m[1]}/${m[2]}`);
19
+ }
20
+ if (sliceDirs.size === 0) return [];
21
+
22
+ let tracked = [];
23
+ try {
24
+ tracked = execSync('git ls-files -- src/slices', { cwd: ctx.repoRoot, encoding: 'utf8' })
25
+ .split('\n')
26
+ .filter(Boolean);
27
+ } catch {
28
+ // best-effort — fall through with whatever's staged
29
+ }
30
+ const known = new Set([...tracked, ...ctx.changes.map((c) => c.path)]);
31
+
32
+ const violations = [];
33
+ for (const dir of sliceDirs) {
34
+ const hasTest = [...known].some((f) => f.startsWith(`${dir}/`) && f.endsWith('.test.ts'));
35
+ if (!hasTest) {
36
+ violations.push({
37
+ path: dir,
38
+ reason: 'no *.test.ts found for this slice — command handlers/projections/processors need test coverage',
39
+ });
40
+ }
41
+ }
42
+ return violations;
43
+ },
44
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ // Heuristic: flags a field name used in a slice's Command/ReadModel type literal
4
+ // that doesn't appear anywhere in that slice's own slice.json. Not a real
5
+ // TS/schema-aware check (no parser dependency) — it regex-extracts the fields
6
+ // declared inside `Command<'Name', { ... }, ...>` and `type XReadModel = { ... }`
7
+ // literals, so unusual formatting (nested object/array field types, nonstandard
8
+ // generics) can slip past undetected. It only ever adds violations for fields it
9
+ // is confident about; when slice.json can't be found/parsed for a slice, that
10
+ // slice's files are skipped entirely rather than guessed at.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { findSliceJson, normalize } = require('../util/find-slice.cjs');
15
+
16
+ const COMMON_ALLOWED = new Set(
17
+ ['id', 'userId', 'correlationId', 'causationId', 'streamName', 'type', 'data', 'metadata', 'createdAt', 'updatedAt', 'timestamp'].map(normalize),
18
+ );
19
+
20
+ const TYPE_LITERALS = [
21
+ /(?:Command|Event)<\s*'[^']+'\s*,\s*{([^}]*)}/g,
22
+ /type\s+[A-Za-z0-9_]+ReadModel\s*=\s*{([^}]*)}/g,
23
+ ];
24
+
25
+ const FIELD_LINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:/gm;
26
+
27
+ function collectDeclaredFields(node, out) {
28
+ if (Array.isArray(node)) {
29
+ for (const item of node) collectDeclaredFields(item, out);
30
+ } else if (node && typeof node === 'object') {
31
+ if (typeof node.name === 'string' && ('type' in node || 'optional' in node)) {
32
+ out.add(normalize(node.name));
33
+ }
34
+ for (const key of Object.keys(node)) collectDeclaredFields(node[key], out);
35
+ }
36
+ }
37
+
38
+ module.exports = {
39
+ name: 'no-invented-fields',
40
+ run(ctx) {
41
+ const bySlice = new Map(); // "context/SliceName" -> { context, sliceName, files: [] }
42
+
43
+ for (const { path: p } of ctx.changes) {
44
+ if (!p.endsWith('.ts') || p.endsWith('.test.ts')) continue;
45
+ const m = /^src\/slices\/([^/]+)\/([^/]+)\//.exec(p);
46
+ if (!m) continue;
47
+ const key = `${m[1]}/${m[2]}`;
48
+ if (!bySlice.has(key)) bySlice.set(key, { context: m[1], sliceName: m[2], files: [] });
49
+ bySlice.get(key).files.push(p);
50
+ }
51
+
52
+ const violations = [];
53
+
54
+ for (const { context, sliceName, files } of bySlice.values()) {
55
+ const slice = findSliceJson(ctx.repoRoot, context, sliceName);
56
+ if (!slice) continue; // can't verify — don't block
57
+
58
+ const declared = new Set();
59
+ collectDeclaredFields(slice, declared);
60
+ if (declared.size === 0) continue; // slice.json shape not recognized — don't block
61
+
62
+ for (const file of files) {
63
+ let content;
64
+ try {
65
+ content = fs.readFileSync(path.join(ctx.repoRoot, file), 'utf8');
66
+ } catch {
67
+ continue; // deleted/unreadable — nothing to check
68
+ }
69
+
70
+ for (const pattern of TYPE_LITERALS) {
71
+ pattern.lastIndex = 0;
72
+ let typeMatch;
73
+ while ((typeMatch = pattern.exec(content))) {
74
+ FIELD_LINE.lastIndex = 0;
75
+ let fieldMatch;
76
+ while ((fieldMatch = FIELD_LINE.exec(typeMatch[1]))) {
77
+ const raw = fieldMatch[1];
78
+ const norm = normalize(raw);
79
+ if (COMMON_ALLOWED.has(norm) || declared.has(norm)) continue;
80
+ violations.push({
81
+ path: file,
82
+ reason: `field "${raw}" is not declared anywhere in slice.json for this slice — check for an invented field`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ return violations;
91
+ },
92
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // Heuristic: a slice's *.test.ts must have at least as many `it(...)` blocks as
4
+ // slice.json has `specifications[]` entries. Doesn't verify each spec is
5
+ // actually tested (that would need matching scenario content, not just count),
6
+ // just that nobody's silently short a test case. Skipped (not blocked) when
7
+ // slice.json can't be found or has no specifications[] array.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { findSliceJson } = require('../util/find-slice.cjs');
12
+
13
+ const TEST_FILE = /^src\/slices\/([^/]+)\/([^/]+)\/[^/]+\.test\.ts$/;
14
+ const IT_BLOCK = /\bit(?:\.(?:only|skip))?\s*\(/g;
15
+
16
+ module.exports = {
17
+ name: 'spec-coverage',
18
+ run(ctx) {
19
+ const violations = [];
20
+
21
+ for (const { path: p } of ctx.changes) {
22
+ TEST_FILE.lastIndex = 0;
23
+ const m = TEST_FILE.exec(p);
24
+ if (!m) continue;
25
+ const [, context, sliceName] = m;
26
+
27
+ const slice = findSliceJson(ctx.repoRoot, context, sliceName);
28
+ if (!slice || !Array.isArray(slice.specifications) || slice.specifications.length === 0) continue;
29
+
30
+ let content;
31
+ try {
32
+ content = fs.readFileSync(path.join(ctx.repoRoot, p), 'utf8');
33
+ } catch {
34
+ continue; // deleted — nothing to check
35
+ }
36
+
37
+ const specCount = slice.specifications.length;
38
+ const itCount = (content.match(IT_BLOCK) || []).length;
39
+
40
+ if (itCount < specCount) {
41
+ violations.push({
42
+ path: p,
43
+ reason: `slice.json declares ${specCount} specification(s) but this test file only has ${itCount} it(...) block(s)`,
44
+ });
45
+ }
46
+ }
47
+
48
+ return violations;
49
+ },
50
+ };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ // The project must still typecheck after the slice change. Uses the project's
4
+ // own `typescript` devDependency via `npx tsc --noEmit` — no new dependency,
5
+ // but it does mean this check needs node_modules already installed (same
6
+ // prerequisite `npm run build` already has).
7
+
8
+ const { execSync } = require('child_process');
9
+
10
+ module.exports = {
11
+ name: 'tsc-build',
12
+ skipIfAlreadyFailing: true, // slow — don't bother once the commit is rejected already
13
+ run(ctx) {
14
+ try {
15
+ execSync('npx tsc --noEmit', { cwd: ctx.repoRoot, stdio: 'pipe' });
16
+ return [];
17
+ } catch (err) {
18
+ const output = String(err.stdout || err.message || '').trim().split('\n').slice(0, 20).join('\n');
19
+ return [{ path: '(tsc --noEmit)', reason: `TypeScript build failed:\n${output}` }];
20
+ }
21
+ },
22
+ };
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # Installed via setup-env.sh (`git config core.hooksPath .githooks`), so this file
3
+ # is versioned and shared by every clone instead of living only in .git/hooks/.
4
+ set -e
5
+
6
+ repo_root=$(git rev-parse --show-toplevel)
7
+ script="$repo_root/.build-kit/lib/check-commit-scope.cjs"
8
+
9
+ if [ -f "$script" ]; then
10
+ node "$script"
11
+ fi
@@ -7,7 +7,8 @@
7
7
  "dev": "node --env-file=.env --require ts-node/register server.ts",
8
8
  "build": "tsc",
9
9
  "start": "NODE_ENV=production node --env-file=.env --require ts-node/register server.ts",
10
- "test": "tsx --test 'src/**/*.test.ts'"
10
+ "test": "tsx --test 'src/**/*.test.ts'",
11
+ "check:scope": "node .build-kit/lib/check-commit-scope.cjs"
11
12
  },
12
13
  "dependencies": {
13
14
  "@event-driven-io/emmett": "^0.42.1-alpha.1",
@@ -50,4 +50,10 @@ FLYWAY_PASSWORD=${DB_PASSWORD}
50
50
  EOF
51
51
 
52
52
  echo ""
53
- echo ".env created successfully."
53
+ echo ".env created successfully."
54
+
55
+ if [ -f .githooks/pre-commit ] && git rev-parse --git-dir >/dev/null 2>&1; then
56
+ chmod +x .githooks/pre-commit 2>/dev/null || true
57
+ git config core.hooksPath .githooks
58
+ echo "Configured git to use .githooks/ (slice commit-scope guard)."
59
+ fi
@@ -0,0 +1,313 @@
1
+ ---
2
+ name: build-automation
3
+ authors:
4
+ - Martin Dilger
5
+ description: >
6
+ Implement automation slices (Event → Command) that react to events off the shared
7
+ EventDispatcher subscription and dispatch a command by calling its command handler directly, in
8
+ this project's one established pattern. Automations can be stateless (direct event-to-command
9
+ mapping) or carry a private read model (to look up data needed for command construction). Use
10
+ when implementing a new automation / event-to-command reactor from a slice.json event model in
11
+ this project. There is exactly one supported style — do not offer alternatives.
12
+ ---
13
+
14
+ # UmaDB — Automation Slice
15
+
16
+ An automation reacts to an event by dispatching a command. In Event Modeling: the **orange**
17
+ stripe. There are two kinds:
18
+
19
+ - **Stateless**: direct event-to-command mapping — no stored state needed. Grounded in the
20
+ `AutoSubscribeToDefaultCourseProcessor` slice (test in
21
+ `AutoSubscribeToDefaultCourseProcessorTest`) — verified, compiled and passing under `mvn test`,
22
+ including end-to-end against a real `umadb/umadb:0.7.5` server (`UmaDbContainerIntegrationTest`):
23
+ a real `CustomerRegistered` event, delivered by the real `EventDispatcher` subscription, really
24
+ causes a real `SubscribeToCourse` command to be handled and its event really appended.
25
+ - **With private read model**: needs data NOT in the trigger event itself (e.g. iterating over all
26
+ entities matching a category). Same shape as `build-state-change`'s Decision-with-fold pattern,
27
+ applied to a private in-memory model instead of a Query/replay — documented below by direct
28
+ analogy, not separately proven against a compiled example in this session; verify it compiles and
29
+ its tests pass before considering it done, the same as any other slice.
30
+
31
+ There is no separate command-bus abstraction in this project (unlike Axon's
32
+ `CommandDispatcher`/`CommandGateway` split) — an automation dispatches by calling the target
33
+ command handler's `handle(...)` method directly, since it's just another Spring bean and the call
34
+ is in-process either way.
35
+
36
+ ## Step 0: Discover Target Project Conventions
37
+
38
+ > **Comments & description**: each element carries a `comments: string[]` array (board comments)
39
+ > and a `description` field — use them as implementation hints, and resolve consumed comments via
40
+ > `POST <BASE_URL>/api/org/<ORG_ID>/boards/<BOARD_ID>/nodes/<nodeId>/comments/<commentId>/resolve`.
41
+
42
+ Read the target project's `.build-kit/CLAUDE.md` and explore existing slices.
43
+
44
+ **Determine `{basePackage}`** — every path below is rooted at
45
+ `{basePackage}.slices.{context}.automation.{slicename}`. Resolve `{basePackage}` as documented in
46
+ `.build-kit/CLAUDE.md`.
47
+
48
+ ## Step 1: Understand the Input
49
+
50
+ Extract these elements from slice.json (or whatever Event Modeling artifact is given):
51
+
52
+ | Element | What to extract |
53
+ |-------------------------|-------------------------------------------------------------------------|
54
+ | **Trigger event** | Which event triggers the automation, and which condition filters it |
55
+ | **Target command** | Which command to dispatch, with what properties |
56
+ | **Mapping logic** | How event properties map to command properties |
57
+ | **Read model needed?** | Does the automation need data NOT in the trigger event itself? |
58
+
59
+ If the slice details include `## Scenarios (GWTs)`, use them to derive test cases. GWT format for
60
+ automations: `Given (events) → Then (command | NOTHING)`. Events in Given include read-model-
61
+ building events first, trigger event last.
62
+
63
+ `slice.json` may also carry an optional `storylines[]` array — see the "Storyline-Derived Tests"
64
+ section under Step 4 for how a trigger-event beat in one of these can add a supplementary test.
65
+
66
+ **If requirements are unclear, invoke `/request-feedback` rather than guessing** — see
67
+ `.build-kit/CLAUDE.md`'s escalation rule.
68
+
69
+ ## Step 2: Ensure Events Exist
70
+
71
+ All events the automation reacts to, and the target command it dispatches, must already exist. If
72
+ they don't, create the event first following `build-state-change` Step 2, and the target command's
73
+ whole slice following `build-state-change` in full (a command never exists without its slice).
74
+
75
+ ## Step 3: Implement the Automation
76
+
77
+ ### Stateless Automation
78
+
79
+ New automation slices live under `src/main/java/.../slices/{context}/automation/{slicename}/`.
80
+
81
+ ```java
82
+ package {basePackage}.slices.{context}.automation.{slicename};
83
+
84
+ import io.umadb.client.Event;
85
+ import {basePackage}.eventstore.EventCodec;
86
+ import {basePackage}.eventstore.SliceEventListener;
87
+ import {basePackage}.slices.{context}.events.{TriggerEvent};
88
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}Command;
89
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}CommandHandler;
90
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
91
+ import org.springframework.stereotype.Component;
92
+
93
+ @Component
94
+ @ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
95
+ public class {AutomationName}Processor implements SliceEventListener {
96
+
97
+ private final {TargetCommand}CommandHandler targetCommandHandler;
98
+
99
+ public {AutomationName}Processor({TargetCommand}CommandHandler targetCommandHandler) {
100
+ this.targetCommandHandler = targetCommandHandler;
101
+ }
102
+
103
+ @Override
104
+ public boolean supports(String eventType) {
105
+ return {TriggerEvent}.TYPE.equals(eventType);
106
+ }
107
+
108
+ @Override
109
+ public void onEvent(Event event) {
110
+ react(EventCodec.fromEvent(event, {TriggerEvent}.class));
111
+ }
112
+
113
+ /** Called directly (no client, no dispatcher) by this class's own test - see Step 4. */
114
+ public void react({TriggerEvent} event) {
115
+ if (!shouldReact(event)) {
116
+ return;
117
+ }
118
+ targetCommandHandler.handle(new {TargetCommand}Command(event.field1() /*, mapped fields */));
119
+ }
120
+
121
+ private boolean shouldReact({TriggerEvent} event) {
122
+ return true; // replace with the actual condition from slice.json, if any
123
+ }
124
+ }
125
+ ```
126
+
127
+ **Idempotency**: `EventDispatcher` may redeliver a trigger event after an application restart (see
128
+ its Javadoc on quickstart-level checkpointing) — a re-dispatched command is safe as long as the
129
+ target command handler's own Decision already no-ops a repeat (verified: re-subscribing to the same
130
+ course a second time is rejected by `SubscribeToCourseDecision.alreadySubscribedToThisCourse`, not
131
+ by anything in the automation itself). Don't add your own idempotency guard in the automation
132
+ unless the target command handler genuinely can't provide one — check that first.
133
+
134
+ ### Automation with Read Model
135
+
136
+ When the automation needs data not in the trigger event, add a private in-memory model — never
137
+ reuse another slice's read model.
138
+
139
+ ```java
140
+ @Component
141
+ @ConditionalOnProperty(prefix = "slices.{context}.automation", name = "{slicename}.enabled")
142
+ public class {AutomationName}Processor implements SliceEventListener {
143
+
144
+ // Private read model, indexed by entity id - belongs to this automation only
145
+ private final Map<String, {AutomationName}Entry> store = new ConcurrentHashMap<>();
146
+
147
+ private final {TargetCommand}CommandHandler targetCommandHandler;
148
+
149
+ public {AutomationName}Processor({TargetCommand}CommandHandler targetCommandHandler) {
150
+ this.targetCommandHandler = targetCommandHandler;
151
+ }
152
+
153
+ @Override
154
+ public boolean supports(String eventType) {
155
+ return {SetupEvent}.TYPE.equals(eventType) || {TriggerEvent}.TYPE.equals(eventType);
156
+ }
157
+
158
+ @Override
159
+ public void onEvent(Event event) {
160
+ if (event.type().equals({SetupEvent}.TYPE)) {
161
+ onSetup(EventCodec.fromEvent(event, {SetupEvent}.class));
162
+ } else if (event.type().equals({TriggerEvent}.TYPE)) {
163
+ react(EventCodec.fromEvent(event, {TriggerEvent}.class));
164
+ }
165
+ }
166
+
167
+ // Phase 1 - build the private read model from setup events
168
+ public void onSetup({SetupEvent} event) {
169
+ store.put(event.entityId(), new {AutomationName}Entry(event.entityId(), event.filterField()));
170
+ }
171
+
172
+ // Phase 2 - trigger: dispatch a command per matching entry
173
+ public void react({TriggerEvent} event) {
174
+ store.values().stream()
175
+ .filter(entry -> entry.filterField().equals(event.filterValue()))
176
+ .forEach(entry -> targetCommandHandler.handle(new {TargetCommand}Command(entry.entityId() /*, other fields */)));
177
+ }
178
+
179
+ record {AutomationName}Entry(String entityId, String filterField) {}
180
+ }
181
+ ```
182
+
183
+ Key rules:
184
+
185
+ - **Two branches in one `onEvent`**: one builds the private model (`onSetup`), one reacts
186
+ (`react`) — `supports` must answer true for both event types.
187
+ - Since `EventDispatcher` delivers events sequentially in position order to every listener on a
188
+ single subscription thread (see its Javadoc), there is no concurrent-mutation race between
189
+ `onSetup` and `react` to guard against here the way a multi-threaded processing group would need
190
+ to — `ConcurrentHashMap` is a safety margin, not a requirement.
191
+ - **Private read model belongs to this automation only** — never share it with another slice.
192
+
193
+ ## Step 4: Implement Tests
194
+
195
+ **Stateless automations** — pure unit test with a mocked target command handler (Mockito):
196
+
197
+ ```java
198
+ package {basePackage}.slices.{context}.automation.{slicename};
199
+
200
+ import {basePackage}.slices.{context}.events.{TriggerEvent};
201
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}Command;
202
+ import {basePackage}.slices.{context}.{targetslicename}.{TargetCommand}CommandHandler;
203
+ import org.junit.jupiter.api.BeforeEach;
204
+ import org.junit.jupiter.api.DisplayName;
205
+ import org.junit.jupiter.api.Test;
206
+ import org.junit.jupiter.api.extension.ExtendWith;
207
+ import org.mockito.Mock;
208
+ import org.mockito.junit.jupiter.MockitoExtension;
209
+
210
+ import static org.mockito.Mockito.verify;
211
+ import static org.mockito.Mockito.verifyNoInteractions;
212
+
213
+ @ExtendWith(MockitoExtension.class)
214
+ class {AutomationName}ProcessorTest {
215
+
216
+ @Mock
217
+ private {TargetCommand}CommandHandler targetCommandHandler;
218
+
219
+ private {AutomationName}Processor processor;
220
+
221
+ @BeforeEach
222
+ void setUp() {
223
+ processor = new {AutomationName}Processor(targetCommandHandler);
224
+ }
225
+
226
+ @Test
227
+ @DisplayName("given trigger event with condition met, then command dispatched")
228
+ void happyPath() {
229
+ processor.react(new {TriggerEvent}("entity-1" /*, fields that meet condition */));
230
+
231
+ verify(targetCommandHandler).handle(new {TargetCommand}Command("entity-1" /*, expected fields */));
232
+ }
233
+
234
+ @Test
235
+ @DisplayName("given trigger event with condition not met, then no command dispatched")
236
+ void conditionNotMet() {
237
+ processor.react(new {TriggerEvent}("entity-1" /*, fields that do NOT meet condition */));
238
+
239
+ verifyNoInteractions(targetCommandHandler);
240
+ }
241
+ }
242
+ ```
243
+
244
+ If Mockito's inline mock maker reports a Byte Buddy / JDK-version incompatibility when mocking a
245
+ project class (seen on very recent JDKs, harmless), add
246
+ `-Dnet.bytebuddy.experimental=true` to the surefire `<argLine>` in `pom.xml` — this affects only
247
+ Mockito's own bytecode generation, not the code under test.
248
+
249
+ **Automations with read model** — call `onSetup`/`react` directly, same mocked-handler style, setup
250
+ events before trigger events:
251
+
252
+ ```java
253
+ processor.onSetup(new {SetupEvent}("entity-1", "filter-A"));
254
+ processor.onSetup(new {SetupEvent}("entity-2", "filter-B"));
255
+
256
+ processor.react(new {TriggerEvent}("filter-A"));
257
+
258
+ verify(targetCommandHandler).handle(new {TargetCommand}Command("entity-1" /*, fields */));
259
+ verifyNoMoreInteractions(targetCommandHandler);
260
+ ```
261
+
262
+ ### Test Cases to Cover
263
+
264
+ **Stateless automations:**
265
+ 1. Condition met → expected command dispatched
266
+ 2. Condition not met → no command dispatched
267
+
268
+ **Automations with read model:**
269
+ 1. Setup + trigger with matching filter → command dispatched for matching entries only
270
+ 2. Setup + trigger with non-matching filter → no command dispatched
271
+ 3. Temporal ordering: setup before trigger vs. setup after trigger → only entries that existed at
272
+ trigger time receive a command
273
+
274
+ ### Mapping GWT Scenarios to Tests
275
+
276
+ | GWT Element | Test Code |
277
+ |---|---|
278
+ | Event in Given | `processor.react(new Event(...))` (or `onSetup(...)` for a setup event) |
279
+ | Multiple events in Given | multiple calls — setup events first, trigger last |
280
+ | Command in Then | `verify(targetCommandHandler).handle(eq(expectedCommand))` |
281
+ | NOTHING in Then | `verifyNoInteractions(targetCommandHandler)` |
282
+
283
+ ### Storyline-Derived Tests (Optional)
284
+
285
+ `slice.json` may also carry a `storylines[]` array — narrated walkthroughs with an ordered
286
+ `elements[]` "beats" sequence (EVENT/COMMAND/READMODEL/...). This is a secondary, supplementary
287
+ source; `specifications[]` above stays the primary and default source of test cases. Most slices
288
+ have no `storylines[]` — skip silently when there's nothing relevant.
289
+
290
+ Find a beat whose `type` is `EVENT` immediately followed by a `COMMAND` beat this automation
291
+ dispatches. That pair is a ready-made test: `given` = the cumulative preceding `EVENT` beats
292
+ (setup events) through the trigger beat, `then` = `verify(targetCommandHandler).handle(eq(...))`
293
+ built from the command beat's fields — same shape as the "Mapping GWT Scenarios to Tests" row
294
+ above, just sourced from the storyline instead of `specifications[]`.
295
+
296
+ If the beat following the trigger event isn't a COMMAND this automation dispatches, don't force a
297
+ test — leave it undocumented rather than fabricating an assertion.
298
+
299
+ ## References
300
+
301
+ - [Feature Flag Patterns](references/feature-flag-patterns.md) — `@ConditionalOnProperty`, wired the same as `build-state-change`
302
+
303
+ ## Final Verification: Does the Implementation Match slice.json?
304
+
305
+ Before marking this slice as `Done`, verify the implementation against slice.json:
306
+
307
+ - [ ] The trigger event in the processor matches the trigger event in slice.json exactly
308
+ - [ ] The command dispatched matches the target command defined in slice.json
309
+ - [ ] All fields mapped from trigger event to command come from the event fields defined in slice.json — no invented mappings
310
+ - [ ] Every GWT scenario in `specifications[]` maps to a test case in the test class
311
+ - [ ] If `storylines[]` is present: every trigger-EVENT→target-COMMAND beat pair for this automation has a storyline test — or was deliberately skipped as untraceable
312
+ - [ ] No filtering conditions were invented — all conditions come from slice.json `description` or `comments`
313
+ - [ ] No field names were assumed or guessed — if a field is not in slice.json, it is not in the code
@@ -0,0 +1,42 @@
1
+ # Feature Flag Patterns
2
+
3
+ This project's one supported approach: `@ConditionalOnProperty` on the command handler and REST
4
+ controller (**not** on the Decision class - verified against the `RegisterCustomer` and
5
+ `SubscribeToCourse` slices, neither of which puts the annotation on its decision-model class).
6
+ Read slices (`build-state-view`) are never feature-flagged - only write and automation slices are.
7
+ Examples use a generic `Ordering` bounded context.
8
+
9
+ ## Annotation on slice components
10
+
11
+ ```java
12
+ // Command handler
13
+ @ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
14
+ @Component
15
+ public class PlaceOrderCommandHandler { ... }
16
+
17
+ // REST controller (if applicable)
18
+ @ConditionalOnProperty(prefix = "slices.ordering.write", name = "placeorder.enabled")
19
+ @RestController
20
+ public class PlaceOrderRestController { ... }
21
+ ```
22
+
23
+ ## `application.properties` (main — enable by default)
24
+
25
+ ```properties
26
+ slices.ordering.write.placeorder.enabled=true
27
+ slices.ordering.automation.notifycustomeronorder.enabled=true
28
+ ```
29
+
30
+ ## `application.properties` (test — disable by default)
31
+
32
+ ```properties
33
+ slices.ordering.write.placeorder.enabled=false
34
+ slices.ordering.automation.notifycustomeronorder.enabled=false
35
+ ```
36
+
37
+ This only matters for tests that boot a Spring context (`@SpringBootTest`). The
38
+ `InMemoryUmaDbClient` unit-test pattern in
39
+ [umadb-query-patterns.md](umadb-query-patterns.md) never boots Spring, so these properties don't
40
+ affect it either way - a Testcontainers-based end-to-end test (see `build-state-view`'s reference)
41
+ overrides them back to `true` for itself via `@DynamicPropertySource`, since it's the one test that
42
+ does need the real beans wired.