@ultimat3/action 24.0.0 → 25.1.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
@@ -1,6 +1,7 @@
1
1
  # @ultimat3/action
2
2
 
3
- Owns the `action` + `mutator` primitives and their six projections. Tier 3.
3
+ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP tool is
4
+ `@ultimat3/mcp`'s one projection (O-tool, 25.0.0).
4
5
 
5
6
  ## Boundary
6
7
 
@@ -27,57 +28,59 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
27
28
  | `http-path.ts` | where an action is served: the app's `pathStyle` + a per-action `http.path` pin, read lazily by every projection |
28
29
  | `path-style-miss.ts` | `explainActionPathMiss` — `@ultimat3/http`'s `hooks.explainMiss`: a POST under the style this app does not serve is `X_CONTRACT_DRIFT` naming the one it does |
29
30
  | `api-declaration.ts` | `defineApi({ http, openapi })` held process-wide for the boot and `x manifest` |
30
- | `errors-http.ts` | the three HTTP-declaration refusals (codes registered in `errors.ts`) |
31
+ | `errors-http.ts` | the three HTTP-declaration refusals |
32
+ | `error-titles.ts` | every code's title, the one `registerErrorCodes` |
31
33
  | `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
32
34
  | `record-wire.ts` | the record envelope on the HTTP projection: `carriesRecords` (from the output schema), the enveloped 200, and its OpenAPI shape. Server-only — `client.ts` never imports it |
33
35
  | `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
34
36
  | `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate |
35
- | — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, re-exported from `src/index.ts`. There is no local copy and must not be one |
36
- | `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` |
37
- | `mcp-tool.ts` | MCP descriptor, same `invoke` |
37
+ | — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, imported from core — `src/index.ts` re-exports only the `ClientFlight`/`ClientRetry` types. There is no local copy and must not be one |
38
38
  | `job-handle.ts` | the `.job()` projection: an action as a queueable payload. **Not** consumed by `@ultimat3/jobs` — see Invariants |
39
39
  | `contract-test.ts` | assertions `x g action` emits |
40
40
  | `sample-input.ts` | a value `input:` accepts, from its own IR — what makes the policy assertion reach a policy |
41
41
  | `idempotency.ts` | the store SEAM: types, the installed-store slot, the scope declaration + `assertIdempotencyScope`, and `withIdempotency` — the replay-or-run gate |
42
42
  | `idempotency-key.ts` | the namespaced key — action + actor + the caller's key, as one JSON tuple — and the refusal of one that names no request |
43
43
  | `idempotency-memory.ts` | the process default: bounded, swept, `scope: 'process'` |
44
+ | `idempotency-redact.ts` | `restingAnswer` — the copy of an answer a store may keep: core's `isRedactedKey` + `isSecret`, the SAME reference when nothing is redacted |
44
45
  | `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
45
- | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
46
46
  | `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
47
47
  | `cache-gate.ts` | the post-COMMIT bust — **the only** file that calls `invalidateTags` |
48
48
  | `tx-scope.ts` | the open transaction — **the only** file that imports `@ultimat3/db` (`tx-scope.test.ts`): `openCommitScope` (the bust) and `liveTransaction` (the settle) |
49
+ | `rate-limit-gate.ts` | **the one place** a declared `rateLimit:` is spent, and the installed-store slot |
49
50
  | `request-deadline.ts` | `requestDeadlineMs` — the app's `requestTimeoutMs`, resolved by http's own `defineHttpConfig` |
50
- | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the installed-sink store |
51
+ | `audit.ts` | the audit seam's TYPES, re-exported from `@ultimat3/core` (shared with `query`); the slot (`setAuditSink`) is core's alone |
51
52
  | `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
52
53
  | `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
53
54
  | `audit-input.ts` | what may be written DOWN: an `input` redacted through core's table and made JSON-representable on every path |
54
55
  | `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
