@ultimat3/schema 22.15.0 → 24.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 CHANGED
@@ -5,7 +5,7 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
5
5
  | Rule | |
6
6
  |---|---|
7
7
  | Deps | none (`bun-types` only) |
8
- | Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`); keep `Symbol.for('ultimate.error')` |
8
+ | Errors | `SchemaError` mirrors `UltimateError` field-for-field **and message-for-message** (`code: title — cause`), `format({ docs })`, `retry` (always `'terminal'`) and a `toJSON().meta` that cannot throw (`render-meta.ts`, core's `renderMetaRecord` restated); keep `Symbol.for('ultimate.error')` |
9
9
  | New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
10
10
  | IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
11
11
  | **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
@@ -20,7 +20,8 @@ provider → t`. `char-count.ts` is imported by BOTH `validators.ts` (which reje
20
20
  `describe-value.ts` (which renders the length in the same message), because they disagreed: the
21
21
  rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
22
22
  with "at least 3 chars, received a string of 3 characters".
23
- `standard.ts` and `errors.ts` depend on nothing but each other and `error-codes.ts`, a leaf of
23
+ `standard.ts` and `errors.ts` depend on nothing but each other, `render-meta.ts` (which reaches
24
+ `describe-value`) and `error-codes.ts`, a leaf of
24
25
  plain data that core imports ALONE so a browser graph never keeps the `SchemaError` classes. `iso-date.ts` imports nothing and
25
26
  is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
26
27
  the rule that a clock time must carry an offset or `Z` has one copy, not one per door.
@@ -144,7 +145,29 @@ Gotchas:
144
145
  22.0.0): `'March 14, 2026'`, `'3/14/2026'` and `'12'` all parsed at the host's LOCAL midnight.
145
146
  `iso-date.test.ts` runs the refusal set under two `TZ` values in subprocesses and requires one
146
147
  answer. A number (epoch ms) still passes — it names an instant on every host.
147
- - Adding a `SchemaKind` means updating `json-schema.ts` and `coerce.ts` in the same commit.
148
+ - **`isIsoDateTime` checks the day against the month** (`As of 2026-10`): `new Date('2026-02-30')`
149
+ answers March 2nd. `DAYS_IN_MONTH` restates `daysInMonth` in `packages/time/src/plain-date.ts`
150
+ (tier 0 cannot import `time`); the `CALENDAR_PARITY` table in `iso-date.test.ts` and its twin in
151
+ `packages/time/src/plain-date.test.ts` hold them equal — the `time` copy asks BOTH predicates.
152
+ - **`t.url` is `isAbsoluteUrl` (`absolute-url.ts`), not bare `URL.canParse`**: input the parser
153
+ would strip (edge spaces / C0 controls, any tab or newline) is refused, since the validator
154
+ returns the string as written. Not `href === value` — that refuses `https://example.com`.
155
+ Non-http schemes still pass; narrowing them is an owner call nobody has made.
156
+ - **`isPlainObject` is a PROTOTYPE test** (`Object.prototype` or `null`). A `Map`, a `Date` and a
157
+ class instance parsed to `{}`. Cost: an object from another realm is refused too.
158
+ - **`.default(v)` runs `v` through the schema at declaration** — `X_SCHEMA_DEFAULT_INVALID`, the
159
+ sibling of `X_SCHEMA_DEFAULT_UNSHAREABLE`, whose title (cannot be copied) does not state this.
160
+ - **Async is "has a callable `then`"** (`isThenable` in `standard.ts`), never `instanceof Promise`:
161
+ a non-native thenable read as a result has no `issues`, so it was a success with no value.
162
+ - **`coerceNode` on a union tries every member** and takes the first whose result `fits`
163
+ (`node-fits.ts`, one level deep, never validation). A string some member takes as a string is
164
+ returned untouched first, so `number | string` never turns `01234` into 1234. Numerics are
165
+ decimal only (`DECIMAL`); `Number()` also reads `0x10`.
166
+ - Adding a `SchemaKind` means updating `json-schema.ts`, `coerce.ts` and `node-fits.ts` in the same commit.
167
+ - **`wire-schema.ts` is the one MCP tool-schema narrowing** (`toWireSchema`, `toWireOutputSchema`,
168
+ `WireJsonSchema`): `@ultimat3/mcp`'s `tools/list` and `@ultimat3/action`'s `.tool()` both publish
169
+ through it, so one declaration has one tool document. It lived in `mcp` (tier 4), out of
170
+ `action`'s reach, and the two documents differed (`format`, output bounds).
148
171
  - **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
149
172
  2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
