@ultimat3/action 6.0.0 → 8.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
@@ -61,6 +61,21 @@ 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
+ - **`meta.auth` is derived from a WALK of the policy tree**, never from the root combinator.
65
+ `def.policy.kind === 'allow'` answered `'required'` for `or(allow(), can('x:y'))`, so the
66
+ pipeline's `auth` stage 401'd an anonymous caller the policy itself ALLOWS — while the MCP tool
67
+ and the job handle let that caller through the same object. One policy, a different answer per
68
+ surface, which is the thing `enforcedBy: 'handler'` exists to prevent. `'public'` here is not
69
+ "unguarded": `invoke` still evaluates the policy for every call.
70
+ **`admitsAnonymous` is `@ultimat3/policy`'s** (`policy.ts`, beside `policyPermissions`) and
71
+ reaches this package through `policy-gate.ts` like every other authz question — never a copy
72
+ here. It cannot be one: `@ultimat3/query` needs the identical answer and is the same tier, so a
73
+ copy in either is a second answer for the other, and the walk is a property of the combinators
74
+ `policy.ts` declares. It is EXACT rather than heuristic — with `actor === null`, `can()`
75
+ short-circuits before its predicate and `allow()`/`deny()` ignore their arguments, so the tree
76
+ alone decides. `packages/policy/src/policy.test.ts` asserts it against
77
+ `policy.run({ actor: null })` itself, case for case; `http.test.ts` proves this projection reads
78
+ the answer, over the real pipeline.
64
79
  - **`stable.ts` holds the DOCUMENT form and NOTHING else, `As of 2026-08`.** `stableStringify` is
65
80
  published as `openapi.json` by `serializeOpenApi` and re-read with `JSON.parse` by
66
81
  `json-schema.ts`, so a non-finite number has to be `null` and a `Date` has to be its ISO string —
@@ -140,16 +155,25 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
140
155
  What this does NOT close: an anonymous actor is one identity, so anonymous callers of a public
141
156
  idempotent action still share a key space — nothing at this tier can tell two apart, and keying
142
157
  on an IP or a cookie would break the retry the header exists to serve.
143
- - **Both stores FENCE a settlement on `in-flight`**, as `@ultimat3/jobs`' `SQL_ACK` fences on
144
- `state = 'running'`. A reservation whose window lapsed is reclaimed by the next caller
145
- (`on conflict … do update`), so a straggler from the first one used to overwrite a record it no
146
- longer owned and the next replay answered a retry with a value produced for a different request.
147
- Postgres fences in SQL and returns `key`, so the no-op is observable and logged; memory checks
148
- the status it holds. It is logged and never thrown — a settlement is post-commit, so raising
149
- there would turn a durable write into the caller's error. The fence is on the STATUS only: the
150
- reservation's own `id` would close the last case (a straggler landing while the replacement is
151
- still in flight) and cannot be checked, because `IdempotencyStore.settle(key, value)` is public
152
- API and does not carry it.
158
+ - **Both stores FENCE a settlement on the reservation `id` AND on `in-flight`**, as
159
+ `@ultimat3/jobs`' `SQL_ACK` fences on `id = $1 and state = 'running'`. A reservation whose window
160
+ lapsed is reclaimed by the next caller (`on conflict … do update`), so a straggler from the first
161
+ one used to overwrite a record it no longer owned and the next replay answered a retry with a
162
+ value produced for a different request. Postgres fences in SQL and returns `key`, so the no-op is
163
+ observable and logged; memory checks the id and status it holds. It is logged and never thrown —
164
+ a settlement is post-commit, so raising there would turn a durable write into the caller's error.
165
+ **The status alone was not enough**, which is why `settle(key, value, reservationId)` and
166
+ `fail(key, failure, reservationId)` carry the id: a reclaimed record is `in-flight` AGAIN, so a
167
+ straggler satisfied a status-only fence exactly and overwrote a LIVE reservation — and the
168
+ replacement's own settle was then fenced out. Public API, changed in the 8.0.0 major; callers
169
+ pass `reservation.record.id`, which `withIdempotency` already holds.
170
+ - **A stored status is NARROWED, never cast.** `isIdempotencyStatus` decides, and an unknown word
171
+ is `X_IDEMPOTENCY_STATUS_UNKNOWN` at `toRecord`. `row.status as IdempotencyStatus` let one
172
+ through and `withIdempotency` has no branch for it: the record fell past `in-flight` and
173
+ `failed` and answered `{ value: null, replayed: true }` — "this already ran, here is its result"
174
+ — for a row nobody could read. The record was written by whatever build was deployed when the
175
+ first attempt ran, which on a rolling deploy is not this one. Same rule, same column shape, as
176
+ `@ultimat3/jobs`' `statusIn`.
153
177
  - **Where the idempotency records live is DECLARED, and refused at registration.**
154
178
  `IdempotencyStore.scope` says what a driver provides; `configureIdempotency({ scope })` says what
155
179
  the deployment requires; `assertIdempotencyScope` compares them inside `registerAction` — the
package/README.md CHANGED
@@ -267,7 +267,16 @@ import {
267
267
  setIdempotencyStore,
268
268
  } from '@ultimat3/action';
269
269
 
