Skip to content

Commit cd6c745

Browse files
dmealingclaude
andcommitted
feat(extract): validator.array bounds are enforced, in every port
An array field's validator.array (@min/@max) was declared and ignored: the extract engine never saw it and the TypeScript generated parse schema emitted a bare z.array(...). Two cold agent sessions both declared "exactly three tags" in the model and then re-checked the count by hand. The bound now travels on FieldSpec (minItems/maxItems) in TS, Java, Python and C# (Kotlin drives the Java engine), built from the field's array validator by each port's metadata-driven schema builder. Semantics, identical everywhere: - more than @max: NORMAL/LOOSE keep the first @max and record a "truncate" coercion, the array analogue of a numeric clamp; STRICT is MALFORMED; - fewer than @min: MALFORMED under every tolerance, so orThrow fails on a @required one and malformedRequired() names it. The TypeScript generated parse/safeParse schema carries the same bounds. The extract-conformance corpus gains three cases (48), a minItems/maxItems key, scalar arrays in the runners, and an optional per-case tolerance; every port's runner reads them. Each port also has a metadata-driven test (validator.array -> schema -> extract -> strict gate). Local CI green: gates, ts, java-fast, python, csharp. This is a behaviour change: a reply outside the bounds that used to pass now truncates or fails. The CHANGELOG says so up front. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 0362a2c commit cd6c745

35 files changed

Lines changed: 596 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ here.**
1212

1313
### Changed
1414

15+
- **Behaviour change in `extract`, every port: `validator.array` bounds are enforced.** An
16+
array field's `validator.array` (`@min`/`@max`) now reaches the extract engine. A reply with
17+
more elements than `@max` keeps the first `@max` (a `truncate` coercion, like a numeric clamp)
18+
under the default tolerance and is `MALFORMED` under `STRICT`; a reply with fewer than `@min`
19+
is `MALFORMED` under every tolerance, so `orThrow` fails on a `@required` one and
20+
`malformedRequired()` names it. The TypeScript generated `parse<Name>` / `safeParse<Name>`
21+
Zod schema carries the same bounds (`z.array(…).min(n).max(m)`). Before, the bounds were
22+
declared and ignored, so code re-checked the count by hand. The extract-conformance corpus
23+
gains three cases (48 in all), a `minItems`/`maxItems` schema key and an optional per-case
24+
`tolerance`, run by every port.
1525
- **SQLite: projection views that use `origin.collect` now open on SQLite 3.35.** They used an
1626
in-aggregate `ORDER BY` (`json_group_array(x ORDER BY …)`), which needs 3.44, so a database
1727
holding one could not be opened by Ubuntu 22.04's SQLite 3.37 or Python 3.10's stdlib. A

‎docs/CONFORMANCE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
3131
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3232
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
3333
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 15 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
34-
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 45 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
34+
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
3535
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
3636
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 33 (27 query + 6 migration) | all 33 | 27 query (migrations TS-only, ADR-0015) | 27 query (via Exposed) | 27 query | 27 query |
3737
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 61 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 7 projection) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |

‎fixtures/extract-conformance/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ Each `fields[]` entry: `name`, `kind` (`STRING|INT|LONG|DOUBLE|BOOLEAN|ENUM|OBJE
4040
> and the gate fails if one appears.
4141
4242
- `INT|LONG|DOUBLE` — optional `min` / `max` (clamp range).
43+
- any kind with `array: true` — optional `minItems` / `maxItems`, the `validator.array`
44+
element-count bounds. A list longer than `maxItems` keeps its first `maxItems` elements under
45+
NORMAL/LOOSE tolerance (a `truncate` coercion, like a numeric clamp) and is `MALFORMED` under
46+
STRICT; a list shorter than `minItems` is `MALFORMED` under every tolerance. A scalar kind
47+
(`STRING`, `INT`, …) with `array: true` is a scalar array: each element is coerced to `kind`.
48+
49+
A schema may also carry a top-level `tolerance` (`STRICT|NORMAL|LOOSE`, default `NORMAL`);
50+
every runner passes it to the engine.
4351
- `ENUM` — `enumValues` (member symbols) plus the FR-011 coercion-pipeline keys:
4452
- `enumAlias` — `{ synonym: MEMBER }` (FR-010); keys matched under the field's mode.
4553
- `normalize` — `none | collapse | strip` (default `strip`). `strip` = ASCII case-fold +
@@ -70,3 +78,7 @@ line comment or a `/* */` block comment outside a string literal is skipped, inc
7078
that holds a brace or a quote (`json-comment-line`), so no field after it is lost; comment
7179
markers INSIDE a string literal are kept verbatim (`json-comment-markers-in-string`). The
7280
strict parser does not change: only the tolerant recovery reads past comments.
81+
82+
The three `json-array-*-items-*` cases pin `validator.array` bounds: too many elements truncated
83+
under NORMAL, too many rejected under STRICT, too few rejected (and listed in
84+
`malformedRequired`) under any tolerance.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{ "empty": false,
2+
"states": { "tags": "MALFORMED", "note": "EXTRACTED" },
3+
"malformedRequired": ["tags"],
4+
"data": { "tags[0]": "fantasy", "tags[1]": "epic", "tags[2]": "dragons", "tags[3]": "magic", "note": "four" } }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"tags": ["fantasy", "epic", "dragons", "magic"], "note": "four"}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{ "format": "JSON", "rootName": "suggestion", "tolerance": "STRICT", "fields": [
2+
{ "name": "tags", "kind": "STRING", "required": true, "array": true, "maxItems": 3 },
3+
{ "name": "note", "kind": "STRING", "required": false } ] }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{ "empty": false,
2+
"states": { "tags": "EXTRACTED", "note": "EXTRACTED" },
3+
"data": { "tags[0]": "fantasy", "tags[1]": "epic", "tags[2]": "dragons", "note": "five" } }
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Here are some tags:
2+
```json
3+
{"tags": ["fantasy", "epic", "dragons", "magic", "quest"], "note": "five"}
4+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{ "format": "JSON", "rootName": "suggestion", "fields": [
2+
{ "name": "tags", "kind": "STRING", "required": true, "array": true, "minItems": 3, "maxItems": 3 },
3+
{ "name": "note", "kind": "STRING", "required": false } ] }
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{ "empty": false,
2+
"states": { "tags": "MALFORMED", "note": "EXTRACTED" },
3+
"malformedRequired": ["tags"],
4+
"data": { "tags[0]": "fantasy", "tags[1]": "epic", "note": "two" } }

0 commit comments

Comments
 (0)