@cratis/pi 2.24.1 → 2.26.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,285 @@
1
+ ---
2
+ name: cratis-arc-validation-kotlin
3
+ description: Add a rejection rule to a Cratis Arc command or query in Kotlin or Java — FluentModelValidator shared rules that also emit TypeScript, plus the server-only CommandValidator, QueryValidator, ConceptValidator, and ModelValidator. Use when a Kotlin or Java Arc command or query must refuse work under some condition. Do not use to define a new command or query, and do not use for append-time Chronicle constraints.
4
+ license: MIT
5
+ ---
6
+
7
+ # Validate an Arc command or query in Kotlin or Java
8
+
9
+ Arc.Kotlin has two validation surfaces, and they compose rather than compete:
10
+ **shared fluent rules** that a build step also turns into generated TypeScript
11
+ client-side checks, and **ordinary imperative validators** that run only on
12
+ the server. Pick shared rules first for anything literal enough to express
13
+ that way; keep service calls, async work, and cross-member logic imperative.
14
+
15
+ ## Verified product sources
16
+
17
+ Verified against `Arc.Kotlin` at tag **`v7.3.0`**, re-verified at commit
18
+ `d0aa6a4` (14 commits past the tag; `guides/commands.md`, `guides/validation.md`,
19
+ and `guides/chronicle.md` are byte-for-byte unchanged since the tag).
20
+ Documented consumer setup: JDK 17, Kotlin 2.4.20, KSP 2.3.12, Spring Boot
21
+ 4.1.x. `io.cratis:arc` supplies `io.cratis.arc.validation.FluentModelValidator`,
22
+ `CommandValidator<T>`, `QueryValidator<T>`, `ConceptValidator<T>`,
23
+ `ModelValidator<T>`, `ConceptValidationExclusion`, `IgnoreValidation`,
24
+ `CommandFilter`, `QueryFilter`. The re-verification added the new
25
+ "Reject at feature scope with a pipeline filter" section below, from the new
26
+ `guides/pipeline-filters.md`.
27
+
28
+ ## Shared fluent rules — server and generated TypeScript
29
+
30
+ Use `FluentModelValidator<T>` for a literal member rule that must run
31
+ identically on the server and in the generated client. It is a public, final,
32
+ top-level class over one concrete model, with one public no-argument
33
+ constructor and a single `init` block containing only direct `ruleFor(...)`
34
+ chains with literal arguments — KSP statically compiles this shape, so no
35
+ lambda, computed accessor, or helper method is allowed inside it.
36
+
37
+ ```kotlin
38
+ package example.tasks
39
+
40
+ import io.cratis.arc.validation.FluentModelValidator
41
+
42
+ class CreateTaskRules : FluentModelValidator<CreateTask>(CreateTask::class.java) {
43
+ init {
44
+ ruleFor("title").notEmpty().withMessage("{PropertyName} must have text; {PropertyName}")
45
+ ruleFor("title").maxLength(120)
46
+ }
47
+ }
48
+ ```
49
+
50
+ ```java
51
+ package example.tasks;
52
+
53
+ import io.cratis.arc.validation.FluentModelValidator;
54
+
55
+ public final class CreateTaskRules extends FluentModelValidator<CreateTask> {
56
+ public CreateTaskRules() {
57
+ super(CreateTask.class);
58
+ ruleFor("title").notEmpty();
59
+ }
60
+ }
61
+ ```
62
+
63
+ Java uses the ordinary constructor and method calls — no Kotlin lambda or
64
+ `Continuation`. Records with default or source-proved identity accessors and
65
+ public fields are supported.
66
+
67
+ ### The rule vocabulary
68
+
69
+ | Rule | Checks |
70
+ | --- | --- |
71
+ | `notNull()` | value is not `null` |
72
+ | `notEmpty()` | non-blank string |
73
+ | `minLength(n)` / `maxLength(n)` / `length(min, max)` | string length |
74
+ | `emailAddress()` | email shape |
75
+ | `phone()` | digits/whitespace/parens/plus/hyphen only |
76
+ | `url()` | `http://`/`https://` prefix |
77
+ | `matches(regex)` | regex match |
78
+ | `greaterThan(n)` / `greaterThanOrEqual(n)` | numeric lower bound |
79
+ | `lessThan(n)` / `lessThanOrEqual(n)` | numeric upper bound |
80
+ | `withMessage(text)` | overrides the preceding rule's message; replaces only the first `{PropertyName}` token |
81
+
82
+ There is no shared `creditCard()` fluent rule — use the server-only
83
+ `@CreditCard` annotation instead (below); the pinned `@cratis/arc` client has
84
+ no matching client-side check for it.
85
+
86
+ Select **one direct public readable member** per `ruleFor(...)` — not a dotted
87
+ path, not an index. Shared rules apply to present nested nodes and collection
88
+ siblings automatically: a `null` parent creates no model node, so a child's
89
+ `notNull()` does not implicitly require the parent to be present — add a
90
+ presence rule on the parent member when the parent itself is required.
91
+
92
+ ### Wire it up
93
+
94
+ Register nothing by hand. The generated module registers every discovered
95
+ `FluentModelValidator` automatically:
96
+
97
+ ```kotlin
98
+ cratisArc {
99
+ moduleName.set("<ModuleName>")
100
+ dependencyVersion.set("<version>")
101
+ }
102
+ ```
103
+
104
+ The Arc Gradle plugin wires the dependency index shared validation needs
105
+ automatically. A manual KSP build must extract and pass that index itself —
106
+ see the Arc.Kotlin `Documentation/reference/configuration.md` dependency
107
+ extraction recipe before reaching for manual wiring.
108
+
109
+ ### Opt a member out
110
+
111
+ ```kotlin
112
+ import io.cratis.arc.validation.IgnoreValidation
113
+
114
+ data class CreateTask(
115
+ @IgnoreValidation val internalNote: String?,
116
+ val title: String
117
+ )
118
+ ```
119
+
120
+ `@IgnoreValidation` cuts one logical validation edge while keeping
121
+ serialization and binding intact — it is not `@JsonIgnore`. Kotlin accepts it
122
+ on the property, `@field:`, or `@get:` placement; Java accepts it on a field,
123
+ bean getter, or record component. Nonignored siblings and independently
124
+ validated child roots still run.
125
+
126
+ ## Server-only validators
127
+
128
+ ### `CommandValidator<T>` / `QueryValidator<T>`
129
+
130
+ ```kotlin
131
+ @Component
132
+ class CreateTaskValidator : CommandValidator<CreateTask> {
133
+ override val commandType = CreateTask::class.java
134
+
135
+ override suspend fun validate(command: CreateTask, context: CommandContext): List<ValidationResult> =
136
+ if (command.title.isBlank()) {
137
+ listOf(ValidationResult.error("A task title is required.", listOf("title")))
138
+ } else {
139
+ emptyList()
140
+ }
141
+ }
142
+ ```
143
+
144
+ Register a Spring bean; `POST <command-route>/validate` runs the same
145
+ pipeline without invoking `handle`. `ValidationResult.information` /
146
+ `.warning` / `.error` are static factories naming severity, members, state,
147
+ reason, and reason detail — Java reads them the same way. Without an explicit
148
+ threshold, only errors block execution; add `@TreatWarningsAsErrors` to make
149
+ `Information` the nonblocking ceiling, so warnings and errors both block.
150
+ Callers can override per request with the `X-Allowed-Severity` header.
151
+
152
+ Java implementations use `BlockingCommandValidator`/`AsyncCommandValidator`
153
+ with the matching `*Adapter`, published as an ordinary `CommandValidator`
154
+ Spring bean — there is no second Java-specific discovery mechanism.
155
+
156
+ ### `ConceptValidator<TConcept>`
157
+
158
+ A reusable invariant on a `ConceptAs<T>` concept type, walked automatically
159
+ wherever that concept type appears — command, nested model, collection,
160
+ array, or map — without each owner repeating the rule. Matching uses
161
+ `conceptType.isInstance`, including subtypes; failures attach to the owning
162
+ member's path.
163
+
164
+ ```kotlin
165
+ @Component
166
+ class TaskTitleValidator : ConceptValidator<TaskTitle> {
167
+ override val conceptType = TaskTitle::class.java
168
+
169
+ override fun validate(concept: TaskTitle): List<ValidationResult> =
170
+ if (concept.value.isBlank()) listOf(ValidationResult.error("A task title is required.")) else emptyList()
171
+ }
172
+ ```
173
+
174
+ Concept rules run independently of Jakarta `@Valid` and do not require a
175
+ Jakarta `Validator` bean. Exclude one direct edge deliberately with
176
+ `ConceptValidationExclusion(ownerType, member)` — the owner must match the
177
+ **exact runtime class**, and the member must be one direct public property (or
178
+ Java record component/field) whose *declared* type implements `ConceptAs`. A
179
+ scalar root, a collection element, or a nested path cannot be excluded this
180
+ way — register the nested owner type and its direct member instead. Excluding
181
+ an edge suppresses only `ConceptValidator` execution there; owner rules, exact
182
+ `ModelValidator` rules, Jakarta constraints, and root typed validators still
183
+ run.
184
+
185
+ ### `ModelValidator<T>`
186
+
187
+ A server-only rule reused by command roots, nested models, and supplied query
188
+ arguments, matched by **exact runtime class** (not subclasses or interfaces).
189
+ Nodes run parent-before-child, model rules before concept rules; records use
190
+ declaration order, Kotlin public properties use alphabetical order. Java
191
+ implementations use `BlockingModelValidator`/`AsyncModelValidator` with the
192
+ matching adapter, published as a `ModelValidator` bean.
193
+
194
+ ### Jakarta constraints
195
+
196
+ When Spring has a Jakarta `Validator` bean (add `spring-boot-starter-validation`
197
+ for one), the starter installs a command/query filter automatically. It
198
+ validates `@Valid`-marked nested objects, arrays, iterables, and maps; maps a
199
+ path like `items[0].name` into Arc member paths; and terminates safely on
200
+ cyclic graphs. `@Phone`, `@Url`, `@CreditCard` are Jakarta constraints that
201
+ work from both Kotlin and Java; `@Phone`/`@Url` also emit matching generated
202
+ TypeScript rules, while `@CreditCard` stays server-only because the pinned
203
+ client runtime has no compatible check. `null` is always valid for these three
204
+ — pair with `@NotNull` when absence itself is invalid.
205
+
206
+ ## Reject at feature scope with a pipeline filter
207
+
208
+ `CommandValidator`/`ConceptValidator`/`ModelValidator` all attach a rule to a
209
+ specific artifact or type. When a rule instead belongs to a whole *feature* —
210
+ "everything under the billing package needs the billing role" — writing it on
211
+ every command in turn means the next command somebody adds is the one that
212
+ forgets it. A `CommandFilter`/`QueryFilter` is that declaration, written once:
213
+
214
+ ```kotlin
215
+ class BillingCommandFilter : CommandFilter {
216
+ override suspend fun execute(context: CommandContext): CommandResult<*> {
217
+ if (!context.commandType.name.startsWith(BILLING_PACKAGE)) {
218
+ return CommandResult.success(context.correlationId)
219
+ }
220
+ return if (context.principal.isInRole(BILLING_ROLE)) {
221
+ CommandResult.success(context.correlationId)
222
+ } else {
223
+ CommandResult.unauthorized(context.correlationId, "Role '$BILLING_ROLE' is required.")
224
+ }
225
+ }
226
+ }
227
+
228
+ @Bean
229
+ fun billingCommandFilter(): CommandFilter = BillingCommandFilter()
230
+ ```
231
+
232
+ - A `QueryFilter` takes the same shape against `QueryContext`, matched on the
233
+ query's fully qualified name; it runs for an observable query too, at
234
+ subscription time, so one rule covers both the one-shot `GET` and the live
235
+ subscription.
236
+ - A filter that does not apply to the artifact in front of it must return
237
+ success and get out of the way — that first check is what keeps a feature's
238
+ rule scoped to the feature instead of becoming a global one by accident.
239
+ - Java implements `BlockingCommandFilter`/`BlockingQueryFilter` (synchronous)
240
+ or `AsyncCommandFilter`/`AsyncQueryFilter` (`CompletionStage`), registered
241
+ through the matching `*FilterAdapter`.
242
+ - Filters run in Spring `@Order` sequence — that order is the complete rule,
243
+ with one exception: a filter implementing `AuthorizationCommandFilter` /
244
+ `AuthorizationQueryFilter` always runs before the rest, regardless of its
245
+ own `@Order`, so a denial is never reached after a filter that already
246
+ caused an effect.
247
+ - Reach for a filter only when the rule genuinely spans artifacts. A rule
248
+ about one command or query reads closer to the code it governs, and stays
249
+ visible in the generated client and OpenAPI document, as `@Authorize`/
250
+ `@Roles` or one of the validators above — prefer those first.
251
+
252
+ ## Order of execution
253
+
254
+ Typed `CommandValidator`/`QueryValidator` rules run once, before the shared
255
+ model/concept traversal. A separately registered `ModelValidator` for the
256
+ command's own class is a distinct rule that also runs once at the root.
257
+ Cancellation from any validator — a concept validator, a Kotlin property
258
+ getter, a Java record accessor — propagates through execution and marks a
259
+ cancelled nested command's root rollback-only. An ordinary thrown exception
260
+ from a validator becomes safe `validatorFailed` feedback instead, not an
261
+ exception result; it is not silently skipped.
262
+
263
+ ## Route near misses
264
+
265
+ - Defining the command or query the rule attaches to:
266
+ `cratis-arc-command-kotlin`, `cratis-arc-query-kotlin`.
267
+ - Append-time Chronicle uniqueness/concurrency: `cratis-chronicle-event-constraints`.
268
+ - The C# Arc validation shape (`ConceptValidator<T>`, `CommandValidator<T>`,
269
+ Provide short-circuiting): `cratis-arc-command-validation`.
270
+
271
+ ## Verify
272
+
273
+ - Every `FluentModelValidator` is public, final, top-level, has exactly one
274
+ public no-argument constructor, and its `init` block contains only direct
275
+ `ruleFor(...)` chains with literal arguments.
276
+ - A rejection the user can act on comes back as a `ValidationResult`, never a
277
+ thrown exception.
278
+ - A concept rule that must not run on one specific edge is excluded through
279
+ `ConceptValidationExclusion`, not by weakening the concept's own rule.
280
+ - `@IgnoreValidation` is used only to cut validation, never in place of
281
+ `@JsonIgnore` for a value that must not serialize at all.
282
+ - The generated TypeScript client rejects the same input the server does for
283
+ every rule expressed with `FluentModelValidator` — verify with a build that
284
+ regenerates the proxy.
285
+ - `./gradlew build` is clean with zero warnings and zero errors.
@@ -10,6 +10,7 @@
10
10
  "cratis/application/chronicle-dotnet",