150
173
  `dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
package/README.md CHANGED
@@ -46,6 +46,15 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
46
46
 
47
47
  Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
48
48
 
49
+ Refused at the boundary rather than guessed at (`As of 2026-10`):
50
+
51
+ | Schema | Refuses | Because |
52
+ |---|---|---|
53
+ | `t.date` | a day its month does not have — `2026-02-30`, `2026-04-31`, month `13` | `new Date` rolls it over to March 2nd; `@ultimat3/time`'s `plainDate` already refused it |
54
+ | `t.url` | leading / trailing spaces and C0 controls, a tab or newline anywhere | the URL parser strips them in silence and the validator returns the string as written. Trim before parsing |
55
+ | `t.object` `t.record` `t.money` | anything whose prototype is not `Object.prototype` or `null` — a `Map`, a `Date`, a class instance | none has the own keys the schema declared, so it parsed to `{}` |
56
+ | `.default(v)` | a `v` the schema itself rejects — `X_SCHEMA_DEFAULT_INVALID`, thrown where it is declared | an omitted field parsed to a value the same schema refuses when sent |
57
+
49
58
  Every string-backed schema (`string` `uuid` `email` `url` `timezone` `locale` `slug` `cursor`, with
50
59
  any `.min/.max/.pattern`) and every `t.record` key **refuses U+0000** — the one character Postgres
51
60
  `text` and `jsonb` cannot store, so a NUL that passed reached the row write as a 500. Tabs, newlines
@@ -75,6 +84,11 @@ extension **and** appended to `description`, which is the only field an LLM read
75
84
  is guaranteed to see. `.refine()` returns a plain `Schema`, so refining after `extend`/`pick`/`omit`
76
85
  — which rebuild from the shape and would drop the rule — is a type error rather than a comment.
77
86
 
87
+ A flagged `.pattern(/^[a-z]+$/i)` keeps its flags the same two ways: JSON Schema's `pattern` has no
88
+ flag syntax (an inline `(?i)` is not ECMA-262), so the projection publishes `pattern` plus an
89
+ `x-ultimate-pattern-flags: 'i'` extension a compiling consumer reads (`@ultimat3/mcp`'s arg
90
+ validator), and states the flags in `description` for a reader.
91
+
78
92
  ### `discriminatedUnion` names the branch it judged
79
93
 
80
94
  ```ts
@@ -146,8 +160,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
146
160
 
147
161
  | Class | Code | Declared in |
148
162
  |---|---|---|
163
+ | `DefaultInvalidError` (extends `SchemaError`) | `X_SCHEMA_DEFAULT_INVALID` | `src/errors.ts` |
149
164
  | `DiscriminantInvalidError` (extends `SchemaError`) | `X_SCHEMA_DISCRIMINANT_INVALID` | `src/errors.ts` |
150
- | `SchemaError` | any schema code; the base of the three that extend it. Extends `Error`, not core's `UltimateError` — schema imports nothing — and carries the same `Symbol.for('ultimate.error')` brand so `isUltimateError` answers `true` | `src/errors.ts` |
165
+ | `SchemaError` | any schema code; the base of the four that extend it. Extends `Error`, not core's `UltimateError` — schema imports nothing — and carries the same `Symbol.for('ultimate.error')` brand so `isUltimateError` answers `true` | `src/errors.ts` |
151
166
  | `SchemaUnsupportedError` (extends `SchemaError`) | `X_SCHEMA_UNSUPPORTED` | `src/errors.ts` |
152
167
  | `ValidationFailedError` (extends `SchemaError`) | `X_VALIDATION_FAILED` | `src/errors.ts` |
153
168
 
@@ -173,11 +188,16 @@ swap. `introspect()` is what OpenAPI/MCP generation needs; a provider without it
173
188
 
174
189
  ```ts
175
190
  toJsonSchema(publishPost); // OpenAPI 3.1 (2020-12)
176
- toMcpInputSchema(publishPost); // MCP tools/list (draft-07, no $schema)
191
+ toWireSchema(publishPost); // an MCP tool's inputSchema: what tools/list serves and .tool() returns
192
+ toWireOutputSchema(publishPost); // its outputSchema: structure only; undefined for a non-object root
177
193
  toJsonSchema(publishPost, { dialect: 'x' }); // X_SCHEMA_UNSUPPORTED — the two spellings are closed
178
194
  parse(publishPost, coerceQuery(publishPost, url.searchParams));
179
195
  ```
180
196
 
181
197
  Coercion is separate from validation on purpose: only the HTTP layer has strings that mean
182
198
  numbers. `coerceQuery` promotes repeated params to arrays and leaves anything ambiguous
183
- untouched so validation still produces the real error.
199
+ untouched so validation still produces the real error. It never invents data: only a **decimal**
200
+ numeral becomes a number (`0x10` stays text), only a plain object is read as one (an array is never
201
+ spread into `{ 0: … }`), and a union tries **every** member in declaration order — a string some
202
+ member already accepts is left alone, and a union of objects coerces through the member whose
203
+ literal fields the value carries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "22.15.0",
3
+ "version": "24.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",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "src",
22
22
  "!src/**/*.test.ts",
23
+ "!src/**/*-fixture.ts",
23
24
  "CLAUDE.md",
24
25
  "README.md",
25
26
  "LICENSE"
@@ -0,0 +1,20 @@
1
+ // Single responsibility: the one rule deciding whether a string is an absolute URL `t.url` may
2
+ // return as written — parseable, and not a string the parser had to cut before it could read it.
3
+
4
+ /**
5
+ * What the URL parser DISCARDS before it reads anything: leading and trailing C0 controls and
6
+ * spaces, and every tab or newline wherever it sits (WHATWG URL §4.4, the two "validation error"
7
+ * strips). `URL.canParse` reports none of it, and this validator returns the string it was given —
8
+ * so `' https://a.b'` validated, was stored untrimmed, and rendered into an `href` as a relative
9
+ * path. The parsed `href` is only stable for input the parser did not have to cut.
10
+ *
11
+ * Deliberately NOT `new URL(value).href === value`: that refuses `https://example.com` (the href
12
+ * gains a `/`) and any upper-case host, which are the same URL written differently, not a
13
+ * different string than the one validated.
14
+ */
15
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the controls are the rule.
16
+ const URL_PARSER_STRIPS = /^[\u0000-\u0020]|[\u0000-\u0020]$|[\t\n\r]/;
17
+
18
+ export function isAbsoluteUrl(value: string): boolean {
19
+ return !URL_PARSER_STRIPS.test(value) && URL.canParse(value);
20
+ }
package/src/builder.ts CHANGED
@@ -2,12 +2,19 @@
2
2
  // that turns a check function plus an IR node into a Standard-Schema-conforming object.
3
3
 
4
4
  import { describeValue } from './describe-value';
5
- import { SchemaError, ValidationFailedError, type ValidationIssue } from './errors';
5
+ import {
6
+ DefaultInvalidError,
7
+ SchemaError,
8
+ ValidationFailedError,
9
+ type ValidationIssue,
10
+ } from './errors';
6
11
  import type { SchemaNode, SchemaRefinement } from './node';
7
12
  import {
13
+ formatIssues,
8
14
  formatPath,
9
15
  type InferInput,
10
16
  type InferOutput,
17
+ isThenable,
11
18
  type StandardIssue,
12
19
  type StandardResult,
13
20
  type StandardSchemaV1,
@@ -43,9 +50,19 @@ export function failWith(issues: readonly StandardIssue[]): CheckErr {
43
50
  return { ok: false, issues };
44
51
  }
45
52
 
46
- /** An object with own keys — not null, not an array. The gate every object-ish check opens with. */
53
+ /**
54
+ * A record of own keys: an object literal, or a null-prototype one (what this package's own
55
+ * object and record parsers answer). The gate every object-ish check opens with.
56
+ *
57
+ * The PROTOTYPE is the test, not "an object that is not an array": a `Map`, a `Date` and a class
58
+ * instance are all that, and each has no own enumerable keys the schema declared — so
59
+ * `t.record(t.number)` parsed a `Map` of anything to `{}` and an all-optional `t.object` parsed a
60
+ * `Date` to `{}`, a success carrying none of what was sent.
61
+ */
47
62
  export function isPlainObject(value: unknown): value is Record<string, unknown> {
48
- return typeof value === 'object' && value !== null && !Array.isArray(value);
63
+ if (typeof value !== 'object' || value === null) return false;
64
+ const prototype: unknown = Object.getPrototypeOf(value);
65
+ return prototype === Object.prototype || prototype === null;
49
66
  }
50
67
 
51
68
  /**
@@ -146,6 +163,22 @@ function defaultFactory<Out>(fallback: Out): () => Out {
146
163
  return () => structuredClone(fallback);
147
164
  }
148
165
 
166
+ /**
167
+ * A fallback the schema itself refuses, refused where it is WRITTEN. `t.number.min(5).default(1)`
168
+ * parsed an omitted field to 1 — a value the same schema rejects when a caller sends it — and
169
+ * published `minimum: 5, default: 1` to every generated client. Wrong for every parse that omits
170
+ * the field, so the first import of the authoring file says so, as `defaultFactory` does.
171
+ */
172
+ function assertDefaultValid<Out>(check: Check<Out>, fallback: Out): void {
173
+ const result = check(fallback, []);
174
+ if (result.ok) return;
175
+ throw new DefaultInvalidError({
176
+ // The issue text is the RULE (`describeValue` never echoes content), so quoting it is safe.
177
+ cause: `default() received ${describeValue(fallback)}, which this schema refuses — ${formatIssues(result.issues).join('; ')}`,
178
+ fix: 'edit the .default(…) named in the stack so its value satisfies the rule quoted in cause, or relax that rule on the schema',
179
+ });
180
+ }
181
+
149
182
  /** The default declaration, dropped — for a wrapper that can no longer reach it. */
150
183
  function withoutDefault(node: SchemaNode): SchemaNode {
151
184
  const { hasDefault: _hasDefault, default: _default, ...rest } = node;
@@ -196,6 +229,8 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
196
229
  );
197
230
  },
198
231
  default(fallback: Out): Schema<In | undefined, Out> {
232
+ // Rule before copy: a refused fallback has to be replaced, so its clone problem is moot.
233
+ assertDefaultValid(check, fallback);
199
234
  const fresh = defaultFactory(fallback);
200
235
  return makeSchema<In | undefined, Out>(
201
236
  // The node keeps the DECLARATION, never a copy: `node.default` is what OpenAPI, the MCP
@@ -234,7 +269,7 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
234
269
  export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
235
270
  return (value, path) => {
236
271
  const result = schema['~standard'].validate(value);
237
- if (result instanceof Promise) {
272
+ if (isThenable(result)) {
238
273
  return fail(path, 'expected a synchronous schema, received an async one');
239
274
  }
240
275
  if (result.issues === undefined) return pass(result.value);
package/src/coerce.ts CHANGED
@@ -2,16 +2,25 @@
2
2
  // HTTP layer has strings that "mean" numbers. Actions, jobs and MCP calls receive real JSON and
3
3
  // must never get this leniency.
4
4
 
5
+ import { isPlainObject } from './builder';
5
6
  import { isIsoDateTime } from './iso-date';
6
7
  import type { SchemaNode } from './node';
8
+ import { fits } from './node-fits';
7
9
  import { tryIntrospect } from './provider';
8
10
 
9
11
  const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
10
12
  const FALSE_VALUES = new Set(['0', 'false', 'no', 'off', '']);
11
13
 
14
+ /**
15
+ * A DECIMAL numeral, and nothing else `Number()` reads: it also takes `0x10`, `0b11` and `0o17`,
16
+ * so `?page=0x10` arrived as page 16 — a number the caller never wrote in the only notation a
17
+ * query string, a form field and the published `type: number` have in common.
18
+ */
19
+ const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
20
+
12
21
  /** A numeric string as a number, or `undefined` for anything that is not confidently one. */
13
22
  function numeric(raw: unknown): number | undefined {
14
- if (typeof raw !== 'string' || raw.trim() === '') return undefined;
23
+ if (typeof raw !== 'string' || !DECIMAL.test(raw.trim())) return undefined;
15
24
  const value = Number(raw);
16
25
  return Number.isFinite(value) ? value : undefined;
17
26
  }
@@ -38,9 +47,7 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
38
47
 
39
48
  switch (node.kind) {
40
49
  case 'number': {
41
- if (typeof raw !== 'string') return raw;
42
- const value = Number(raw);
43
- return raw.trim() !== '' && Number.isFinite(value) ? value : raw;
50
+ return numeric(raw) ?? raw;
44
51
  }
45
52
  case 'boolean':
46
53
  return booleanish(raw);
@@ -72,20 +79,23 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
72
79
  return itemNode === undefined ? items : items.map((item) => coerceNode(itemNode, item));
73
80
  }
74
81
  case 'record': {
75
- if (typeof raw !== 'object' || node.valueNode === undefined) return raw;
82
+ if (!isPlainObject(raw) || node.valueNode === undefined) return raw;
76
83
  // A null prototype for the reason `recordSchema` uses one: on a `{}` literal, assigning
77
84
  // `out['__proto__']` hits the `Object.prototype` SETTER and the key vanishes, so the
78
85
  // record validator's deliberate refusal of it never ran — the key was reported absent
79
86
  // rather than rejected, on the one path (HTTP query) where it is caller-controlled.
80
87
  const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
81
- for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
88
+ for (const [key, value] of Object.entries(raw)) {
82
89
  out[key] = coerceNode(node.valueNode, value);
83
90
  }
84
91
  return out;
85
92
  }
86
93
  case 'object': {
87
- if (typeof raw !== 'object' || node.properties === undefined) return raw;
88
- const source = raw as Record<string, unknown>;
94
+ // A plain object or nothing: `{ ...['x'] }` is `{ 0: 'x' }` and `{ ...new Date() }` is `{}`,
95
+ // so spreading anything else MADE an object the caller never sent — and an all-optional
96
+ // schema then accepted it. Untouched, validation says "expected an object".
97
+ if (!isPlainObject(raw) || node.properties === undefined) return raw;
98
+ const source = raw;
89
99
  const out: Record<string, unknown> = { ...source };
90
100
  for (const [key, child] of Object.entries(node.properties)) {
91
101
  // `Object.hasOwn`, never `key in source`: `{ ...source }` above already dropped what a
@@ -96,8 +106,8 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
96
106
  return out;
97
107
  }
98
108
  case 'money': {
99
- if (typeof raw !== 'object') return raw;
100
- const source = raw as Record<string, unknown>;
109
+ if (!isPlainObject(raw)) return raw;
110
+ const source = raw;
101
111
  // Through `numeric` for the same reason `scale` is: `Number('')` is 0, so a blank amount
102
112
  // field converted here would reach the validator as a legitimate zero and book an empty
103
113
  // price input as free. A blank stays a blank and fails validation, which is the real error.
@@ -110,11 +120,18 @@ export function coerceNode(node: SchemaNode, raw: unknown): unknown {
110
120
  return { ...source, minor, ...(scale === undefined ? {} : { scale }) };
111
121
  }
112
122
  case 'union': {
113
- // Only unambiguous single-kind unions (e.g. `number | undefined`) are safe to coerce.
114
- const kinds = new Set((node.anyOf ?? []).map((member) => member.kind));
115
- if (kinds.size !== 1) return raw;
116
- const [member] = node.anyOf ?? [];
117
- return member === undefined ? raw : coerceNode(member, raw);
123
+ const members = node.anyOf ?? [];
124
+ // A string some member takes AS A STRING is never converted behind its back: under
125
+ // `number | string` a postcode `01234` must not arrive as 1234.
126
+ if (typeof raw === 'string' && members.some((member) => fits(member, raw))) return raw;
127
+ // Every member is tried, in declaration order, and the first whose coercion FITS it wins.
128
+ // The first member alone used to decide: `'auto' | 2` left `"2"` a string because `'auto'`
129
+ // needs no conversion, and a union of objects coerced every value by its first branch.
130
+ for (const member of members) {
131
+ const coerced = coerceNode(member, raw);
132
+ if (fits(member, coerced)) return coerced;
133
+ }
134
+ return raw;
118
135
  }
119
136
  default:
120
137
  return raw;
@@ -26,4 +26,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
26
26
  X_SCHEMA_DEFAULT_UNSHAREABLE: {
27
27
  title: 'a schema default cannot be copied per parse',
28
28
  },
29
+ X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
29
30
  });
package/src/errors.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // and carries the same `Symbol.for('ultimate.error')` brand — `isUltimateError()` still matches.
4
4
 
5
5
  import { SCHEMA_ERROR_CODES } from './error-codes';
6
+ import { renderMetaRecord } from './render-meta';
6
7
  import { formatIssues } from './standard';
7
8
 
8
9
  /**
@@ -78,9 +79,16 @@ export interface SchemaErrorJSON {
78
79
  readonly cause: string;
79
80
  readonly fix: string;
80
81
  readonly docs: string;
82
+ /** Always present, as on `UltimateErrorJSON` — a client never has to infer it. */
83
+ readonly retry: 'terminal';
81
84
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
82
85
  }
83
86
 
87
+ export interface SchemaFormatOptions {
88
+ /** Append a 4th `docs:` line. Off by default, as `UltimateError.format()` has it. */
89
+ readonly docs?: boolean | undefined;
90
+ }
91
+
84
92
  export class SchemaError extends Error {
85
93
  readonly [ULTIMATE_ERROR_BRAND] = true;
86
94
  override readonly name: string = 'SchemaError';
@@ -89,6 +97,12 @@ export class SchemaError extends Error {
89
97
  declare readonly cause: string;
90
98
  readonly fix: string;
91
99
  readonly docs: string;
100
+ /**
101
+ * `terminal` on every instance: a value that does not match its schema does not match it on
102
+ * attempt five. Carried HERE as well as in core's retry registry because `errorRetryOf` reads
103
+ * the field off the error, and a process that never imported core has no registry to ask.
104
+ */
105
+ readonly retry = 'terminal' as const;
92
106
  readonly meta: Readonly<Record<string, unknown>> | undefined;
93
107
 
94
108
  constructor(init: SchemaErrorInit) {
@@ -119,10 +133,10 @@ export class SchemaError extends Error {
119
133
  }
120
134
 
121
135
  /** The same 3-line rendering as `UltimateError.format()`, escaped in the same place: neither. */
122
- format(): string {
123
- return [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`].join(
124
- '\n',
125
- );
136
+ format(options?: SchemaFormatOptions): string {
137
+ const lines = [`${this.code}: ${this.title}`, ` cause: ${this.cause}`, ` fix: ${this.fix}`];
138
+ if (options?.docs === true) lines.push(` docs: ${this.docs}`);
139
+ return lines.join('\n');
126
140
  }
127
141
 
128
142
  toJSON(): SchemaErrorJSON {
@@ -132,7 +146,10 @@ export class SchemaError extends Error {
132
146
  cause: this.cause,
133
147
  fix: this.fix,
134
148
  docs: this.docs,
135
- meta: this.meta,
149
+ retry: this.retry,
150
+ // A schema error's `meta` can hold what the caller sent; a `bigint` or a cycle in it threw
151
+ // here, at `--json` render time, one layer past a constructor that had already succeeded.
152
+ meta: renderMetaRecord(this.meta),
136
153
  };
137
154
  }
138
155
  }
@@ -185,6 +202,16 @@ export class DiscriminantInvalidError extends SchemaError {
185
202
  }
186
203
  }
187
204
 
205
+ /** Thrown where `.default()` is WRITTEN: a fallback the schema itself refuses is wrong for every parse. */
206
+ export class DefaultInvalidError extends SchemaError {
207
+ static readonly code = 'X_SCHEMA_DEFAULT_INVALID';
208
+ override readonly name = 'DefaultInvalidError';
209
+
210
+ constructor(init: Omit<SchemaErrorInit, 'code'>) {
211
+ super({ ...init, code: DefaultInvalidError.code });
212
+ }
213
+ }
214
+
188
215
  export class SchemaUnsupportedError extends SchemaError {
189
216
  static readonly code = 'X_SCHEMA_UNSUPPORTED';
190
217
  override readonly name = 'SchemaUnsupportedError';
package/src/index.ts CHANGED
@@ -30,9 +30,11 @@ export { SCHEMA_ERROR_CODES } from './error-codes';
30
30
  export type {
31
31
  SchemaErrorInit,
32
32
  SchemaErrorJSON,
33
+ SchemaFormatOptions,
33
34
  ValidationIssue,
34
35
  } from './errors';
35
36
  export {
37
+ DefaultInvalidError,
36
38
  DiscriminantInvalidError,
37
39
  isSchemaError,
38
40
  SchemaError,
@@ -49,7 +51,7 @@ export type {
49
51
  JsonSchemaType,
50
52
  ToJsonSchemaOptions,
51
53
  } from './json-schema';
52
- export { nodeToJsonSchema, toJsonSchema, toMcpInputSchema } from './json-schema';
54
+ export { nodeToJsonSchema, toJsonSchema } from './json-schema';
53
55
  export type { MoneyValue } from './money-value';
54
56
  export {
55
57
  CURRENCY_CODE_PATTERN,
@@ -112,3 +114,5 @@ export {
112
114
  refineSchema,
113
115
  unionSchema,
114
116
  } from './validators';
117
+ export type { WireJsonSchema } from './wire-schema';
118
+ export { toWireOutputSchema, toWireSchema } from './wire-schema';
package/src/iso-date.ts CHANGED
@@ -20,7 +20,28 @@ const UTC_OFFSET = /(?:z|[+-]\d{2}:?\d{2})$/i;
20
20
  * because RFC 3339 §5.6 permits a lowercase `t` and `z`.
21
21
  */
22
22
  const ISO_SHAPE =
23
- /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
23
+ /^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/i;
24
+
25
+ /**
26
+ * Days per month, January first, in a common year. **Twin: `daysInMonth` in
27
+ * `packages/time/src/plain-date.ts`** — this package is tier 0 and cannot import `@ultimat3/time`,
28
+ * so the Gregorian table is restated rather than shared. The two are held equal by the parity
29
+ * table in `iso-date.test.ts` and `packages/time/src/plain-date.test.ts`; edit both or neither.
30
+ */
31
+ const DAYS_IN_MONTH: readonly number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
32
+
33
+ const isLeapYear = (year: number): boolean =>
34
+ (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
35
+
36
+ /**
37
+ * The half a regex cannot hold. `new Date('2026-02-30')` does not refuse — it answers March 2nd —
38
+ * so a typo validated, and the instant that was stored is one the caller never wrote.
39
+ */
40
+ function isCalendarDay(year: number, month: number, day: number): boolean {
41
+ const days = DAYS_IN_MONTH[month - 1];
42
+ if (days === undefined) return false;
43
+ return day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : days);
44
+ }
24
45
 
25
46
  /** What a `t.date` string must not be: a clock time with no offset and no `Z`. */
26
47
  export function isZonelessDateTime(value: string): boolean {
@@ -28,9 +49,12 @@ export function isZonelessDateTime(value: string): boolean {
28
49
  }
29
50
 
30
51
  /**
31
- * True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset: the only strings
32
- * whose instant is the same on every host. Check it before `new Date(value)`, every time.
52
+ * True when `value` is an ISO-8601 date, or date-time carrying `Z` or an offset, naming a day its
53
+ * month has: the only strings whose instant is the same on every host and is the one written.
54
+ * Check it before `new Date(value)`, every time.
33
55
  */
34
56
  export function isIsoDateTime(value: string): boolean {
35
- return ISO_SHAPE.test(value) && !isZonelessDateTime(value);
57
+ const match = ISO_SHAPE.exec(value);
58
+ if (match === null || isZonelessDateTime(value)) return false;
59
+ return isCalendarDay(Number(match[1]), Number(match[2]), Number(match[3]));
36
60
  }
@@ -34,6 +34,13 @@ export interface JsonSchema {
34
34
  readonly minLength?: number;
35
35
  readonly maxLength?: number;
36
36
  readonly pattern?: string;
37
+ /**
38
+ * The RegExp flags `pattern` is applied with, when there are any. JSON Schema's `pattern` has no
39
+ * flag syntax and an inline `(?i)` is not ECMA-262, so they travel beside it as an extension a
40
+ * consumer that compiles the pattern (`@ultimat3/mcp`'s arg validator) reads; the prose copy in
41
+ * `description` is for a reader.
42
+ */
43
+ readonly 'x-ultimate-pattern-flags'?: string;
37
44
  readonly minimum?: number;
38
45
  readonly maximum?: number;
39
46
  readonly enum?: readonly (string | number)[];
@@ -99,6 +106,9 @@ function stringNode(node: SchemaNode): JsonSchema {
99
106
  ...(node.minLength === undefined ? {} : { minLength: node.minLength }),
100
107
  ...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }),
101
108
  ...(node.pattern === undefined ? {} : { pattern: node.pattern }),
109
+ ...(node.pattern === undefined || node.patternFlags === undefined || node.patternFlags === ''
110
+ ? {}
111
+ : { 'x-ultimate-pattern-flags': node.patternFlags }),
102
112
  };
103
113
  }
104
114
 
@@ -315,7 +325,7 @@ export function toJsonSchema(schema: unknown, options?: ToJsonSchemaOptions): Js
315
325
  };
316
326
  }
317
327
 
318
- /** The exact shape an MCP `tools/list` entry needs: draft-07, no `$schema`. */
328
+ /** Draft-07, no `$schema`: the document `wire-schema.ts` narrows to what an MCP tool publishes. */
319
329
  export function toMcpInputSchema(schema: unknown): JsonSchema {
320
330
  return toJsonSchema(schema, { dialect: 'draft-07', includeDialect: false });
321
331
  }
@@ -0,0 +1,48 @@
1
+ // Single responsibility: does a value have the SHAPE a node names, one level deep — the question
2
+ // `coerce.ts` asks to pick the union member a raw HTTP value was written for.
3
+
4
+ import { isPlainObject } from './builder';
5
+ import type { SchemaNode } from './node';
6
+
7
+ /**
8
+ * Whether `value` is the SHAPE `node` names — one level deep, which is all a union needs to pick
9
+ * a branch. Not validation: it never reads a bound, a pattern or a refinement, so a value that
10
+ * fits can still be refused, with the real message, by the schema it is handed to next.
11
+ *
12
+ * An object fits when every LITERAL field it declares holds that literal — the tag of a
13
+ * discriminated union, read off the IR without asking which key the discriminant is.
14
+ */
15
+ export function fits(node: SchemaNode, value: unknown): boolean {
16
+ switch (node.kind) {
17
+ case 'string':
18
+ return typeof value === 'string';
19
+ case 'number':
20
+ return typeof value === 'number';
21
+ case 'boolean':
22
+ return typeof value === 'boolean';
23
+ case 'date':
24
+ return value instanceof Date;
25
+ case 'literal':
26
+ return value === node.literal;
27
+ case 'enum':
28
+ return (node.values ?? []).some((member) => member === value);
29
+ case 'array':
30
+ return Array.isArray(value);
31
+ case 'record':
32
+ case 'money':
33
+ return isPlainObject(value);
34
+ case 'object': {
35
+ if (!isPlainObject(value)) return false;
36
+ return Object.entries(node.properties ?? {}).every(
37
+ ([key, child]) =>
38
+ child.kind !== 'literal' ||
39
+ (child.optional === true && !Object.hasOwn(value, key)) ||
40
+ (Object.hasOwn(value, key) && value[key] === child.literal),
41
+ );
42
+ }
43
+ case 'union':
44
+ return (node.anyOf ?? []).some((member) => fits(member, value));
45
+ default:
46
+ return false;
47
+ }
48
+ }
package/src/node.ts CHANGED
@@ -54,7 +54,8 @@ export interface SchemaNode {
54
54
  /**
55
55
  * The RegExp's flags, carried beside the source for the same reason. Dropping them made
56
56
  * `t.string.pattern(/^[a-z]+$/i)` reject `ABC` while quoting the pattern that matches it.
57
- * JSON Schema's `pattern` has no flags, so `json-schema.ts` states them in `description`.
57
+ * JSON Schema's `pattern` has no flags, so `json-schema.ts` publishes them as
58
+ * `x-ultimate-pattern-flags` beside it, and states them in `description` for a reader.
58
59
  */
59
60
  readonly patternFlags?: string | undefined;
60
61
  readonly minimum?: number | undefined;
@@ -0,0 +1,53 @@
1
+ // Single responsibility: an error's `meta` as a record `JSON.stringify` cannot throw on. The twin
2
+ // of `renderMetaRecord` in `packages/core/src/error-render.ts`, restated because this package is
3
+ // tier 0 and may not import core — `SchemaError.toJSON()` makes the same `--json` promise.
4
+
5
+ import { describeValue } from './describe-value';
6
+
7
+ type Meta = Readonly<Record<string, unknown>>;
8
+
9
+ /** `JSON.stringify` with its throw removed: did the value survive being serialised at all? */
10
+ function canRender(value: unknown): boolean {
11
+ try {
12
+ JSON.stringify(value);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ /** A record's own keys, or none — a `Proxy` may refuse to be enumerated. */
20
+ function metaKeys(meta: Meta): readonly string[] {
21
+ try {
22
+ return Object.keys(meta);
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ /**
29
+ * One entry, kept as it is when it serialises. What does not is DESCRIBED, never echoed: a schema
30
+ * error's `meta` holds the value a caller sent, so `describeValue` — shape and length, no content —
31
+ * is the only rendering this package has for one. A function is described too: `JSON.stringify`
32
+ * drops it without throwing, but copying a `toJSON` across would have it invoked one layer out.
33
+ */
34
+ function metaEntry(meta: Meta, key: string): unknown {
35
+ try {
36
+ const value = meta[key];
37
+ return canRender(value) && typeof value !== 'function' ? value : describeValue(value);
38
+ } catch {
39
+ return 'a value that cannot be read';
40
+ }
41
+ }
42
+
43
+ /**
44
+ * `meta` is machine-read, so a record that serialises is returned UNCHANGED, identity included.
45
+ * Only what cannot be rendered degrades, one key at a time — a `bigint` or a cycle in one entry
46
+ * must not cost the reader the entries beside it, and must not throw at `--json` render time.
47
+ */
48
+ export function renderMetaRecord(meta: Meta | undefined): Meta | undefined {
49
+ if (meta === undefined || canRender(meta)) return meta;
50
+ const out: Record<string, unknown> = {};
51
+ for (const key of metaKeys(meta)) out[key] = metaEntry(meta, key);
52
+ return out;
53
+ }
package/src/standard.ts CHANGED
@@ -96,11 +96,22 @@ export function toValidationIssues(issues: readonly StandardIssue[]): readonly V
96
96
  }));
97
97
  }
98
98
 
99
+ /**
100
+ * Async by the rule `await` itself uses — a callable `then` — never `instanceof Promise`. A
101
+ * library's own promise class, a polyfill or a Promise from another realm fails that test, and was
102
+ * then read as a RESULT: no `issues` key, so a success, with `value` undefined. `parse` returned
103
+ * `undefined` for input nothing had validated.
104
+ */
105
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
106
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return false;
107
+ return typeof (value as { then?: unknown }).then === 'function';
108
+ }
109
+
99
110
  function assertSync<Output>(
100
111
  result: StandardResult<Output> | Promise<StandardResult<Output>>,
101
112
  vendor: string,
102
113
  ): StandardResult<Output> {
103
- if (result instanceof Promise) {
114
+ if (isThenable(result)) {
104
115
  throw new SchemaUnsupportedError({
105
116
  cause: `${vendor} validated asynchronously in a synchronous call site`,
106
117
  fix: 'call validateAsync()/parseAsync() instead of validate()/parse()',
package/src/validators.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // ArkType or Zod replace them wholesale via `configureSchemaProvider()` — neither ships; the IR and the
3
3
  // Standard Schema surface are what the rest of the framework actually depends on.
4
4
 
5
+ import { isAbsoluteUrl } from './absolute-url';
5
6
  import {
6
7
  type AnySchema,
7
8
  type Check,
@@ -453,9 +454,7 @@ export const builtinT: TNamespace = Object.freeze({
453
454
  email: makeStringSchema({ kind: 'string', format: 'email' }, 'an email address', (value) =>
454
455
  EMAIL_RE.test(value),
455
456
  ),
456
- url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL', (value) =>
457
- URL.canParse(value),
458
- ),
457
+ url: makeStringSchema({ kind: 'string', format: 'uri' }, 'an absolute URL', isAbsoluteUrl),
459
458
  date: dateSchema,
460
459
  money: moneySchema,
461
460
  timezone: makeStringSchema(
@@ -0,0 +1,126 @@
1
+ // Single responsibility: the JSON Schema subset an MCP tool publishes, and the two narrowings onto
2
+ // it — an input's and an output's. One home, tier 0, so `@ultimat3/action`'s `.tool()` and
3
+ // `@ultimat3/mcp`'s `tools/list` publish ONE document for one declaration rather than two.
4
+
5
+ import type { JsonSchema } from './json-schema';
6
+ import { toMcpInputSchema } from './json-schema';
7
+
8
+ /**
9
+ * The subset a tool's `inputSchema` and `outputSchema` speak, and the one `@ultimat3/mcp`'s
10
+ * `validateArgs` holds a hand-written tool to. Narrow on purpose: a tool schema an agent cannot
11
+ * fully understand is a tool it will call wrong.
12
+ */
13
+ export interface WireJsonSchema {
14
+ readonly type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null';
15
+ readonly title?: string;
16
+ readonly description?: string;
17
+ readonly properties?: Readonly<Record<string, WireJsonSchema>>;
18
+ readonly required?: readonly string[];
19
+ readonly additionalProperties?: boolean;
20
+ readonly items?: WireJsonSchema;
21
+ readonly enum?: readonly (string | number | boolean | null)[];
22
+ readonly const?: string | number | boolean | null;
23
+ readonly default?: unknown;
24
+ /**
25
+ * NOT in the subset: `format` NAMES a rule whose meaning lives in this package (`uuid`, `email`,
26
+ * `iana-time-zone`), and `validateArgs` cannot hold a hand-written tool to it without a second
27
+ * definition of each one. `never`, so a narrowing that starts copying it again fails to compile.
28
+ * `pattern` is the opposite case — the rule travels WITH the schema, so it is kept.
29
+ */
30
+ readonly format?: never;
31
+ readonly minimum?: number;
32
+ readonly maximum?: number;
33
+ readonly minLength?: number;
34
+ readonly maxLength?: number;
35
+ /** A `RegExp.source`, JSON Schema semantics (a partial match unless it anchors itself). */
36
+ readonly pattern?: string;
37
+ /** The flags `pattern` compiles with — JSON Schema's `pattern` carries none. */
38
+ readonly 'x-ultimate-pattern-flags'?: string;
39
+ readonly anyOf?: readonly WireJsonSchema[];
40
+ }
41
+
42
+ /** A Standard Schema → a tool's `inputSchema`. Throws `X_SCHEMA_UNSUPPORTED` when it cannot say. */
43
+ export function toWireSchema(schema: unknown): WireJsonSchema {
44
+ return narrow(toMcpInputSchema(schema));
45
+ }
46
+
47
+ /**
48
+ * A Standard Schema → a tool's `outputSchema`, or `undefined` when its root is not an object (MCP's
49
+ * `structuredContent` is one) or it cannot be introspected. STRUCTURE ONLY: a client refuses a call
50
+ * whose `structuredContent` misses this document, and a bound, a pattern or `additionalProperties:
51
+ * false` on a value the server produced (a query's rows are never re-parsed) is a promise nothing
52
+ * enforces on the way out.
53
+ */
54
+ export function toWireOutputSchema(schema: unknown): WireJsonSchema | undefined {
55
+ let rich: JsonSchema;
56
+ try {
57
+ rich = toMcpInputSchema(schema);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ return rich.type === 'object' ? shape(rich) : undefined;
62
+ }
63
+
64
+ // exactOptionalPropertyTypes: every field is attached only when present, never as an explicit
65
+ // `undefined` — `tools/list` serialises this object verbatim.
66
+ function narrow(source: JsonSchema): WireJsonSchema {
67
+ return {
68
+ ...(source.type === undefined ? {} : { type: source.type }),
69
+ ...(source.title === undefined ? {} : { title: source.title }),
70
+ ...(source.description === undefined ? {} : { description: source.description }),
71
+ ...(source.properties === undefined ? {} : { properties: mapKeys(source.properties, narrow) }),
72
+ ...(source.required === undefined ? {} : { required: source.required }),
73
+ // The subset says yes or no to extra keys; an unrepresentable value schema becomes `true`
74
+ // (permit) rather than a rejection the agent was never warned about.
75
+ ...(source.additionalProperties === undefined
76
+ ? {}
77
+ : { additionalProperties: source.additionalProperties !== false }),
78
+ ...(source.items === undefined ? {} : { items: narrow(source.items) }),
79
+ ...(source.enum === undefined ? {} : { enum: source.enum }),
80
+ ...(source.const === undefined ? {} : { const: source.const }),
81
+ ...(source.default === undefined ? {} : { default: source.default }),
82
+ ...(source.minimum === undefined ? {} : { minimum: source.minimum }),
83
+ ...(source.maximum === undefined ? {} : { maximum: source.maximum }),
84
+ ...(source.minLength === undefined ? {} : { minLength: source.minLength }),
85
+ ...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
86
+ ...(source.pattern === undefined ? {} : { pattern: source.pattern }),
87
+ ...(source['x-ultimate-pattern-flags'] === undefined
88
+ ? {}
89
+ : { 'x-ultimate-pattern-flags': source['x-ultimate-pattern-flags'] }),
90
+ ...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
91
+ };
92
+ }
93
+
94
+ function shape(source: JsonSchema): WireJsonSchema {
95
+ return {
96
+ ...(source.type === undefined ? {} : { type: source.type }),
97
+ ...(source.title === undefined ? {} : { title: source.title }),
98
+ ...(source.description === undefined ? {} : { description: source.description }),
99
+ ...(source.properties === undefined ? {} : { properties: mapKeys(source.properties, shape) }),
100
+ ...(source.required === undefined ? {} : { required: source.required }),
101
+ ...(source.items === undefined ? {} : { items: shape(source.items) }),
102
+ ...(source.enum === undefined ? {} : { enum: source.enum }),
103
+ ...(source.const === undefined ? {} : { const: source.const }),
104
+ ...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(shape) }),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * `out[key] = …` is not an assignment for exactly one name: `__proto__` runs `Object.prototype`'s
110
+ * setter and RE-PROTOTYPES the record, so a declared field vanishes from `tools/list`.
111
+ */
112
+ function mapKeys(
113
+ properties: Readonly<Record<string, JsonSchema>>,
114
+ each: (child: JsonSchema) => WireJsonSchema,
115
+ ): Readonly<Record<string, WireJsonSchema>> {
116
+ const out: Record<string, WireJsonSchema> = {};
117
+ for (const [key, child] of Object.entries(properties)) {
118
+ Object.defineProperty(out, key, {
119
+ value: each(child),
120
+ writable: true,
121
+ enumerable: true,
122
+ configurable: true,
123
+ });
124
+ }
125
+ return out;
126
+ }