Skip to content

Commit 002bd49

Browse files
dmealingclaude
andcommitted
feat(api-contract): the filter/sort 400 envelope names the rejected field, in all five ports
`invalid_filter_field`, `invalid_filter_op`, `invalid_filter_value` and `invalid_sort` now each carry `{"error": "<code>", "field": "<name>"}`. TypeScript sent `field`; the other four ports did not; and nobody could tell. Every api-contract corpus assertion is a deep-SUBSET match, so TS's extra key passed a corpus the other four satisfied without it. That is the defect being fixed, not the divergence itself: an "optional" member is un-gateable — the corpus can neither require nor forbid it — so the drift was permanent and invisible rather than merely undecided. Ruled additive, because adding the key is PATCH-safe and names what was rejected, where deleting it from TS would be breaking and would lose a diagnostic. TypeScript needed fixing too, not just the other four: `sort.invalid_order` carried only `{expected}`, the `or`/`and` array branch carried `{key}`, and neither the ObjectManager-Fastify list mount nor either projection (read-only) mount put the field on the wire at all. Each non-TS port carries the field through its `FilterParseResult` / `error_envelope` and emits it from every route generator, including the `invalid_sort` sites that build the body inline rather than through the result type. Java's `err(...)` requires it non-null at construction, so a missing field fails in the parser's own tests instead of surfacing as a 500 out of `Map.of` in an adopter's process. Gated where it was un-gateable. The three existing scenarios now assert `field`, and a new `filter-invalid-value` scenario covers the fourth code, which no scenario reached before. It triggers on `?filter[bio][isNull]=maybe`: `isNull` is the one operator whose value every port coerces in its own parser (the scalar comparison ops pass the raw string through to the substrate), so all five exercise the same path rather than only the ports with a typed coercer. Corpus 51 -> 52. Also fixes, found while implementing: `@metaobjectsdev/runtime-ts`'s Hono mounts emitted the INTERNAL dotted code (`filter.unknown_field`) where the Fastify mounts emit the wire code (`invalid_filter_field`), contradicting the package's documented guarantee that the two flavors are byte-identical on the wire. Two tests pinned it. No corpus lane runs Hono, which is how it survived. And corrects `docs/features/api-contract.md` "Error response": its example code `FILTER_UNKNOWN_FIELD` exists in no port and never has. The four codes are now stated as a hard cross-port invariant with `field` required, and the doc records that a code which is not about a field carries none. Verified per port, both api-contract lanes (reference + generated artifact over HTTP): TS 60/60, C# 103/103, Java 60/60, Kotlin 60/60, Python 60/60, plus each port's codegen unit suite and regenerated Kotlin snapshots. `scripts/ci-local.sh --quick`: LOCAL CI PASSED. The showcase regen also swept up un-regenerated drift from the earlier constraint-error work: `examples/showcase/generated/` had never been refreshed after the 409 mapping landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015NrWUxA6euwvnFcwKxcK6A
1 parent 9747e73 commit 002bd49

40 files changed

Lines changed: 547 additions & 208 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,40 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
1515

1616
### Changed
1717

