# @ultimat3/schema — agent notes

Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**

| Rule | |
|---|---|
| Deps | none (`bun-types` only) |
| 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')` |
| New validator | add to `validators.ts` **and** `TNamespace` **and** `t.ts` **and** `json-schema.ts` |
| IR | every schema carries `.node: SchemaNode`; generators read that, never the closure |
| **Issue messages** | the shape of the rejected value, **never its content** — see `describe-value.ts` |
| **Issue paths** | framework-chosen segments only. A `t.object` segment is a DECLARED field name; a `t.record` KEY is the caller's, so `recordSchema` names a failing entry by POSITION (`meta[3]`) |
| Coercion | HTTP boundary only — never call it from actions, jobs or MCP |
| Exports | explicit in `src/index.ts`; no `export *`; a namespace member and its free function ship together (`t.nullable`/`nullableSchema`) |
| Re-exports | `action`, `query`, `jobs`, `entity` re-export `t` verbatim so an authoring file imports one package — never let them wrap or copy it |

Module order (no cycles):
`char-count → describe-value → node → builder → money-value → json-value → array-schema →
validators → discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value`
too).
`char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
`describe-value.ts` (which renders the length in the same message), because they disagreed: the
rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
with "at least 3 chars, received a string of 3 characters".
`standard.ts` and `errors.ts` depend on nothing but each other, `render-meta.ts` (which reaches
`describe-value`) and `error-codes.ts`, a leaf of
plain data that core imports ALONE so a browser graph never keeps the `SchemaError` classes. `iso-date.ts` imports nothing and
is imported by `validators.ts` and `coerce.ts` — the two doors a `t.date` string comes through, so
the rule that a clock time must carry an offset or `Z` has one copy, not one per door.

**An issue PATH is the same public surface as its message, `As of 2026-08-25`.** It travels
`formatIssue` -> `@ultimat3/http`'s `bodyInvalid` -> `X_BODY_INVALID`'s `cause` -> the problem
document **and** the log line, and `bodyInvalid`'s own doc block promises the `issues` it renders
"name only facts the framework itself chose". A `t.record` key is not one: a record keyed by an
email address, a phone number or a pasted credential wrote every one of them into the central log
index, in the shape the password bug did. `recordSchema` emits the entry's INDEX — the segment
`arraySchema` already uses, so `formatPath` renders `meta[3]` with no new spelling — because
dropping the segment entirely makes three failing entries render three identical lines. It is
`Object.entries` order, so it names an entry that exists rather than a byte offset in the body.
A `t.object` segment stays as written: the schema author chose it, not the caller.

**An issue message is a public surface.** `@ultimat3/http` folds it into `X_BODY_INVALID`'s `cause`,
which is returned to the caller AND interpolated into the log line — and core's logger redacts by
KEY, so a value baked into a string has no key left to redact. `'password'` being in `redactedKeys`
did not help: `expected(…, value)` had already written `received "hunter2"` before the logger saw
it. Every rejected value goes through `describeValue`, which reports length and type and nothing
else, in CHARACTERS (`char-count.ts`) — the unit the rule that rejected it counts in. No dev flag
re-enables the echo — one misconfigured environment is the same breach, and a dev
overlay already holds the raw body. `describe-value.test.ts` is the enforcement.

`t.array(items, { min, max })` (`array-schema.ts`) carries its bounds as `minItems`/`maxItems` ON
the node — JSON Schema's names — and `json-schema.ts` and `wire-schema.ts` publish both;
`@ultimat3/action`'s `sampleFor` samples `minItems` items. A bound no count meets is
`X_SCHEMA_BOUNDS_INVALID` where it is declared, the same rule as the discriminant below.

`X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
is parsed. A member with no literal at the discriminant, or a second member claiming a tag the
first already owns, can never be routed to — so it is wrong for every input, and the first import
of the authoring file is the earliest honest place to say so.