270
- setIdempotencyStore(postgresIdempotencyStore({ executor: Bun.sql }));
270
+ // NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
271
+ // template whose positional form is `unsafe`), so that line compiles and throws on the first
272
+ // reservation.
273
+ // The framework boot installs this store for you; reach for it by hand only from a host that
274
+ // boots the framework itself, and wrap the client that host already opened.
275
+ setIdempotencyStore(
276
+ postgresIdempotencyStore({
277
+ executor: { query: (text, values) => client.query({ text, values }) },
278
+ }),
279
+ );
271
280
  configureIdempotency({ scope: 'shared' });
272
281
  ```
273
282
 
@@ -296,6 +305,12 @@ const outcome = await withIdempotency(
296
305
  );
297
306
  ```
298
307
 
308
+ `settle` and `fail` take the reservation's own id — `outcome`'s reservation, never the key alone.
309
+ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/jobs`' `SQL_ACK` fences on
310
+ `id = $1 and state = 'running'`: a reservation whose window lapsed is reclaimed by the next caller,
311
+ so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
312
+ reservation.
313
+
299
314
  A `query` has none and never will: a read has nothing to be idempotent about.
300
315
 
301
316
  ## `deprecated:` — a compat window, not a version
@@ -447,8 +462,9 @@ never a pass — the assertion says which code got in the way and names `input:`
447
462
  | `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
448
463
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
449
464
  | `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
450
- | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor: Bun.sql })` at boot |
465
+ | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
451
466
  | `X_IDEMPOTENCY_REPLAYED_FAILURE` | a retried key replays a first attempt that failed and carried no framework code of its own | read the first attempt, then send a fresh key |
467
+ | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `x_idempotency.status` holds a word this build has no branch for — written by a newer deploy | finish the rollout onto the build that writes it, then reconcile those requests — never DELETE the rows, which frees the key to run an already-committed action a second time |
452
468
  | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
453
469
  | `X_RPC_FAILED` | non-`problem+json` failure, or a body naming no `X_` code | check the gateway |
454
470
  | `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "6.0.0",
3
+ "version": "8.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": "6.0.0",
35
- "@ultimat3/core": "6.0.0",
36
- "@ultimat3/http": "6.0.0",
37
- "@ultimat3/policy": "6.0.0",
38
- "@ultimat3/schema": "6.0.0"
34
+ "@ultimat3/cache": "8.0.0",
35
+ "@ultimat3/core": "8.0.0",
36
+ "@ultimat3/http": "8.0.0",
37
+ "@ultimat3/policy": "8.0.0",
38
+ "@ultimat3/schema": "8.0.0"
39
39
  }
40
40
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The five idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
3
+ * stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
4
+ * `registerErrorCodes` call, because a second registration is how two modules end up deciding a
5
+ * title by load order.
6
+ */
7
+ import { errorDocsUrl, renderCauseValue, UltimateError } from '@ultimat3/core';
8
+ // Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
9
+ // cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
10
+ import type { IdempotencyFailure } from './idempotency';
11
+
12
+ // Core's spelling, aliased — never a second one, and the same alias `errors.ts` takes.
13
+ const docs = errorDocsUrl;
14
+
15
+ export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
16
+
17
+ export class IdempotencyConflictError extends UltimateError {
18
+ constructor(key: string, reason: IdempotencyConflictReason) {
19
+ super({
20
+ code: 'X_IDEMPOTENCY_CONFLICT',
21
+ cause:
22
+ reason === 'payload-mismatch'
23
+ ? `idempotency key "${key}" was already used with a different payload`
24
+ : `idempotency key "${key}" is still in flight from an earlier request`,
25
+ // A paste-able call, the spelling `IdempotencyKeyInvalidError` already uses: both failures
26
+ // are the CLIENT's to act on, and one header built two ways is two ways to get it wrong.
27
+ fix:
28
+ reason === 'payload-mismatch'
29
+ ? 'set the Idempotency-Key header to a fresh crypto.randomUUID() — one key per payload, since this one already names a different request'
30
+ : 'resend this request with the same Idempotency-Key once the first one settles — a fresh crypto.randomUUID() here would run the mutation twice',
31
+ docs: docs('X_IDEMPOTENCY_CONFLICT'),
32
+ });
33
+ }
34
+ }
35
+
36
+ export type IdempotencyKeyProblem = 'empty' | 'too-long';
37
+
38
+ /**
39
+ * The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
40
+ * answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
41
+ * every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
42
+ * because a client whose key interpolation produced nothing would lose the protection silently
43
+ * and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
44
+ * same reason; it uses `assert` because the empty key there is the app's own declaration, while
45
+ * this one is a caller's header and therefore a 4xx.
46
+ *
47
+ * The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
48
+ * A contract that disagrees with the runtime is worse than no contract.
49
+ */
50
+ export class IdempotencyKeyInvalidError extends UltimateError {
51
+ constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
52
+ super({
53
+ code: 'X_IDEMPOTENCY_KEY_INVALID',
54
+ cause:
55
+ problem === 'empty'
56
+ ? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
57
+ : `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
58
+ fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
59
+ docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
60
+ meta: { action, problem, length },
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
67
+ * registration, before a socket opens, because the failure it replaces is silent and expensive:
68
+ * a per-process store under `replicas: 3` means the retry that lands on another replica finds no
69
+ * record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
70
+ * An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
71
+ * to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
72
+ */
73
+ export class IdempotencyNotSharedError extends UltimateError {
74
+ constructor(storeScope: string | undefined) {
75
+ super({
76
+ code: 'X_IDEMPOTENCY_NOT_SHARED',
77
+ cause:
78
+ storeScope === undefined
79
+ ? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
80
+ : `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
81
+ // NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
82
+ // positional form is `unsafe`), so that line compiled and would have thrown on the first
83
+ // reservation. The framework's own boot already installs this store; a host booting the
84
+ // framework itself wraps the client it opened.
85
+ fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
86
+ docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
87
+ meta: { storeScope: storeScope ?? null },
88
+ });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
94
+ * and the input parse both run before the idempotency gate, so everything the gate can see throw
95
+ * is post-authorization and possibly post-commit — a handler that took the money and then failed
96
+ * its own `output:` schema is the case this exists for. Releasing the reservation there let the
97
+ * client's automatic retry charge a second time, which made idempotency the cause of the double
98
+ * charge it exists to prevent.
99
+ *
100
+ * The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
101
+ * the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
102
+ * is the code only when the original throw carried none of its own.
103
+ */
104
+ export class IdempotencyReplayedFailureError extends UltimateError {
105
+ /** The recorded first attempt, so a caller reads the original code without parsing a message. */
106
+ readonly failure: IdempotencyFailure;
107
+
108
+ constructor(key: string, failure: IdempotencyFailure | undefined) {
109
+ const recorded: IdempotencyFailure = failure ?? {
110
+ code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
111
+ cause: 'the first attempt under this key failed and the store kept no detail of it',
112
+ fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
113
+ };
114
+ super({
115
+ code: recorded.code,
116
+ cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
117
+ fix: recorded.fix,
118
+ ...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
119
+ // `replayed` is what tells an operator this is not a second execution: nothing ran here.
120
+ meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
121
+ });
122
+ this.failure = recorded;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The stored record holds a status word this build has no branch for. Refused, never cast: with
128
+ * `row.status as IdempotencyStatus`, an unknown word fell through every branch of
129
+ * `withIdempotency` and answered `{ value: null, replayed: true }` — "this already ran, here is
130
+ * its result" — for a record nobody could read, which is the silent wrong answer idempotency
131
+ * exists to prevent. The mirror of `@ultimat3/jobs`' `X_JOB_ROW_STATUS_UNKNOWN`, for the same
132
+ * column shape and the same cause: the record was written by whatever build was deployed when the
133
+ * first attempt ran, which on a rolling deploy is not this one.
134
+ */
135
+ export class IdempotencyStatusUnknownError extends UltimateError {
136
+ constructor(input: { key: string; value: unknown; known: readonly string[] }) {
137
+ super({
138
+ code: 'X_IDEMPOTENCY_STATUS_UNKNOWN',
139
+ cause:
140
+ `x_idempotency.status holds ${renderCauseValue(input.value)} for key "${input.key}", ` +
141
+ `which this build does not know — it reads ${input.known.join(', ')}`,
142
+ fix: `psql "$DATABASE_URL" -c "select key, status from x_idempotency where status not in ('in-flight', 'settled', 'failed')" # then drain the older processes: a status this build cannot read was written by a newer deploy`,
143
+ docs: docs('X_IDEMPOTENCY_STATUS_UNKNOWN'),
144
+ meta: { key: input.key, value: renderCauseValue(input.value), known: [...input.known] },
145
+ });
146
+ }
147
+ }
package/src/errors.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Every failure @ultimat3/action can produce, one subclass per stable code so
3
3
  * callers `instanceof` a specific failure instead of string-matching a message.
