@ultimat3/action 2.0.0 → 4.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
@@ -35,7 +35,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
35
35
  | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the memory sink, the installed-sink store |
36
36
  | `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
37
37
  | `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
38
- | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers |
38
+ | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer only — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
39
39
 
40
40
  ## Invariants
41
41
 
@@ -61,16 +61,32 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
61
61
  author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
62
62
  it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
63
63
  drives a row-level action over the real pipeline and counts the evaluations: exactly one.
64
- - **`stable.ts` holds TWO serializers, and they are not one function.** `stableStringify` is the
65
- DOCUMENT form: `serializeOpenApi` publishes it as `openapi.json` and `json-schema.ts` re-reads it
66
- with `JSON.parse`, so a non-finite number has to be `null` the bare token `NaN` would make a
67
- published spec unparseable. `canonicalJson` is the HASH form `fingerprint` is taken over, and it
68
- must be INJECTIVE: `NaN`, `±Infinity` and JSON `null` all encoded as `'null'` and `String(-0)` is
69
- `"0"`, so four distinct inputs shared one `requestHash` one caller handed another's stored
70
- response on replay and one job dedupe key. That is why the fix `@ultimat3/query` made in its own
71
- `stable.ts` could not simply be copied here; it needed the split first. Ordinary payloads are
72
- byte-identical between the two, so no idempotency record and no enqueued job moved.
73
- `stable.test.ts` pins both duties, including a `JSON.parse` of the document form.
64
+ - **`stable.ts` holds the DOCUMENT form and NOTHING else, `As of 2026-08`.** `stableStringify` is
65
+ published as `openapi.json` by `serializeOpenApi` and re-read with `JSON.parse` by
66
+ `json-schema.ts`, so a non-finite number has to be `null` and a `Date` has to be its ISO string —
67
+ the bare token `NaN` would make a published spec unparseable. The HASH form left this file:
68
+ `canonicalJson` + `fingerprint` are **`@ultimat3/core`'s**, because `@ultimat3/query` and
69
+ `@ultimat3/realtime` each held their own copy of the identical function and all three are tier 3,
70
+ so no two of them could import each other and a copy in any was a second answer for the other
71
+ two. They had already diverged query's had no `Date` branch, so every date window of a read
72
+ shared one cache key. Nothing about this package's keys moved: `fingerprint` is the same code at
73
+ the same width, `stable.test.ts` still pins the document duty (a `JSON.parse` of what it emits)
74
+ and now pins the byte-equality against core's form for an ordinary payload, which is what says no
75
+ idempotency record and no enqueued job moved.
76
+ **Why the hash form must be a separate function at all** — the reason that survives the move.
77
+ `NaN`, `±Infinity` and JSON `null` all encoded as `'null'` and `String(-0)` is `"0"`, so four
78
+ distinct inputs shared one `requestHash` (one caller handed another's stored response on replay)
79
+ and one job dedupe key. And three more values folded onto `{}`, the first of which `t.date`
80
+ produces on every parse: a `Date`, a `Map` and a `Set` have no own enumerable key, so
81
+ `Object.keys` was empty and the object branch rendered all three `{}` —
82
+ `fingerprint({ x: new Map([['a', 1]]) })` equalled `fingerprint({ x: new Set([1, 2]) })` equalled
83
+ `fingerprint({ x: {} })`, and an `idempotent: true` action taking `t.object({ at: t.date })`,
84
+ called twice under one key with two DIFFERENT dates, handed the second caller the first one's
85
+ stored response with no `X_IDEMPOTENCY_CONFLICT` and the handler run once. The two forms disagree
86
+ about all four exactly as they disagree about numbers: the document form is `JSON.stringify`'s own
87
+ rendering, and the hash form TAGS them — `Date(<epoch>)`, `Map(k:v,...)`, `Set(v,...)`. The tag is
88
+ not decoration: an untagged epoch is the same token a `t.number` field holding that epoch emits.
89
+ Map and Set entries are SORTED, as object keys are.
74
90
  - **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `packages/action/src/tags.ts`
75
91
  and `packages/query/src/tags.ts` were byte-identical, and both packages are tier 3, so neither can
76
92
  import the other and a copy in either is a second answer for the other. `tagKey` went with it: it
@@ -201,8 +217,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
201
217
  can drive it by hand and still get the action's parse + policy. **The missing halves are
202
218
  `tenant` and `retry`**, both required on `JobDefinition` with deliberately no default, so "zero
203
219
  rewriting" was never reachable regardless of wiring. The bridge is one `job({ … })` call, and it
204
- belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. Not built here — that is
205
- code, and this was a comment correction.
220
+ belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. **It exists, `As of
221
+ 2026-08`** — `agentJob()` in `@ultimat3/ai` (`agent-job.ts`), tier 4, which takes an `Action` and
222
+ nothing agent-specific and supplies the two missing halves as its own options. So "nothing in the
223
+ framework consumes it", which `job-handle.ts`'s header said until this was checked, is false: it
224
+ has one consumer, and an app writes the same three lines.
206
225
  - An action has no `.def`. Inside the package read it with `defOf(target)`; outside,
207
226
  read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`.
208
227
  - **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased
@@ -332,6 +351,32 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
332
351
  `X_AUDIT_SINK_FAILED` fell into the failure branch and wrote a *second* record saying the
333
352
  action `failed` — for a handler that had committed. An audit trail lying about a write is
334
353
  worse than no audit trail; `audit.test.ts` counts the records a refusing sink was offered.
354
+ - **`auditOutcomeFor` is TOTAL, because both callers ask inside a `catch`.**
355
+ `error instanceof ActionDeniedError` runs a `Proxy`'s `getPrototypeOf` trap, and the two call
356
+ sites are `execute`'s span attribute and the one place the `failed` record is produced — so a
357
+ handler throwing such a value made the probe throw *from the frame holding the app's error*,
358
+ and the caller got a `TypeError` in place of its own throwable. It fails closed to `failed`: a
359
+ value that refuses to be examined is not evidence of a policy denial. Same rule as core's
360
+ `isThrownError` / `isUltimateError`, and the same defect `@ultimat3/http`'s `finalize.ts` and
361
+ `factsOf` carried; `audit.test.ts` asserts the caller's throwable by IDENTITY, which is the only
362
+ assertion that catches a replacement.
363
+ - **`json-schema.ts`'s refusal names `introspect()`, never a `toJsonSchema` member.**
364
+ `SchemaProvider` declares no such member and `toJsonSchema()` calls `introspect()`
365
+ unconditionally, so the old `fix:` told a reader to implement an API that does not exist —
366
+ axiom 4 inverted, and invisible to `x verify`'s `errors` step, which checks a fix line's shape
367
+ and never whether the API it names is real. The guard is `normalizeJsonSchema`, exported from
368
+ the module for its own test and absent from `src/index.ts` exactly as `sortSchema` is; the
369
+ shipped `toJsonSchema` cannot reach it today (it returns an object literal on every path), so
370
+ the test drives the guard directly rather than pretending a converter can be swapped.
371
+ - **A lookup table is read with `Object.hasOwn`, never with the index alone.** `IRREGULAR[word]`
372
+ in `naming.ts` and `BY_FORMAT[node.format]` in `sample-input.ts` both read the prototype chain:
373
+ `splitWords` lowercases, which keeps `toString` and `hasOwnProperty` out of reach, but
374
+ `constructor` is already lowercase and survived — so `pluralize('constructor')` answered the
375
+ `Object` FUNCTION where its return type says `string`, `derivePath('addConstructor')` mounted the
376
+ action at `/api/function Object() { [native code] }/add` and published that as its OpenAPI path
377
+ and `tags`, and a provider emitting `format: 'constructor'` put a function in the payload the
378
+ policy contract test invokes with. Both keys are caller- or provider-supplied, which is the whole
379
+ test for whether this applies. Same discriminator `packages/flags/src/subject.ts:75` uses.
335
380
  - App code reaches a projection through the action (`publishPost.tool()`), never through
336
381
  `.def` and never by importing the projection function. `facade.ts` is where a new method
337
382
  is bound; the projection itself keeps living in its own file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,10 +31,10 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/cache": "2.0.0",
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/http": "2.0.0",
37
- "@ultimat3/policy": "2.0.0",
38
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/cache": "4.0.0",
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/http": "4.0.0",
37
+ "@ultimat3/policy": "4.0.0",
38
+ "@ultimat3/schema": "4.0.0"
39
39
  }
40
40
  }
package/src/audit-gate.ts CHANGED
@@ -20,9 +20,23 @@ export function auditSinkFor(action: string): AuditSink {
20
20
  return sink;
21
21
  }
22
22
 
23
- /** An authz refusal is `denied`; everything else that threw is `failed`, an unparsed input included. */
23
+ /**
24
+ * An authz refusal is `denied`; everything else that threw is `failed`, an unparsed input
25
+ * included.
26
+ *
27
+ * TOTAL, the same rule core's `isThrownError` states: `instanceof` runs a `Proxy`'s
28
+ * `getPrototypeOf` trap, and both callers ask this question inside a `catch` that is still
29
+ * holding the app's own error — `execute`'s span attribute and the one place the `failed` record
30
+ * is produced. A probe that threw there REPLACED the caller's throwable with its own `TypeError`,
31
+ * so an app catching by `instanceof` upstream stopped matching. A value that refuses to be
32
+ * examined is not evidence of a policy denial, so it fails closed to `failed`.
33
+ */
24
34
  export function auditOutcomeFor(error: unknown): AuditOutcome {
25
- return error instanceof ActionDeniedError ? 'denied' : 'failed';
35
+ try {
36
+ return error instanceof ActionDeniedError ? 'denied' : 'failed';
37
+ } catch {
38
+ return 'failed';
39
+ }
26
40
  }
27
41
 
28
42
  export function auditFailureFor(error: unknown): AuditFailure {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
- import { createContext, isUltimateError } from '@ultimat3/core';
8
+ import { createContext, isUltimateError, logger } from '@ultimat3/core';
9
9
  import type { AnyAction } from './action';
10
10
  import { ActionDeniedError, ContractDriftError } from './errors';
11
11
  import { actionName, invoke } from './invoke';
@@ -51,8 +51,9 @@ export function contractTestsFor(
51
51
  await expectThrow(
52
52
  () => invoke(target, garbage, { ctx, surface: 'http' }),
53
53
  'X_INPUT_INVALID',
54
- `${name} accepted ${JSON.stringify(garbage) ?? 'undefined'} as input`,
54
+ `${name} accepted the value this assertion sent as garbage`,
55
55
  `tighten \`input:\` in the ${name} definition`,
56
+ { action: name, garbage },
56
57
  );