11
11
  "cratis/application/csharp",
12
12
  "cratis/application/elixir",
13
+ "cratis/application/java",
13
14
  "cratis/application/kotlin",
14
15
  "cratis/application/react",
15
16
  "cratis/application/typescript",
@@ -19,6 +20,7 @@
19
20
  "cratis/arc/csharp",
20
21
  "cratis/arc/ef-core",
21
22
  "cratis/arc/identity",
23
+ "cratis/arc/java",
22
24
  "cratis/arc/kotlin",
23
25
  "cratis/arc/react",
24
26
  "cratis/chronicle",
@@ -32,6 +34,7 @@
32
34
  "cratis/chronicle/core",
33
35
  "cratis/chronicle/csharp",
34
36
  "cratis/chronicle/elixir",
37
+ "cratis/chronicle/java",
35
38
  "cratis/chronicle/kotlin",
36
39
  "cratis/chronicle/mcp",
37
40
  "cratis/chronicle/multi-tenancy",
@@ -44,12 +47,14 @@
44
47
  "cratis/full",
45
48
  "cratis/full/csharp",
46
49
  "cratis/full/elixir",
50
+ "cratis/full/java",
47
51
  "cratis/full/kotlin",
48
52
  "cratis/full/typescript",
49
53
  "cratis/fundamentals",