4
+ * The idempotency five live in `errors-idempotency.ts` — this file reached the line ceiling —
5
+ * and are re-exported here, so `./errors` stays the one import path for all of them.
4
6
  */
5
7
  import {
6
8
  assertNever,
@@ -11,9 +13,17 @@ import {
11
13
  UltimateError,
12
14
  } from '@ultimat3/core';
13
15
  import type { SurfaceDenial } from '@ultimat3/policy';
14
- // Type-only: `idempotency.ts` imports the error classes below, and a runtime edge here would
15
- // close the cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
16
- import type { IdempotencyFailure } from './idempotency';
16
+
17
+ // Re-exported, not re-declared: the five idempotency failures moved to their own file when this
18
+ // one reached the line ceiling, and every importer still reads them from `./errors`.
19
+ export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
20
+ export {
21
+ IdempotencyConflictError,
22
+ IdempotencyKeyInvalidError,
23
+ IdempotencyNotSharedError,
24
+ IdempotencyReplayedFailureError,
25
+ IdempotencyStatusUnknownError,
26
+ } from './errors-idempotency';
17
27
 
18
28
  // Core's spelling, aliased — never a second one. A local template drifts from what
19
29
  // `x errors explain` prints the moment `ERROR_DOCS_BASE` moves.
@@ -41,6 +51,7 @@ const OWNED_TITLES: Readonly<Record<string, string>> = {
41
51
  'idempotency is declared fleet-wide and the installed store is per-process',
42
52
  X_IDEMPOTENCY_REPLAYED_FAILURE:
43
53
  'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
54
+ X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
44
55
  X_INPUT_INVALID: 'input failed schema validation',
45
56
  X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
46
57
  X_RPC_FAILED: 'an RPC call failed without a problem+json body',
@@ -202,115 +213,6 @@ export class OutputInvalidError extends UltimateError {
202
213
  }
203
214
  }
204
215
 
205
- export type IdempotencyConflictReason = 'payload-mismatch' | 'in-flight';
206
-
207
- export class IdempotencyConflictError extends UltimateError {
208
- constructor(key: string, reason: IdempotencyConflictReason) {
209
- super({
210
- code: 'X_IDEMPOTENCY_CONFLICT',
211
- cause:
212
- reason === 'payload-mismatch'
213
- ? `idempotency key "${key}" was already used with a different payload`
214
- : `idempotency key "${key}" is still in flight from an earlier request`,
215
- fix:
216
- reason === 'payload-mismatch'
217
- ? 'send a fresh Idempotency-Key header for a different payload'
218
- : 'retry the same Idempotency-Key after the first request settles',
219
- docs: docs('X_IDEMPOTENCY_CONFLICT'),
220
- });
221
- }
222
- }
223
-
224
- export type IdempotencyKeyProblem = 'empty' | 'too-long';
225
-
226
- /**
227
- * The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
228
- * answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
229
- * every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
230
- * because a client whose key interpolation produced nothing would lose the protection silently
231
- * and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
232
- * same reason; it uses `assert` because the empty key there is the app's own declaration, while
233
- * this one is a caller's header and therefore a 4xx.
234
- *
235
- * The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
236
- * A contract that disagrees with the runtime is worse than no contract.
237
- */
238
- export class IdempotencyKeyInvalidError extends UltimateError {
239
- constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
240
- super({
241
- code: 'X_IDEMPOTENCY_KEY_INVALID',
242
- cause:
243
- problem === 'empty'
244
- ? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
245
- : `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
246
- fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
247
- docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
248
- meta: { action, problem, length },
249
- });
250
- }
251
- }
252
-
253
- /**
254
- * The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
255
- * registration, before a socket opens, because the failure it replaces is silent and expensive:
256
- * a per-process store under `replicas: 3` means the retry that lands on another replica finds no
257
- * record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
258
- * An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
259
- * to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
260
- */
261
- export class IdempotencyNotSharedError extends UltimateError {
262
- constructor(storeScope: string | undefined) {
263
- super({
264
- code: 'X_IDEMPOTENCY_NOT_SHARED',
265
- cause:
266
- storeScope === undefined
267
- ? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
268
- : `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
269
- // NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
270
- // positional form is `unsafe`), so that line compiled and would have thrown on the first
271
- // reservation. The framework's own boot already installs this store; a host booting the
272
- // framework itself wraps the client it opened.
273
- fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
274
- docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
275
- meta: { storeScope: storeScope ?? null },
276
- });
277
- }
278
- }
279
-
280
- /**
281
- * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
282
- * and the input parse both run before the idempotency gate, so everything the gate can see throw
283
- * is post-authorization and possibly post-commit — a handler that took the money and then failed
284
- * its own `output:` schema is the case this exists for. Releasing the reservation there let the
285
- * client's automatic retry charge a second time, which made idempotency the cause of the double
286
- * charge it exists to prevent.
287
- *
288
- * The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
289
- * the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
290
- * is the code only when the original throw carried none of its own.
291
- */
292
- export class IdempotencyReplayedFailureError extends UltimateError {
293
- /** The recorded first attempt, so a caller reads the original code without parsing a message. */
294
- readonly failure: IdempotencyFailure;
295
-
296
- constructor(key: string, failure: IdempotencyFailure | undefined) {
297
- const recorded: IdempotencyFailure = failure ?? {
298
- code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
299
- cause: 'the first attempt under this key failed and the store kept no detail of it',
300
- fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
301
- };
302
- super({
303
- code: recorded.code,
304
- cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
305
- fix: recorded.fix,
306
- ...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
307
- // `replayed` is what tells an operator this is not a second execution: nothing ran here.
308
- meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
309
- });
310
- this.failure = recorded;
311
- }
312
- }
313
-
314
216
  /**
315
217
  * A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
316
218
  * declaration is converted, so the route and the OpenAPI operation refuse the same value — the
package/src/http.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  schemaRef,
26
26
  toOperationId,
27
27
  } from './naming';
28
- import { policyCapability } from './policy-gate';
28
+ import { admitsAnonymous, policyCapability } from './policy-gate';
29
29
 
30
30
  /** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
31
31
  * CLIENT's claim, never `ctx.buildId`, which is the build this process serves. */