57
58
  },
58
59
  },
@@ -75,7 +76,7 @@ export function contractTestsFor(
75
76
  if (document.paths[path] === undefined) {
76
77
  throw new ContractDriftError(
77
78
  `OpenAPI document has no entry for ${path}`,
78
- 'x verify --contract',
79
+ 'x verify --json # the contract suite is a step of it',
79
80
  );
80
81
  }
81
82
  },
@@ -163,12 +164,21 @@ async function expectDenied(
163
164
  );
164
165
  }
165
166
 
167
+ /**
168
+ * `fields` and not a rendered value: `garbage` is the caller's, typed `unknown`, and it was
169
+ * interpolated into `cause` with `JSON.stringify` — which throws on a circular object and on a
170
+ * `bigint`. That throw happened on the way INTO this helper, so the assertion never ran and the
171
+ * failure reported was the stringifier's, not the schema's. The logger takes the value as a field
172
+ * and shapes it itself, the way `cache-gate.ts` and `idempotency.ts` hand it their `error`.
173
+ */
166
174
  async function expectThrow(
167
175
  run: () => Promise<unknown>,
168
176
  code: string,
169
177
  cause: string,
170
178
  fix: string,
179
+ fields: Readonly<Record<string, unknown>>,
171
180
  ): Promise<void> {
181
+ const report = (): void => logger.error('action.contract.assertion-failed', { ...fields, code });
172
182
  try {
173
183
  await run();
174
184
  } catch (error) {
@@ -179,7 +189,9 @@ async function expectThrow(
179
189
  // answers it. It keeps its own code and its own runnable fix — the same rule `expectDenied`
180
190
  // follows for every code it cannot attribute to `input:`.
181
191
  if (error.code === 'X_AUDIT_SINK_MISSING') throw error;
192
+ report();
182
193
  throw new ContractDriftError(`${cause} (got ${error.code}, expected ${code})`, fix);
183
194
  }
195
+ report();
184
196
  throw new ContractDriftError(cause, fix);
185
197
  }
package/src/define-api.ts CHANGED
@@ -132,7 +132,14 @@ function moduleList(modules: ApiModules | undefined): readonly ApiModule[] {
132
132
  * a name no surface serves, and the last module exporting that name would win in silence.
133
133
  */
134
134
  function byRegisteredName(primitives: readonly RegisteredPrimitive[]): ApiModule {
135
- const map: Record<string, RegisteredPrimitive> = {};
135
+ // A null-prototype map, because `name` is an export name and `map['__proto__'] = value` on a
136
+ // plain object runs the PROTOTYPE SETTER instead of adding a key: the primitive would vanish
137
+ // from `Object.keys`, from `api.actions` and from `rpc()`, while `registerPrimitive` reported
138
+ // it registered. `Object.create(null)` has no such accessor, so the assignment is a key.
139
+ const map: Record<string, RegisteredPrimitive> = Object.create(null) as Record<
140
+ string,
141
+ RegisteredPrimitive
142
+ >;
136
143
  for (const primitive of primitives) map[primitive.name] = primitive;
137
144
  return Object.freeze(map);
138
145
  }
package/src/http.ts CHANGED
@@ -95,7 +95,17 @@ export function toRoute(target: AnyAction): Route {
95
95
  // stage deciding first would decide from `row: null` — a denial for the row's own
96
96
  // author, from an authz system that never saw the row.
97
97
  enforcedBy: 'handler',
98
- input: def.input,
98
+ // `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
99
+ // Setting it hands the action's schema to the pipeline's `body` stage, which throws
100
+ // `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
101
+ // the operation two hundred lines down published `400 X_INPUT_INVALID`, and the SAME action
102
+ // called over MCP, over the typed client or directly answered `X_INPUT_INVALID`. One action,
103
+ // one input schema, two codes, decided by the surface the call arrived through.
104
+ //
105
+ // `invoke`'s own `validateInput` is the one parser now, and nothing is lost by dropping this:
106
+ // `bodyRaw()` still parses by content-type, caches, and enforces the size cap — the body stage
107
+ // only ever added SCHEMA validation on top. `X_BODY_INVALID` keeps its job, which is a body
108
+ // failing a plain `route.ts`'s own schema, where no primitive owns the input.
99
109
  cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
100
110
  tags: [resource],
101
111
  // Name AND numbers. The name alone selected a bucket the limiter's table never held, so
@@ -145,7 +155,15 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
145
155
  description: 'ok',
146
156
  content: { 'application/json': { schema: { $ref: schemaRef(outputSchemaName(name)) } } },
147
157
  },
158
+ // BOTH, because they are two different failures and this operation published only one of
159
+ // them while the route answered only the other. `X_INPUT_INVALID` is the body that parsed
160
+ // and failed THIS action's declared schema — the primitive's own code, identical over MCP,
161
+ // the typed client and a direct call. `X_BODY_INVALID` is the bytes never becoming a body
162
+ // at all: malformed JSON, over `bodyLimitBytes`, an unreadable content type. That one is
163
+ // HTTP-only, because only HTTP has bytes, and it is raised in `bodyRaw()` before any schema
164
+ // exists to fail.
148
165
  '400': problemResponse('X_INPUT_INVALID'),
166
+ '422': problemResponse('X_BODY_INVALID'),
149
167
  '403': problemResponse('policy denied'),
150
168
  ...(idempotent ? { '409': problemResponse('X_IDEMPOTENCY_CONFLICT') } : {}),
151
169
  },
@@ -4,14 +4,13 @@
4
4
  * a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
5
5
  * double charge is worse than a 409.
6
6
  */
7
- import { isUltimateError, logger } from '@ultimat3/core';
7
+ import { fingerprint, isUltimateError, logger } from '@ultimat3/core';
8
8
  import {
9
9
  IdempotencyConflictError,
10
10
  IdempotencyNotSharedError,
11
11
  IdempotencyReplayedFailureError,
12
12
  } from './errors';
13
13
  import { MemoryIdempotencyStore } from './idempotency-memory';
14
- import { fingerprint } from './stable';
15
14
 
16
15
  /**
17
16
  * Where a store's records live. Declared by the driver, never inferred — the same rule
package/src/job-handle.ts CHANGED
@@ -2,13 +2,14 @@
2
2
  * Projection 5: an action as durable work — its input schema, a payload-derived
3
3
  * idempotency key, and an `invoke` that runs the action's one execution path under
4
4
  * `surface: 'job'`, so a queued run gets the same validation and policy evaluation
5
- * as the HTTP call. Nothing in the framework consumes itan app bridges it into `job()`.
5
+ * as the HTTP call. `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any
6
+ * `Action` and composes `job()` around this shape — and an app may write the same three lines.
6
7
  */
7
8
  import type { Ctx } from '@ultimat3/core';
9
+ import { fingerprint } from '@ultimat3/core';
8
10
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
9
11
  import type { Action } from './action';
10
12
  import { actionName, invoke } from './invoke';
11
- import { fingerprint } from './stable';
12
13
 
13
14
  /**
14
15
  * **`@ultimat3/jobs` does not consume this, and cannot as written** (`As of 2026-08`; the header
@@ -24,9 +25,10 @@ import { fingerprint } from './stable';
24
25
  * `tenant` and `retry` are what the bridge cannot fill: both are REQUIRED on `JobDefinition` with
25
26
  * no default, on purpose — jobs states that every candidate default for `tenant` is a
26
27
  * cross-tenant read waiting to happen. So "enqueueing an action costs zero rewriting" was never
27
- * reachable; two facts an action does not declare have to come from somewhere. Whoever closes
28
- * this writes the adapter in the app or at tier 4+: `action` and `jobs` are both tier 3, so
29
- * neither may import the other.
28
+ * reachable; two facts an action does not declare have to come from somewhere. The adapter that
29
+ * supplies them is `agentJob()` in `@ultimat3/ai` (`agent-job.ts`) tier 4, which is where this
30
+ * paragraph said it belongs, since `action` and `jobs` are both tier 3 and neither may import the
31
+ * other. It takes an `Action` and nothing agent-specific, so it is the bridge for any action.
30
32
  */
31
33
  export interface ActionJobHandle<
32
34
  TInput extends StandardSchemaV1 = StandardSchemaV1,
@@ -20,22 +20,33 @@ export type JsonSchemaObject = Record<string, unknown>;
20
20
  * reach a projection that throws.
21
21
  */
22
22
  export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
23
- return normalize(() => toJsonSchema(schema));
23
+ return normalizeJsonSchema(() => toJsonSchema(schema));
24
24
  }