50
54
  "cratis/fundamentals/type-discovery",
51
55
  "cratis/language/csharp",
52
56
  "cratis/language/elixir",
57
+ "cratis/language/java",
53
58
  "cratis/language/kotlin",
54
59
  "cratis/language/typescript",
55
60
  "cratis/lens",
@@ -80,6 +85,7 @@
80
85
  "composes": [
81
86
  "cratis/application/csharp",
82
87
  "cratis/application/elixir",
88
+ "cratis/application/java",
83
89
  "cratis/application/kotlin",
84
90
  "cratis/application/typescript",
85
91
  "cratis/arc/core",
@@ -93,6 +99,7 @@
93
99
  "languages": [
94
100
  "csharp",
95
101
  "elixir",
102
+ "java",
96
103
  "kotlin",
97
104
  "react",
98
105
  "typescript"
@@ -161,6 +168,17 @@
161
168
  "elixir"
162
169
  ]
163
170
  },
171
+ {
172
+ "id": "cratis/application/java",
173
+ "composes": [
174
+ "cratis/arc/client-kotlin",
175
+ "cratis/chronicle/client-java",
176
+ "cratis/language/java"
177
+ ],
178
+ "languages": [
179
+ "java"
180
+ ]
181
+ },
164
182
  {
165
183
  "id": "cratis/application/kotlin",
166
184
  "composes": [
@@ -204,18 +222,26 @@
204
222
  "id": "cratis/arc",
205
223
  "composes": [
206
224
  "cratis/arc/csharp",
225
+ "cratis/arc/java",
207
226
  "cratis/arc/kotlin"
208
227
  ],
209
228
  "languages": [
210
229
  "csharp",
230
+ "java",
231
+ "kotlin",
211
232
  "react",
212
- "typescript",
213
- "kotlin"
233
+ "typescript"
214
234
  ]
215
235
  },
216
236
  {
217
237
  "id": "cratis/arc/client-kotlin",
238
+ "availableTargets": [
239
+ "cratis-arc-command-kotlin",
240
+ "cratis-arc-query-kotlin",
241
+ "cratis-arc-validation-kotlin"
242
+ ],
218
243
  "languages": [
244
+ "java",
219
245
  "kotlin"
220
246
  ]
221
247
  },
@@ -274,6 +300,16 @@
274
300
  "typescript"
275
301
  ]