18+
- **The filter/sort 400 envelope now NAMES the rejected field, in every port — `field` is
19+
REQUIRED, not optional.** `invalid_filter_field`, `invalid_filter_op`,
20+
`invalid_filter_value` and `invalid_sort` each carry
21+
`{"error": "<code>", "field": "<name>"}`. Before this, TypeScript sent `field` and the
22+
other four ports did not, and nobody could tell: every api-contract corpus assertion is a
23+
deep-SUBSET match, so TS's extra key passed a corpus the other four satisfied without it.
24+
**That is the actual defect being fixed — an "optional" member is un-gateable.** The
25+
corpus can neither require nor forbid it, so the divergence was permanent and invisible
26+
rather than merely undecided. Ruled additive: adding the key is PATCH-safe and names what
27+
was rejected, where deleting it from TS would be breaking and would lose a diagnostic.
28+
29+
**Consumers gain a member and lose nothing** — no existing key changed and no status code
30+
moved. **Adopters with committed generated code should run `meta gen`** (`dotnet meta gen`
31+
/ `mvn metaobjects:generate` / `metaobjects gen`): C#, Java, Kotlin and Python all emit a
32+
changed list handler, and the JVM/C# `FilterParseResult` gains a `field` member (`err(...)`
33+
/ `Err(...)` now take it, so a hand-written caller of that runtime type needs one edit).
34+
TypeScript needs no regen — its routes delegate to `@metaobjectsdev/runtime-ts`.
35+
36+
Gated, not just shipped: `fixtures/api-contract-conformance/scenarios/` asserts `field` on
37+
all four codes, in both the reference and generated lanes of all five ports, and a new
38+
`filter-invalid-value` scenario covers the one code no scenario reached before (it uses
39+
`?filter[<f>][isNull]=maybe`, the one operator whose value every port coerces in its own
40+
parser). A code that is *not* about a field — filter nesting depth, in-list size — carries
41+
no `field` and stays outside the contract.
42+
43+
- **`@metaobjectsdev/runtime-ts`'s Hono mounts now emit the cross-port wire code, as the
44+
Fastify mounts always did.** They were sending the library's INTERNAL dotted code, so the
45+
same metadata answered `{"error":"filter.unknown_field"}` on Hono and
46+
`{"error":"invalid_filter_field"}` on Fastify — contradicting this package's own
47+
documented guarantee that the two flavors are byte-identical on the wire. Found while
48+
implementing the `field` change above. The projection (read-only) mounts on both flavors
49+
also now include the parser's details, so a view endpoint names the field too.
50+
51+
1852
- **BREAKING (metadata): a M:N junction that cannot be PAIRED is now a load error in every
1953
port — previously-loading metadata stops loading.** `@through` has always been documented
2054
as a junction declaring "two `identity.reference` children, **one per FK side**", but the

