@ultimat3/schema 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,90 @@
1
+ # @ultimat3/schema — agent notes
2
+
3
+ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
4
+
5
+ | Rule | |
6
+ |---|---|
7
+ | Deps | none (`bun-types` only) |
8
+ | Errors | `SchemaError` mirrors `UltimateError` field-for-field; keep `Symbol.for('ultimate.error')` |
9
+ | New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
10
+ | IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
11
+ | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
12
+ | Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
13
+ | Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
14
+ | Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |
15
+
16
+ Module order (no cycles):
17
+ `describe-value → node → builder → money-value → validators → discriminated-union → provider → t`.
18
+ `standard.ts` and `errors.ts` depend on nothing but each other.
19
+
20
+ **An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
21
+ which is returned to the caller AND interpolated into the log line — and core's logger redacts by
22
+ KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
23
+ did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw
24
+ it. Every rejected value goes through `describeValue`, which reports length and type and nothing
25
+ else. No dev flag re-enables the echo — one misconfigured environment is the same breach, and a dev
26
+ overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
27
+
28
+ `X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
29
+ is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
30
+ first already owns, can never be routed to — so it is wrong for every input, and the first import
31
+ of the authoring file is the earliest honest place to say so.
32
+
33
+ `SCHEMA_ERROR_CODES` in `errors.ts` is data, not a `registerErrorCodes()` call — this package is
34
+ tier 0 and cannot import `@ultimat3/core` to reach it. `@ultimat3/core`'s `schema-error-codes.ts`
35
+ carries a duplicate of these titles and registers them unconditionally, so every process gets the
36
+ real titles just by importing core. Add a code here **and** update that duplicate in the same
37
+ change — `schema-error-codes-pin.test.ts` in `@ultimat3/cli` fails the build if they disagree.
38
+
39
+ `MoneyValue` in `money-value.ts` — its own file, because it is the only builtin whose *shape* other
40
+ packages alias — is the framework's **one** declaration of a money value. Tier 0 is
41
+ the only tier every package may import, and `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s
42
+ `MoneyValue` are aliases of it. Never let either restate the shape: it was three structural copies,
43
+ entity's had a `bigint` `minor`, and a row that layer decoded then failed both `t.money` and
44
+ `JSON.stringify`. `minor` stays a `number` for the same reason it is a `number` here — this node is
45
+ the OpenAPI contract, and money crosses every wire the framework projects.
46
+
47
+ `MoneyValue.scale` is the **optional** decimal exponent `minor` counts in, `0…MAX_MONEY_SCALE`
48
+ (15, the last power of ten that is itself a safe integer). Absent means the currency's own minor
49
+ unit, which is every value that predates it — so `{ minor, currency }` parses to exactly
50
+ `{ minor, currency }`, key for key, and the validator adds nothing. What a legal scale is lives in
51
+ `isMoneyScale` here and nowhere else; `@ultimat3/money` imports it rather than restating the
52
+ bound. Adding it to the type means adding it in three more places in the same change — the node's
53
+ `properties`, `json-schema.ts` (optional, never `required`, or a generated client refuses a value
54
+ this validator accepts) and `coerce.ts` (a query string carries it as text like everything else).
55
+
56
+ `MoneyValue.currency` is bounded by `isCurrencyCode`, `isMoneyScale`'s twin, over
57
+ `CURRENCY_CODE_PATTERN` — the pattern **source**, exported because the two projections that cannot
58
+ call a predicate need the string: `json-schema.ts`'s published `pattern` and `@ultimat3/entity`'s
59
+ Postgres `~` CHECK (`currencyCheck`). It was four copies of `^[A-Z]{3}$` across three packages,
60
+ each individually correct, and only a psql session would have seen them diverge. Keep the pattern
61
+ inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, a literal
62
+ class, a bounded repetition. `@ultimat3/entity`'s `currency-check.live.test.ts` is what proves a
63
+ real server still reads it the way the predicate does; a `\d` or a lookahead is where that stops.
64
+
65
+ `t` delegates through `schemaProvider()` on every property access — that is what makes
66
+ `configureSchemaProvider()` work for modules that already imported `t`. Do not cache members.
67
+
68
+ ```bash
69
+ bun test # from packages/schema
70
+ bun run typecheck
71
+ ```
72
+
73
+ Gotchas:
74
+ - `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and
75
+ present on output. Object key optionality is derived from that — don't hand-roll it.
76
+ - `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
77
+ - Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
78
+ - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
79
+ - **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
80
+ has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
81
+ value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
82
+ a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
83
+ reason: a refined string still reads as a string, and a discriminated union still reads as a
84
+ union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
85
+ - A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
86
+ predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
87
+ verbatim on both — so it states the rule and never interpolates a value.
88
+ - `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
89
+ schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
90
+ there; fixing it is that package's change, not this one's.
package/README.md CHANGED
@@ -9,7 +9,9 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
9
9
  | the blessed `t` namespace | `t.ts` |
10
10
  | builtin validators behind `t` | `validators.ts` |
11
11
  | the introspectable IR every generator walks | `node.ts` |
12
- | `Schema` factory, `Infer` machinery | `builder.ts` |
12
+ | `Schema` factory, `Infer` machinery, `.refine()` | `builder.ts` |
13
+ | how a rejected value is described — its shape, never its content | `describe-value.ts` |
14
+ | a union routed by one literal key | `discriminated-union.ts` |
13
15
  | `configureSchemaProvider()` — the swap point | `provider.ts` |
14
16
  | schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
15
17
  | HTTP-boundary coercion, kept out of validation | `coerce.ts` |
@@ -26,6 +28,7 @@ export const publishPost = t.object({
26
28
  title: t.string.min(3).max(80),
27
29
  tags: t.array(t.slug),
28
30
  price: t.money, // { minor: 1999, currency: 'EUR' } — never a float
31
+ // { minor: 2, currency: 'USD', scale: 6 } is $0.000002
29
32
  timeZone: t.timezone, // real IANA validation, not an annotation
30
33
  cursor: t.optional(t.cursor),
31
34
  });
@@ -35,7 +38,7 @@ type PublishPost = Infer<typeof publishPost>;
35
38
 
36
39
  | Value schemas | Factories | Methods |
37
40
  |---|---|---|
38
- | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `record` `optional` `nullable` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` |
41
+ | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.refine(r)` |
39
42
  | `money` `timezone` `locale` `slug` `cursor` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
40
43
 
41
44
  `nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