55
56
  | `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
56
- | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer plus a re-export of core's `isJsonObject` — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
57
+ | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer (core's `isJsonObject` is imported from core, never re-exported) — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
57
58
 
58
59
  ## Invariants — execution and authz
59
60
 
60
- - Every surface goes through `invoke`: the policy's actor half (`guardBeforeInput`, 403 before the
61
+ - Every surface goes through `invoke`: the policy's actor half (`guardActionBeforeInput`, 403 before the
61
62
  parse), parse input, evaluate policy, handle, parse output. A second
62
63
  execution path is the one unforgivable change here.
63
64
  - **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
64
- `@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
65
+ `@ultimat3/entity`'s tenant guard reads) must be the identity `guardAction()` decided about.
65
66
  `invoke-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
66
67
  - The declaration never leaves `invoke.ts`: `defOf`/`stashDef` are never re-exported from
67
68
  `src/index.ts` (`index.test.ts`). An action has no `.def`; outside, read `.input`/`.output`/
68
69
  `.policy`/`.mcp` or `describe()`. App code reaches a projection through the action
69
- (`publishPost.tool()`); new methods are bound in `facade.ts`.
70
+ (`publishPost.openapi()`); new methods are bound in `facade.ts`. **No `.tool()`**: it was a second
71
+ MCP projection beside mcp's `toolFrom` (`index.test.ts`, `type-pins.ts`).
70
72
  - **`toRoute` sets `enforcedBy: 'handler'`** — `invoke` is the one evaluation and the only one holding
71
73
  the row `def.row` loaded; `meta.policy` stays set. `http.test.ts` counts exactly one evaluation.
72
- - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, via
73
- `policy-gate.ts`), never the root combinator — never a copy here (`@ultimat3/query` needs the same
74
+ - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, imported
75
+ from `@ultimat3/policy` by `http.ts`; 25.0.0 dropped this package's re-export, `index.test.ts`
76
+ pins the absence), never the root combinator — never a copy here (`@ultimat3/query` needs the same
74
77
  answer).
75
78
  - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a denial becomes
76
79
  `ActionDeniedError`, keeping the policy's code. `policy-gate.ts` is the only RUNTIME edge to
77
80
  `@ultimat3/policy` (`errors.ts`'s `import type { SurfaceDenial }` erases).
78
81
  - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
79
82
  - **`registerAction` guards the derived PATH as well as the name** (`X_ACTION_PATH_DUPLICATE`; a
80
- second index cleared by `resetRegistry`).
83
+ second index cleared by `resetActions`).
81
84
  - **A path is the app's `pathStyle` or the action's pin, never a third answer** (`http-path.ts`).
82
85
  `'resource'` stays the default (changing it moves every URL). `defineApi` sets the style FIRST and
83
86
  `configureActionPathStyle` re-derives every seated action (the module scan may have seated them
@@ -117,7 +120,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
117
120
  - **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
118
121
  codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
119
122
  `audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
120
- `t.uuid`.
123
+ `t.uuid`; `row` goes to `mutator()` as declared, never loaded here (#687).
121
124
  - **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
122
125
  `sample-input.ts`) — caller- or provider-supplied keys.
123
126
 
@@ -152,11 +155,21 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
152
155
  `json-schema.ts`). The HASH form is core's `canonicalJson`/`fingerprint` (injective: tags
153
156
  `NaN`/`±Infinity`/`-0`, `Date`, `Map`, `Set`); `stable.test.ts` pins byte-equality for ordinary
154
157
  payloads.