276
302
  },
303
+ {
304
+ "id": "cratis/arc/java",
305
+ "composes": [
306
+ "cratis/arc/client-kotlin",
307
+ "cratis/language/java"
308
+ ],
309
+ "languages": [
310
+ "java"
311
+ ]
312
+ },
277
313
  {
278
314
  "id": "cratis/arc/kotlin",
279
315
  "composes": [
@@ -304,6 +340,7 @@
304
340
  "cratis/chronicle/compliance",
305
341
  "cratis/chronicle/csharp",
306
342
  "cratis/chronicle/elixir",
343
+ "cratis/chronicle/java",
307
344
  "cratis/chronicle/kotlin",
308
345
  "cratis/chronicle/multi-tenancy",
309
346
  "cratis/chronicle/typescript",
@@ -311,10 +348,11 @@
311
348
  ],
312
349
  "languages": [
313
350
  "csharp",
314
- "kotlin",
315
351
  "elixir",
316
- "typescript",
317
- "language-agnostic"
352
+ "java",
353
+ "kotlin",
354
+ "language-agnostic",
355
+ "typescript"
318
356
  ]
319
357
  },
320
358
  {
@@ -337,6 +375,9 @@
337
375
  },
338
376
  {
339
377
  "id": "cratis/chronicle/client-java",
378
+ "composes": [
379
+ "cratis/chronicle/client-kotlin"
380
+ ],
340
381
  "languages": [
341
382
  "java"
342
383
  ]
@@ -347,6 +388,7 @@
347
388
  "cratis-chronicle-client-kotlin"
348
389
  ],
