@ultimat3/action 23.0.0 → 25.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/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # @ultimat3/action ⚡
2
2
 
3
- One declaration → six artifacts.
3
+ One declaration → six artifacts. Five are projected here; the MCP tool is `@ultimat3/mcp`'s
4
+ (tier 4), from the same declaration.
4
5
 
5
6
  | # | Artifact | Reach it with | Guarantees |
6
7
  |---|---|---|---|
7
8
  | 1 | HTTP route `POST /api/<resource>/<verb>` | `toRoute(publishPost)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional |
8
9
  | 2 | OpenAPI 3.1 operation + document | `publishPost.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` |
9
10
  | 3 | Typed RPC client | `publishPost.client({ baseUrl })` / `rpc<Api['actions']>()` | server typo = compile error in Solid |
10
- | 4 | MCP tool | `publishPost.tool()` | *identical* policy evaluation to the route |
11
+ | 4 | MCP tool | `toolFrom(publishPost)` from `@ultimat3/mcp` — what `tools/list` serves | *identical* policy evaluation to the route: it calls this package's `invoke` with `surface: 'mcp'` |
11
12
  | 5 | Job handle | `publishPost.job()` | enqueue durable work, no rewrite |
12
13
  | 6 | Contract tests | `publishPost.contract()` | garbage rejected, anonymous denied, spec present |
13
14
 
@@ -22,16 +23,17 @@ publishPost.output // the declared output schema
22
23
  publishPost.policy // the one policy object
23
24
  publishPost.mcp // { expose, description }, as declared
24
25
  await publishPost.as(actor, { postId }) // run as someone, one execution path
25
- publishPost.tool() // MCP descriptor
26
26
  publishPost.openapi() // OpenAPI operation
27
27
  publishPost.client({ baseUrl }) // typed RPC method
28
28
  publishPost.job() // durable-work handle
29
29
  publishPost.contract() // the three generated assertions
30
30
  ```
31
31
 
32
- `publishPost.tool().policy === publishPost.policy` — the same object, so an MCP call
33
- cannot reach a different authz path. `.as()` keeps the surrounding context whole and
34
- swaps only the actor: impersonation, not a second context.
32
+ There is no `.tool()` (`As of 25.0.0`): it was a second MCP projection built a tier below the
33
+ one `@ultimat3/mcp` serves, and the two disagreed. The tool an agent is shown is
34
+ `toolFrom(publishPost)`, and its every call lands in this package's `invoke` — the one
35
+ policy object, so an MCP call cannot reach a different authz path. `.as()` keeps the
36
+ surrounding context whole and swaps only the actor: impersonation, not a second context.
35
37
 
36
38
  ## Declare
37
39
 
@@ -133,7 +135,7 @@ returned none, so one operation has one wire shape. Every other action's body is
133
135
  what it was before the envelope existed, and its OpenAPI operation is unchanged; an enveloped one
134
136
  documents `data`, `records` and `removed`, and the header, generated from the declaration.
135
137
 
136
- ### Flight control — `createClientFlight`, opt-in
138
+ ### Flight control — `clientFlight`, opt-in
137
139
 
138
140
  Same object as `@ultimat3/query`'s, installed the same way (`rpc({ baseUrl, flight })`), and the
139
141
  write half is the half made of refusals:
@@ -154,13 +156,14 @@ declare const orderId: string;
154
156
  await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { attempts: 3 } });
155
157
  ```
156
158
 
157
- `createClientFlight` is **`@ultimat3/core`'s**, re-exported here: it is the same object
158
- `@ultimat3/query` re-exports, because both packages are tier 3 and neither may import the other.
159
- It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
160
- this package exactly as before.
159
+ `clientFlight` is **`@ultimat3/core`'s**, and imported from there — `import {
160
+ clientFlight } from '@ultimat3/core'`. It shipped as a byte-identical copy here and in
161
+ `@ultimat3/query` (both tier 3, neither may import the other); the copies are gone, and since 25.0.0
162
+ so are the re-exports: one value, one import path (`X_HELPER_COPY`, `bun run flight-copies`). The
163
+ types `ClientFlight` and `ClientRetry` stay re-exported, because `rpc`'s options name them.
161
164
 
162
165
  Importing `rpc` alone from this package is **19,671 B** minified for the browser; adding
163
- `createClientFlight` is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
166
+ `clientFlight` (measured through this barrel, before 25.0.0 moved the import to core) is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
164
167
  what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
165
168
  keeps the second number off the first caller's bill.
166
169
 
@@ -252,14 +255,14 @@ Under the default style:
252
255
  | `likePost` | `POST /api/posts/like` | `likePost` |
253
256
  | `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