155
- - **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (re-exported; raises
156
- `X_RATE_LIMIT_INVALID`). Never restore a local copy; never use `@ultimat3/render`'s `tagKeys`.
157
- - **`rateLimit:` reaches the limiter**: `toRoute` sets `meta.rateLimit` AND `meta.rateLimitBucket`
158
- (`toBucket`), which `@ultimat3/http`'s `withRouteBuckets` registers. The computed rate must be
159
- finite and `limit` at least one token.
158
+ - **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (imported, never
159
+ re-exported; `toBucket` raises `X_RATE_LIMIT_INVALID`). Never restore a local copy.
160
+ - **`rateLimit:` is spent ONCE, inside `invoke`** (`rate-limit-gate.ts`), so HTTP, MCP, the agent
161
+ tool and `.job()` share one bucket; `surface: 'server'` spends nothing. `toRoute` sets
162
+ `rateLimitedBy: 'handler'` and NO `meta.rateLimit`/`rateLimitBucket` — the stage spends neither
163
+ the action's bucket (the old HTTP-only point) nor `default`. Key `action:<name>|<subject>`.
164
+ HTTP gets `RateLimit-*` via `onRateLimit` → http's `publishRateLimit`. The store is
165
+ `@ultimat3/http`'s installed one, adopted by the boot on every role. Spent after
166
+ `guardActionBeforeInput`, BEFORE the parse and `def.row()` (an input/row-denied flood is refused). A key
167
+ the idempotency store holds spends nothing (replay); a reservation `withIdempotency` CREATES that
168
+ the peek did not pay for spends in `beforeRun`, released on refusal. A job run is charged to its
169
+ org, else `job:unattributed`. A surface's `clientAddress` is inherited by nested `invoke`s
170
+ (`withCallerAddress`); a `'job'` invoke opens a fresh frame (`withJobFrame`) so no visitor's
171
+ address follows work into the worker, and calls nested in it are the job's.
172
+ `rate-limit-surfaces.test.ts`. The computed rate must be finite and `limit` at least one token.
160
173
  - **`deprecated:` is a compat WINDOW** — headers on every response including failures, a
161
174
  `rel="successor-version"` link via `derivePath`, `deprecated: true` in OpenAPI,
162
175
  `deprecated_calls_total`. Rendered ONCE at projection (`X_ACTION_DEPRECATION_INVALID` at mount).
@@ -164,11 +177,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
164
177
  - **The span wraps `execute` whole** (including `def.row()`), with bounded attributes plus the
165
178
  namespaced idempotency key (never a metric label). `telemetry.test.ts`.
166
179
  - **`policyCapability` is a display label; `policyPermissions` (`ActionDescriptor.permissions`) is
167
- what a report matches on** (a `not()` clause contributes its permissions).
168
- - **MCP exposure reads core's `isMcpExposed`, in all three places** (`toMcpTools`, `describeAction`,
169
- `x-ultimate.mcpTool`). **A tool's NAME is the export name verbatim everywhere** — `toToolName` is
170
- deleted; `mcp-tool.test.ts` asserts one verbatim name across surfaces. `ActionFact.mcp` in the app
171
- manifest carries only `{ expose, description? }`.
180
+ what a report matches on** (a `not()` clause contributes its permissions). Both are
181
+ `@ultimat3/policy`'s, imported from there.
182
+ - **MCP exposure is core's `isMcpExposed`** (`describeAction`, `x-ultimate.mcpTool`); **a tool's
183
+ NAME is the export name verbatim** (`mcp-surface.test.ts`). `ActionFact.mcp` in the app manifest
184
+ carries only `{ expose, description? }`.
172
185
  - **`ActionJobHandle` is not consumed by `@ultimat3/jobs`** (`isJobHandle` needs `job()`'s private
173
186
  map). `.job()` gives `action:<name>` as a queue key, a payload-derived idempotency key and an
174
187
  `invoke` under `surface: 'job'`. The bridge is `agentJob()` in `@ultimat3/ai`, or an app's own
@@ -176,24 +189,25 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
176
189
 
177
190
  ## Invariants — flight control
178
191
 
179
- - **Flight control is `@ultimat3/core`'s, re-exported** — the same objects `@ultimat3/query`
180
- exports. Fix the pipeline in `packages/core/src/client-flight.ts`. No new code, curve, fence or
181
- retry loop here (`bun run flight-copies`).
192
+ - **Flight control is `@ultimat3/core`'s, and NOT re-exported** — its values are imported from
193
+ core (`X_HELPER_COPY` refuses a re-export); only the types `ClientFlight`/`ClientRetry`. Fix the
194
+ pipeline in `packages/core/src/client-flight.ts`. No new code, curve, fence or retry loop here (`bun run flight-copies`).
182
195
  - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** (a caller's `AbortError`
183
196
  is terminal). Must survive.