@@ -43,6 +46,46 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
43
46
 
44
47
  Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
45
48
 
49
+ ### Cross-field rules live on the schema
50
+
51
+ A rule the IR cannot state structurally still belongs to the schema, or it moves into a handler and
52
+ disappears from `openapi.json`, the MCP tool schema, the typed client and every form binding at
53
+ once — axiom 2 broken for every such rule in the product.
54
+
55
+ ```ts
56
+ const booking = t.object({ startDate: t.date, endDate: t.date }).refine({
57
+ name: 'end-after-start', // stable id → `x-ultimate-refinements`
58
+ message: 'endDate must be after startDate', // the rule, never the value
59
+ path: ['endDate'], // which field the issue lands on
60
+ check: (value) => value.endDate > value.startDate,
61
+ });
62
+ ```
63
+
64
+ The predicate runs on the **parsed** output and only after the shape passed, so it compares coerced
65
+ values and never defends against a type the schema already refused. What ships in the IR is the
66
+ declaration, not the closure: `node.refinements`, projected as an `x-ultimate-refinements`
67
+ extension **and** appended to `description`, which is the only field an LLM reading a tool schema
68
+ is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
69
+ — which rebuild from the shape and would drop the rule — is a type error rather than a comment.
70
+
71
+ ### `discriminatedUnion` names the branch it judged
72
+
73
+ ```ts
74
+ const body = t.discriminatedUnion(
75
+ 'kind',
76
+ t.object({ kind: t.literal('post'), slug: t.slug }),
77
+ t.object({ kind: t.literal('page'), title: t.string.min(3) }),
78
+ );
79
+ ```
80
+
81
+ `t.union` reports every member's reasons at once, so one bad field in a `post` body arrives as N
82
+ contradictory complaints naming fields the caller never sent. This routes on `kind` first and
83
+ reports that branch's issues only. A member with no literal (or enum) at the discriminant, or two
84
+ members claiming one tag, is `X_SCHEMA_DISCRIMINANT_INVALID` **where the union is built** — a
85
+ branch nothing can route to is wrong for every input, not for one request. The node stays
86
+ `kind: 'union'` with a `discriminant` beside it, so every existing IR consumer keeps working, and
87
+ JSON Schema gains `discriminator: { propertyName }`.
88
+
46
89
  **You rarely import this package.** `action`, `query`, `jobs` and `entity` each re-export the same
