Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/validation-failed-code-constant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/objectql": minor
---

`ValidationError` publishes its error `code` as an importable constant — the last row of #16159's census.

`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.

One new export from `@objectstack/objectql`:

- `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`.**

**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.

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.

**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.

**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.

**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.

**`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.
14 changes: 13 additions & 1 deletion packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,19 @@ export type { RelatedTitleTarget } from './record-title.js';
export { evaluateFormulaField } from './engine.js';

// Export Validation
export { ValidationError, validateRecord } from './validation/record-validator.js';
// [#16159] `VALIDATION_FAILED_CODE` joins the class it belongs to: this package
// declares BOTH realms in its own `exports` (`import` -> dist/index.mjs,
// `require` -> dist/index.js), so a consumer holding the other realm's copy of
// `ValidationError` gets `instanceof` === false, silently (#14936). The sound
// route is a `code` compare, and until now that meant re-spelling the wire
// string in the consumer's own package -- which acquires a
// `check:error-code-provenance` stamp site there and is then free to drift from
// what this engine throws with no compile error to say so. The class stays
// exported exactly as it was; this adds an affordance and removes nothing.
// [#16260] The constant is batteries-only while `ValidationError` is ALSO on the
// lean `./core` entry, matching every existing `*_CODE` in this package; that
// asymmetry is #16260's question, deliberately not decided here.
export { ValidationError, validateRecord, VALIDATION_FAILED_CODE } from './validation/record-validator.js';
export type { FieldValidationError } from './validation/record-validator.js';
// [ADR-0104 / #4769] The counterexample a boot produces by ADMITTING an
// off-shape value. Exported because the fresh-datastore attestation
Expand Down
128 changes: 128 additions & 0 deletions packages/objectql/src/validation-failed-code-constant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #16159 — the LAST row of the card's eleven-row census: `ValidationError`
* publishes its ADR-0112 `code` as an importable constant.
*
* ## What this pins, and why each assertion is here
*
* `@objectstack/objectql` declares BOTH realms in its own `exports` (`import`
* to `dist/index.mjs`, `require` to `dist/index.js`), so a consumer holding the
* other realm's copy of this class gets `instanceof` === false — measured on
* #14936, and silent. The sound route is a `code` compare, and until this
* change the only way to write one was to RE-SPELL the wire string in the
* consumer's own package: that acquires a `check:error-code-provenance` stamp
* site there and can then drift from what this engine throws with no compile
* error to say so. Two in-repo recognizers do exactly that today
* (`packages/types/src/validation-failure.ts` and
* `packages/rest/src/error-response.ts`), each holding its own copy of the
* string.
*
* ⚠️ This refusal carries NO `status` field, so ADR-0112's `code` + `status`
* minimum reduces here to `code` plus the field that discriminates the refusal
* (`fields[]`). ⛔ Inventing a `status` on the class to satisfy a habit would be
* new published surface, and that is not what this card converts.
*
* Six facts, each its own case so a failure reads as the specific regression:
*
* 1. the constant holds the exact wire string, spelled LITERALLY here on
* purpose. The test layer sits outside `check:error-code-provenance`'s
* scanned population, so pinning it costs no stamp site while making a
* silent rename of a published code impossible to pass off as "still the
* same code". ⛔ This is the byte-identity fence — the conversion moves
* where a spelling lives, never what it says. ⛔ Do not "simplify" it into
* a constant compare: a pin that reads the constant cannot catch the
* constant being wrong, and every OTHER case in this file compares against
* the constant, so this is the only case that can.
* 2. the constant IS the code a real refusal carries, asserted with `name`
* and with `fields[]` — the field the class exists to report. ⛔ Never a
* bare `toThrow()`: a throw-shaped assertion stays green when a DIFFERENT
* refusal fires one step later, which is exactly the confusion `code` is
* meant to end.
* 3. it is reachable from the package BARREL, which is the whole affordance
* this card buys — a constant a consumer cannot import is not an answer to
* "identify it by `code`" — and it is what a future barrel edit would lose
* silently.
* 4. the barrel's constant and the barrel's already-exported class name the
* same refusal. Both routes are published, so a consumer can hold either
* and they must agree.
* 5. a `code` compare matches a foreign-realm copy of the refusal where
* `instanceof` returns false. THE CONTROL, and the reason the convention
* exists (#14936). Without this case the others would pass just as happily
* against an `instanceof`-based recommendation — the thing this card
* replaces.
* 6. ⛔ the constant is NOT the sibling validation spelling. `secret-fields.ts`
* publishes `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`, and the
* card explicitly leaves "whether they should converge" unruled. This case
* pins that this conversion did NOT quietly converge them: the two remain
* two, and a future ruling that merges them will fail HERE first, which is
* where a rename of a registered wire code should be forced to argue for
* itself rather than arriving as a side effect.
*/

import { describe, it, expect } from 'vitest';
import { ValidationError, VALIDATION_FAILED_CODE } from './validation/record-validator.js';
import { EMPTY_CREDENTIAL_REFUSAL_CODE } from './secret-fields.js';
import * as barrel from './index.js';

describe('#16159 ValidationError publishes its code as a constant', () => {
it('the constant holds the exact wire string it replaced', () => {
expect(VALIDATION_FAILED_CODE).toBe('VALIDATION_FAILED');
});

it('the constant IS the code a real record-validation refusal carries', () => {
const err = new ValidationError([
{ field: 'amount', code: 'required', message: 'Amount is required' },
]);

expect(err.code).toBe(VALIDATION_FAILED_CODE);
expect(err.name).toBe('ValidationError');
// `fields[]` is what a form acts on — the per-field breakdown this refusal
// exists to carry, and the half a caller reads after branching on `code`.
expect(err.fields).toEqual([
{ field: 'amount', code: 'required', message: 'Amount is required' },
]);
// The top-level message carries the HUMAN text, which is what generic UI
// surfaces display verbatim.
expect(err.message).toBe('Amount is required');
});

it('it is re-exported from the package barrel, which is where a consumer reaches it', () => {
// Identity, not equality: a barrel that re-declared the string instead of
// re-exporting the constant would satisfy `toBe` on the VALUE while having
// re-introduced exactly the second spelling this card exists to remove.
expect(barrel.VALIDATION_FAILED_CODE).toBe(VALIDATION_FAILED_CODE);
});

it("the barrel's constant and the barrel's already-exported class name the same refusal", () => {
const err = new barrel.ValidationError([
{ field: 'email', code: 'invalid_format', message: 'Not an email' },
]);
expect(err.code).toBe(barrel.VALIDATION_FAILED_CODE);
});

it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
// What a consumer holding the other realm's copy of this module actually
// has: a structurally identical refusal from a DIFFERENT class object.
class ValidationErrorOtherRealmCopy extends Error {
readonly code = 'VALIDATION_FAILED';
}
const fromOtherRealm = new ValidationErrorOtherRealmCopy();

// THE CONTROL. Without this line the assertion below would pass against an
// `instanceof` recommendation too, i.e. against the defect the convention
// exists to avoid.
expect(fromOtherRealm instanceof ValidationError).toBe(false);
expect(fromOtherRealm.code).toBe(VALIDATION_FAILED_CODE);
});

it('⛔ it did NOT converge with the sibling `VALIDATION_ERROR` spelling — that question stays open', () => {
// The card fences this off in its own words: EMPTY_CREDENTIAL_REFUSAL_CODE
// is already 'VALIDATION_ERROR' while this site uses 'VALIDATION_FAILED',
// "and whether they should converge is a question this card does not
// answer". Publishing the current spelling must not decide it by side
// effect, so the divergence is pinned rather than left to be noticed.
expect(EMPTY_CREDENTIAL_REFUSAL_CODE).toBe('VALIDATION_ERROR');
expect(VALIDATION_FAILED_CODE).not.toBe(EMPTY_CREDENTIAL_REFUSAL_CODE);
});
});
56 changes: 55 additions & 1 deletion packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,62 @@ export interface FieldValidationError {
options?: string[];
}