184
197
  - **`ClientFlight` is a TYPE inside `client.ts`, never a value.** Measured,
185
198
  `bun build --target=browser --minify`, one entry importing from `@ultimat3/action` — **the ONE
186
199
  table for these figures** (`packages/core/CLAUDE.md` points here):
187
200
 
188
- | Entry | before (HEAD `98d16d84`) | onto `clientTransport` | trace headers moved to core's outbound slot | As of 2026-09-23 |
189
- |---|---|---|---|---|
190
- | `rpc` | 18,097 B | 23,007 B | 18,119 B | 19,074 B |
191
- | `rpc` + `createClientFlight` | 23,903 B | 28,823 B | not measured | 25,197 B |
201
+ | Entry | `As of 2026-10-01` |
202
+ |---|---|
203
+ | `rpc` | 19,671 B (+181 B for the document's path-style stamp) |
204
+ | `rpc` + core's `clientFlight` | 25,954 B |
192
205
 
193
- Net against the pre-transport figure: +977 B, about the envelope decoder's size. `As of 2026-10-01`
194
- `rpc` is 19,671 B (19,490 B before the document's path-style stamp was read: +181 B, +45 gzipped)
195
- and `rpc` + `createClientFlight` 25,954 B.
196
- - **The `sideEffects` array is load-bearing**: `errors.ts` runs `registerErrorCodes` at import. Never `false`.
206
+ Earlier columns (pre-transport 18,097 B, +977 B net for the envelope decoder) are in git history.
207
+ - **`sideEffects` is `["./src/error-titles.ts"]`**, never `false` (drops its bare imports): the
208
+ titles ride into every barrel chunk (`problemError` decodes by code), `errors.ts`'s classes do
209
+ not. An http refusal needs no http module: core's decoders take its title off the body and its
210
+ `retry-after` off the header (`rpc-refusal.test.ts`).
197
211
 
198
212
  ## Invariants — idempotency
199
213
 
@@ -208,8 +222,17 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
208
222
  inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
209
223
  never a gate.
210
224
  - **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
211
- `settle(key, value, reservationId)` / `fail(key, failure, reservationId)`. A fenced no-op is logged,
212
- never thrown.
225
+ `settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)` /
226
+ `release(key, reservationId)`. A fenced no-op is never thrown.
227
+ - **An answer rests REDACTED** (#591): `withIdempotency` passes the store `restingAnswer(value)`
228
+ (core's `isRedactedKey` + `isSecret` — never a second rule) and `redacted` as `settle`'s
229
+ REQUIRED 4th argument; both stores keep it (`x_idempotency.redacted`, memory's record field).
230
+ A flagged replay is `X_IDEMPOTENT_REPLAY_REDACTED`, decided by the FLAG, never by scanning for
231
+ `[redacted]`. Plain data is the same reference, replayed as before; anything else that
232
+ `JSON.stringify` would run app code on (`toJSON`, `Map`/`Set`, class instances, `Date`
233
+ subclasses) is redacted FAIL-CLOSED, and a getter is read once. `keepsRedaction: true` is a
234
+ required store declaration (`type-pins.ts`: a pre-25 store is a build error — a required
235
+ parameter alone is not). A reclaim and a `fail` reset the flag.
213
236
  - **A stored status is NARROWED** (`isIdempotencyStatus`; unknown is
214
237
  `X_IDEMPOTENCY_STATUS_UNKNOWN`), never cast.
215
238
  - **Where records live is DECLARED and refused at registration**: `IdempotencyStore.scope` vs
@@ -248,6 +271,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
248
271
  DENIED attempt is recorded. No audit entity, retention, hash chain or "who" convention. The
249
272
  vocabulary matches `@ultimat3/admin`'s by name (tier 5, no edge); admin's `AuditSink` is a known
250
273
  duplicate that unifying would need core for.
274
+ - **Records carry `name` + `primitive`, required** (`type-pins.ts`); no `action` alias since 25.0.0.
275
+ The `x_audit.action` column holds `name`.
251
276
  - **The memory sink DROPS** — a ring at `DEFAULT_MAX_AUDIT_RECORDS`, oldest first, counting `dropped`;
252
277
  no spelling of "unbounded".
253
278
  - **A durable sink writes what `audit-input.ts` allows**: input redacted through core's
@@ -257,7 +282,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
257
282
  `locale`, `tz`, `buildId`, `role`, actor `id`/`kind`/`orgId`/`onBehalfOf`) and keeps
258
283
  `failure.code`, never the error.
259
284
  - **`x_audit` ships no purge**, deliberately. **`SQL_AUDIT_INSERT` is positional**, pinned by
260
- `audit-parity.test.ts` with a distinct value per field.
285
+ `audit-parity.test.ts` with a distinct value per field. Only `*_TABLE` DDL is exported
286
+ (`X_SQL_EXPORT_UNREAD`).
261
287
  - **The two failure policies are opposites**: `X_AUDIT_SINK_MISSING` before the input parse (no
262
288
  logger-backed default sink); a sink refusing an ALLOWED record is `X_AUDIT_SINK_FAILED`, whose `fix:`
263
289
  branches on `record.idempotencyKey !== null` (`meta.replayable`); a sink refusing a denied or failed
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 |
@@ -308,8 +311,8 @@ no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FORE
308
311
 
309
312
  ## mutator = action + local twin
310
313
 
311
- `mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets all
312
- 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`
313
316
  strategy for the rebase.
314
317
 
315
318
  ```ts
@@ -407,6 +410,7 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
407
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 |
408
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 |
409
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` |
413
+ | a row-level `policy` gets its row from **`row`** | the action's own loader, handed through: `row: ({ input, ctx }) => posts(ctx).find(input.id)`, then `can<TransitionValues<S>, Post>('post:move', ({ actor, row }) => row !== null && row.authorId === actor?.id)`. Loaded once, after the input parse, before the guard and the statement; the factory reads no row itself. Omitted, the rule sees `row: null` and must fail closed. **Check-then-act**, as every row-policy action's: the read is unlocked and outside any transaction, and the move's predicate is `id` + `from` only, so a concurrent change to a column the rule read (an `authorId` reassigned in between) is not seen. Where that matters, write an `action` whose handler opens `withTransaction`, reads the row `for update`, decides and calls `Table.transition` inside it |
410
414
  | `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 |
411
415
 
412
416
  `table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
@@ -427,17 +431,43 @@ import type { ActionRateLimit } from '@ultimat3/action';
427
431
  const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
428
432
  ```
429
433
 
430
- One declaration, three places it lands: the bucket the limiter runs on (named after the action,
431
- registered by `@ultimat3/http`'s `withRouteBuckets` when the route is mounted), the
432
- `ratelimit-limit` header the caller reads, and `x-ultimate.rateLimit` in the OpenAPI operation.
434
+ One declaration, one bucket, every surface. `invoke` spends it — so the HTTP route, the MCP tool,
435
+ the in-app agent's tool call and `.job()` draw on the **same** bucket, and the third call to a
436
+ `limit: 2` action is refused `X_RATE_LIMITED` whichever door it came through. A direct in-process
437
+ call (`surface: 'server'` — a service, a seed, a test) is app code, not a caller, and spends nothing.
438
+
439
+ | Fact | Answer |
440
+ |---|---|
441
+ | 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 |
442
+ | 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 |
443
+ | what HTTP answers | `RateLimit-*` from the bucket closest to refusing, and on a refusal `429` + `Retry-After` — the pipeline's own shape |
444
+ | 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 |
445
+ | an idempotent replay | spends nothing: the stored answer is served, never a 429 |
446
+ | 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' |
447
+ | OpenAPI | `x-ultimate.rateLimit`, and with `defineApi({ openapi })` the `RateLimit-*` headers and `429` |
448
+
449
+ ```ts
450
+ import type { PgExecutor } from '@ultimat3/core';
451
+ import { installRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
452
+
453
+ declare const executor: PgExecutor; // the pool this boot already opened
454
+
455
+ // Boot, before the first call — the one store every surface and every replica counts in.
456
+ // `x dev` and a container boot do this themselves, and `httpServer({ rateLimitStore })` adopts
457
+ // the store it is handed; a process with no server (a worker of your own) installs it here.
458
+ const rateLimitStore = postgresRateLimitStore({ executor });
459
+ installRateLimitStore(rateLimitStore);
460
+ ```
461
+
433
462
  `toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
434
463
  — so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
435
- beside `Bucket` and the limiter maths, and is re-exported here: `@ultimat3/query` needs the same
436
- conversion and is the same tier, so a copy in either package would be a second answer for the
437
- other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. An action that declares nothing
438
- stays on the `default` bucket. An app that also configures `http.rateLimit.buckets.<actionName>`
439
- with **different** numbers is `X_RATE_LIMIT_BUCKET_CONFLICT` at boot: neither source wins, because
440
- the loser would go on being read as enforced.
464
+ beside `Bucket` and the limiter maths, and is imported from there — never re-exported here (25.0.0
465
+ dropped that second path): `@ultimat3/query` needs the same conversion and is the same tier, so a
466
+ 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
467
+ `rateLimitedBy: 'handler'`, so the pipeline's stage spends no caller bucket for it — not `default`,
468
+ which would cap an action declaring more than it — and only the tenant allowance. An
469
+ `http.rateLimit.buckets.<actionName>` entry is a different table: it neither conflicts with nor
470
+ loosens what the action declared.
441
471
 
442
472
  ## `idempotent:` — and where its records live
443
473
 
@@ -455,12 +485,47 @@ interpolation broke. Omit the header to run un-keyed; the published `maxLength:
455
485
  by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
456
486
  public idempotent action still share a key space: a UUID key is what keeps them apart.
457
487
 
458
- **A failed first attempt is replayed too, not re-run.** `guard()` and the input parse both happen
488
+ **A failed first attempt is replayed too, not re-run.** `guardAction()` and the input parse both happen
459
489
  *before* the idempotency gate, so everything it can see throw is post-authorization and possibly
460
490
  post-commit: a handler that took the money and then failed its own `output:` schema is the case.
461
491
  The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
462
492
  code. Releasing it there is what made idempotency the cause of a double charge.
463
493
 
494
+ **A credential in the answer is never kept at rest.** The record lives a day, and a mutator is
495
+ always idempotent — so a "create an API key" mutator would otherwise leave the plaintext key in
496
+ `x_idempotency.value`. On settle, every value under a key `@ultimat3/core`'s `isRedactedKey` names
497
+ (the table the logger and the audit row use, extended by `defineEnv({ secret: true })`) and every
498
+ `Secret` box is stored as `[redacted]`, at any depth, and the record is flagged `redacted`. The
499
+ first caller still gets the whole answer. A replay of a flagged record is
500
+ `X_IDEMPOTENT_REPLAY_REDACTED` (409) rather than `[redacted]` served as the answer: the first call
501
+ RAN, so read its effect with a query — a fresh key runs the mutation again. An answer with no such
502
+ key is stored and replayed exactly as before — plain data (plain objects, arrays, `Date`,
503
+ primitives) comes back as the same reference. **What the walk cannot vouch for is redacted too**:
504
+ a value with a `toJSON` method, a `Map`, a `Set`, a class instance or a `Date` subclass, because
505
+ `JSON.stringify` would run app code the walk never saw; an own getter is read once and the stored
506
+ copy holds that read. A custom `IdempotencyStore` receives the redacted copy, must declare
507
+ `keepsRedaction: true` (a store without it is a compile error), and must keep `settle`'s required
508
+ fourth argument and hand it back as `IdempotencyRecord.redacted`:
509
+
510
+ ```ts
511
+ import type { IdempotencyStore } from '@ultimat3/action';
512
+
513
+ // A store that wraps another — a metrics or tracing decorator — passes the flag through.
514
+ export function counted(inner: IdempotencyStore, onSettle: () => void): IdempotencyStore {
515
+ return {
516
+ scope: inner.scope,
517
+ keepsRedaction: true,
518
+ reserve: (key, requestHash) => inner.reserve(key, requestHash),
519
+ settle: (key, value, reservationId, redacted) => {
520
+ onSettle();
521
+ return inner.settle(key, value, reservationId, redacted);
522
+ },
523
+ release: (key, reservationId) => inner.release(key, reservationId),
524
+ get: (key) => inner.get(key),
525
+ };
526
+ }
527
+ ```
528
+
464
529
  **The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
465
530
  `@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
466
531
  secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
@@ -511,15 +576,16 @@ opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is
511
576
  development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
512
577
  .purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
513
578
 
514
- **A plain mutating `route` can use the same gate.** `withIdempotency`, `IDEMPOTENCY_HEADER`,
515
- `idempotencyKeyFor` and `getIdempotencyStore` are all public, so a route that is not an action
516
- reserves and replays through the one implementation rather than growing a second:
579
+ **A plain mutating `route` can use the same gate.** `withIdempotency`, `idempotencyKeyFor` and
580
+ `getIdempotencyStore` are public here and `IDEMPOTENCY_HEADER` is `@ultimat3/core`'s (imported from
581
+ core — 25.0.0 dropped this package's re-export, and `BUILD_ID_HEADER`'s with it), so a route that is
582
+ not an action reserves and replays through the one implementation rather than growing a second:
517
583
 
518
584
  ```ts
519
585
  const key = req.header(IDEMPOTENCY_HEADER);
520
586
  // No header is the caller declining idempotency; a BLANK one is not, and
521
587
  // `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
522
- if (key === null) return json(await refund(input));
588
+ if (key === null) return jsonResponse(await refund(input));
523
589
  const outcome = await withIdempotency(
524
590
  getIdempotencyStore(),
525
591
  // Namespaced by action AND actor: otherwise two routes share one caller's key, and two
@@ -536,6 +602,14 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
536
602
  so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
537
603
  reservation.
538
604
 
605
+ `release` takes it too (`As of 2026-10-07`): it is the pre-handler cleanup when `beforeRun` refuses,
606
+ and keyed alone it DELETED whatever record the key held — a replacement's reservation, or its
607
+ settled answer, so the next retry re-ran a handler that had already committed. A release that
608
+ matches nothing does nothing (`idempotency-release.test.ts`, both stores). **A custom store's
609
+ `release(key, reservationId)` must delete only the record whose id is `reservationId` and whose
610
+ status is `in-flight`** — one that ignores the second argument reopens the double-run window, and
611
+ type-checking cannot catch it: a one-parameter function still satisfies the signature.
612
+
539
613
  **Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
540
614
  `idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
541
615
  inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
@@ -561,7 +635,7 @@ A `query` has none and never will: a read has nothing to be idempotent about.
561
635
  ## `deprecated:` — a compat window, not a version
562
636
 
563
637
  ```ts
564
- import type { Deprecation } from '@ultimat3/action';
638
+ import type { Deprecation } from '@ultimat3/core';
565
639
 
566
640
  // The `deprecated:` key of an `action()`.
567
641
  const deprecated: Deprecation = {
@@ -578,6 +652,9 @@ operation, and a `deprecated_calls_total{primitive,name}` counter — which is t
578
652
  answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
579
653
  `X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
580
654
 
655
+ `Deprecation`, `renderDeprecation` and `recordDeprecatedCall` are `@ultimat3/core`'s and are
656
+ imported from there — this package does not re-export them (`As of 25.0.0`).
657
+
581
658
  **Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
582
659
  action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
583
660
  no router feature, no path prefix and no second registry. What ships is the window: a date, a
@@ -591,7 +668,7 @@ price change are not the same event, and the framework is not the thing that kno
591
668
  them your business has to keep.
592
669
 
593
670
  ```ts
594
- import { setAuditSink } from '@ultimat3/action';
671
+ import { setAuditSink } from '@ultimat3/core';
595
672
 
596
673
  setAuditSink({
597
674
  async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
@@ -603,14 +680,19 @@ What the framework supplies is what it genuinely knows:
603
680
  | Field | |
604
681
  |---|---|
605
682
  | `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
606
- | `action` / `mutator` | the registered name, and which primitive it was |
607
- | `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
683
+ | `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 |
684
+ | `surface` | `server` \| `http` \| `mcp` \| `job` (and `live` on a read) — the same price change over MCP is not the same event |
608
685
  | `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
609
686
  | `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
610
687
  | `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
611
688
  | `outcome` | `allowed` \| `denied` \| `failed` |
612
689
  | `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
613
690
 
691
+ **One contract, one sink, shared with reads.** `AuditRecord`, `AuditSink` and the installed slot
692
+ are `@ultimat3/core`'s, and so is the one way to install it: `setAuditSink` from `@ultimat3/core`
693
+ (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
694
+ tells the two apart by `record.primitive`.
695
+
614
696
  What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
615
697
  or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
616
698
  one would make three of them wrong.
@@ -629,7 +711,8 @@ is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the res
629
711
 
630
712
  ```ts
631
713
  // apps/web/server.ts — the app owns the connection, so the app installs the sink
632
- import { postgresAuditSink, setAuditSink } from '@ultimat3/action';
714
+ import { postgresAuditSink } from '@ultimat3/action';
715
+ import { setAuditSink } from '@ultimat3/core';
633
716
  import { db } from '@ultimat3/db';
634
717
 
635
718
  const client = db();
@@ -651,7 +734,7 @@ the actor, the correlation ids, the idempotency key — and the parsed `input` a
651
734
  through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
652
735
  value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
653
736
  boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
654
- itself (`createContext` spreads every installed service onto it, and an HTTP surface's is a
737
+ itself (`ctxOf` spreads every installed service onto it, and an HTTP surface's is a
655
738
  `RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
656
739
  a failure — the row keeps `failure.code`, never the throwable.
657
740
 
@@ -659,7 +742,7 @@ The table has **no purge**, deliberately, and it is the one framework table that
659
742
  stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
660
743
  legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
661
744
 
662
- A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
745
+ A denial is recorded because `invoke` wraps the whole path — `guardAction` throws **before** `handle`,
663
746
  so nothing you could write around your own handler would ever see one. That is the reason this
664
747
  lives in the framework and the row does not.
665
748
 
@@ -710,7 +793,7 @@ setAuditSink({
710
793
  const prev = await chainHead(ctx); // hash-chained: the app's choice
711
794
  await ctx.db.auditRows.insert({
712
795
  orgId: orgOf(ctx.actor), // tenancy: derived from the actor
713
- subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
796
+ subjectId: subjectOf(record.name, record.input), // queryable by subject: the app's index
714
797
  actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
715
798
  at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
716
799
  prevHash: prev, hash: await sha256(prev, record),
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "24.0.0",
3
+ "version": "25.1.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,19 +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": "24.0.0",
39
- "@ultimat3/core": "24.0.0",
40
- "@ultimat3/db": "24.0.0",
41
- "@ultimat3/entity": "24.0.0",
42
- "@ultimat3/http": "24.0.0",
43
- "@ultimat3/policy": "24.0.0",
44
- "@ultimat3/schema": "24.0.0"
38
+ "@ultimat3/cache": "25.1.0",
39
+ "@ultimat3/core": "25.1.0",
40
+ "@ultimat3/db": "25.1.0",
41
+ "@ultimat3/entity": "25.1.0",
42
+ "@ultimat3/http": "25.1.0",
43
+ "@ultimat3/policy": "25.1.0",
44
+ "@ultimat3/schema": "25.1.0"
45
45
  }
46
46
  }