25
25
 
26
26
  /** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
27
27
  export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
28
- return normalize(() => toMcpInputSchema(schema));
28
+ return normalizeJsonSchema(() => toMcpInputSchema(schema));
29
29
  }
30
30
 
31
- function normalize(convert: () => unknown): JsonSchemaObject {
31
+ /**
32
+ * The narrowing both projections share, and the refusal it earns: a converter that answered with
33
+ * something that is not a JSON object is the same failure by a quieter route, so it gets the same
34
+ * shipped code rather than a permissive node. Exported for its own test and nothing else — it is
35
+ * absent from `src/index.ts`, exactly as `sortSchema` is.
36
+ *
37
+ * **The `fix:` names `introspect`, never a `toJsonSchema` member.** `SchemaProvider` declares no
38
+ * such member (`packages/schema/src/provider.ts`) and `toJsonSchema()` calls `introspect()`
39
+ * unconditionally, so the old line — "configure a provider whose toJsonSchema returns an object" —
40
+ * instructed a reader to implement an API that does not exist, which is axiom 4 inverted. It is
41
+ * the same false clause `@ultimat3/schema` deleted from its own docs; this was the user-visible
42
+ * half, one package over.
43
+ */
44
+ export function normalizeJsonSchema(convert: () => unknown): JsonSchemaObject {
32
45
  const raw: unknown = convert();
33
46
  if (isJsonObject(raw)) return raw;
34
- // A converter that answered with something that is not a JSON object is the same failure by a
35
- // quieter route, so it gets the same shipped code rather than a permissive node.
36
47
  throw new SchemaUnsupportedError({
37
48
  cause: `the schema converted to ${raw === null ? 'null' : typeof raw}, not a JSON Schema object`,
38
- fix: 'declare the schema with `t` from @ultimat3/action, or configure a provider whose toJsonSchema returns an object: configureSchemaProvider({ ... })',
49
+ fix: 'declare the schema with `t` from @ultimat3/action, or add an introspect() returning a SchemaNode to the object passed to configureSchemaProvider()',
39
50
  });
40
51
  }
