Skip to content

Commit fc1e765

Browse files
committed
feat(objectql): ValidationError publishes its error code as a constant
The last row of #16159's eleven-row census. `readonly code = 'VALIDATION_FAILED'` becomes `readonly code = VALIDATION_FAILED_CODE`, with the exported constant holding text byte-identical to the literal it replaces and re-exported from the `index.ts` barrel beside the class. The docs already teach `catch it by code, not instanceof` — following that for record validation meant re-spelling the wire string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can drift from what the engine throws with no compile error. ⛔ Does NOT converge `VALIDATION_FAILED` with the sibling `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`; #16159 leaves that question unruled and a pin test asserts the two are still two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent ac76425 commit fc1e765

4 files changed

Lines changed: 219 additions & 2 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
`ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census.
6+
7+
`content/docs/kernel/contracts/data-engine.mdx` teaches the convention: catch an engine refusal *by `code`, not `instanceof`*. Following it for record validation meant re-spelling `'VALIDATION_FAILED'` in your own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so.
8+
9+
One new export from `@objectstack/objectql`:
10+
11+
- `VALIDATION_FAILED_CODE``ValidationError`'s ADR-0112 `code`. Thrown by `validateRecord` when an insert/update payload violates the object's own field metadata, carrying the per-field breakdown on `fields[]`. **Additive widening, `minor`.**
12+
13+
**This row's consumer-side drift is the widest on the card, and worth stating precisely rather than as a slogan.** `'VALIDATION_FAILED'` is re-authored as an inline literal at **148 non-test sites across 33 files** in this repo — but the honest reading of that number is that the large majority are **independent producers** minting their own house-code envelope (`@objectstack/rest`'s response bodies, `plugin-approvals`' `VALIDATION_FAILED: …` message-prefix convention, `plugin-sharing`'s locally-declared `SharingCriteriaValidationError`, `@objectstack/metadata-protocol`'s own class whose docblock calls the code *"this package's own house code"*). Those are not consumers of this class and nothing about them changes.
14+
15+
The sites this export actually serves are the **recognizers**, and there are four: `packages/types/src/validation-failure.ts` and `packages/rest/src/error-response.ts` both test `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, `packages/rest/src/error-response.ts` tests the wire body's `code` a second time, and `packages/plugins/plugin-auth/src/objectql-adapter.ts` does the same to map an engine refusal onto a `better-auth` `APIError`. Each holds its own copy of the string. **No consumer is rewired here** — the card's scope is the producer-side importable constant, and re-pointing another package's recognizer is a cross-package coupling this card never asked for.
16+
17+
**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of `ValidationError` gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives that boundary.
18+
19+
**Nothing about the wire changed.** The constant holds text byte-identical to the literal it replaces; the refusal throws the same `code` and the same message as before. Consumers that spell the string themselves keep working unchanged — this adds an affordance and removes nothing.
20+
21+
**It does not converge `VALIDATION_FAILED` with `VALIDATION_ERROR`.** `EMPTY_CREDENTIAL_REFUSAL_CODE` in the same package is `'VALIDATION_ERROR'`; #16159 explicitly leaves *"whether they should converge"* unruled, and publishing the current spelling keeps that decision exactly as open as it was — a convergence is a breaking rename of a registered wire code either way. A pin test asserts the two are still two, so a future ruling has to argue for itself rather than arrive as a side effect.
22+
23+
**`ValidationError` was already exported and stays exported.** The constant joins it on the batteries barrel only, matching every existing `*_CODE` in this package; the class is *also* on the lean `./core` entry, so this adds one more instance to the asymmetry #16260 owns — deliberately not decided here.

packages/objectql/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,19 @@ export type { RelatedTitleTarget } from './record-title.js';
354354
export { evaluateFormulaField } from './engine.js';
355355