`SCHEMA_ERROR_CODES` in `errors.ts` is data, not a `registerErrorCodes()` call — this package is
tier 0 and cannot import `@ultimat3/core` to reach it, and never will. `@ultimat3/core`'s
`schema-error-codes.ts` READS this object over the declared `core -> schema` edge and registers it
unconditionally, so every process gets the real titles just by importing core. **Adding a code here
is the whole edit `As of 2026-08-27`** — the title and its `terminal` retry classification are both
derived from this set, where they used to be a hand-kept duplicate in core plus a hand-written
retry list a fifth code would have been silently missing from.

`ERROR_DOCS_URL` in `errors.ts` is the second deliberate tier-0 duplicate, beside `singleLine` —
one URL, spelled out, because `SchemaError` cannot import
`@ultimat3/core`'s constant. There is no per-code URL anywhere in the framework: codes live in
`wiki/Error-Codes.md` as table ROWS and a table row has no anchor, so
`https://ultimate.dev/errors/<code>` was a 404 on every error and was deleted `As of 2026-08-23`. Change it
here and in `packages/core/src/error-codes.ts` in the same edit. **It is pinned `As of 2026-08-27`**
— `packages/core/src/single-line-pin.test.ts` asserts a `SchemaError`'s `docs` equals an
`UltimateError`'s, which was the one copy with no mechanical check at all and the one that decides
where every schema refusal sends its reader.

**These two are the whole of the duplication that remains, and the edge does not reach them.**
`core -> schema` was declared 2026-08-27 and collapsed five copies on the CORE side, and
`ULTIMATE_ERROR_BRAND` in 25.0.0 — declared HERE once, imported by core's `errors.ts`, because one
value name has one declaring package (`scripts/factory-names.ts`). `singleLine` and
`ERROR_DOCS_URL` go the other way, and `schema -> core` stays forbidden
on its merits — `t` is in every bundle graph an app has, so a dependency here is a dependency
everywhere.

`MoneyValue` in `money-value.ts` — its own file, because it is the only builtin whose *shape* other
packages alias — is the framework's **one** declaration of a money value. Tier 0 is
the only tier every package may import, and `@ultimat3/money`'s `Money` and `@ultimat3/entity`'s
`MoneyValue` are aliases of it. Never let either restate the shape: it was three structural copies,
entity's had a `bigint` `minor`, and a row that layer decoded then failed both `t.money` and
`JSON.stringify`. `minor` stays a `number` for the same reason it is a `number` here — this node is
the OpenAPI contract, and money crosses every wire the framework projects.

`MoneyValue.scale` is the **optional** decimal exponent `minor` counts in, `0…MAX_MONEY_SCALE`
(15, the last power of ten that is itself a safe integer). Absent means the currency's own minor
unit, which is every value that predates it — so `{ minor, currency }` parses to exactly
`{ minor, currency }`, key for key, and the validator adds nothing. What a legal scale is lives in
`isMoneyScale` here and nowhere else; `@ultimat3/money` imports it rather than restating the
bound. Adding it to the type means adding it in three more places in the same change — the node's
`properties`, `json-schema.ts` (optional, never `required`, or a generated client refuses a value
this validator accepts) and `coerce.ts` (a query string carries it as text like everything else).

`MoneyValue.currency` is bounded by `isCurrencyCode`, `isMoneyScale`'s twin, over
`CURRENCY_CODE_PATTERN` — the pattern **source**, exported because the two projections that cannot
call a predicate need the string: `json-schema.ts`'s published `pattern` and `@ultimat3/entity`'s
Postgres `~` CHECK (`currencyCheck`). It was four copies of `^[A-Z]{3}$` across three packages,
each individually correct, and only a psql session would have seen them diverge. Keep the pattern
inside the syntax ECMAScript, JSON Schema and POSIX ERE spell identically — anchors, a literal
class, a bounded repetition. `@ultimat3/entity`'s `currency-check.live.test.ts` is what proves a
real server still reads it the way the predicate does; a `\d` or a lookahead is where that stops.

`t` delegates through `schemaProvider()` on every property access — that is what makes
`configureSchemaProvider()` work for modules that already imported `t`. Do not cache members.

```bash
bun test                      # from packages/schema
bun run typecheck
```

`t.number.int()` demands `Number.isSafeInteger`, not `Number.isInteger` (`As of 2026-08-25`) — the
defect `money-value.ts` carries the write-up for having fixed one file over, and `@ultimat3/entity`'s
`columns.ts` had it right too. `t.number.int()` was the one that did not get the fix: it accepted
`2 ** 53` at the boundary as a 200, the policy gate and the handler ran, and the ROW WRITE refused
it as a 500 — the same value refused twice, once with a field path and once without. `json-schema.ts`
publishes the same bound (`minimum`/`maximum` at `±Number.MAX_SAFE_INTEGER`, a caller's own bound
when it is narrower and clamped when it is not), because a contract promising what the parser
refuses is that disagreement one layer out. The IR node is untouched — `@ultimat3/action`'s
`sampleNumber` reads `node.minimum`, and a default there would make every generated contract sample
`-9007199254740991`.

Gotchas:
- `Schema<In, Out>` splits input from output: `.default()` makes the key optional on input and
  present on output. Object key optionality is derived from that — don't hand-roll it.
- `AnySchema = Schema<unknown, unknown>` is the general constraint. Never `any`.
- Unknown object keys are dropped by design; JSON Schema says `additionalProperties: false`.
- **An object parse reads every declared field with `Object.hasOwn` and answers a null-prototype
  object** — the same two halves `recordSchema` has. A raw `value[key]` read `toString` off the
  PROTOTYPE, so a field named after one was unsatisfiable for every input and its `.default()`
  never fired; a `{}` output let a declared `__proto__` field write through the setter. Never
  reintroduce either half, and never assume a parsed object has `Object.prototype` on it.
- **Every map keyed by a DECLARED FIELD NAME is built on `Object.create(null)`** — `objectSchema`'s
  `properties`, `json-schema.ts`'s converted `properties`, and `pick`/`omit`'s rebuilt shape. That
  last pair is the one with teeth: `next['__proto__'] = member` on a `{}` literal hits the setter,
  so `pick('__proto__')` answered a schema with **zero** properties and dropped the one field the
  caller asked to keep from validation, from the IR and from `parse()`'s output. `extend` needs
  nothing: object spread defines own properties and never invokes a setter. A field named
  `__proto__` is declarable — `t.object({ ['__proto__']: t.string })`, a COMPUTED key, which is an
  ordinary own property where the literal `{ __proto__: … }` form is the setter.
- `t.date` refuses a clock time with no offset and no `Z` (`iso-date.ts`): a zone-less string is a
  different instant per host `TZ`, and `coerceQuery` puts it one query parameter from the wire.
  A date-only string carries no clock time and is UTC by spec, so it still parses.
- `t.date` refuses any string that is not ISO-8601 in shape (`isIsoDateTime`, `As of 2026-09-23`,
  22.0.0): `'March 14, 2026'`, `'3/14/2026'` and `'12'` all parsed at the host's LOCAL midnight.
  `iso-date.test.ts` runs the refusal set under two `TZ` values in subprocesses and requires one
  answer. A number (epoch ms) still passes — it names an instant on every host.
- **`isIsoDateTime` checks the day against the month** (`As of 2026-10`): `new Date('2026-02-30')`
  answers March 2nd. `DAYS_IN_MONTH` restates `daysInMonth` in `packages/time/src/plain-date.ts`
  (tier 0 cannot import `time`); the `CALENDAR_PARITY` table in `iso-date.test.ts` and its twin in
  `packages/time/src/plain-date.test.ts` hold them equal — the `time` copy asks BOTH predicates.
- **`t.url` is `isAbsoluteUrl` (`absolute-url.ts`), not bare `URL.canParse`**: input the parser
  would strip (edge spaces / C0 controls, any tab or newline) is refused, since the validator
  returns the string as written. Not `href === value` — that refuses `https://example.com`.
  Non-http schemes still pass; narrowing them is an owner call nobody has made.
- **`isPlainObject` is a PROTOTYPE test** (`Object.prototype` or `null`). A `Map`, a `Date` and a
  class instance parsed to `{}`. Cost: an object from another realm is refused too.
- **`.default(v)` runs `v` through the schema at declaration** — `X_SCHEMA_DEFAULT_INVALID`, the
  sibling of `X_SCHEMA_DEFAULT_UNSHAREABLE`, whose title (cannot be copied) does not state this.
- **Async is "has a callable `then`"** (`isThenable` in `standard.ts`), never `instanceof Promise`:
  a non-native thenable read as a result has no `issues`, so it was a success with no value.
- **`coerceNode` on a union tries every member** and takes the first whose result `fits`
  (`node-fits.ts`, one level deep, never validation). A string some member takes as a string is
  returned untouched first, so `number | string` never turns `01234` into 1234. Numerics are
  decimal only (`DECIMAL`); `Number()` also reads `0x10`.
- Adding a `SchemaKind` means updating `json-schema.ts`, `coerce.ts` and `node-fits.ts` in the same commit.
- **`wire-schema.ts` is the one MCP tool-schema narrowing** (`toWireSchema`, `toWireOutputSchema`,
  `WireJsonSchema`): `@ultimat3/mcp`'s `tools/list` (`toolFrom`, the one tool projection
  since 25.0.0 deleted `.tool()`) publishes through it. It lived in `mcp` (tier 4) while
  `action` carried a second projection, and the two documents differed (`format`, output bounds).
- **`ToJsonSchemaOptions.dialect` is a closed vocabulary read with `Object.hasOwn`** (`As of
  2026-09-06`). `DIALECTS[dialect]` on an object literal answered the `Object` FUNCTION for
  `dialect: 'constructor'` — measured: `$schema` held it, `JSON.stringify` dropped the key in
  silence, and every consumer that does not serialise (the MCP tool schema, the OpenAPI writer
  that spreads this document) carried a function. It is a field on a PUBLIC entry point, so the
  key is caller data. `bun run proto-index` could not see the read until its null-prototype
  exemption became per TABLE: `convert`'s unrelated `Object.create(null)` exempted the whole file.
  The refusal is `X_SCHEMA_UNSUPPORTED` — the same question that code already answers, and a code
  is stable forever once shipped — and it fires whether or not `includeDialect` would have emitted
  the value, because a rule a caller learns half of is a rule they learn wrong. The rejected value
  is `describeValue`d in the `cause` and whole in `meta`, never echoed.
- **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
  has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
  value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
  a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
  reason: a refined string still reads as a string, and a discriminated union still reads as a
  union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
- **`t.json()` is a `SchemaKind`, the one exception to the line above** (`As of 2026-10`):
  `unknown` means the IR cannot describe a value, `json` that it can — and every foreign
  `default:` already projects it correctly (`{}`, raw pass-through, a `null` sample, admin's JSON
  textarea). `JsonValue` in `json-value.ts` is the framework's one declaration; `cli`, `render`,
  `manifest` and `realtime` still restate it. Its path segments are POSITIONS (the `t.record`
  rule), shared values are memoised by height so a DAG is linear, and `JSON_MAX_DEPTH` is fixed:
  a caller-set bound needs a declaration-time refusal code this package does not have yet.
- A refinement is carried as a **declaration** (`name`, `message`, `path`), never a closure: a
  predicate cannot cross into OpenAPI or an MCP tool schema, and `refine`'s `message` is rendered
  verbatim on both — so it states the rule and never interpolates a value.
- `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
  schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
  there; fixing it is that package's change, not this one's.
