You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 002bd49
Browse filesBrowse the repository at this point in the historyBrowse files
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
in TS; `{"error": "FILTER_UNKNOWN_FIELD", ...}` for filter
294
-
errors).
292
+
- HTTP 400 — validation and filter/sort-parser errors.
295
293
- 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.
296
295
- HTTP 5xx — implementation-defined.
297
296
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 /
0 commit comments