/**
* [#16159] The ADR-0112 `code` {@link ValidationError} carries, as a constant a
* consumer can import instead of re-spelling.
*
* This is the LAST row of #16159's eleven-row census, and the one whose
* consumer-side re-spelling is measurably the widest: `'VALIDATION_FAILED'` is
* re-authored as an inline literal at 148 non-test sites in 33 files across
* this repo. Most of those are INDEPENDENT PRODUCERS minting their own
* house-code envelope, not consumers of this class — but the recognizers that
* genuinely catch THIS error had, until now, no importable spelling to compare
* against: `packages/types/src/validation-failure.ts` and
* `packages/rest/src/error-response.ts` both test
* `code === 'VALIDATION_FAILED' || name === 'ValidationError'`, each holding
* its own copy of the string, each free to drift from what this engine throws
* with no compile error to say so.
*
* ⛔ The string is byte-identical to the literal it replaces. This moves where a
* spelling lives, never what it says.
*
* ⛔⛔ It also does NOT answer the question the card fenced off: `secret-fields.ts`
* spells its refusal `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'` while
* this one is `'VALIDATION_FAILED'`, and *"whether they should converge is a
* question this card does not answer"*. Publishing the current spelling leaves
* that decision exactly as open as it was: converging them was a breaking
* rename of a registered wire code before this constant existed and still is
* after, and a rename would move this constant's VALUE, not its existence.
*
* ⚠️ `VALIDATION_FAILED` IS registered in `ERROR_CODE_LEDGER` under
* `@objectstack/objectql` (`packages/spec/src/api/error-code-ledger.zod.ts`),
* so this declaration is a `constdef` stamp site `check:error-code-provenance`
* DOES see — that gate skips unregistered codes — and it is listed under this
* package's own owner key, which is what makes the gate accept it. Equally, no
* row moves in `packages/runtime/src/dispatcher-error-vocabulary.ts`: that
* table records UNREGISTERED code sites, so a registered code is invisible to
* it by construction. The two gates are exactly inverted — measured on this
* branch, not assumed.
*
* The `_CODE` NAME and the bare `readonly code = VALIDATION_FAILED_CODE;`
* spelling are load-bearing rather than cosmetic: the first is the shape
* `check:error-code-provenance`'s `constdef` pattern can see, the second is the
* shape `check:dispatcher-error-vocabulary` classifies as `classconst` — its
* pattern requires the constant name to be followed by `;`, `,` or a newline,
* so an `as const` suffix on the FIELD takes the site out of it. ⛔ Never rename
* out of either shape to quiet a gate.
*
* ⚠️ Re-exported from the `index.ts` barrel beside the class, and ⛔ NOT from the
* lean `./core` entry — matching every existing `*_CODE` in this package.
* {@link ValidationError} itself IS on `./core`, so this row adds one more
* instance to the asymmetry #16260 owns; ⛔ deciding that question for one
* member of the family inside a mechanical sweep is the thing this card's
* slicing exists to prevent.
*/
export const VALIDATION_FAILED_CODE = 'VALIDATION_FAILED' as const;

export class ValidationError extends Error {
readonly code = 'VALIDATION_FAILED';
readonly code = VALIDATION_FAILED_CODE;
readonly fields: FieldValidationError[];
constructor(fields: FieldValidationError[]) {
// The top-level message is what generic UI surfaces (toasts, CLI output)
Expand Down
Loading