@@ -86,9 +86,13 @@ export function toRoute(target: AnyAction): Route {
86
86
 
87
87
  const meta: RouteMeta = {
88
88
  name,
89
- // `allow(...)` is the only way an action is public, and saying so explicitly is
90
- // what keeps "forgot the policy" from ever looking like "meant to be public".
91
- auth: def.policy.kind === 'allow' ? 'public' : 'required',
89
+ // Derived from a WALK of the policy tree, never from the root combinator alone. A saying that
90
+ // "`allow(...)` is the only way an action is public" is true of the leaf and was false of the
91
+ // read: `policy.kind === 'allow'` answered `required` for `or(allow(), can('x:y'))`, so the
92
+ // pipeline 401'd an anonymous caller the policy itself allows — while the MCP tool and the job
93
+ // handle let the same caller through the same object. `public` here is not "unguarded":
94
+ // `enforcedBy: 'handler'` below means `invoke` still evaluates the policy for every call.
95
+ auth: admitsAnonymous(def.policy) ? 'public' : 'required',
92
96
  policy: policyCapability(def.policy),
93
97
  // Named so the pipeline's authz stage stands down: `invoke` is this route's one
94
98
  // evaluation, and it is the only one that has run `row` by the time it decides. A
@@ -81,28 +81,38 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
81
81
  }
82
82
 
83
83
  /**
84
- * Both settlements are FENCED on `in-flight`, as `SQL_IDEMPOTENCY_SETTLE` is and as
85
- * `@ultimat3/jobs`' `SQL_ACK` is: a record past the window is reclaimed by the next caller, so a
86
- * straggler from the reservation before it would otherwise overwrite a record it no longer owns
87
- * and the next replay would answer one request with another's value. Both stores fence, or the
88
- * guarantee is whichever store the deployment happens to install.
84
+ * Both settlements are FENCED on the reservation's own `id` AND on `in-flight`, as
85
+ * `SQL_IDEMPOTENCY_SETTLE` is and as `@ultimat3/jobs`' `SQL_ACK` is. A record past the window is
86
+ * reclaimed by the next caller, so a straggler from the reservation before it would otherwise
87
+ * overwrite a record it no longer owns and the next replay would answer one request with
88
+ * another's value. The status alone does not catch it — the reclaimed record is `in-flight`
89
+ * again — which is why the id is half the fence. Both stores fence, or the guarantee is
90
+ * whichever store the deployment happens to install.
89
91
  */
90
- settle(key: string, value: unknown): Promise<void> {
91
- const existing = this.#records.get(key);
92
- if (existing?.status === 'in-flight') {
92
+ settle(key: string, value: unknown, reservationId: string): Promise<void> {
93
+ const existing = this.#owned(key, reservationId);
94
+ if (existing !== undefined) {
93
95
  this.#records.set(key, { ...existing, status: 'settled', value });
94
96
  }
95
97
  return Promise.resolve();
96
98
  }
97
99
 
98
- fail(key: string, failure: IdempotencyFailure): Promise<void> {
99
- const existing = this.#records.get(key);
100
- if (existing?.status === 'in-flight') {
100
+ fail(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void> {
101
+ const existing = this.#owned(key, reservationId);
102
+ if (existing !== undefined) {
101
103
  this.#records.set(key, { ...existing, status: 'failed', value: undefined, failure });
102
104
  }
103
105
  return Promise.resolve();
104
106
  }
105
107
 
108
+ /** The record this reservation may still write, or nothing — the fence, in one place. */
109
+ #owned(key: string, reservationId: string): IdempotencyRecord | undefined {
110
+ const existing = this.#records.get(key);
111
+ if (existing === undefined) return undefined;
112
+ if (existing.status !== 'in-flight' || existing.id !== reservationId) return undefined;
113
+ return existing;
114
+ }
115
+
106
116
  release(key: string): Promise<void> {
107
117
  this.#records.delete(key);
108
118
  return Promise.resolve();
@@ -5,14 +5,15 @@
5
5
  * Statements are spelled out so an agent can run the exact one it saw in a log.
6
6
  */
7
7
  import { logger, uuid } from '@ultimat3/core';
8
+ import { IdempotencyStatusUnknownError } from './errors';
8
9
  import type {
9
10
  IdempotencyFailure,
10
11
  IdempotencyRecord,
11
12
  IdempotencyReservation,
12
13
  IdempotencyScope,
13
- IdempotencyStatus,
14
14
  IdempotencyStore,
15
15
  } from './idempotency';
16
+ import { IDEMPOTENCY_STATUSES, isIdempotencyStatus } from './idempotency';
16
17
  import { DEFAULT_IDEMPOTENCY_WINDOW_MS } from './idempotency-memory';
17
18
 
18
19
  /**
@@ -82,22 +83,26 @@ select key, id, request_hash, status, value, failure,
82
83
  `;
83
84
 
84
85
  /**
85
- * `and status = 'in-flight'` is a FENCE, not a filter — the one `@ultimat3/jobs`' `SQL_ACK` carries
86
- * as `and state = 'running'`, for the same failure. A reservation whose window lapsed is reclaimed
87
- * by the next caller (`do update` above), so a straggler from the first one arriving afterwards
88
- * overwrote a record it no longer owned: the next replay under that key answered a retry with a
89
- * value produced for a different request. `returning key` is what makes the refusal observable —
90
- * an update matching no row is indistinguishable from one that matched, otherwise.
86
+ * `and id = $3 and status = 'in-flight'` is a FENCE, not a filter — the one `@ultimat3/jobs`'
87
+ * `SQL_ACK` carries as `where id = $1 and state = 'running'`, for the same failure. A reservation
88
+ * whose window lapsed is reclaimed by the next caller (`do update` above), so a straggler from the
89
+ * first one arriving afterwards overwrote a record it no longer owned: the next replay under that
90
+ * key answered a retry with a value produced for a different request.
91
+ *
92
+ * BOTH halves, because either alone leaves a case open. The status alone misses the reclaimed
93
+ * record — it is `in-flight` again, belonging to someone else — and the id alone would let a
94
+ * straggler overwrite a record its own attempt had already settled. `returning key` is what makes
95
+ * the refusal observable: an update matching no row is indistinguishable from one that matched.
91
96
  */
92
97
  export const SQL_IDEMPOTENCY_SETTLE = `
93
98
  update x_idempotency set status = 'settled', value = $2::jsonb, failure = null
94
- where key = $1 and status = 'in-flight'
99
+ where key = $1 and id = $3::uuid and status = 'in-flight'
95
100
  returning key
96
101
  `;
97
102
 
98
103
  export const SQL_IDEMPOTENCY_FAIL = `
99
104
  update x_idempotency set status = 'failed', value = null, failure = $2::jsonb
100
- where key = $1 and status = 'in-flight'
105
+ where key = $1 and id = $3::uuid and status = 'in-flight'
101
106
  returning key
102
107
  `;
103
108
 
@@ -121,6 +126,12 @@ interface IdempotencyRow {
121
126
  export interface PostgresIdempotencyStoreOptions {
122
127
  readonly executor: PgExecutor;
123
128
  readonly windowMs?: number | undefined;
129
+ /**
130
+ * Injectable, exactly as `MemoryIdempotencyStoreOptions.now` is. The two stores are one seam and
131
+ * a caller must be able to drive either from the same clock; a hardcoded `Date.now()` here made
132
+ * the one record this store stamps itself untestable and unfreezable.
133
+ */
134
+ readonly now?: (() => number) | undefined;
124
135
  }
125
136
 
126
137
  export interface PostgresIdempotencyStore extends IdempotencyStore {
@@ -164,6 +175,7 @@ export function postgresIdempotencyStore(
164
175
  const windowMs = Math.max(1, Math.floor(options.windowMs ?? DEFAULT_IDEMPOTENCY_WINDOW_MS));
165
176
  const windowSecs = windowMs / 1000;
166
177
  const exec = options.executor;
178
+ const now = options.now ?? ((): number => Date.now());
167
179
 
168
180
  const fetch = async (key: string): Promise<IdempotencyRecord | undefined> => {
169
181
  const rows = await exec.query<IdempotencyRow>(SQL_IDEMPOTENCY_GET, [key, windowSecs]);
@@ -200,20 +212,28 @@ export function postgresIdempotencyStore(
200
212
  requestHash,
201
213
  status: 'in-flight',
202
214
  value: undefined,
203
- createdAt: Date.now(),
215
+ createdAt: now(),
204
216
  },
205
217
  created: false,
206
218
  };
207
219
  },
208
220
 
209
- async settle(key, value): Promise<void> {
210
- const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [key, JSON.stringify(value ?? null)]);
211
- fenced(rows, key, 'settle');
221
+ async settle(key, value, reservationId): Promise<void> {
222
+ const rows = await exec.query(SQL_IDEMPOTENCY_SETTLE, [
223
+ key,
224
+ JSON.stringify(value ?? null),
225
+ reservationId,
226
+ ]);
227
+ fenced(rows, key, reservationId, 'settle');
212
228
  },
213
229
 
214
- async fail(key, failure: IdempotencyFailure): Promise<void> {
215
- const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [key, JSON.stringify(failure)]);
216
- fenced(rows, key, 'fail');
230
+ async fail(key, failure: IdempotencyFailure, reservationId): Promise<void> {
231
+ const rows = await exec.query(SQL_IDEMPOTENCY_FAIL, [
232
+ key,
233
+ JSON.stringify(failure),
234
+ reservationId,
235
+ ]);
236
+ fenced(rows, key, reservationId, 'fail');
217
237
  },
218
238
 
219
239
  async release(key): Promise<void> {
@@ -238,18 +258,36 @@ export function postgresIdempotencyStore(
238
258
  * store that refuses. An operator still has to see it: a fenced settle means this attempt's record
239
259
  * belongs to another reservation, and the value this attempt produced is stored nowhere.
240
260
  */
241
- function fenced(rows: readonly unknown[], key: string, statement: 'settle' | 'fail'): void {
261
+ function fenced(
262
+ rows: readonly unknown[],
263
+ key: string,
264
+ reservationId: string,
265
+ statement: 'settle' | 'fail',
266
+ ): void {
242
267
  if (rows.length > 0) return;
243
- logger.warn('action.idempotency.settlement-fenced', { key, statement });
268
+ logger.warn('action.idempotency.settlement-fenced', { key, reservationId, statement });
244
269
  }
245
270
 
271
+ /**
272
+ * The narrowing, never a cast. `row.status as IdempotencyStatus` let an unknown word through, and
273
+ * `withIdempotency` has no branch for one: it fell past `in-flight` and `failed` and answered
274
+ * `{ value: null, replayed: true }` — "this already ran, here is its result" — for a record nobody
275
+ * could read. The rule `@ultimat3/jobs`' `statusIn` already writes out for the same column.
276
+ */
246
277
  function toRecord(row: IdempotencyRow): IdempotencyRecord {
247
278
  const failure = toFailure(row.failure);
279
+ if (!isIdempotencyStatus(row.status)) {
280
+ throw new IdempotencyStatusUnknownError({
281
+ key: row.key,
282
+ value: row.status,
283
+ known: IDEMPOTENCY_STATUSES,
284
+ });
285
+ }
248
286
  return {
249
287
  id: row.id,
250
288
  key: row.key,
251
289
  requestHash: row.request_hash,
252
- status: row.status as IdempotencyStatus,
290
+ status: row.status,
253
291
  value: row.value,
254
292
  ...(failure === undefined ? {} : { failure }),
255
293
  createdAt: Number(row.created_at),
@@ -32,7 +32,27 @@ export interface IdempotencyFailure {
32
32
  readonly docs?: string | undefined;
33
33
  }
34
34
 
35
- export type IdempotencyStatus = 'in-flight' | 'settled' | 'failed';
35
+ /**
36
+ * The closed list, in the order a record moves through it — and the ONE declaration. The type is
37
+ * derived from it below rather than restated beside it, so a fourth status cannot be added to one
38
+ * and missed by the other, which is exactly how `isIdempotencyStatus` would start refusing a word
39
+ * this build writes itself.
40
+ */
41
+ export const IDEMPOTENCY_STATUSES = Object.freeze(['in-flight', 'settled', 'failed'] as const);
42
+
43
+ export type IdempotencyStatus = (typeof IDEMPOTENCY_STATUSES)[number];
44
+
45
+ /**
46
+ * The one narrowing for the status column, and never a cast. A record crosses a process boundary —
47
+ * the row under this key was written by whatever build was deployed when the first attempt ran,
48
+ * which on a rolling deploy is not this one — so `row.status as IdempotencyStatus` made an unknown
49
+ * word answer `{ value: null, replayed: true }` in `withIdempotency`: the caller was told "this
50
+ * already ran, here is its result" for a row nobody could read. The rule `@ultimat3/jobs`'
51
+ * `statusIn` already writes out for the same column in the same situation.
52
+ */
53
+ export function isIdempotencyStatus(value: string): value is IdempotencyStatus {
54
+ return (IDEMPOTENCY_STATUSES as readonly string[]).includes(value);
55
+ }
36
56
 
37
57
  export interface IdempotencyRecord {
38
58
  readonly id: string;
@@ -68,14 +88,29 @@ export interface IdempotencyStore {
68
88
  readonly windowMs?: number | undefined;
69
89
  /** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
70
90
  reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
71
- settle(key: string, value: unknown): Promise<void>;
91
+ /**
92
+ * Settle the record `reservationId` owns — `IdempotencyReservation.record.id`, never the key
93
+ * alone. Fenced on the id AND the status, the way `@ultimat3/jobs`' `SQL_ACK` fences on
94
+ * `id = $1 and state = 'running'`.
95
+ *
96
+ * The status alone was not enough, and the gap it left is silent: a reservation whose window
97
+ * lapsed is reclaimed by the next caller, so the record under that key is `in-flight` AGAIN and
98
+ * belongs to someone else. A straggler from the first attempt satisfied the status fence
99
+ * exactly, overwrote a live reservation, and the replacement's own settle was then fenced out —
100
+ * so the retry replayed a value produced for a different request. A settlement that matches no
101
+ * record is logged, never thrown: it lands after the handler has committed.
102
+ */
103
+ settle(key: string, value: unknown, reservationId: string): Promise<void>;
72
104
  /**
73
105
  * Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
74
- * have committed. Optional so an existing store still type-checks and when it is absent the
75
- * gate leaves the reservation standing rather than releasing it, because refusing the retry is
76
- * the safe answer and re-running it is the double charge.
106
+ * have committed. Fenced on the same reservation id as `settle`, for the same case a
107
+ * straggler's failure marking a live reservation `failed` is the worse half of it.
108
+ *
109
+ * Optional so an existing store still type-checks — and when it is absent the gate leaves the
110
+ * reservation standing rather than releasing it, because refusing the retry is the safe answer
111
+ * and re-running it is the double charge.
77
112
  */
78
- fail?(key: string, failure: IdempotencyFailure): Promise<void>;
113
+ fail?(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void>;
79
114
  /** Drop a reservation, so a retry can run. Only ever correct BEFORE the handler starts. */
80
115
  release(key: string): Promise<void>;
81
116
  get(key: string): Promise<IdempotencyRecord | undefined>;
@@ -176,13 +211,16 @@ export async function withIdempotency<T>(
176
211
  try {
177
212
  value = await run();
178
213
  } catch (error) {
179
- await settleFailure(store, key, error);
214
+ await settleFailure(store, key, record.id, error);
180
215
  throw error;
181
216
  }
182
217
  // Outside the `try` on purpose: a `settle` that refuses is itself post-commit, and the record
183
218
  // stays in flight rather than being released — a retry then gets a 409 it can act on instead of
184
219
  // re-running a handler that has already committed.
185
- await store.settle(key, value);
220
+ //
221
+ // `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
222
+ // lapsed cannot land on the replacement that reclaimed the key.
223
+ await store.settle(key, value, record.id);
186
224
  return { value, replayed: false };
187
225
  }
188
226
 
@@ -191,7 +229,12 @@ export async function withIdempotency<T>(
191
229
  * here would otherwise surface as the caller's error, hiding the `X_OUTPUT_INVALID` or the
192
230
  * handler's own throw that is the thing worth reading — the same rule `auditThrew` follows.
193
231
  */
194
- async function settleFailure(store: IdempotencyStore, key: string, error: unknown): Promise<void> {
232
+ async function settleFailure(
233
+ store: IdempotencyStore,
234
+ key: string,
235
+ reservationId: string,
236
+ error: unknown,
237
+ ): Promise<void> {
195
238
  if (store.fail === undefined) {
196
239
  // Deliberately NOT `release`. A store with no failure slot cannot say "this already ran", and
197
240
  // the retry-safe reading of that is "refuse the retry", not "run it again".
@@ -199,7 +242,7 @@ async function settleFailure(store: IdempotencyStore, key: string, error: unknow
199
242
  return;
200
243
  }
201
244
  try {
202
- await store.fail(key, failureOf(error));
245
+ await store.fail(key, failureOf(error), reservationId);
203
246
  } catch (sinkError) {
204
247
  // Never the error's own text: rendering an `unknown` into a message is the second throw this
205
248
  // branch exists to prevent. The logger takes it as a field and shapes it itself.
package/src/index.ts CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  IdempotencyKeyInvalidError,
83
83
  IdempotencyNotSharedError,
84
84
  IdempotencyReplayedFailureError,
85
+ IdempotencyStatusUnknownError,
85
86
  InputInvalidError,
86
87
  OutputInvalidError,
87
88
  RemoteActionError,
@@ -116,7 +117,9 @@ export {
116
117
  configureIdempotency,
117
118
  DEFAULT_IDEMPOTENCY_CONFIG,
118
119
  getIdempotencyStore,
120
+ IDEMPOTENCY_STATUSES,
119
121
  idempotencyConfig,
122
+ isIdempotencyStatus,
120
123
  resetIdempotency,
121
124
  setIdempotencyStore,
122
125
  withIdempotency,
@@ -174,8 +177,19 @@ export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './nami
174
177
  export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
175
178
  export { buildOpenApi, serializeOpenApi } from './openapi';
176
179
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
177
- /** `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on. */
178
- export { actorOf, guard, policyCapability, policyPermissions } from './policy-gate';
180
+ /**
181
+ * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
182
+ * `admitsAnonymous` is `@ultimat3/policy`'s, re-exported here beside them: it is what `toRoute`
183
+ * derives `meta.auth` from, so a plain `route` sets that field from the same walk rather than
184
+ * re-reading the root combinator.
185
+ */
186
+ export {
187
+ actorOf,
188
+ admitsAnonymous,
189
+ guard,
190
+ policyCapability,
191
+ policyPermissions,
192
+ } from './policy-gate';
179
193
  export {
180
194
  describeActions,
181
195
  getAction,
@@ -7,7 +7,11 @@
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
8
  import { assertNever, isAnonymous } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import { enforce, policyPermissions as flattenedPermissions } from '@ultimat3/policy';
10
+ import {
11
+ enforce,
12
+ policyPermissions as flattenedPermissions,
13
+ admitsAnonymous as policyAdmitsAnonymous,
14
+ } from '@ultimat3/policy';
11
15
  import { ActionDeniedError } from './errors';
12
16
 
13
17
  /**
@@ -91,3 +95,16 @@ export function policyCapability(policy: ActionPolicy): string {
91
95
  export function policyPermissions(policy: ActionPolicy): readonly string[] {
92
96
  return flattenedPermissions(policy);
93
97
  }
98
+
99
+ /**
100
+ * Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
101
+ * `http.ts` reads it through this file like every other authz question. `toRoute` derives
102
+ * `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
103
+ * combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `invoke` ran
104
+ * while the MCP tool and the job handle allowed it. `true` is not "unguarded" — `invoke` still
105
+ * evaluates the policy for every call. Declared once in `policy.ts`, exactly as
106
+ * `policyPermissions` is: the answer is a property of the combinators it walks.
107
+ */
108
+ export function admitsAnonymous(policy: ActionPolicy): boolean {
109
+ return policyAdmitsAnonymous(policy);
110
+ }
@@ -75,13 +75,25 @@ function sampleNumber(node: SchemaNode): number {
75
75
  return node.integer === true ? Math.floor(node.maximum) : node.maximum;
76
76
  }
77
77
 
78
+ /**
79
+ * `Object.create(null)`, the shape `@ultimat3/schema`'s own object check already builds: on a `{}`
80
+ * literal `sample['__proto__'] = value` reaches `Object.prototype`'s SETTER, so a required field
81
+ * named `__proto__` never became an own key and REPLACED the sample's prototype instead. The
82
+ * payload then failed the very schema it was derived from, and the contract test reported the
83
+ * action as drifted. A field can carry that name through any computed key
84
+ * (`t.object({ [name]: t.string })`) or a provider whose IR was parsed from JSON.
85
+ *
86
+ * The read is guarded for the same reason `patternAt` guards its own: `requiredKeys` happens to
87
+ * answer own keys only (`Object.entries`), so this is the invariant stated locally rather than
88
+ * borrowed from a function two packages away.
89
+ */
78
90
  function sampleObject(node: SchemaNode): Record<string, unknown> {
79
91
  const properties = node.properties ?? {};
80
- const sample: Record<string, unknown> = {};
92
+ const sample: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
81
93
  // Required-only is what "minimal" means: an optional key and a defaulted one are both
82
94
  // absences the schema already accepts, so adding them would only widen what can go wrong.
83
95
  for (const key of requiredKeys(node)) {
84
- const child = properties[key];
96
+ const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
85
97
  if (child !== undefined) sample[key] = sampleFor(child);
86
98
  }
87
99
  return sample;
@@ -157,7 +169,7 @@ function gapsIn(node: SchemaNode, path: string): string[] {
157
169
  const properties = node.properties ?? {};
158
170
  const out: string[] = [];
159
171
  for (const key of requiredKeys(node)) {
160
- const child = properties[key];
172
+ const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
161
173
  if (child !== undefined) out.push(...gapsIn(child, path === '' ? key : `${path}.${key}`));
162
174
  }
163
175
  return out;