356356
// Export Validation
357-
export { ValidationError, validateRecord } from './validation/record-validator.js';
357+
// [#16159] `VALIDATION_FAILED_CODE` joins the class it belongs to: this package
358+
// declares BOTH realms in its own `exports` (`import` -> dist/index.mjs,
359+
// `require` -> dist/index.js), so a consumer holding the other realm's copy of
360+
// `ValidationError` gets `instanceof` === false, silently (#14936). The sound
361+
// route is a `code` compare, and until now that meant re-spelling the wire
362+
// string in the consumer's own package -- which acquires a
363+
// `check:error-code-provenance` stamp site there and is then free to drift from
364+
// what this engine throws with no compile error to say so. The class stays
365+
// exported exactly as it was; this adds an affordance and removes nothing.
366+
// [#16260] The constant is batteries-only while `ValidationError` is ALSO on the
367+
// lean `./core` entry, matching every existing `*_CODE` in this package; that
368+
// asymmetry is #16260's question, deliberately not decided here.
369+
export { ValidationError, validateRecord, VALIDATION_FAILED_CODE } from './validation/record-validator.js';
358370
export type { FieldValidationError } from './validation/record-validator.js';
359371
// [ADR-0104 / #4769] The counterexample a boot produces by ADMITTING an
360372
// off-shape value. Exported because the fresh-datastore attestation
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 — the LAST row of the card's eleven-row census: `ValidationError`
5+
* publishes its ADR-0112 `code` as an importable constant.
6+
*
7+
* ## What this pins, and why each assertion is here
8+
*
9+
* `@objectstack/objectql` declares BOTH realms in its own `exports` (`import`
10+
* to `dist/index.mjs`, `require` to `dist/index.js`), so a consumer holding the
11+
* other realm's copy of this class gets `instanceof` === false — measured on
12+
* #14936, and silent. The sound route is a `code` compare, and until this
13+
* change the only way to write one was to RE-SPELL the wire string in the
14+
* consumer's own package: that acquires a `check:error-code-provenance` stamp
15+
* site there and can then drift from what this engine throws with no compile
16+
* error to say so. Two in-repo recognizers do exactly that today
17+
* (`packages/types/src/validation-failure.ts` and
18+
* `packages/rest/src/error-response.ts`), each holding its own copy of the
19+
* string.
20+
*
21+
* ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status`
22+
* minimum reduces here to `code` plus the field that discriminates the refusal
23+
* (`fields[]`). ⛔ Inventing a `status` on the class to satisfy a habit would be
24+
* new published surface, and that is not what this card converts.
25+
*
26+
* Six facts, each its own case so a failure reads as the specific regression:
27+
*
28+
* 1. the constant holds the exact wire string, spelled LITERALLY here on
29+
* purpose. The test layer sits outside `check:error-code-provenance`'s
30+
* scanned population, so pinning it costs no stamp site while making a
31+
* silent rename of a published code impossible to pass off as "still the
32+
* same code". ⛔ This is the byte-identity fence — the conversion moves
33+
* where a spelling lives, never what it says. ⛔ Do not "simplify" it into
34+
* a constant compare: a pin that reads the constant cannot catch the
35+
* constant being wrong, and every OTHER case in this file compares against
36+
* the constant, so this is the only case that can.
37+
* 2. the constant IS the code a real refusal carries, asserted with `name`
38+
* and with `fields[]` — the field the class exists to report. ⛔ Never a
39+
* bare `toThrow()`: a throw-shaped assertion stays green when a DIFFERENT
40+
* refusal fires one step later, which is exactly the confusion `code` is
41+
* meant to end.
42+
* 3. it is reachable from the package BARREL, which is the whole affordance
43+
* this card buys — a constant a consumer cannot import is not an answer to
44+
* "identify it by `code`" — and it is what a future barrel edit would lose
45+
* silently.
46+
* 4. the barrel's constant and the barrel's already-exported class name the
47+
* same refusal. Both routes are published, so a consumer can hold either
48+
* and they must agree.
49+
* 5. a `code` compare matches a foreign-realm copy of the refusal where
50+
* `instanceof` returns false. THE CONTROL, and the reason the convention
51+
* exists (#14936). Without this case the others would pass just as happily
52+
* against an `instanceof`-based recommendation — the thing this card
53+
* replaces.
54+
* 6. ⛔ the constant is NOT the sibling validation spelling. `secret-fields.ts`
55+
* publishes `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`, and the
56+
* card explicitly leaves "whether they should converge" unruled. This case
57+
* pins that this conversion did NOT quietly converge them: the two remain
58+
* two, and a future ruling that merges them will fail HERE first, which is
59+
* where a rename of a registered wire code should be forced to argue for
60+
* itself rather than arriving as a side effect.
61+
*/
62+
63+
import { describe, it, expect } from 'vitest';
64+
import { ValidationError, VALIDATION_FAILED_CODE } from './validation/record-validator.js';
65+
import { EMPTY_CREDENTIAL_REFUSAL_CODE } from './secret-fields.js';
66+
import * as barrel from './index.js';
67+
68+
describe('#16159 ValidationError publishes its code as a constant', () => {
69+
it('the constant holds the exact wire string it replaced', () => {
70+
expect(VALIDATION_FAILED_CODE).toBe('VALIDATION_FAILED');
71+
});
72+
73+
it('the constant IS the code a real record-validation refusal carries', () => {
74+
const err = new ValidationError([
75+
{ field: 'amount', code: 'required', message: 'Amount is required' },
76+
]);
77+
78+
expect(err.code).toBe(VALIDATION_FAILED_CODE);
79+
expect(err.name).toBe('ValidationError');
80+
// `fields[]` is what a form acts on — the per-field breakdown this refusal
81+
// exists to carry, and the half a caller reads after branching on `code`.
82+
expect(err.fields).toEqual([
83+
{ field: 'amount', code: 'required', message: 'Amount is required' },
84+
]);
85+
// The top-level message carries the HUMAN text, which is what generic UI
86+
// surfaces display verbatim.
87+
expect(err.message).toBe('Amount is required');
88+
});
89+
90+
it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
91+
// Identity, not equality: a barrel that re-declared the string instead of
92+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
93+
// re-introduced exactly the second spelling this card exists to remove.
94+
expect(barrel.VALIDATION_FAILED_CODE).toBe(VALIDATION_FAILED_CODE);
95+
});
96+
97+
it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
98+
const err = new barrel.ValidationError([
99+
{ field: 'email', code: 'invalid_format', message: 'Not an email' },
100+
]);
101+
expect(err.code).toBe(barrel.VALIDATION_FAILED_CODE);
102+
});
103+
104+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
105+
// What a consumer holding the other realm's copy of this module actually
106+
// has: a structurally identical refusal from a DIFFERENT class object.
107+
class ValidationErrorOtherRealmCopy extends Error {
108+
readonly code = 'VALIDATION_FAILED';
109+
}
110+
const fromOtherRealm = new ValidationErrorOtherRealmCopy();
111+
112+
// THE CONTROL. Without this line the assertion below would pass against an
113+
// `instanceof` recommendation too, i.e. against the defect the convention
114+
// exists to avoid.
115+
expect(fromOtherRealm instanceof ValidationError).toBe(false);
116+
expect(fromOtherRealm.code).toBe(VALIDATION_FAILED_CODE);
117+
});
118+
119+
it('⛔ it did NOT converge with the sibling `VALIDATION_ERROR` spelling — that question stays open', () => {
120+
// The card fences this off in its own words: EMPTY_CREDENTIAL_REFUSAL_CODE
121+
// is already 'VALIDATION_ERROR' while this site uses 'VALIDATION_FAILED',
122+
// "and whether they should converge is a question this card does not
123+
// answer". Publishing the current spelling must not decide it by side
124+
// effect, so the divergence is pinned rather than left to be noticed.
125+
expect(EMPTY_CREDENTIAL_REFUSAL_CODE).toBe('VALIDATION_ERROR');
126+
expect(VALIDATION_FAILED_CODE).not.toBe(EMPTY_CREDENTIAL_REFUSAL_CODE);
127+
});
128+
});

packages/objectql/src/validation/record-validator.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,62 @@ export interface FieldValidationError {
132132
options?: string[];
133133
}
134134

135+
/**
136+
* [#16159] The ADR-0112 `code` {@link ValidationError} carries, as a constant a
137+
* consumer can import instead of re-spelling.
138+
*
139+
* This is the LAST row of #16159's eleven-row census, and the one whose
140+
* consumer-side re-spelling is measurably the widest: `'VALIDATION_FAILED'` is
141+
* re-authored as an inline literal at 148 non-test sites in 33 files across
142+
* this repo. Most of those are INDEPENDENT PRODUCERS minting their own
143+
* house-code envelope, not consumers of this class — but the recognizers that
144+
* genuinely catch THIS error had, until now, no importable spelling to compare
145+
* against: `packages/types/src/validation-failure.ts` and
146+
* `packages/rest/src/error-response.ts` both test
147+
* `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, each holding
148+
* its own copy of the string, each free to drift from what this engine throws
149+
* with no compile error to say so.
150+
*
151+
* ⛔ The string is byte-identical to the literal it replaces. This moves where a
152+
* spelling lives, never what it says.
153+
*
154+
* ⛔⛔ It also does NOT answer the question the card fenced off: `secret-fields.ts`
155+
* spells its refusal `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'` while
156+
* this one is `'VALIDATION_FAILED'`, and *"whether they should converge is a
157+
* question this card does not answer"*. Publishing the current spelling leaves
158+
* that decision exactly as open as it was: converging them was a breaking
159+
* rename of a registered wire code before this constant existed and still is
160+
* after, and a rename would move this constant's VALUE, not its existence.
161+
*
162+
* ⚠️ `VALIDATION_FAILED` IS registered in `ERROR_CODE_LEDGER` under
163+
* `@objectstack/objectql` (`packages/spec/src/api/error-code-ledger.zod.ts`),
164+
* so this declaration is a `constdef` stamp site `check:error-code-provenance`
165+
* DOES see — that gate skips unregistered codes — and it is listed under this
166+
* package's own owner key, which is what makes the gate accept it. Equally, no
167+
* row moves in `packages/runtime/src/dispatcher-error-vocabulary.ts`: that
168+
* table records UNREGISTERED code sites, so a registered code is invisible to
169+
* it by construction. The two gates are exactly inverted — measured on this
170+
* branch, not assumed.
171+
*
172+
* The `_CODE` NAME and the bare `readonly code = VALIDATION_FAILED_CODE;`
173+
* spelling are load-bearing rather than cosmetic: the first is the shape
174+
* `check:error-code-provenance`'s `constdef` pattern can see, the second is the
175+
* shape `check:dispatcher-error-vocabulary` classifies as `classconst` — its
176+
* pattern requires the constant name to be followed by `;`, `,` or a newline,
177+
* so an `as const` suffix on the FIELD takes the site out of it. ⛔ Never rename
178+
* out of either shape to quiet a gate.
179+
*
180+
* ⚠️ Re-exported from the `index.ts` barrel beside the class, and ⛔ NOT from the
181+
* lean `./core` entry — matching every existing `*_CODE` in this package.
182+
* {@link ValidationError} itself IS on `./core`, so this row adds one more
183+
* instance to the asymmetry #16260 owns; ⛔ deciding that question for one
184+
* member of the family inside a mechanical sweep is the thing this card's
185+
* slicing exists to prevent.
186+
*/
187+
export const VALIDATION_FAILED_CODE = 'VALIDATION_FAILED' as const;
188+
135189
export class ValidationError extends Error {
136-
readonly code = 'VALIDATION_FAILED';
190+
readonly code = VALIDATION_FAILED_CODE;
137191
readonly fields: FieldValidationError[];
138192
constructor(fields: FieldValidationError[]) {
139193
// The top-level message is what generic UI surfaces (toasts, CLI output)

0 commit comments

Comments
 (0)