254
257
 
255
- **One name, four surfaces** — `.tool().name`, `openapi.json`'s `x-ultimate.mcpTool`,
258
+ **One name, three surfaces** — `openapi.json`'s `x-ultimate.mcpTool`,
256
259
  `describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the
257
260
  `actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
258
- serves. It was two until 2026-08: a `toToolName()` here snake_cased the first three to
261
+ serves. It was two until 2026-08: a `toToolName()` here snake_cased the published ones to
259
262
  `publish_post` while the server answered only `publishPost`, so an agent that read the published
260
263
  contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second
261
- derivation is a second name. `mcp-tool.test.ts`'s "one name per action, on every surface" is what
262
- keeps it that way.
264
+ derivation is a second name. `mcp-surface.test.ts` here and `@ultimat3/mcp`'s
265
+ `cross-surface.test.ts` are what keep it that way.
263
266
 
264
267
  `x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so
265
268
  the manifest never carried a tool name and was never wrong about one.
@@ -277,7 +280,7 @@ no `.def`. A second authz path cannot be written without deleting that store.
277
280
 
278
281
  | Stage | Failure |
279
282
  |---|---|
280
- | policy, actor half (`guardBeforeInput`) | `X_UNAUTHENTICATED` / `X_FORBIDDEN` for a caller the policy refuses whatever the input — never a 400 describing the schema of an operation they may not call |
283
+ | policy, actor half (`guardActionBeforeInput`) | `X_UNAUTHENTICATED` / `X_FORBIDDEN` for a caller the policy refuses whatever the input — never a 400 describing the schema of an operation they may not call |
281
284
  | parse input | `X_INPUT_INVALID` |
282
285
  | evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
283
286
  | handle | whatever the handler throws |
@@ -296,13 +299,20 @@ fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
296
299
  `action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
297
300
  call busts nothing; the first call already did.
298
301
 
302
+ **After the COMMIT, not after the handler** (`As of 2026-10-02`). An action invoked inside a
303
+ `withTransaction` — an app's own unit of work, or another action's — hands its bust to the ROOT
304
+ transaction's `onCommit`: it fires once the commit is answered and never for a rollback or an
305
+ aborted transaction (`X_DB_TRANSACTION_ABORTED`). Busting at the handler's return let a
306
+ concurrent read refill the cache from the pre-commit rows, which then stayed for the TTL. With no
307
+ transaction open the bust runs when the handler returns, as it always did.
308
+
299
309
  Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
300
310
  no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
301
311
 
302
312
  ## mutator = action + local twin
303
313
 
304
- `mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets all
305
- six projections; it adds `local(tx, input)` for the optimistic write and a `conflict`
314
+ `mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets every
315
+ projection an action has; it adds `local(tx, input)` for the optimistic write and a `conflict`
306
316
  strategy for the rebase.
307
317
 
308
318
  ```ts
@@ -310,6 +320,7 @@ export const likePost = mutator({
310
320
  input: t.object({ postId: t.uuid }),
311
321
  output: PostLikes,
312
322
  policy: can('post:like'),
323
+ idempotent: true, // REQUIRED — a replayed write answers the first result (below)
313
324
  // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
314
325
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
315
326
  local(tx, { postId }) {
@@ -341,6 +352,14 @@ denies is denied there exactly as over HTTP. `.local()` is the only half that sk
341
352
  the core, because it never leaves the client; keep it a pure function of `(tx, input)`
342
353
  — no I/O, no clock, no randomness — since every rebase replays it.
343
354
 
355
+ **`idempotent: true` is required on every mutator** — a compile error without it, and
356
+ `X_MUTATOR_NOT_IDEMPOTENT` at declaration for a caller the compiler never saw. A mutator's write
357
+ is replayed by construction: `useMutation` retries, and the offline queue drains after a dropped
358
+ response, under ONE `Idempotency-Key`. The server reads that key only for an idempotent action, so
359
+ without the declaration the replay ran `server` a second time — a toggle flipped back. It is
360
+ declared, never defaulted, because the replay is answered from the idempotency store and which
361
+ store that is (below) is the app's to decide. `transition()` declares it for you.
362
+
344
363
  `LocalTx` is the client write surface, implemented by `@ultimat3/realtime` over the page's
345
364
  record store — the SAME shape as that store's tx. Every table is addressed by **key**:
346
365
  `get(key)`, `all()`, `insert(key, row)`, `upsert(key, row)`, `update(key, patch | fn)`,
@@ -388,12 +407,13 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
388
407
  | **`from` is required, and never defaulted or inferred** | it rides in the UPDATE's own predicate, so the state observed and the state written are one decision under the row's lock. Measured on the mechanism underneath: twenty concurrent moves at one row gave 14 winners with a read-then-check-then-write and **1 winner plus 19 refusals** with `from` in the predicate. Anything that supplies `from` for the caller is the lost update coming back |
389
408
  | the states are the **input schema**, not a `t.string` | the union survives into `InferOutput`, so the typed client refuses a typo at **compile** time, the MCP tool's `inputSchema` and the OpenAPI component both publish the legal set, and a bad state is `X_INPUT_INVALID` before a database is touched |
390
409
  | `conflict: 'server-wins'`, not overridable | the server is the half that REFUSED the move; a local twin winning the rebase would leave the client showing a state the database rejected |
410
+ | `id` is the entity's **key**, read off `output.id` | a uuid key stays `t.uuid` (a malformed one is `X_INPUT_INVALID` before a database reads it); a `text()` key keeps its length and a `bigint()` key its digits pattern — never a fixed `t.uuid`, which refused every move on an entity keyed by anything else. An `output` that does not carry the key as `id` passes the key's schema as `id:`; with neither, the id is a uuid |
411
+ | `idempotent: true`, always | a move replayed under its `Idempotency-Key` answers the first outcome, not an `X_STATE_CONFLICT` for a move that already happened |
391
412
  | `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
392
413
  | `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
393
414
 
394
- `table` is typed structurally (`TransitionTarget`), not imported: `@ultimat3/action` holds no
395
- dependency edge on `@ultimat3/entity` — the tier table permits one, the manifest and the lockfile do
396
- not — and a real `Table` satisfies the seam as written.
415
+ `table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
416
+ seam as written.
397
417
 
398
418
  ## Determinism + idempotency
399
419
 
@@ -410,17 +430,43 @@ import type { ActionRateLimit } from '@ultimat3/action';
410
430
  const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
411
431
  ```
412
432
 
413
- One declaration, three places it lands: the bucket the limiter runs on (named after the action,
414
- registered by `@ultimat3/http`'s `withRouteBuckets` when the route is mounted), the
415
- `ratelimit-limit` header the caller reads, and `x-ultimate.rateLimit` in the OpenAPI operation.
433
+ One declaration, one bucket, every surface. `invoke` spends it — so the HTTP route, the MCP tool,
434
+ the in-app agent's tool call and `.job()` draw on the **same** bucket, and the third call to a
435
+ `limit: 2` action is refused `X_RATE_LIMITED` whichever door it came through. A direct in-process
436
+ call (`surface: 'server'` — a service, a seed, a test) is app code, not a caller, and spends nothing.
437
+
438
+ | Fact | Answer |
439
+ |---|---|
440
+ | whose bucket | the caller's: actor → org → connection address (`@ultimat3/http`'s `rateLimitSpends`). An anonymous caller with no address (MCP, a job) shares one bucket |
441
+ | where it is counted | `@ultimat3/http`'s installed store (`installRateLimitStore()`) — the slot `@ultimat3/query` spends from too; the boot fills it with the SAME instance it gives `httpServer({ rateLimitStore })`, on every role. Default: one process' memory |
442
+ | what HTTP answers | `RateLimit-*` from the bucket closest to refusing, and on a refusal `429` + `Retry-After` — the pipeline's own shape |
443
+ | a retry | an admitted attempt spends one token; a refused one spends nothing. A job refused `X_RATE_LIMITED` is rescheduled for its `Retry-After` WITHOUT counting an attempt, so a backlog is delayed, never dead-lettered |
444
+ | an idempotent replay | spends nothing: the stored answer is served, never a 429 |
445
+ | who a job run is | its actor, else its tenant (`org:<orgId>`); a run with neither shares one `job:unattributed` bucket, never the anonymous callers' |
446
+ | OpenAPI | `x-ultimate.rateLimit`, and with `defineApi({ openapi })` the `RateLimit-*` headers and `429` |
447
+
448
+ ```ts
449
+ import type { PgExecutor } from '@ultimat3/core';
450
+ import { installRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
451
+
452
+ declare const executor: PgExecutor; // the pool this boot already opened
453
+
454
+ // Boot, before the first call — the one store every surface and every replica counts in.
455
+ // `x dev` and a container boot do this themselves, and `httpServer({ rateLimitStore })` adopts
456
+ // the store it is handed; a process with no server (a worker of your own) installs it here.
457
+ const rateLimitStore = postgresRateLimitStore({ executor });
458
+ installRateLimitStore(rateLimitStore);
459
+ ```
460
+
416
461
  `toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
417
462
  — so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
418
- beside `Bucket` and the limiter maths, and is re-exported here: `@ultimat3/query` needs the same
419
- conversion and is the same tier, so a copy in either package would be a second answer for the
420
- other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. An action that declares nothing
421
- stays on the `default` bucket. An app that also configures `http.rateLimit.buckets.<actionName>`
422
- with **different** numbers is `X_RATE_LIMIT_BUCKET_CONFLICT` at boot: neither source wins, because
423
- the loser would go on being read as enforced.
463
+ beside `Bucket` and the limiter maths, and is imported from there — never re-exported here (25.0.0
464
+ dropped that second path): `@ultimat3/query` needs the same conversion and is the same tier, so a
465
+ copy in either package would be a second answer for the other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. The route says
466
+ `rateLimitedBy: 'handler'`, so the pipeline's stage spends no caller bucket for it — not `default`,
467
+ which would cap an action declaring more than it — and only the tenant allowance. An
468
+ `http.rateLimit.buckets.<actionName>` entry is a different table: it neither conflicts with nor
469
+ loosens what the action declared.
424
470
 
425
471
  ## `idempotent:` — and where its records live
426
472
 
@@ -438,12 +484,47 @@ interpolation broke. Omit the header to run un-keyed; the published `maxLength:
438
484
  by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
439
485
  public idempotent action still share a key space: a UUID key is what keeps them apart.
440
486
 
441
- **A failed first attempt is replayed too, not re-run.** `guard()` and the input parse both happen
487
+ **A failed first attempt is replayed too, not re-run.** `guardAction()` and the input parse both happen
442
488
  *before* the idempotency gate, so everything it can see throw is post-authorization and possibly
443
489
  post-commit: a handler that took the money and then failed its own `output:` schema is the case.
444
490
  The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
445
491
  code. Releasing it there is what made idempotency the cause of a double charge.
446
492
 
493
+ **A credential in the answer is never kept at rest.** The record lives a day, and a mutator is
494
+ always idempotent — so a "create an API key" mutator would otherwise leave the plaintext key in
495
+ `x_idempotency.value`. On settle, every value under a key `@ultimat3/core`'s `isRedactedKey` names
496
+ (the table the logger and the audit row use, extended by `defineEnv({ secret: true })`) and every
497
+ `Secret` box is stored as `[redacted]`, at any depth, and the record is flagged `redacted`. The
498
+ first caller still gets the whole answer. A replay of a flagged record is
499
+ `X_IDEMPOTENT_REPLAY_REDACTED` (409) rather than `[redacted]` served as the answer: the first call
500
+ RAN, so read its effect with a query — a fresh key runs the mutation again. An answer with no such
501
+ key is stored and replayed exactly as before — plain data (plain objects, arrays, `Date`,
502
+ primitives) comes back as the same reference. **What the walk cannot vouch for is redacted too**:
503
+ a value with a `toJSON` method, a `Map`, a `Set`, a class instance or a `Date` subclass, because
504
+ `JSON.stringify` would run app code the walk never saw; an own getter is read once and the stored
505
+ copy holds that read. A custom `IdempotencyStore` receives the redacted copy, must declare
506
+ `keepsRedaction: true` (a store without it is a compile error), and must keep `settle`'s required
507
+ fourth argument and hand it back as `IdempotencyRecord.redacted`:
508
+
509
+ ```ts
510
+ import type { IdempotencyStore } from '@ultimat3/action';
511
+
512
+ // A store that wraps another — a metrics or tracing decorator — passes the flag through.
513
+ export function counted(inner: IdempotencyStore, onSettle: () => void): IdempotencyStore {
514
+ return {
515
+ scope: inner.scope,
516
+ keepsRedaction: true,
517
+ reserve: (key, requestHash) => inner.reserve(key, requestHash),
518
+ settle: (key, value, reservationId, redacted) => {
519
+ onSettle();
520
+ return inner.settle(key, value, reservationId, redacted);
521
+ },
522
+ release: (key) => inner.release(key),
523
+ get: (key) => inner.get(key),
524
+ };
525
+ }
526
+ ```
527
+
447
528
  **The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
448
529
  `@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
449
530
  secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
@@ -464,6 +545,7 @@ no record and runs the handler again:
464
545
  import {
465
546
  configureIdempotency,
466
547
  postgresIdempotencyStore,
548
+ requestDeadlineMs,
467
549
  setIdempotencyStore,
468
550
  } from '@ultimat3/action';
469
551
  import { db } from '@ultimat3/db';
@@ -478,6 +560,10 @@ const client = db();
478
560
  setIdempotencyStore(
479
561
  postgresIdempotencyStore({
480
562
  executor: { query: (text, values) => client.query({ text, values }) },
563
+ // The client transactions on THIS database open on: a settle rides one of those and no other.
564
+ origin: () => client,
565
+ // The app's `requestTimeoutMs` (`configureHttp`), read per reservation — never a number here.
566
+ reclaimAfterMs: requestDeadlineMs,
481
567
  }),
482
568
  );
483
569
  configureIdempotency({ scope: 'shared' });
@@ -489,15 +575,16 @@ opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is
489
575
  development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
490
576
  .purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
491
577
 
492
- **A plain mutating `route` can use the same gate.** `withIdempotency`, `IDEMPOTENCY_HEADER`,
493
- `idempotencyKeyFor` and `getIdempotencyStore` are all public, so a route that is not an action
494
- reserves and replays through the one implementation rather than growing a second:
578
+ **A plain mutating `route` can use the same gate.** `withIdempotency`, `idempotencyKeyFor` and
579
+ `getIdempotencyStore` are public here and `IDEMPOTENCY_HEADER` is `@ultimat3/core`'s (imported from
580
+ core — 25.0.0 dropped this package's re-export, and `BUILD_ID_HEADER`'s with it), so a route that is
581
+ not an action reserves and replays through the one implementation rather than growing a second:
495
582
 