41
52
 
package/src/naming.ts CHANGED
@@ -40,8 +40,12 @@ export function splitWords(name: string): string[] {
40
40
  * alone, so `publishPosts` and `publishPost` agree on the `posts` resource.
41
41
  */
42
42
  export function pluralize(word: string): string {
43
- const irregular = IRREGULAR[word];
44
- if (irregular !== undefined) return irregular;
43
+ // `Object.hasOwn`, never a truthiness check on the read: `IRREGULAR['constructor']` is the
44
+ // `Object` FUNCTION off the prototype chain, not `undefined`, and `splitWords` lowercases —
45
+ // which keeps `toString` out of reach and lets `constructor` straight through. `pluralize` is
46
+ // public API returning `string`, and `derivePath` publishes what it answers as the action's
47
+ // HTTP path, its OpenAPI `paths` key and its `tags` entry.
48
+ if (Object.hasOwn(IRREGULAR, word)) return IRREGULAR[word] ?? word;
45
49
  if (word.endsWith('s')) return word;
46
50
  if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
47
51
  if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
@@ -33,7 +33,11 @@ function sampleString(node: SchemaNode): string {
33
33
  if (node.format !== undefined) {
34
34
  // A format value is already the exact shape its validator wants; padding or truncating it
35
35
  // to a length bound would break the only thing that makes it valid.
36
- const known: string | undefined = BY_FORMAT[node.format];
36
+ // `Object.hasOwn`, never the read alone: `node.format` comes from a provider's IR, and
37
+ // `BY_FORMAT['constructor']` is the `Object` function off the prototype chain rather than
38
+ // `undefined` — a function in the payload the policy assertion invokes with, where the type
39
+ // says `string`. Same discriminator `naming.ts` uses on its irregular-plural table.
40
+ const known = Object.hasOwn(BY_FORMAT, node.format) ? BY_FORMAT[node.format] : undefined;
37
41
  return known ?? SAMPLE_STRING;
38
42
  }
39
43
  const min = node.minLength ?? 0;
@@ -172,6 +176,10 @@ function patternAt(node: SchemaNode | undefined, path: string): string | undefin
172
176
  if (node === undefined) return undefined;
173
177
  if (path === '' || path === ROOT_PATH) return node.pattern;
174
178
  const [head, ...rest] = path.split('.');
175
- const child = head === undefined ? undefined : node.properties?.[head];
179
+ // `path` is the caller's `describeSampleGap` is exported — so the same own-property read the
180
+ // format table takes: `properties['constructor']` is otherwise the `Object` function.
181
+ const properties = node.properties ?? {};
182
+ const child =
183
+ head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
176
184
  return child === undefined ? undefined : patternAt(child, rest.join('.'));
177
185
  }
package/src/stable.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
- * Deterministic JSON in two forms, deliberately NOT one function. `stableStringify` is the
3
- * DOCUMENT form — `openapi.json` is published from it and `json-schema.ts` re-reads it with
4
- * `JSON.parse`, so a non-finite number must be `null`. `canonicalJson` is the HASH form it is
5
- * only ever hashed, so it must be INJECTIVE. One walk, two number rules.
2
+ * Deterministic JSON, DOCUMENT form. `serializeOpenApi` publishes this string as `openapi.json`
3
+ * and `json-schema.ts` re-reads it with `JSON.parse`, so what it emits must be valid JSON: a
4
+ * non-finite number is `null` and a `Date` is what `JSON.stringify` makes of one.
5
+ *
6
+ * The HASH form is `@ultimat3/core`'s `canonicalJson`, and the two are deliberately NOT one
7
+ * function. That one must be INJECTIVE, so it emits bare `NaN` / `Infinity` / `-0` tokens and tags
8
+ * a `Date`, a `Map` and a `Set` — none of which parses, which is exactly why it may not be
9
+ * published. Ordinary payloads are byte-identical between the two; `stable.test.ts` pins that.
6
10
  */
7
11
 
8
12
  export type JsonObject = Record<string, unknown>;
@@ -11,47 +15,19 @@ export function isJsonObject(value: unknown): value is JsonObject {
11
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
16
  }
13
17
 
14
- /** How the two forms disagree, and the only thing they disagree about. */
15
- type NumberForm = (value: number) => string;
16
-
17
- /** JSON's own rule: a non-finite number has no token in the grammar, so it is `null`. */
18
- const jsonNumber: NumberForm = (value) => (Number.isFinite(value) ? String(value) : 'null');
19
-
20
- /**
21
- * Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
22
- * word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
23
- * JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
24
- * one `requestHash` and therefore one idempotency record. `-0` is spelled out for the same reason:
25
- * `String(-0)` is `"0"`, so `-0` and `0` were one record too. The twin of `@ultimat3/query`'s rule
26
- * in its own `stable.ts`; both are tier 3, so neither can import the other.
27
- */
28
- const hashNumber: NumberForm = (value) => {
29
- if (Number.isNaN(value)) return 'NaN';
30
- if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
31
- return Object.is(value, -0) ? '-0' : String(value);
32
- };
33
-
34
18
  /** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
35
19
  export function stableStringify(value: unknown, indent = 0): string {
36
- return write(value, indent, 0, jsonNumber);
37
- }
38
-
39
- /**
40
- * The canonical form a `fingerprint` is taken over. Byte-identical to `stableStringify(value)` for
41
- * any value carrying no `NaN`, no `±Infinity` and no `-0`, which is why no idempotency record and
42
- * no job dedupe key issued before this moved.
43
- */
44
- export function canonicalJson(value: unknown): string {
45
- return write(value, 0, 0, hashNumber);
20
+ return write(value, indent, 0);
46
21
  }
47
22
 
48
- function write(value: unknown, indent: number, depth: number, number: NumberForm): string {
23
+ function write(value: unknown, indent: number, depth: number): string {
49
24
  if (value === null) return 'null';
50
25
  switch (typeof value) {
51
26
  case 'string':
52
27
  return JSON.stringify(value);
28
+ // JSON's own rule: a non-finite number has no token in the grammar, so it is `null`.
53
29
  case 'number':
54
- return number(value);
30
+ return Number.isFinite(value) ? String(value) : 'null';
55
31
  case 'boolean':
56
32
  return String(value);
57
33
  case 'bigint':
@@ -63,11 +39,17 @@ function write(value: unknown, indent: number, depth: number, number: NumberForm
63
39
  default:
64
40
  break;
65
41
  }
42
+ // Ahead of the object branch, because none of the three has an own enumerable key: `Object.keys`
43
+ // is empty for all of them, so the branch below would answer `{}` for a date as well. What each
44
+ // becomes is `JSON.stringify`'s own answer — the ISO string (`null` for an Invalid Date), and
45
+ // `{}` for a Map and a Set — because this string is published and re-parsed.
46
+ if (value instanceof Date) return JSON.stringify(value);
47
+ if (value instanceof Map || value instanceof Set) return '{}';
66
48
  const pad = indent > 0 ? '\n'.padEnd(1 + indent * (depth + 1), ' ') : '';
67
49
  const close = indent > 0 ? '\n'.padEnd(1 + indent * depth, ' ') : '';
68
50
  if (Array.isArray(value)) {
69
51
  if (value.length === 0) return '[]';
70
- const items = value.map((item) => write(item, indent, depth + 1, number));
52
+ const items = value.map((item) => write(item, indent, depth + 1));
71
53
  return `[${pad}${items.join(`,${pad || ''}`)}${close}]`;
72
54
  }
73
55
  const record = value as JsonObject;
@@ -77,27 +59,7 @@ function write(value: unknown, indent: number, depth: number, number: NumberForm
77
59
  if (keys.length === 0) return '{}';
78
60
  const gap = indent > 0 ? ' ' : '';
79
61
  const entries = keys.map(
80
- (key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1, number)}`,
62
+ (key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1)}`,
81
63
  );
82
64
  return `{${pad}${entries.join(`,${pad || ''}`)}${close}}`;
83
65
  }
84
-
85
- /**
86
- * SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/query`'s `fingerprint`
87
- * and `@ultimat3/realtime`'s `stableDigest` already chose, and for the same reason.
88
- *
89
- * A fingerprint here is a SHARING key over input a client chooses, not a checksum. It is the
90
- * `requestHash` that decides "same request, replay the stored response" and the job dedupe key
91
- * `job-handle.ts` files an enqueue under, so a collision hands one caller's stored response to a
92
- * different request, or drops an enqueue as a duplicate of a job it shares nothing with. FNV-1a/32
93
- * — what this was — is 4x10^9 values, brute-forceable offline in seconds, so a payload landing on
94
- * another request's hash was something an attacker could mint rather than something they had to
95
- * wait for.
96
- *
97
- * It is taken over `canonicalJson` and not `stableStringify` for the second half of the same
98
- * argument: the document form is not injective, so four distinct inputs shared one hash without
99
- * anyone having to mint anything.
100
- */
101
- export function fingerprint(value: unknown): string {
102
- return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
103
- }