349
390
  "languages": [
391
+ "java",
350
392
  "kotlin"
351
393
  ]
352
394
  },
@@ -418,6 +460,16 @@
418
460
  "elixir"
419
461
  ]
420
462
  },
463
+ {
464
+ "id": "cratis/chronicle/java",
465
+ "composes": [
466
+ "cratis/chronicle/client-java",
467
+ "cratis/language/java"
468
+ ],
469
+ "languages": [
470
+ "java"
471
+ ]
472
+ },
421
473
  {
422
474
  "id": "cratis/chronicle/kotlin",
423
475
  "composes": [
@@ -522,16 +574,18 @@
522
574
  "composes": [
523
575
  "cratis/full/csharp",
524
576
  "cratis/full/elixir",
577
+ "cratis/full/java",
525
578
  "cratis/full/kotlin",
526
579
  "cratis/full/typescript"
527
580
  ],
528
581
  "languages": [
529
582
  "csharp",
530
- "kotlin",
531
583
  "elixir",
532
- "typescript",
584
+ "java",
585
+ "kotlin",
586
+ "language-agnostic",
533
587
  "react",
534
- "language-agnostic"
588
+ "typescript"
535
589
  ]
536
590
  },
537
591
  {
@@ -561,6 +615,19 @@
561
615
  "language-agnostic"
562
616
  ]
563
617
  },
618
+ {
619
+ "id": "cratis/full/java",
620
+ "composes": [
621
+ "cratis/application/java",
622
+ "cratis/arc/java",
623
+ "cratis/chronicle/java",
624
+ "cratis/modeling/screenplay-stage"
625
+ ],
626
+ "languages": [
627
+ "java",
628
+ "language-agnostic"
629
+ ]
630
+ },
564
631
  {
565
632
  "id": "cratis/full/kotlin",
566
633
  "composes": [
@@ -620,6 +687,12 @@
620
687
  "elixir"
621
688
  ]
622
689
  },
690
+ {
691
+ "id": "cratis/language/java",
692
+ "languages": [
693
+ "java"
694
+ ]
695
+ },
623
696
  {
624
697
  "id": "cratis/language/kotlin",
625
698
  "languages": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "2.24.1",
3
+ "version": "2.26.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [