@ultimat3/schema 29.3.2 → 30.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
@@ -16,8 +16,22 @@ Tier 0. **Imports no `@ultimat3/*` package — not even `@ultimat3/core`.**
16
16
 
17
17
  Module order (no cycles):
18
18
  `char-count → describe-value → node → builder → money-value → json-value → array-schema →
19
- validators → discriminated-union → provider → t` (`node-fits` and `json-schema` read `json-value`
20
- too).
19
+ validators → record-schema → discriminated-union → provider → t` (`node-fits` and `json-schema` read
20
+ `json-value` too). `record-schema.ts` (`t.record`) split from `validators.ts` at the ceiling.
21
+ `schema-rules.ts` is a leaf (`SCHEMA_RULES`, `issueRuleFacts`), imported by `standard.ts`,
22
+ `errors.ts` (type only) and `builder.ts`. `sensitive.ts` reads `provider.ts` and is not in `t`'s graph.
23
+
24
+ - **A builtin refusal names its rule**: `rejected(path, message, rule, params?)` (`builder.ts`,
25
+ internal) has `rule` REQUIRED; `mistyped(path, message, value, type)` answers `required` for
26
+ `undefined` and `type { type }` otherwise. The exported `fail` keeps `rule?` optional for
27
+ providers. `schema-rules.test.ts` fails if a builtin file calls `fail(`, and pins `SCHEMA_RULES`
28
+ (add, never rename). `params` are declared facts only, never the rejected value.
29
+ - **`.sensitive()` is a node fact, never a key table**: `maskSensitive` memoises "holds a flag" per
30
+ node in a WeakMap, so an unflagged schema costs one map read and returns the same reference. A
31
+ union masks every branch's flagged keys (fails closed). `assertNoSensitiveOutput` lives here
32
+ because `query` cannot import `action`.
33
+ - Bytes: the `t`-only browser chunk is 19,788 B minified (+1,321 B, gzip +404, for a rule and params
34
+ at every refusal site), `Bun.build --minify`, 2026-11-03.
21
35
  `char-count.ts` is imported by BOTH `validators.ts` (which rejects on length) and
22
36
  `describe-value.ts` (which renders the length in the same message), because they disagreed: the
23
37
  rule counted code points and the message counted UTF-16 units, so `t.string.min(3)` refused `'👍a'`
@@ -50,7 +64,7 @@ overlay already holds the raw body. `describe-value.test.ts` is the enforcement.
50
64
 
51
65
  `t.array(items, { min, max })` (`array-schema.ts`) carries its bounds as `minItems`/`maxItems` ON
52
66
  the node — JSON Schema's names — and `json-schema.ts` and `wire-schema.ts` publish both;
53
- `@ultimat3/action`'s `sampleFor` samples `minItems` items. A bound no count meets is
67
+ `sample.ts`'s walker samples `minItems` items. A bound no count meets is
54
68
  `X_SCHEMA_BOUNDS_INVALID` where it is declared, the same rule as the discriminant below.
55
69
 
56
70
  `X_SCHEMA_DISCRIMINANT_INVALID` is thrown where a `discriminatedUnion` is BUILT, not where a value
@@ -190,7 +204,7 @@ Gotchas:
190
204
  is `describeValue`d in the `cause` and whole in `meta`, never echoed.
191
205
  - **Prefer a new `SchemaNode` FIELD to a new `SchemaKind`.** Every consumer that switches on `kind`
192
206
  has a `default:` that degrades quietly — `json-schema.ts` emits `{}`, `coerce.ts` passes the raw
193
- value through, `@ultimat3/action`'s `sample-input.ts` answers `null` — and they live in packages
207
+ value through, `sample.ts` answers `null` — and they live in packages
194
208
  a schema change is not allowed to edit. `refinements` and `discriminant` are both fields for that
195
209
  reason: a refined string still reads as a string, and a discriminated union still reads as a
196
210
  union, everywhere. `lazy` and `tuple` cannot be — which is why neither has shipped.
@@ -207,3 +221,10 @@ Gotchas:
207
221
  - `@ultimat3/action`'s `sampleInput` does not read `refinements`, so a contract test over a refined
208
222
  schema reports `X_CONTRACT_DRIFT` exactly as it already does for a bare `pattern`. Documented
209
223
  there; fixing it is that package's change, not this one's.
224
+
225
+ ## The `./sample` subpath
226
+
227
+ - `sample.ts`, `sample-draw.ts` and `sample-seeded.ts` are the `./sample` subpath, never re-exported
228
+ from `src/index.ts` (axiom 6: `t` is in every bundle graph).
229
+ - Module order: `sample-draw → sample-seeded → sample`, reading `node` and `provider`.
230
+ - `seededDraw` is mulberry32 over the seed it is handed. It never uses `Math.random` or the clock.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Tier 0. The validation seam. One schema declaration projects to a runtime validator, a TypeScript
4
4
  type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query coercion.
5
5
 
6
+ `bun add @ultimat3/schema` · guide: [The Eight Primitives](https://github.com/developerz-ai/ultimate/wiki/The-Eight-Primitives)
7
+
6
8
  | Owns | Module |
7
9
  |---|---|
8
10
  | Standard Schema v1 types + `validate()` / `parse()` for **any** conforming library | `standard.ts` |
@@ -17,6 +19,9 @@ type, a JSON Schema, an OpenAPI body, an MCP tool `inputSchema` and HTTP query c
17
19
  | schema → JSON Schema (OpenAPI + MCP) | `json-schema.ts` |
18
20
  | HTTP-boundary coercion, kept out of validation | `coerce.ts` |
19
21
  | `X_VALIDATION_FAILED` with path-annotated issues | `errors.ts` |
22
+ | `SCHEMA_RULES` — the closed vocabulary an issue's `rule` names — and `issueRuleFacts` | `schema-rules.ts` |
23
+ | `.sensitive()`'s two readers: `maskSensitive`, `assertNoSensitiveOutput` | `sensitive.ts` |
24
+ | `@ultimat3/schema/sample`, off the barrel: `sampleInput` (the minimal value), `arbitrary(schema, { seed, run })` (the contract step's generated inputs), one walker over a `Draw` (`minimalDraw`, `seededDraw`), `sampleGaps` | `sample.ts`, `sample-draw.ts`, `sample-seeded.ts` |
20
25
 
21
26
  ## `t`
22
27
 
@@ -39,7 +44,7 @@ type PublishPost = Infer<typeof publishPost>;
39
44
 
40
45
  | Value schemas | Factories | Methods |
41
46
  |---|---|---|
42
- | `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)` |
47
+ | `string` `number` `boolean` `date` `uuid` `email` `url` | `object` `array` `enum` `literal` `union` `discriminatedUnion` `record` `optional` `nullable` `refine` | `.default(v)` `.optional()` `.nullable()` `.describe(s)` `.sensitive()` `.refine(r)` |
43
48
  | `money` `timezone` `locale` `slug` `cursor` `json()` | `object(...).extend/.pick/.omit` | `string.min/.max/.pattern`, `number.min/.max/.int` |
44
49
 
45
50
  `nullable` is a value the row holds; `optional` is the caller omitting the key. Both ship as a
@@ -47,6 +52,43 @@ namespace member (`t.nullable`) and a free function (`nullableSchema`) — symme
47
52
 
48
53
  Unknown object keys are **dropped**, never forwarded — an action cannot be mass-assigned.
49
54
 
55
+ ### `.sensitive()` — sent in, never written down, never sent back
56
+
57
+ `cardPan: t.string.sensitive()` is JSON Schema's `writeOnly`, published as such. Last in a chain:
58
+ it answers a plain `Schema`. Read by the walkers that hold the schema, never by a key table, so
59
+ flagging `note` on one action changes nothing another action records:
60
+
61
+ | Reader | What it does |
62
+ |---|---|
63
+ | `maskSensitive(schema, value, '[redacted]')` | a copy with every flagged field replaced — through objects, arrays, records and EVERY union branch (fail closed, no branch resolution). The same reference when nothing is flagged |
64
+ | `assertNoSensitiveOutput(owner, schema)` | `X_SCHEMA_SENSITIVE_OUTPUT` naming the path — an action's `output` and a query's `rows` are checked at registration |
65
+
66
+ Reach, exactly: the `audit: true` record of an action or a query, and `x jobs show` (a job whose
67
+ declaration is gone shows its input `[redacted]` whole). **Not** an app's own log line — a
68
+ `logger.info('x', input)` holds no schema; that stays `redactKeys` or a `Secret`. PII read back
69
+ and only hidden in logs is `redactKeys(['ssn'])`, not this flag.
70
+
71
+ ### An issue names its `rule`
72
+
73
+ Every builtin refusal carries `rule` from `SCHEMA_RULES` and the declared `params` a translator
74
+ interpolates — ``t(`form.${issue.rule}`, issue.params)``, never a match on the English `message`:
75
+
76
+ | `rule` | `params` |
77
+ |---|---|
78
+ | `required` (nothing sent) · `type` | — · `{ type }` (`string`, `number`, `object`, `money`, …) |
79
+ | `string.min` `string.max` | `{ min }` `{ max }` (characters) |
80
+ | `string.pattern` · `string.format` | `{ pattern }` · `{ format }` (`uuid`, `email`, `uri`, `timezone`, `locale`, `date-time`) |
81
+ | `number.min` `number.max` `number.integer` | `{ min }` `{ max }` — |
82
+ | `enum` · `literal` · `union` | `{ values }` (`a \| b`) · `{ value }` · — (a discriminated union: `{ values }`) |
83
+ | `array.min` `array.max` | `{ min }` `{ max }` (items) |
84
+ | `record.key` `json` `money` | — |
85
+ | `refine` | `{ name }` |
86
+
87
+ The values are contract like an `X_*` code: added, never renamed (`schema-rules.test.ts` pins the
88
+ list). `rule` is optional on the issue type — a third-party provider names none — and the exported
89
+ `fail(path, message, rule?, params?)` takes both optionally for a provider. Inside this package a
90
+ builtin refuses through `rejected`, whose `rule` is required.
91
+
50
92
  Refused at the boundary rather than guessed at (`As of 2026-10`):
51
93
 
52
94
  | Schema | Refuses | Because |
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/schema",
3
- "version": "29.3.2",
3
+ "version": "30.0.0",
4
4
  "description": "Ultimate's validation seam: Standard Schema interface, the t namespace, JSON Schema output",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "validation",
10
+ "schema",
11
+ "standard-schema",
12
+ "json-schema"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/The-Eight-Primitives",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "sideEffects": false,
@@ -15,7 +25,8 @@
15
25
  "provenance": true
16
26
  },
17
27
  "exports": {
18
- ".": "./src/index.ts"
28
+ ".": "./src/index.ts",
29
+ "./sample": "./src/sample.ts"
19
30
  },
20
31
  "files": [
21
32
  "src",
@@ -2,7 +2,16 @@
2
2
  // bounds. Its own file because the bounds are a declaration-time refusal as well as a parse rule,
3
3
  // and `validators.ts` was at the line ceiling.
4
4
 
5
- import { type AnySchema, checkOf, fail, failWith, makeSchema, pass, type Schema } from './builder';
5
+ import {
6
+ type AnySchema,
7
+ checkOf,
8
+ failWith,
9
+ makeSchema,
10
+ mistyped,
11
+ pass,
12
+ rejected,
13
+ type Schema,
14
+ } from './builder';
6
15
  import { describeValue, expected } from './describe-value';
7
16
  import { BoundsInvalidError } from './errors';
8
17
  import type { SchemaNode } from './node';
@@ -57,14 +66,18 @@ export function arraySchema<S extends AnySchema>(
57
66
  ...(max === undefined ? {} : { maxItems: max }),
58
67
  };
59
68
  return makeSchema<readonly InferInput<S>[], InferOutput<S>[]>(node, (value, path) => {
60
- if (!Array.isArray(value)) return fail(path, expected('an array', value));
69
+ if (!Array.isArray(value)) return mistyped(path, expected('an array', value), value, 'array');
61
70
  // The count before the items: an over-long array is refused without reading a member, so a
62
71
  // caller's 10,000-element body costs one comparison, not 10,000 item checks.
63
72
  if (min !== undefined && value.length < min) {
64
- return fail(path, expected(`an array of at least ${items(min)}`, value));
73
+ return rejected(path, expected(`an array of at least ${items(min)}`, value), 'array.min', {
74
+ min,
75
+ });
65
76
  }
66
77
  if (max !== undefined && value.length > max) {
67
- return fail(path, expected(`an array of at most ${items(max)}`, value));
78
+ return rejected(path, expected(`an array of at most ${items(max)}`, value), 'array.max', {
79
+ max,
80
+ });
68
81
  }
69
82
  const issues: StandardIssue[] = [];
70
83
  const out: unknown[] = [];
package/src/builder.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type ValidationIssue,
10
10
  } from './errors';
11
11
  import type { SchemaNode, SchemaRefinement } from './node';
12
+ import { issueRuleFacts, type SchemaIssueParams, type SchemaRule } from './schema-rules';
12
13
  import {
13
14
  formatIssues,
14
15
  formatPath,
@@ -42,8 +43,46 @@ export function pass<Out>(value: Out): CheckOk<Out> {
42
43
  return { ok: true, value };
43
44
  }
44
45
 
45
- export function fail(path: Path, message: string): CheckErr {
46
- return { ok: false, issues: [{ message, path: [...path] }] };
46
+ /**
47
+ * The PROVIDER's refusal: `rule` and `params` optional, because a library backing `t` may have no
48
+ * name for its rule. A builtin validator never calls this — it calls `rejected`, whose `rule` is
49
+ * required, so a builtin refusal a translator cannot key on is a type error (`schema-rules.test.ts`
50
+ * pins that no builtin file reaches for `fail`).
51
+ */
52
+ export function fail(
53
+ path: Path,
54
+ message: string,
55
+ rule?: string,
56
+ params?: SchemaIssueParams,
57
+ ): CheckErr {
58
+ return {
59
+ ok: false,
60
+ issues: [
61
+ {
62
+ message,
63
+ path: [...path],
64
+ ...(rule === undefined ? {} : { rule }),
65
+ ...(params === undefined ? {} : { params }),
66
+ },
67
+ ],
68
+ };
69
+ }
70
+
71
+ /** Package-internal: a builtin's refusal, naming its rule from the closed `SCHEMA_RULES`. */
72
+ export function rejected(
73
+ path: Path,
74
+ message: string,
75
+ rule: SchemaRule,
76
+ params?: SchemaIssueParams,
77
+ ): CheckErr {
78
+ return fail(path, message, rule, params);
79
+ }
80
+
81
+ /** The wrong kind of value: `required` when nothing was sent, `type` naming what was wanted. */
82
+ export function mistyped(path: Path, message: string, value: unknown, type: string): CheckErr {
83
+ return value === undefined
84
+ ? rejected(path, message, 'required')
85
+ : rejected(path, message, 'type', { type });
47
86
  }
48
87
 
49
88
  export function failWith(issues: readonly StandardIssue[]): CheckErr {
@@ -98,6 +137,12 @@ export interface Schema<In = unknown, Out = In> extends StandardSchemaV1<In, Out
98
137
  nullable(): Schema<In | null, Out | null>;
99
138
  default(value: Out): Schema<In | undefined, Out>;
100
139
  describe(description: string): Schema<In, Out>;
140
+ /**
141
+ * Sent in, never written down, never sent back. The audit record and `x jobs show` mask it, it
142
+ * publishes as `writeOnly`, and an output that reaches it is `X_SCHEMA_SENSITIVE_OUTPUT`. NOT the
143
+ * app's own log lines — a `logger.info('x', input)` holds no schema; that stays `redactKeys`.
144
+ */
145
+ sensitive(): Schema<In, Out>;
101
146
  /**
102
147
  * Attach a cross-field rule. Returns a plain `Schema` on purpose — `extend`/`pick`/`omit`
103
148
  * rebuild from the shape and would silently drop the refinement, so refining last is a type
@@ -191,6 +236,7 @@ function toIssues(result: CheckErr): readonly ValidationIssue[] {
191
236
  expected: issue.message,
192
237
  received: '',
193
238
  message: issue.message,
239
+ ...issueRuleFacts(issue),
194
240
  }));
195
241
  }
196
242
 
@@ -242,6 +288,9 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
242
288
  describe(description: string): Schema<In, Out> {
243
289
  return makeSchema<In, Out>({ ...node, description }, check);
244
290
  },
291
+ sensitive(): Schema<In, Out> {
292
+ return makeSchema<In, Out>({ ...node, sensitive: true }, check);
293
+ },
245
294
  refine(refinement: Refinement<Out>): Schema<In, Out> {
246
295
  const declared: SchemaRefinement = {
247
296
  name: refinement.name,
@@ -257,7 +306,9 @@ export function makeSchema<In, Out>(node: SchemaNode, check: Check<Out>): Schema
257
306
  if (!result.ok) return result;
258
307
  return refinement.check(result.value)
259
308
  ? result
260
- : fail([...path, ...(refinement.path ?? [])], refinement.message);
309
+ : rejected([...path, ...(refinement.path ?? [])], refinement.message, 'refine', {
310
+ name: refinement.name,
311
+ });
261
312
  },
262
313
  );
263
314
  },
@@ -270,6 +321,7 @@ export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
270
321
  return (value, path) => {
271
322
  const result = schema['~standard'].validate(value);
272
323
  if (isThenable(result)) {
324
+ // A provider fault, not a rule a value broke: no `rule`, so a form falls back to the text.
273
325
  return fail(path, 'expected a synchronous schema, received an async one');
274
326
  }
275
327
  if (result.issues === undefined) return pass(result.value);
@@ -277,6 +329,8 @@ export function checkOf<In, Out>(schema: Schema<In, Out>): Check<Out> {
277
329
  result.issues.map((issue) => ({
278
330
  message: issue.message,
279
331
  path: [...path, ...(issue.path ?? [])],
332
+ // A composite re-roots a member's issues; the rule that refused rides along unchanged.
333
+ ...issueRuleFacts(issue),
280
334
  })),
281
335
  );
282
336
  };
@@ -6,9 +6,10 @@ import {
6
6
  type AnySchema,
7
7
  type Check,
8
8
  checkOf,
9
- fail,
10
9
  isPlainObject,
11
10
  makeSchema,
11
+ mistyped,
12
+ rejected,
12
13
  type Schema,
13
14
  } from './builder';
14
15
  import { expected } from './describe-value';
@@ -101,13 +102,23 @@ export function discriminatedUnionSchema<S extends readonly [AnySchema, ...AnySc
101
102
 
102
103
  return makeSchema<InferInput<S[number]>, Out>(node, (value, path) => {
103
104
  if (!isPlainObject(value)) {
104
- return fail(path, expected(`an object with a "${discriminant}" discriminant`, value));
105
+ return mistyped(
106
+ path,
107
+ expected(`an object with a "${discriminant}" discriminant`, value),
108
+ value,
109
+ 'object',
110
+ );
105
111
  }
106
112
  const branch = branches.get(value[discriminant]);
107
113
  if (branch === undefined) {
108
- return fail(
114
+ const listed = renderKnown(known);
115
+ return rejected(
109
116
  [...path, discriminant],
110
- expected(`one of ${renderKnown(known)}`, value[discriminant]),
117
+ expected(`one of ${listed}`, value[discriminant]),
118
+ 'union',
119
+ {
120
+ values: listed,
121
+ },
111
122
  );
112
123
  }
113
124
  // The named branch's issues, and only those: the caller already said which shape they meant,
@@ -28,4 +28,5 @@ export const SCHEMA_ERROR_CODES: Readonly<Record<string, SchemaErrorCodeDeclarat
28
28
  },
29
29
  X_SCHEMA_DEFAULT_INVALID: { title: 'a schema default fails its own schema' },
30
30
  X_SCHEMA_BOUNDS_INVALID: { title: 'an array bound no array can satisfy' },
31
+ X_SCHEMA_SENSITIVE_OUTPUT: { title: 'a sensitive field is reachable from an output' },
31
32
  });
package/src/errors.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { ULTIMATE_ERROR_BRAND } from './error-brand';
6
6
  import { SCHEMA_ERROR_CODES } from './error-codes';
7
7
  import { renderMetaRecord } from './render-meta';
8
+ import type { SchemaIssueParams } from './schema-rules';
8
9
  import { formatIssues } from './standard';
9
10
 
10
11
  /**
@@ -158,6 +159,9 @@ export interface ValidationIssue {
158
159
  readonly expected: string;
159
160
  readonly received: string;
160
161
  readonly message: string;
162
+ /** Which rule refused (`SCHEMA_RULES`), when the schema said. A `string`: a newer server may name one this build does not know. */
163
+ readonly rule?: string;
164
+ readonly params?: SchemaIssueParams;
161
165
  }
162
166
 
163
167
  export class ValidationFailedError extends SchemaError {
@@ -223,6 +227,19 @@ export class BoundsInvalidError extends SchemaError {
223
227
  }
224
228
  }
225
229
 
230
+ /**
231
+ * Thrown where an action or a query is REGISTERED: a `.sensitive()` field reachable from what the
232
+ * server sends back contradicts `writeOnly` for every response, so boot is the honest place.
233
+ */
234
+ export class SensitiveOutputError extends SchemaError {
235
+ static readonly code = 'X_SCHEMA_SENSITIVE_OUTPUT';
236
+ override readonly name = 'SensitiveOutputError';
237
+
238
+ constructor(init: Omit<SchemaErrorInit, 'code'>) {
239
+ super({ ...init, code: SensitiveOutputError.code });
240
+ }
241
+ }
242
+
226
243
  export class SchemaUnsupportedError extends SchemaError {
227
244
  static readonly code = 'X_SCHEMA_UNSUPPORTED';
228
245
  override readonly name = 'SchemaUnsupportedError';
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  isSchemaError,
44
44
  SchemaError,
45
45
  SchemaUnsupportedError,
46
+ SensitiveOutputError,
46
47
  ValidationFailedError,
47
48
  } from './errors';
48
49
  // Exported for `@ultimat3/time`'s `fromIso`: one ISO-shape rule, whichever door a date comes in by.
@@ -75,6 +76,10 @@ export {
75
76
  schemaProvider,
76
77
  tryIntrospect,
77
78
  } from './provider';
79
+ export { recordSchema } from './record-schema';
80
+ export type { IssueRuleFacts, SchemaIssueParams, SchemaRule } from './schema-rules';
81
+ export { issueRuleFacts, SCHEMA_RULES } from './schema-rules';
82
+ export { assertNoSensitiveOutput, maskSensitive } from './sensitive';
78
83
  export type {
79
84
  FormattableIssue,
80
85
  InferInput,
@@ -114,7 +119,6 @@ export {
114
119
  nullableSchema,
115
120
  objectSchema,
116
121
  optionalSchema,
117
- recordSchema,
118
122
  refineSchema,
119
123
  unionSchema,
120
124
  } from './validators';
@@ -59,6 +59,8 @@ export interface JsonSchema {
59
59
  readonly anyOf?: readonly JsonSchema[];
60
60
  readonly discriminator?: JsonSchemaDiscriminator;
61
61
  readonly title?: string;
62
+ /** `.sensitive()`: a value the caller sends and the server never returns. */
63
+ readonly writeOnly?: true;
62
64
  /**
63
65
  * The refinements a consumer can act on mechanically. The prose copy also lands in
64
66
  * `description`, because that is the only field an LLM reading an MCP tool schema is
@@ -97,7 +99,7 @@ const FORMAT_MAP: Readonly<Record<string, string>> = Object.freeze({
97
99
  * `Object.hasOwn`, never the read alone: `node.format` comes from a schema PROVIDER's IR, so
98
100
  * `FORMAT_MAP['constructor']` answered with the `Object` function and `?? node.format` could not
99
101
  * rescue it — `JSON.stringify` then dropped `format` from the published document in silence.
100
- * Same discriminator as `@ultimat3/action`'s `BY_FORMAT[node.format]`, one package up.
102
+ * Same discriminator as `sample-draw.ts`'s `BY_FORMAT[node.format]`.
101
103
  */
102
104
  const publishedFormat = (format: string): string =>
103
105
  Object.hasOwn(FORMAT_MAP, format) ? (FORMAT_MAP[format] ?? format) : format;
@@ -185,6 +187,7 @@ function convert(node: SchemaNode): JsonSchema {
185
187
  const annotations: JsonSchema = {
186
188
  ...(described === undefined ? {} : { description: described }),
187
189
  ...(node.hasDefault === true ? { default: node.default } : {}),
190
+ ...(node.sensitive === true ? { writeOnly: true as const } : {}),
188
191
  ...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }),
189
192
  };
190
193
 
package/src/json-value.ts CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  import {
6
6
  type Check,
7
- fail,
8
7
  failWith,
9
8
  isPlainObject,
10
9
  makeSchema,
11
10
  pass,
11
+ rejected,
12
12
  type Schema,
13
13
  } from './builder';
14
14
  import { expected } from './describe-value';
@@ -69,7 +69,7 @@ const CYCLE = 'expected a JSON value, received a cycle (a reference back to an e
69
69
  const NOT_PLAIN = 'expected a JSON value, received an object that is not a plain object or array';
70
70
 
71
71
  function refuse(state: Walk, at: At | readonly PropertyKey[], message: string): null {
72
- state.issues.push({ message, path: pathOf(at) });
72
+ state.issues.push({ message, path: pathOf(at), rule: 'json' });
73
73
  return null;
74
74
  }
75
75
 
@@ -180,7 +180,7 @@ const checkJson: Check<JsonValue> = (value, path) => {
180
180
  // Every refusal pushes its issue; the fallback only keeps a refusal from ever reading as success.
181
181
  return state.issues.length > 0
182
182
  ? failWith(state.issues)
183
- : fail(path, expected('a JSON value', value));
183
+ : rejected(path, expected('a JSON value', value), 'json');
184
184
  };
185
185
 
186
186
  /**
@@ -3,7 +3,7 @@
3
3
  // — `@ultimat3/money`'s `Money`, `@ultimat3/entity`'s `MoneyValue` — so it earns a file a reader
4
4
  // can open by name instead of scrolling to.
5
5
 
6
- import { fail, failWith, isPlainObject, makeSchema, pass, type Schema } from './builder';
6
+ import { failWith, isPlainObject, makeSchema, mistyped, pass, type Schema } from './builder';
7
7
  import { expected } from './describe-value';
8
8
  import type { StandardIssue } from './standard';
9
9
 
@@ -101,7 +101,9 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
101
101
  },
102
102
  },
103
103
  (value, path) => {
104
- if (!isPlainObject(value)) return fail(path, expected('a Money object', value));
104
+ if (!isPlainObject(value)) {
105
+ return mistyped(path, expected('a Money object', value), value, 'money');
106
+ }
105
107
  const minor = value['minor'];
106
108
  const currency = value['currency'];
107
109
  const scale = value['scale'];
@@ -113,12 +115,14 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
113
115
  issues.push({
114
116
  message: expected('a safe integer number of minor units', minor),
115
117
  path: [...path, 'minor'],
118
+ rule: 'money',
116
119
  });
117
120
  }
118
121
  if (!isCurrencyCode(currency)) {
119
122
  issues.push({
120
123
  message: expected('a 3-letter ISO 4217 code', currency),
121
124
  path: [...path, 'currency'],
125
+ rule: 'money',
122
126
  });
123
127
  }
124
128
  if (scale !== undefined && !isMoneyScale(scale)) {
@@ -128,6 +132,7 @@ export const moneySchema: Schema<MoneyValue, MoneyValue> = makeSchema<MoneyValue
128
132
  scale,
129
133
  ),
130
134
  path: [...path, 'scale'],
135
+ rule: 'money',
131
136
  });
132
137
  }
133
138
  if (issues.length > 0) return failWith(issues);
package/src/node.ts CHANGED
@@ -54,6 +54,11 @@ export interface SchemaNode {
54
54
  readonly hasDefault?: boolean | undefined;
55
55
  readonly default?: unknown;
56
56
  readonly description?: string | undefined;
57
+ /**
58
+ * `.sensitive()`: sent in, never written down by the framework, never sent back — JSON Schema's
59
+ * `writeOnly`. Read by the walkers that hold this schema (`maskSensitive`), never a key table.
60
+ */
61
+ readonly sensitive?: boolean | undefined;
57
62
  readonly format?: SchemaFormat | undefined;
58
63
  readonly minLength?: number | undefined;
59
64
  readonly maxLength?: number | undefined;
@@ -0,0 +1,78 @@
1
+ // Single responsibility: `t.record(values)` — a map of caller-chosen keys to one value schema.
2
+ // Its own file because a record key is CALLER data, unlike every other path segment, and the rules
3
+ // that follow from that (position paths, refused keys) are the whole of it.
4
+
5
+ import {
6
+ type AnySchema,
7
+ checkOf,
8
+ failWith,
9
+ isPlainObject,
10
+ makeSchema,
11
+ mistyped,
12
+ pass,
13
+ type Schema,
14
+ } from './builder';
15
+ import { expected } from './describe-value';
16
+ import type { SchemaNode } from './node';
17
+ import { isPrototypeKey, PROTOTYPE_KEYS } from './prototype-keys';
18
+ import type { InferInput, InferOutput, StandardIssue } from './standard';
19
+ import { unstorable } from './unstorable-text';
20
+
21
+ export function recordSchema<S extends AnySchema>(
22
+ values: S,
23
+ ): Schema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>> {
24
+ const valueCheck = checkOf(values);
25
+ const node: SchemaNode = { kind: 'record', valueNode: values.node };
26
+ return makeSchema<Readonly<Record<string, InferInput<S>>>, Record<string, InferOutput<S>>>(
27
+ node,
28
+ (value, path) => {
29
+ if (!isPlainObject(value))
30
+ return mistyped(path, expected('an object', value), value, 'record');
31
+ const issues: StandardIssue[] = [];
32
+ const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
33
+ for (const [index, [key, entry]] of Object.entries(value).entries()) {
34
+ // The entry's POSITION, never its key. `describe-value.ts`'s absolute is stated about the
35
+ // rejected value, and a record's KEY is the caller's data just as much: the path travels
36
+ // `formatIssue` -> `bodyInvalid`'s `cause` -> the problem document AND the log line, and
37
+ // core's logger redacts by key, so a key baked into a string has no key left to redact.
38
+ // `@ultimat3/http`'s `bodyInvalid` states the contract this was breaking in its own doc
39
+ // block — *"`issues` … must name only facts the framework itself chose"*. A record keyed
40
+ // by an email address, a phone number or a pasted credential wrote every one of them into
41
+ // the central log index, in the same shape the password bug did.
42
+ //
43
+ // The index rather than nothing at all: the segment's whole job is to say WHICH entry
44
+ // failed, and dropping it makes three failing entries render three identical lines. It is
45
+ // the same segment `arraySchema` already uses, so `formatPath` renders `meta[3]` with no
46
+ // new spelling to learn. It is `Object.entries` order — integer-like keys sort ahead of
47
+ // string ones — so it names an entry that exists rather than a byte offset in the body.
48
+ const at = [...path, index];
49
+ // A record is stored as `jsonb` far more often than not, and jsonb refuses an unstorable
50
+ // KEY exactly as it does a value — so the key is refused here, by position, not quoted.
51
+ const unfitKey = unstorable(key);
52
+ if (unfitKey !== undefined) {
53
+ issues.push({
54
+ message: `expected a record key, received one ${unfitKey}`,
55
+ path: at,
56
+ rule: 'record.key',
57
+ });
58
+ continue;
59
+ }
60
+ if (isPrototypeKey(key)) {
61
+ issues.push({
62
+ // The refused NAMES are a closed set declared right here, so the message may state
63
+ // them: that is the one part of this line the caller did not choose.
64
+ message: expected(`a record key that is not ${PROTOTYPE_KEYS.join(' | ')}`, key),
65
+ path: at,
66
+ rule: 'record.key',
67
+ });
68
+ continue;
69
+ }
70
+ const result = valueCheck(entry, at);
71
+ if (result.ok) out[key] = result.value;
72
+ else issues.push(...result.issues);
73
+ }
74
+ if (issues.length > 0) return failWith(issues);
75
+ return pass(out as Record<string, InferOutput<S>>);
76
+ },
77
+ );
78
+ }