47
90
  `t`, so an authoring file imports its primitive and nothing else. Import `@ultimat3/schema` directly
48
91
  only where there is no primitive to hang the schema off — app config, a standalone view schema, a
@@ -56,10 +99,18 @@ parse(t.object({ postId: t.uuid, notify: t.boolean }), { postId: 'abc', notify:
56
99
 
57
100
  ```text
58
101
  X_VALIDATION_FAILED: value did not match its schema
59
- cause: postId: expected a uuid, received "abc"; notify: expected a boolean, received "yes"
102
+ cause: postId: expected a uuid, received a string of 3 characters; notify: expected a boolean, received a string of 3 characters
60
103
  fix: send input with the field(s) named in cause corrected to the expected type
61
104
  ```
62
105
 
106
+ **An issue message names the shape of the rejected value, never its content**, and `received` on
107
+ the issue is empty for the same reason. `@ultimat3/http` folds these messages into
108
+ `X_BODY_INVALID`'s `cause`, which is returned to the caller *and* written to the log line — where
109
+ the logger redacts by key and a value baked into a string has no key left to redact. Echoing the
110
+ value meant a password-strength rule wrote every mistyped password to the central log index in
111
+ cleartext. There is no dev-only escape hatch: a flag is one misconfigured environment away from
112
+ being the same breach. `describe-value.ts` owns the rule and `describe-value.test.ts` enforces it.
113
+
63
114
  `error.issues` is `{ path, expected, received, message }[]` with paths like `items[0].price`;
64
115
  `formatIssues()` renders one line per issue for the dev overlay. `validate()` never throws and
65
116
  returns the Standard Schema result. Async libraries: `parseAsync()` / `validateAsync()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
package/src/builder.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // Single responsibility: the `Schema` type every builtin validator returns, and the factory
2
2
  // that turns a check function plus an IR node into a Standard-Schema-conforming object.
3
3
 
4
- import { ValidationFailedError, type ValidationIssue } from './errors';
5
- import type { SchemaNode } from './node';
4
+ import { describeValue } from './describe-value';
5
+ import { SchemaError, ValidationFailedError, type ValidationIssue } from './errors';
6
+ import type { SchemaNode, SchemaRefinement } from './node';
6
7
  import {
7
8
  formatPath,
8
9
  type InferInput,
@@ -42,21 +43,28 @@ export function failWith(issues: readonly StandardIssue[]): CheckErr {
42
43
  return { ok: false, issues };
43
44
  }
44
45
 
45
- /** `expected uuid, received "abc"` — the message an agent can act on without guessing. */
46
- export function expected(what: string, value: unknown): string {
47
- return `expected ${what}, received ${describeValue(value)}`;
46
+ /** An object with own keys — not null, not an array. The gate every object-ish check opens with. */
47
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
49
  }
49
50
 
50
- export function describeValue(value: unknown): string {
51
- if (value === undefined) return 'undefined';
52
- if (value === null) return 'null';
53
- if (typeof value === 'string') return JSON.stringify(value);
54
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
55
- if (Array.isArray(value)) return `array(${value.length})`;
56
- if (value instanceof Date) {
57
- return Number.isNaN(value.getTime()) ? 'Date(Invalid Date)' : `Date(${value.toISOString()})`;
58
- }
59
- return typeof value;
51
+ /**
52
+ * A rule the IR cannot state structurally — `endDate > startDate`, `total === sum(lines)`. It
53
+ * lives on the schema rather than in a handler so it reaches OpenAPI, the MCP tool schema, the
54
+ * typed client and the form binding from the one declaration, like every other constraint.
55
+ *
56
+ * `message` is authored by the developer and rendered verbatim, so it must state the RULE and
57
+ * never the value — see `describe-value.ts` for why an issue message is a public surface.
58
+ */
59
+ export interface Refinement<Out> {
60
+ /** Stable machine id, kebab-case: `end-after-start`. Projected as `x-ultimate-refinements`. */
61
+ readonly name: string;
62
+ /** The rule as a sentence, e.g. `endDate must be after startDate`. Never interpolate a value. */
63
+ readonly message: string;
64
+ /** Runs on the PARSED output, so a cross-field rule compares coerced values, not raw input. */
65
+ readonly check: (value: Out) => boolean;
66
+ /** Which field the issue is reported against. Absent reports it against the object itself. */
67
+ readonly path?: readonly string[] | undefined;
60
68
  }
61
69
 
62
70
  export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out> {
@@ -73,6 +81,12 @@ export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out
73
81
  nullable(): Schema<In | null, Out | null>;
74
82
  default(value: Out): Schema<In | undefined, Out>;
75
83
  describe(description: string): Schema<In, Out>;
84
+ /**
85
+ * Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit`
86
+ * rebuild from the shape and would silently drop the refinement, so refining last is a type
87
+ * error to get wrong rather than a comment asking you not to.
88
+ */
89
+ refine(refinement: Refinement<Out>): Schema<In, Out>;
76
90
  }
77
91
 
78
92
  /** The general constraint for "some schema". Methods are bivariant, so this is a real supertype. */
@@ -102,6 +116,42 @@ export type ShapeInput<S extends Shape> = Simplify<
102
116
  }