‎docs/CONFORMANCE.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
3434
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 33 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
3535
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 14 | ✓ | ✓ | ✓ | ✓ | ✓ |
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 |
37-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 51 (29 core + 9 tph + 9 m2m + 2 jsonb + 2 write-through) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |
37+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 52 (30 core + 9 tph + 9 m2m + 2 jsonb + 2 write-through) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) |
3838
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 16 cases | ✓ | ✓ | ✓ | ✓ | ✓ |
3939
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
4040
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -226,9 +226,9 @@ All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-d
226226
- `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section)
227227
- `queries/*` (27) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`)
228228

229-
### `fixtures/api-contract-conformance/` (51)
229+
### `fixtures/api-contract-conformance/` (52)
230230

231-
All 51 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
231+
All 52 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
232232
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
233233
routes answer identically over HTTP — list / get / create / patch+put / delete,
234234
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
@@ -238,7 +238,7 @@ status codes.
238238
The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`,
239239
`gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator
240240
and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the
241-
URL grammar `?filter[<field>][<op>]=<value>` (FR-009). On top of the 29 core
241+
URL grammar `?filter[<field>][<op>]=<value>` (FR-009). On top of the 30 core
242242
scenarios the corpus carries four sub-corpora — `tph/` (9, single-table
243243
inheritance), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
244244
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
@@ -329,7 +329,7 @@ Phase 1a is TypeScript + Python only; those three ports arrive in Phase 2.
329329
## Orphaned fixtures (tested but not yet documented)
330330

331331
The fixtures in the nine corpora mapped above (metamodel 330 + yaml 16 + verify 31
332-
+ render 15 + persistence 33 + api-contract 51 + source-resolution 25 + scope 10 +
332+
+ render 15 + persistence 33 + api-contract 52 + source-resolution 25 + scope 10 +
333333
dependency 23) each map to a feature doc. None are orphaned today. The remaining
334334
corpora in the totals table gate tooling contracts (registry manifests, provider
335335
composition, agent context, docs emit) rather than user-facing metamodel behaviour,

‎docs/features/api-contract.md‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,37 @@ Non-2xx responses MUST return:
289289
{ "error": "<short_code>", "message": "<optional human string>" }
290290
```
291291

292-
- HTTP 400 — validation / filter-parser errors (`{ "error": "validation", "issues": [...] }`
293-
in TS; `{ "error": "FILTER_UNKNOWN_FIELD", ... }` for filter
294-
errors).
292+
- HTTP 400 — validation and filter/sort-parser errors.
295293
- HTTP 404 — `{ "error": "not_found" }`.
294+
- HTTP 409 — a declared constraint conflicting with existing state (a uniqueness or referential violation); a constraint rejecting the request's own value stays a 400.
296295
- HTTP 5xx — implementation-defined.
297296

298-
The exact `error` code vocabulary is not yet a hard cross-port
299-
invariant; consumers should treat any 4xx as user-facing and any 5xx as
300-
retryable / log-only.
297+
#### Filter and sort errors name the field
298+
299+
These four codes are a hard cross-port invariant, and each one MUST carry
300+
**`field`** — the filter or sort field the request was rejected for:
301+
302+
| `error` | Raised when | Example |
303+
|---|---|---|
304+
| `invalid_filter_field` | the field is not on the entity's filter allowlist | `{ "error": "invalid_filter_field", "field": "unknown" }` |
305+
| `invalid_filter_op` | the operator is not valid for that field's subtype | `{ "error": "invalid_filter_op", "field": "name" }` |
306+
| `invalid_filter_value` | the value will not coerce for that field and operator | `{ "error": "invalid_filter_value", "field": "bio" }` |
307+
| `invalid_sort` | the sort field is off the sort allowlist, or the order is not `asc`/`desc` | `{ "error": "invalid_sort", "field": "unknownfield" }` |
308+
309+
`field` is REQUIRED, not optional. An optional member is un-gateable — the
310+
corpus can neither require nor forbid it — which is exactly how one port came
311+
to send it while four did not, invisibly, for several releases. A code that is
312+
*not* about a particular field (an implementation-specific guard such as filter
313+
nesting depth or in-list size) carries no `field`, and those guards are outside
314+
this contract: a port may emit them under its own code.
315+
316+
Responses may carry additional diagnostic members beyond `field` (TS adds `op`,
317+
`expected` and the allowlist); a consumer must tolerate them. Gated by
318+
`fixtures/api-contract-conformance/scenarios/filter-invalid-*.yaml` and
319+
`invalid-sort-400.yaml` in both the reference and generated lanes of every port.
320+
321+
Outside those four, treat any 4xx as user-facing and any 5xx as retryable /
322+
log-only.
301323

302324
## Per-port route codegen status
303325

‎examples/showcase/.metaobjects/.gen-state/.hashes.json‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
{
2-
"AppDbContext.g.cs": "314c6078030ba151687430b37e6b6ea936238bb5df02df238e4acce2f95a430d",
3-
"Subscriber.g.cs": "3ca160941378fbb8b6ad6acf080ff85573f463931e21b8756873a4476e5dd163",
4-
"SubscriberBlurbPayload.payload.cs": "2a02b73a14b39d3bd39b924ef017498c6ea4320b8143e893099562868a825044",
5-
"SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472",
6-
"SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174",
7-
"SubscriberRoutes.g.cs": "93896ab53e74872435b2527525b82564fdafd15e8033d7823c1675d6196fee3e",
2+
"generated/csharp/AppDbContext.g.cs": "314c6078030ba151687430b37e6b6ea936238bb5df02df238e4acce2f95a430d",
3+
"generated/csharp/Subscriber.g.cs": "3ca160941378fbb8b6ad6acf080ff85573f463931e21b8756873a4476e5dd163",
4+
"generated/csharp/SubscriberBlurbPayload.payload.cs": "2a02b73a14b39d3bd39b924ef017498c6ea4320b8143e893099562868a825044",
5+
"generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472",
6+
"generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174",
7+
"generated/csharp/SubscriberRoutes.g.cs": "41058cfbeb017e6b145d0288721d40e1d27e54b9cd398d82a13dbd62b39c3fb8",
88
"generated/python/Subscriber.py": "9dd4c0acea47f99d1daf79930f9de2e400f30780ebce6c63f023be528a72b762",
99
"generated/python/SubscriberBlurbPayload.py": "a5ef8a08e63efbe0f73e39a21a368cff5150c68cc8beeea6e8aab0c755f8bbdd",
1010
"generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5",
1111
"generated/python/subscriber_blurb_payload.py": "e0c87b4fa08e20879abe86e9e10b283ffc58b6978fefbd29a6b4e9c76bf23d27",
1212
"generated/python/subscriber_filter_allowlist.py": "b0414cc95ff3982c80a0e1a06839f090c0b423e41f5a94275df93426cea09df5",
1313
"generated/python/subscriber_names.py": "269637a02bf9b46f55658b58cb42f83196c03a877e1e2101337847fd406c1561",
14-
"generated/python/subscriber_router.py": "faf5ae6367f192d34bbab67198df2b2c7ccaae3b0f148935769419d39390ba30",
14+
"generated/python/subscriber_router.py": "7cf221a042731a1f1c939fbd30db10414787bacbd59ff6dbe759669b045e6737",
1515
"generated/ts/Subscriber.queries.ts": "2a142561bd9bcc3cfb88e6c306aafc638e982d48b0d07f71a79d810be16adf14",
1616
"generated/ts/Subscriber.routes.ts": "887b0aa76d32cf12fa1d16f0eae52fbc795ca8d8a29588b7089b6eb478f0cd93",
1717
"generated/ts/Subscriber.ts": "f19eeac95d012580f1b9023d807a8b9c893fb1a2ee983cf085fe958b65cbaba9",

‎examples/showcase/generated/csharp/SubscriberRoutes.g.cs‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public static IEndpointRouteBuilder MapSubscriberRoutes(this IEndpointRouteBuild
3434
// Filter: ?filter[<field>][<op>]=<value> against the per-entity allowlist.
3535
var filter = FilterParser.Parse(qs, SubscriberFilterAllowlist.Fields, SubscriberFilterAllowlist.OpsByField);
3636
if (filter.ErrorEnvelope is not null)
37-
return Results.BadRequest(new { error = filter.ErrorEnvelope });
37+
return Results.BadRequest(new { error = filter.ErrorEnvelope, field = filter.Field });
3838
q = EfCoreFilterDispatch.ApplyFilter(q, filter.Predicates);
3939

4040
// Sort: ?sort=field:asc|desc against the static allowlist.
@@ -43,7 +43,7 @@ public static IEndpointRouteBuilder MapSubscriberRoutes(this IEndpointRouteBuild
4343
var parts = sortRaw.ToString().Split(':', 2);
4444
var field = parts[0];
4545
if (!SortAllowlist.TryGetValue(field, out var resolved))
46-
return Results.BadRequest(new { error = "invalid_sort" });
46+
return Results.BadRequest(new { error = "invalid_sort", field });
4747
var desc = parts.Length > 1
4848
? string.Equals(parts[1], "desc", System.StringComparison.OrdinalIgnoreCase)
4949
: SortDefaultDesc.TryGetValue(resolved, out var dd) && dd;
@@ -94,7 +94,15 @@ await db.Subscribers.FindAsync(id) is { } found
9494
return Results.BadRequest(new { error = "validation" });
9595
input.CreatedAt = System.DateTimeOffset.UtcNow;
9696
db.Subscribers.Add(input);
97-
await db.SaveChangesAsync();
97+
try { await db.SaveChangesAsync(); }
98+
catch (System.Exception __saveError)
99+
{
100+
if (ConstraintErrors.Classify(__saveError) is { } __constraint)
101+
return Results.Json(
102+
new { error = __constraint.Error, constraint = __constraint.Constraint },
103+
statusCode: __constraint.Status);
104+
throw;
105+
}
98106
return Results.Created(prefix + "/subscribers", input);
99107
});
100108

@@ -136,7 +144,15 @@ async System.Threading.Tasks.Task<IResult> UpdateSubscriber(long id, HttpContext
136144
return Results.BadRequest(new { error = "validation" });
137145
entry.CurrentValues[target] = __val;
138146
}
139-
await db.SaveChangesAsync();
147+
try { await db.SaveChangesAsync(); }
148+
catch (System.Exception __saveError)
149+
{
150+
if (ConstraintErrors.Classify(__saveError) is { } __constraint)
151+
return Results.Json(
152+
new { error = __constraint.Error, constraint = __constraint.Constraint },
153+
statusCode: __constraint.Status);
154+
throw;
155+
}
140156
return Results.Ok(existing);
141157
}
142158
app.MapPatch(prefix + "/subscribers/{id}", UpdateSubscriber);
@@ -147,7 +163,15 @@ async System.Threading.Tasks.Task<IResult> UpdateSubscriber(long id, HttpContext
147163
var existing = await db.Subscribers.FindAsync(id);
148164
if (existing is null) return Results.NotFound(new { error = "not_found" });
149165
db.Subscribers.Remove(existing);
150-
await db.SaveChangesAsync();
166+
try { await db.SaveChangesAsync(); }
167+
catch (System.Exception __saveError)
168+
{
169+
if (ConstraintErrors.Classify(__saveError) is { } __constraint)
170+
return Results.Json(
171+
new { error = __constraint.Error, constraint = __constraint.Constraint },
172+
statusCode: __constraint.Status);
173+
throw;
174+
}
151175
return Results.NoContent();
152176
});
153177

‎examples/showcase/generated/python/subscriber_router.py‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
from fastapi import APIRouter, Depends, Query, Request, status
1212
from fastapi.responses import JSONResponse
13+
from metaobjects.codegen.runtime.constraint_errors import (
14+
classify_constraint_error,
15+
)
1316
from metaobjects.codegen.runtime.filter_parser import (
1417
FilterPredicate,
1518
parse_filter,
@@ -109,7 +112,10 @@ def list_subscribers(
109112
if sort is not None:
110113
sort_clause = _parse_sort(sort)
111114
if sort_clause is None:
112-
return JSONResponse(status_code=400, content={"error": "invalid_sort"})
115+
return JSONResponse(
116+
status_code=400,
117+
content={"error": "invalid_sort", "field": sort.split(":", 1)[0]},
118+
)
113119
filter_result = parse_filter(
114120
request.query_params, SUBSCRIBER_FILTER_FIELDS, SUBSCRIBER_FILTER_OPS_BY_FIELD
115121
)
@@ -148,7 +154,13 @@ def create_subscriber(
148154
# #203/ADR-0045: stamp @autoSet columns (server-owned; caller ignored).
149155
_asnow = _dt.datetime.now(_dt.timezone.utc)
150156
dto["createdAt"] = _asnow
151-
return repo.create(dto)
157+
try:
158+
return repo.create(dto)
159+
except Exception as exc:
160+
failure = classify_constraint_error(exc)
161+
if failure is None:
162+
raise
163+
return JSONResponse(status_code=failure.status, content=failure.body())
152164

153165

154166
@router.patch("/{subscriber_id}")
@@ -171,7 +183,13 @@ def update_subscriber(
171183
except ValidationError:
172184
return JSONResponse(status_code=400, content={"error": "validation"})
173185
dto.pop("createdAt", None) # onCreate @autoSet is write-once (server-owned)
174-
saved = repo.update(subscriber_id, dto)
186+
try:
187+
saved = repo.update(subscriber_id, dto)
188+
except Exception as exc:
189+
failure = classify_constraint_error(exc)
190+
if failure is None:
191+
raise
192+
return JSONResponse(status_code=failure.status, content=failure.body())
175193
if saved is None:
176194
return JSONResponse(status_code=404, content={"error": "not_found"})
177195
return saved
@@ -182,5 +200,12 @@ def delete_subscriber(
182200
subscriber_id: int,
183201
repo: Annotated[SubscriberRepository, Depends(get_repository)],
184202
) -> None:
185-
if not repo.delete(subscriber_id):
203+
try:
204+
deleted = repo.delete(subscriber_id)
205+
except Exception as exc:
206+
failure = classify_constraint_error(exc)
207+
if failure is None:
208+
raise
209+
return JSONResponse(status_code=failure.status, content=failure.body())
210+
if not deleted:
186211
return JSONResponse(status_code=404, content={"error": "not_found"})

0 commit comments

Comments
 (0)