496
583
  ```ts
497
584
  const key = req.header(IDEMPOTENCY_HEADER);
498
585
  // No header is the caller declining idempotency; a BLANK one is not, and
499
586
  // `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
500
- if (key === null) return json(await refund(input));
587
+ if (key === null) return jsonResponse(await refund(input));
501
588
  const outcome = await withIdempotency(
502
589
  getIdempotencyStore(),
503
590
  // Namespaced by action AND actor: otherwise two routes share one caller's key, and two
@@ -514,12 +601,32 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
514
601
  so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
515
602
  reservation.
516
603
 
604
+ **Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
605
+ `idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
606
+ inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
607
+ its `onCommit` (memory).
608
+
609
+ | The unit of work | The record | A retry |
610
+ |---|---|---|
611
+ | commits | `settled`, by the same `COMMIT` | replays the first result |
612
+ | rolls back after the handler returned | still `in-flight` — never `settled` for rows nobody stored | `X_IDEMPOTENCY_CONFLICT` until the request deadline, then it runs |
613
+ | dies mid-flight | still `in-flight` | the same |
614
+ | outlives the deadline and a retry takes the key | the retry's | the slow attempt's settle is refused `X_IDEMPOTENCY_RESERVATION_LOST`, which rolls its transaction back — one write |
615
+
616
+ | Rule | Why |
617
+ |---|---|
618
+ | the deadline is `reclaimAfterMs: () => number`, **required** on the Postgres store | it is the app's `requestTimeoutMs`, declared (`configureHttp`) after the boot built the store — so it is read per reservation, and `requestDeadlineMs` is the reader. `0` is "no deadline": nothing is reclaimed before the window. The memory store defaults to the same reader |
619
+ | it frees only a record whose settle was BOUND to a transaction (`x_idempotency.tx_bound`) | a handler with no transaction around it keeps the old rule — reserve, run and settle are three commits, "died before the write" and "wrote, then died before the settle" are one row, and that row answers 409 for the whole window rather than risk the second charge |
620
+ | the reservation and a recorded failure always go through the pool | a duplicate sees the first at once, and a failure outlives its rollback |
621
+ | `origin: () => client` is **required** on the Postgres store | a transaction opened on ANOTHER database (`withTransaction(fn, { client: shard })`) has no `x_idempotency`; its record is settled on the pool, as an autocommit handler's is |
622
+ | a finished transaction binds nothing | a promise chain the body forgot to await still finds the scope's handle; the settle then runs at once (`liveTxConnection`), never as a commit hook that a rollback already dropped |
623
+
517
624
  A `query` has none and never will: a read has nothing to be idempotent about.
518
625
 
519
626
  ## `deprecated:` — a compat window, not a version
520
627
 
521
628
  ```ts
522
- import type { Deprecation } from '@ultimat3/action';
629
+ import type { Deprecation } from '@ultimat3/core';
523
630
 
524
631
  // The `deprecated:` key of an `action()`.
525
632
  const deprecated: Deprecation = {
@@ -536,6 +643,9 @@ operation, and a `deprecated_calls_total{primitive,name}` counter — which is t
536
643
  answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
537
644
  `X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
538
645
 
646
+ `Deprecation`, `renderDeprecation` and `recordDeprecatedCall` are `@ultimat3/core`'s and are
647
+ imported from there — this package does not re-export them (`As of 25.0.0`).
648
+
539
649
  **Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
540
650
  action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
541
651
  no router feature, no path prefix and no second registry. What ships is the window: a date, a
@@ -549,7 +659,7 @@ price change are not the same event, and the framework is not the thing that kno
549
659
  them your business has to keep.
550
660
 
551
661
  ```ts
552
- import { setAuditSink } from '@ultimat3/action';
662
+ import { setAuditSink } from '@ultimat3/core';
553
663
 
554
664
  setAuditSink({
555
665
  async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
@@ -561,14 +671,19 @@ What the framework supplies is what it genuinely knows:
561
671
  | Field | |
562
672
  |---|---|
563
673
  | `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
564
- | `action` / `mutator` | the registered name, and which primitive it was |
565
- | `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
674
+ | `name` / `primitive` / `mutator` | the registered name; `'action'` (or `'query'` for an audited read); and whether `mutator()` built it. `name` and `primitive` are required, and the `action` alias of `name` is gone (`As of 25.0.0`) — `postgresAuditSink` still files `name` in the `x_audit.action` column |
675
+ | `surface` | `server` \| `http` \| `mcp` \| `job` (and `live` on a read) — the same price change over MCP is not the same event |
566
676
  | `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
567
677
  | `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
568
678
  | `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
569
679
  | `outcome` | `allowed` \| `denied` \| `failed` |
570
680
  | `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
571
681
 
682
+ **One contract, one sink, shared with reads.** `AuditRecord`, `AuditSink` and the installed slot
683
+ are `@ultimat3/core`'s, and so is the one way to install it: `setAuditSink` from `@ultimat3/core`
684
+ (this package re-exports the two types, never the slot — `As of 25.0.0`) installs the sink `query({ audit: true })` writes to as well (`@ultimat3/query`'s README). A sink
685
+ tells the two apart by `record.primitive`.
686
+
572
687
  What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
573
688
  or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
574
689
  one would make three of them wrong.
@@ -587,7 +702,8 @@ is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the res
587
702
 
588
703
  ```ts
589
704
  // apps/web/server.ts — the app owns the connection, so the app installs the sink
590
- import { postgresAuditSink, setAuditSink } from '@ultimat3/action';
705
+ import { postgresAuditSink } from '@ultimat3/action';
706
+ import { setAuditSink } from '@ultimat3/core';
591
707
  import { db } from '@ultimat3/db';
592
708
 
593
709
  const client = db();
@@ -609,7 +725,7 @@ the actor, the correlation ids, the idempotency key — and the parsed `input` a
609
725
  through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
610
726
  value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
611
727
  boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
612
- itself (`createContext` spreads every installed service onto it, and an HTTP surface's is a
728
+ itself (`ctxOf` spreads every installed service onto it, and an HTTP surface's is a
613
729
  `RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
614
730
  a failure — the row keeps `failure.code`, never the throwable.
615
731
 
@@ -617,7 +733,7 @@ The table has **no purge**, deliberately, and it is the one framework table that
617
733
  stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
618
734
  legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
619
735
 
620
- A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
736
+ A denial is recorded because `invoke` wraps the whole path — `guardAction` throws **before** `handle`,
621
737
  so nothing you could write around your own handler would ever see one. That is the reason this
622
738
  lives in the framework and the row does not.
623
739
 
@@ -668,7 +784,7 @@ setAuditSink({
668
784
  const prev = await chainHead(ctx); // hash-chained: the app's choice
669
785
  await ctx.db.auditRows.insert({
670
786
  orgId: orgOf(ctx.actor), // tenancy: derived from the actor
671
- subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
787
+ subjectId: subjectOf(record.name, record.input), // queryable by subject: the app's index
672
788
  actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
673
789
  at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
674
790
  prevHash: prev, hash: await sha256(prev, record),
@@ -685,7 +801,7 @@ setAuditSink({
685
801
  |---|---|
686
802
  | input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
687
803
  | policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
688
- | OpenAPI document contains its operation | the derived path is in `buildOpenApi()` |
804
+ | OpenAPI document contains its operation | the action's path, in the document built from the WHOLE registry (plus this action when a test drives an unregistered `.named()` twin), is this action's operation — a second registered action deriving the same route fails it |
689
805
 
690
806
  The denial assertion sends an input synthesized from `input:`'s own schema — required keys
691
807
  only, formats included — because a payload the schema rejects never reaches a policy. It
@@ -716,8 +832,10 @@ never a pass — the assertion says which code got in the way and names `input:`
716
832
  | `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions describe <name> --json` |
717
833
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
718
834
  | `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 |
719
- | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
835
+ | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor, origin, reclaimAfterMs })` at boot |
720
836
  | `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 |
837
+ | `X_IDEMPOTENCY_RESERVATION_LOST` | a settle inside the handler's transaction matched no record: the attempt outlived the request deadline and a retry took the key. This attempt's transaction rolls back | resend with the same `Idempotency-Key` |
838
+ | `X_MUTATOR_NOT_IDEMPOTENT` | `mutator()` declared without `idempotent: true` | add `idempotent: true` to the definition |
721
839
  | `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 |
722
840
  | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
723
841
  | `X_RPC_FAILED` | registered, thrown by nothing since 21.0.0 — a non-`problem+json` failure is core's `X_CLIENT_TRANSPORT_FAILED` now, as for a query | match `X_CLIENT_TRANSPORT_FAILED` instead |
@@ -762,13 +880,15 @@ a job boundary the class is gone and the `code` is what survives — match on th
762
880
  | `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
763
881
  | `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
764
882
  | `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
883
+ | `IdempotencyReservationLostError` | `X_IDEMPOTENCY_RESERVATION_LOST` | `src/errors-idempotency.ts` |
765
884
  | `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
766
885
  | `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
886
+ | `MutatorNotIdempotentError` | `X_MUTATOR_NOT_IDEMPOTENT` | `src/errors-idempotency.ts` |
767
887
  | `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
768
888
  | `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
769
889
  | `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
770
890
 
771
891
  ## Boundaries
772
892
 
773
- Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
893
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `db`, `entity`, `policy`, `http`. Never imports
774
894
  `query`, `jobs`, `realtime` (same tier) or anything above it — those import *this*.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "23.0.0",
3
+ "version": "25.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",
7
7
  "sideEffects": [
8
- "./src/errors.ts"
8
+ "./src/error-titles.ts"
9
9
  ],
10
10
  "repository": {
11
11
  "type": "git",
@@ -28,18 +28,19 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "engines": {
31
- "bun": ">=1.4.0"
31
+ "bun": ">=1.4.2"
32
32
  },
33
33
  "scripts": {
34
34
  "typecheck": "tsc --noEmit -p tsconfig.json",
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@ultimat3/cache": "23.0.0",
39
- "@ultimat3/core": "23.0.0",
40
- "@ultimat3/entity": "23.0.0",
41
- "@ultimat3/http": "23.0.0",
42
- "@ultimat3/policy": "23.0.0",
43
- "@ultimat3/schema": "23.0.0"
38
+ "@ultimat3/cache": "25.0.0",
39
+ "@ultimat3/core": "25.0.0",
40
+ "@ultimat3/db": "25.0.0",
41
+ "@ultimat3/entity": "25.0.0",
42
+ "@ultimat3/http": "25.0.0",
43
+ "@ultimat3/policy": "25.0.0",
44
+ "@ultimat3/schema": "25.0.0"
44
45
  }
45
46
  }