103
117
  >;
104
118
 
119
+ /**
120
+ * One FRESH copy of a default per parse. `.default([])` handed every parse the same array, so a
121
+ * handler pushing onto it made that push the next request's starting value for the life of the
122
+ * process — cross-request data bleed declared in a schema.
123
+ *
124
+ * Primitives are immutable, so they are still shared and cost nothing. Everything else is decided
125
+ * ONCE, here at declaration: a value `structuredClone` can copy is copied per parse, and one it
126
+ * cannot is refused at the first import of the authoring file — the same "wrong for every input,
127
+ * so say so where it is written" rule `X_SCHEMA_DISCRIMINANT_INVALID` follows. What that refuses
128
+ * was never a working declaration: `node.default` is published as JSON, so a default carrying a
129
+ * function or a symbol could not reach OpenAPI either.
130
+ *
131
+ * Known narrow cost: a class instance clones to a plain object, so a default that relied on its
132
+ * prototype loses it. Schema defaults are wire values, which is the only thing `node.default` can
133
+ * publish, so that shape was already outside what a default may mean.
134
+ */
135
+ function defaultFactory<Out>(fallback: Out): () => Out {
136
+ if (fallback === null || typeof fallback !== 'object') return () => fallback;
137
+ try {
138
+ structuredClone(fallback);
139
+ } catch {
140
+ throw new SchemaError({
141
+ code: 'X_SCHEMA_DEFAULT_UNSHAREABLE',
142
+ cause: `default() received ${describeValue(fallback)}, which structuredClone cannot copy`,
143
+ fix: 'pass a JSON-shaped default (plain object, array, Date, Map, Set), or drop .default() and answer the absent value in the handler',
144
+ });
145
+ }
146
+ return () => structuredClone(fallback);
147
+ }
148
+
149
+ /** The default declaration, dropped — for a wrapper that can no longer reach it. */
150
+ function withoutDefault(node: SchemaNode): SchemaNode {
151
+ const { hasDefault: _hasDefault, default: _default, ...rest } = node;
152
+ return rest;
153
+ }
154
+
105
155
  function toIssues(result: CheckErr): readonly ValidationIssue[] {
106
156
  return result.issues.map((issue) => ({
107
157
  path: formatPath(issue.path),
@@ -133,7 +183,10 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
133
183
  },
134
184
  optional(): Schema<In | undefined, Out | undefined> {
135
185
  return makeSchema<In | undefined, Out | undefined>(
136
- { ...node, optional: true },
186
+ // `.default(x).optional()` published `default: x` while `parse` answered `undefined`:
187
+ // this wrapper short-circuits `undefined` before the default is ever reached, so a client
188
+ // honouring the published default assumed a value the server never produced.
189
+ withoutDefault({ ...node, optional: true }),
137
190
  (value, path) => (value === undefined ? pass(undefined) : check(value, path)),
138
191
  );
139
192
  },
@@ -143,14 +196,36 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
143
196
  );
144
197
  },
145
198
  default(fallback: Out): Schema<In | undefined, Out> {
199
+ const fresh = defaultFactory(fallback);
146
200
  return makeSchema<In | undefined, Out>(
201
+ // The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
202
+ // tool schema and the typed client publish, and they describe what was written.
147
203
  { ...node, hasDefault: true, default: fallback },
148
- (value, path) => (value === undefined ? pass(fallback) : check(value, path)),
204
+ (value, path) => (value === undefined ? pass(fresh()) : check(value, path)),
149
205
  );
150
206
  },
151
207
  describe(description: string): Schema<In, Out> {
152
208
  return makeSchema<In, Out>({ ...node, description }, check);
153
209
  },
210
+ refine(refinement: Refinement<Out>): Schema<In, Out> {
211
+ const declared: SchemaRefinement = {
212
+ name: refinement.name,
213
+ message: refinement.message,
214
+ ...(refinement.path === undefined ? {} : { path: [...refinement.path] }),
215
+ };
216
+ return makeSchema<In, Out>(
217
+ { ...node, refinements: [...(node.refinements ?? []), declared] },
218
+ (value, path) => {
219
+ // Shape first: a predicate written against `Out` must never be handed an unparsed
220
+ // value, or every refinement grows a defensive typeof the schema already performed.
221
+ const result = check(value, path);
222
+ if (!result.ok) return result;
223
+ return refinement.check(result.value)
224
+ ? result
225
+ : fail([...path, ...(refinement.path ?? [])], refinement.message);
226
+ },
227
+ );
228
+ },
154
229
  };
155
230
  return schema;
156
231
  }
package/src/coerce.ts CHANGED
@@ -8,6 +8,22 @@ import { tryIntrospect } from './provider';
8
8
  const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
9
9
  const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
10
10
 
11
+ /** A numeric string as a number, or `undefined` for anything that is not confidently one. */
12
+ function numeric(raw: unknown): number | undefined {
13
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined;
14
+ const value = Number(raw);
15
+ return Number.isFinite(value) ? value : undefined;
16
+ }
17
+
18
+ /** A truthy/falsy query-string spelling as a boolean, or the raw value when it is neither. */
19
+ function booleanish(raw: unknown): unknown {
20
+ if (typeof raw !== 'string') return raw;
21
+ const lowered = raw.toLowerCase();
22
+ if (TRUE_VALUES.has(lowered)) return true;
23
+ if (FALSE_VALUES.has(lowered)) return false;
24
+ return raw;
25
+ }
26
+
11
27
  export type QuerySource =
12
28
  | URLSearchParams
13
29
  | Readonly<Record<string, string | readonly string[] | undefined>>;
@@ -25,11 +41,17 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
25
41
  const value = Number(raw);
26
42
  return raw.trim() !== '' && Number.isFinite(value) ? value : raw;
27
43
  }
28
- case 'boolean': {
44
+ case 'boolean':
45
+ return booleanish(raw);
46
+ case 'literal': {
47
+ // Toward the literal's OWN type, because `literalSchema` compares with `===`: without this
48
+ // a numeric or boolean `t.literal` was unsatisfiable over its own GET route — `t.literal(2)`
49
+ // received `"2"` and the endpoint 400d on every request — while the identical declaration
50
+ // worked over an action's JSON body and over MCP. A string literal needs nothing, which is
51
+ // exactly why the gap read as arbitrary.
29
52
  if (typeof raw !== 'string') return raw;
30
- const lowered = raw.toLowerCase();
31
- if (TRUE_VALUES.has(lowered)) return true;
32
- if (FALSE_VALUES.has(lowered)) return false;
53
+ if (typeof node.literal === 'number') return numeric(raw) ?? raw;
54
+ if (typeof node.literal === 'boolean') return booleanish(raw);
33
55
  return raw;
34
56
  }
35
57
  case 'date': {
@@ -44,7 +66,11 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
44
66
  }
45
67
  case 'record': {
46
68
  if (typeof raw !== 'object' || node.valueNode === undefined) return raw;
47
- const out: Record<string, unknown> = {};
69
+ // A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
70
+ // `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
71
+ // record validator's deliberate refusal of it never ran — the key was reported absent
72
+ // rather than rejected, on the one path (HTTP query) where it is caller-controlled.
73
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
48
74
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
49
75
  out[key] = coerceNode(node.valueNode, value);
50
76
  }
@@ -62,8 +88,16 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
62
88
  case 'money': {
63
89
  if (typeof raw !== 'object') return raw;
64
90
  const source = raw as Record<string, unknown>;
65
- const minor = typeof source['minor'] === 'string' ? Number(source['minor']) : source['minor'];
66
- return Number.isFinite(minor) ? { ...source, minor } : raw;
91
+ // Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
92
+ // field converted here would reach the validator as a legitimate zero and book an empty
93
+ // price input as free. A blank stays a blank and fails validation, which is the real error.
94
+ const rawMinor = source['minor'];
95
+ const minor = typeof rawMinor === 'string' ? numeric(rawMinor) : rawMinor;
96
+ if (typeof minor !== 'number' || !Number.isFinite(minor)) return raw;
97
+ // `scale` arrives as text from a query string exactly as `minor` does. Left a string it
98
+ // would fail validation on a value whose `minor` the same request just had converted.
99
+ const scale = numeric(source['scale']);
100
+ return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
67
101
  }
68
102
  case 'union': {
69
103
  // Only unambiguous single-kind unions (e.g. `number | undefined`) are safe to coerce.
@@ -0,0 +1,71 @@
1
+ // Single responsibility: render a REJECTED value as its shape, and the `expected X, received Y`
2
+ // line every builtin validator fails with. Its whole job is the rule in `describeValue`'s doc
3
+ // comment — the rejected value's content never appears in an issue message. Its own file so that
4
+ // rule has one place to live and one test file to enforce it.
5
+
6
+ /**
7
+ * The shape of a rejected value — **never its content**.
8
+ *
9
+ * WHY: an issue message is not a private diagnostic. `@ultimat3/http` folds these messages into
10
+ * `X_BODY_INVALID`'s `cause`, which is returned to the caller AND written to the log line; the
11
+ * logger redacts `fields`/`contextFields` by key, and a value baked into a message string has no
12
+ * key left to redact. Echoing the value meant a signup form with a password-strength rule wrote
13
+ * every mistyped password to the central log index in cleartext (30-day retention) and into the
14
+ * user's own network tab — same for a card number, an SSN, or an API key pasted in the wrong box.
15
+ *
16
+ * So: length and type, which is what a min/max/type violation actually needs, and nothing else.
17
+ * There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
18
+ * from being the same breach, and a dev overlay already holds the raw request body.
19
+ *
20
+ * Constants are exempt only where they carry no caller data: `undefined`, `null`, `NaN` and the
21
+ * infinities name themselves because "received a number" for a `NaN` reads as a lie.
22
+ */
23
+ export function describeValue(value: unknown): string {
24
+ if (value === undefined) return 'undefined';
25
+ if (value === null) return 'null';
26
+ switch (typeof value) {
27
+ case 'string':
28
+ // Code units, not code points: the length checks in `validators.ts` use `.length` too, so a
29
+ // message quoting a different count than the rule that rejected it would send an agent
30
+ // chasing an off-by-one that is not there.
31
+ return countOf(value.length, 'string', 'character');
32
+ case 'number':
33
+ return describeNumber(value);
34
+ case 'boolean':
35
+ return 'a boolean';
36
+ case 'bigint':
37
+ return 'a bigint';
38
+ case 'symbol':
39
+ return 'a symbol';
40
+ case 'function':
41
+ return 'a function';
42
+ default:
43
+ break;
44
+ }
45
+ if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
46
+ // `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
47
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
48
+ return 'an object';
49
+ }
50
+
51
+ function describeNumber(value: number): string {
52
+ if (Number.isNaN(value)) return 'NaN';
53
+ if (value === Number.POSITIVE_INFINITY) return 'Infinity';
54
+ if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
55
+ return 'a number';
56
+ }
57
+
58
+ function countOf(size: number, noun: string, unit: string): string {
59
+ if (size === 0) return `an empty ${noun}`;
60
+ const article = noun === 'array' ? 'an' : 'a';
61
+ return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
62
+ }
63
+
64
+ /**
65
+ * `expected a uuid, received a string of 3 characters` — the message an agent can act on without
66
+ * guessing, and without the value ever leaving the process. `what` is authored by the schema, so
67
+ * it may say anything; the second half is `describeValue` and may not.
68
+ */
69
+ export function expected(what: string, value: unknown): string {
70
+ return `expected ${what}, received ${describeValue(value)}`;
71
+ }
@@ -0,0 +1,117 @@
1
+ // Single responsibility: a union that dispatches on one literal key instead of trying every
2
+ // member. Its own file because the dispatch table and its two authoring refusals are the whole of
3
+ // it, and `validators.ts` is already at the size where one more composite stops being readable.
4
+
5
+ import {
6
+ type AnySchema,
7
+ type Check,
8
+ checkOf,
9
+ fail,
10
+ isPlainObject,
11
+ makeSchema,
12
+ type Schema,
13
+ } from './builder';
14
+ import { expected } from './describe-value';
15
+ import { DiscriminantInvalidError } from './errors';
16
+ import type { SchemaNode } from './node';
17
+ import type { InferInput, InferOutput } from './standard';
18
+
19
+ /**
20
+ * What a tag can be — exactly what `SchemaNode.literal` and `SchemaNode.values` can hold. Spelled
21
+ * out rather than left `unknown` because these reach a `cause:` through `String()`, and a value an
22
+ * app controls reaching a refusal's own text is how three constructors learned to throw instead
23
+ * of refusing (`bun run error-render`).
24
+ */
25
+ type SchemaTag = string | number | boolean | null | undefined;
26
+
27
+ /** The literal(s) a member declares at the discriminant, or `undefined` if it declares none. */
28
+ function declaredValues(node: SchemaNode, discriminant: string): readonly SchemaTag[] | undefined {
29
+ const child = node.properties?.[discriminant];
30
+ if (child === undefined) return undefined;
31
+ // `kind` gates rather than `literal !== undefined`: `t.literal(false)` is a legal discriminant
32
+ // and a truthiness test would have called it undeclared.
33
+ if (child.kind === 'literal') return [child.literal];
34
+ // An enum member is a legal discriminant too — one branch owning several codes is a real shape
35
+ // (`status: t.enum(['queued', 'running'])`), and refusing it would force a duplicated branch.
36
+ if (child.kind === 'enum' && child.values !== undefined && child.values.length > 0) {
37
+ return [...child.values];
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function refuseUndeclared(discriminant: string, index: number, kind: string): never {
43
+ throw new DiscriminantInvalidError({
44
+ cause: `member #${index} of discriminatedUnion("${discriminant}") — IR kind "${kind}" — declares no literal at "${discriminant}"`,
45
+ fix: `give member #${index} a literal discriminant, t.object({ ${discriminant}: t.literal('…'), … }), or use t.union(...) if the members share no key`,
46
+ meta: { discriminant, index, kind },
47
+ });
48
+ }
49
+
50
+ function refuseDuplicate(discriminant: string, index: number, tag: SchemaTag): never {
51
+ throw new DiscriminantInvalidError({
52
+ cause: `member #${index} of discriminatedUnion("${discriminant}") claims the tag ${String(tag)}, which an earlier member already claims`,
53
+ fix: `give member #${index} its own value at "${discriminant}", or merge the two members into one`,
54
+ meta: { discriminant, index, tag: String(tag) },
55
+ });
56
+ }
57
+
58
+ /** `post | page | 2 | false` — every accepted tag, in declaration order. Developer data only. */
59
+ function renderKnown(known: readonly SchemaTag[]): string {
60
+ return known.map((value) => String(value)).join(' | ');
61
+ }
62
+
63
+ /**
64
+ * A union whose branch is chosen by one key, not by trying every member in turn.
65
+ *
66
+ * Two things `t.union(...)` cannot do. The message: a failure reports the branch the discriminant
67
+ * NAMED — `t.union` reports every branch's reasons at once, so a typo in a `post` body arrived as
68
+ * N contradictory complaints and the field that was actually wrong was named in none of them. The
69
+ * IR: `discriminator.propertyName` reaches OpenAPI and the MCP tool schema, so a code generator
70
+ * emits one tagged type instead of an untagged `anyOf`.
71
+ *
72
+ * The node stays `kind: 'union'` with a `discriminant` beside it — see `node.ts` for why a new
73
+ * `SchemaKind` would have degraded silently in every consumer that already handles unions.
74
+ */
75
+ export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySchema[]]>(
76
+ discriminant: string,
77
+ ...members: S
78
+ ): Schema<InferInput<S[number]>, InferOutput<S[number]>> {
79
+ type Out = InferOutput<S[number]>;
80
+ const branches = new Map<unknown, Check<Out>>();
81
+ const known: SchemaTag[] = [];
82
+
83
+ for (const [index, member] of members.entries()) {
84
+ const values = declaredValues(member.node, discriminant);
85
+ if (values === undefined) refuseUndeclared(discriminant, index, member.node.kind);
86
+ const check = checkOf(member) as Check<Out>;
87
+ for (const value of values) {
88
+ // Letting the first declaration win would leave a member that can never run, which is the
89
+ // same defect as an unreachable case and is invisible until the wrong branch validates.
90
+ if (branches.has(value)) refuseDuplicate(discriminant, index, value);
91
+ branches.set(value, check);
92
+ known.push(value);
93
+ }
94
+ }
95
+
96
+ const node: SchemaNode = {
97
+ kind: 'union',
98
+ discriminant,
99
+ anyOf: members.map((member) => member.node),
100
+ };
101
+
102
+ return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
103
+ if (!isPlainObject(value)) {
104
+ return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
105
+ }
106
+ const branch = branches.get(value[discriminant]);
107
+ if (branch === undefined) {
108
+ return fail(
109
+ [...path, discriminant],
110
+ expected(`one of ${renderKnown(known)}`, value[discriminant]),
111
+ );
112
+ }
113
+ // The named branch's issues, and only those: the caller already said which shape they meant,
114
+ // so the other branches' complaints are noise that hides the one real field error.
115
+ return branch(value, path);
116
+ });
117
+ }