@ultimat3/action 21.0.0 → 22.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
@@ -46,602 +46,172 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
46
46
  | `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
47
47
  | `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` |
48
48
 
49
- ## Invariants
49
+ ## Invariants — execution and authz
50
50
 
51
- - **Every browser call goes through `@ultimat3/core`'s `clientTransport`, `As of 2026-09-22`.**
52
- `client.ts` hands it the method, the URL (core's `actionPath`, the one path rule — `naming.ts`'s
53
- `derivePath`/`pluralize`/`splitWords` are core's too, re-exported), body, headers, signal,
54
- idempotency key, flight and this call's `retry`; the transport owns credentials, the JSON body,
55
- the idempotency header, envelope decode, adoption into `pageClient().store`, the principal fence
56
- and the terminal `fetch`. Only what the action alone knows rides in as hooks: `onResponse` (the
57
- build-id check, `X_CONTRACT_DRIFT`) and `decodeError` (`RemoteActionError` for a body naming a
58
- framework code; `undefined` otherwise, so the transport answers `X_CLIENT_TRANSPORT_FAILED` — the
59
- same code a query gets for the same failure). `X_RPC_FAILED` / `RpcFailedError` stay registered
60
- and exported — a shipped code never changes — and nothing in the framework throws them since
61
- 21.0.0.
62
- `retry` is computed here — `{ attempts: 1 }` unless the call carries an idempotency key — so a
63
- flight-wide retry never re-sends an unkeyed write.
64
- - **The record envelope is derived from the output schema, decided per ACTION, never per
65
- response.** `carriesRecords(output)` is `@ultimat3/entity`'s `hasEntityRows`, evaluated once in
66
- `toRoute`; an enveloped action answers `{ data, records }` under `x-ultimate-records: 1` even
67
- when this call returned no row, so one operation has one wire shape and one OpenAPI schema. An
68
- output with no entity row is byte-identical on the wire and in the spec — `record-wire.test.ts`
69
- pins both, the bytes as `JSON.stringify(output)`. The envelope is HTTP-only: MCP, `.job()` and a
70
- direct call still answer the bare output.
71
- - **`conflict` is `@ultimat3/core`'s `ConflictPolicy`, over ROWS.** `custom(merge)` builds
72
- `{ kind: 'custom', merge(localRow, serverRow) }`; the output-shaped `CustomConflict`,
73
- `Conflict<T>`, `strategyOf` and this package's `resolveConflict` are deleted (21.0.0) — core's
74
- `resolveConflict` is the one resolver, and its `last-write-wins` compares the server's
75
- `updatedAt` rather than preferring the local side. `strategyOf` survives as a private helper for
76
- `describeMutator()`.
77
- - **`X_INPUT_INVALID` carries the rejections TWICE, and they are one value.** The flattened line
78
- stays in `cause` — it is what an operator reads in a log and what a non-form caller sees — and
79
- `meta.issues` carries the same list structured, so a client rebuilding a form knows WHICH field
80
- each rejection belongs to instead of splitting a string on `'; '` and guessing. `validate.ts` is
81
- the one caller that passes both, and `validate.test.ts` pins `cause` to
82
- `formatIssues(issues).join('; ')`; the rendering deliberately does NOT happen inside
83
- `InputInvalidError`, because that module is reachable from browser-safe `client.ts` and
84
- `@ultimat3/schema` declares no `sideEffects`, so a value import of `formatIssues` there would drag
85
- that package's whole barrel into every bundle holding the typed client.
86
- - **`toValidationIssues`, never a library's raw issues.** A conforming schema library's issue object
87
- may carry members Ultimate's shape does not — including the rejected VALUE — and this list is
88
- handed to an HTTP surface that returns it to the caller. Four members travel. The same rule on the
89
- way back in: `issuesFromWire` REBUILDS each entry member by member rather than copying it.
90
- - **`X_OUTPUT_INVALID` keeps the line alone.** An output rejection is a server defect whose remedy
91
- is a code change; no client can act on a per-field list, and shipping the handler's internal
92
- projection to a caller is new surface for nothing.
93
- - **An `issues` list off the wire is all-or-nothing.** A partly-parsed list would DROP the entries
94
- it could not read, and a caller that finds `meta.issues` uses it INSTEAD of `cause` — so a dropped
95
- entry is a rejection the user never hears about. `MAX_WIRE_ISSUES` bounds it, because whoever
96
- displays the list renders it into a DOM.
97
- - **`transition()` is a factory, not a primitive, and it decides nothing about the machine.** It
98
- returns a `mutator`, so every projection is inherited rather than re-declared, and it holds no
99
- legality rule: `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
100
- `@ultimat3/entity`'s and propagate untouched. `from` is REQUIRED — it is the UPDATE's predicate,
101
- which is what makes the refusal free; defaulting or inferring it is the lost update coming back.
102
- `conflict: 'server-wins'` is fixed (the server is the half that refused), and `audit` is OFF
103
- unless declared (`audit: true` with no sink is `X_AUDIT_SINK_MISSING` before the input parse, so
104
- defaulting it on would hold the factory hostage to an unrelated decision).
105
- - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse
106
- output. Adding a second execution path is the one unforgivable change here.
107
- - **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `invoke` entered
108
- `runWithContext` only when `options.actor` was supplied; given `options.ctx` alone it called
109
- `core(target, raw, options.ctx, options)` directly. So `guard()` decided about that actor while
110
- everything reading the AMBIENT context — above all `@ultimat3/entity`'s tenant guard, which
111
- derives from `tryUseContext()` and not from the ctx it is handed — saw a different identity, or
112
- none: `writeAnywhere.job().invoke(input, ctxAsOrgA)` wrote a row naming org B while the identical
113
- call under an ambient context was `X_TENANCY_ACTOR_MISMATCH`. Absent a `ctx` this reinstalls the
114
- ambient one, a no-op on every path that already worked. The twin fix is `@ultimat3/query`'s
115
- `asActor`, and `invoke-context.test.ts` asserts an EQUALITY between the three spellings of one
116
- caller — ambient, `options.actor`, `options.ctx` — because three independent expectations are
117
- exactly what let this ship.
118
- - The declaration never leaves `invoke.ts`. `defOf`/`stashDef` are internal and must
119
- never be re-exported from `src/index.ts` — that absence is the enforcement, and
120
- `index.test.ts` is what makes it one.
121
- - **`toRoute` sets `enforcedBy: 'handler'`, so the HTTP pipeline's authz stage stands down.**
122
- `invoke` is the route's single evaluation and the only one holding the row `def.row` loaded;
123
- a stage deciding first would decide the same policy from `row: null`, deny the row's own
124
- author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
125
- it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
126
- drives a row-level action over the real pipeline and counts the evaluations: exactly one.
127
- - **`meta.auth` is derived from a WALK of the policy tree**, never from the root combinator.
128
- `def.policy.kind === 'allow'` answered `'required'` for `or(allow(), can('x:y'))`, so the
129
- pipeline's `auth` stage 401'd an anonymous caller the policy itself ALLOWS — while the MCP tool
130
- and the job handle let that caller through the same object. One policy, a different answer per
131
- surface, which is the thing `enforcedBy: 'handler'` exists to prevent. `'public'` here is not
132
- "unguarded": `invoke` still evaluates the policy for every call.
133
- **`admitsAnonymous` is `@ultimat3/policy`'s** (`policy.ts`, beside `policyPermissions`) and
134
- reaches this package through `policy-gate.ts` like every other authz question — never a copy
135
- here. It cannot be one: `@ultimat3/query` needs the identical answer and is the same tier, so a
136
- copy in either is a second answer for the other, and the walk is a property of the combinators
137
- `policy.ts` declares. It is EXACT rather than heuristic — with `actor === null`, `can()`
138
- short-circuits before its predicate and `allow()`/`deny()` ignore their arguments, so the tree
139
- alone decides. `packages/policy/src/policy.test.ts` asserts it against
140
- `policy.run({ actor: null })` itself, case for case; `http.test.ts` proves this projection reads
141
- the answer, over the real pipeline.
142
- - **`stable.ts` holds the DOCUMENT form and NOTHING else, `As of 2026-08`.** `stableStringify` is
143
- published as `openapi.json` by `serializeOpenApi` and re-read with `JSON.parse` by
144
- `json-schema.ts`, so a non-finite number has to be `null` and a `Date` has to be its ISO string —
145
- the bare token `NaN` would make a published spec unparseable. The HASH form left this file:
146
- `canonicalJson` + `fingerprint` are **`@ultimat3/core`'s**, because `@ultimat3/query` and
147
- `@ultimat3/realtime` each held their own copy of the identical function and all three are tier 3,
148
- so no two of them could import each other and a copy in any was a second answer for the other
149
- two. They had already diverged — query's had no `Date` branch, so every date window of a read
150
- shared one cache key. Nothing about this package's keys moved: `fingerprint` is the same code at
151
- the same width, `stable.test.ts` still pins the document duty (a `JSON.parse` of what it emits)
152
- and now pins the byte-equality against core's form for an ordinary payload, which is what says no
153
- idempotency record and no enqueued job moved.
154
- **Why the hash form must be a separate function at all** — the reason that survives the move.
155
- `NaN`, `±Infinity` and JSON `null` all encoded as `'null'` and `String(-0)` is `"0"`, so four
156
- distinct inputs shared one `requestHash` (one caller handed another's stored response on replay)
157
- and one job dedupe key. And three more values folded onto `{}`, the first of which `t.date`
158
- produces on every parse: a `Date`, a `Map` and a `Set` have no own enumerable key, so
159
- `Object.keys` was empty and the object branch rendered all three `{}` —
160
- `fingerprint({ x: new Map([['a', 1]]) })` equalled `fingerprint({ x: new Set([1, 2]) })` equalled
161
- `fingerprint({ x: {} })`, and an `idempotent: true` action taking `t.object({ at: t.date })`,
162
- called twice under one key with two DIFFERENT dates, handed the second caller the first one's
163
- stored response with no `X_IDEMPOTENCY_CONFLICT` and the handler run once. The two forms disagree
164
- about all four exactly as they disagree about numbers: the document form is `JSON.stringify`'s own
165
- rendering, and the hash form TAGS them — `Date(<epoch>)`, `Map(k:v,...)`, `Set(v,...)`. The tag is
166
- not decoration: an untagged epoch is the same token a `t.number` field holding that epoch emits.
167
- Map and Set entries are SORTED, as object keys are.
168
- - **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `packages/action/src/tags.ts`
169
- and `packages/query/src/tags.ts` were byte-identical, and both packages are tier 3, so neither can
170
- import the other and a copy in either is a second answer for the other. `tagKey` went with it: it
171
- was `serializeTag` under a second name with zero call sites. Same shape as `toBucket`. Never
172
- restore a local one, and never reach for `@ultimat3/render`'s same-named `tagKeys` — that one
173
- preserves declaration order and is a different function.
174
- - **`toBucket` is `@ultimat3/http`'s, not this package's — moved 2026-08.** http owns `Bucket` and
175
- the limiter maths, and `@ultimat3/query` needs the identical conversion while being the same
176
- tier as this package, so a copy in either is a second answer to "what does this limit mean" for
177
- the other. It is re-exported from `src/index.ts` so an action file still reaches it through one
178
- import, and it raises http's `X_RATE_LIMIT_INVALID`; `X_ACTION_RATE_LIMIT_INVALID` is gone with
179
- the copy. Never restore a local one.
180
- - **`rateLimit:` reaches the limiter, not only the spec.** `toRoute` sets `meta.rateLimit` (the
181
- bucket name) **and** `meta.rateLimitBucket` (`toBucket(name, def.rateLimit)`), and
182
- `@ultimat3/http`'s `withRouteBuckets` registers the second under the first. Until 2026-08 only
183
- the name was set, so `bucketFor` fell through to `default` — 120 burst / 2 per second for an
184
- action that declared 5, with the declared numbers published in `x-ultimate.rateLimit` all the
185
- same: looser in practice than what the author wrote, which is the dangerous direction. `toBucket`
186
- is the **only** conversion between `{ limit, windowMs }` and `{ capacity, refillPerSecond }`, and
187
- both projections that read the declaration call it, so the spec cannot publish a pair the limiter
188
- refuses. http's `X_RATE_LIMIT_INVALID` covers all three checks, and the third is the one that is
189
- easy to miss: the **computed** rate, not just the two declared halves. `windowMs: 0` is an
190
- infinite refill, and so is `{ limit: Number.MAX_VALUE, windowMs: 1 }` — two finite positive
191
- numbers whose division enforces nothing. `limit` must also be at least one whole token, or the
192
- first caller is already refused and the endpoint is closed rather than limited.
193
- - **Everything `withIdempotency`'s `run` throws is treated as possibly-committed.** `guard()` and
194
- `validateInput` both run before the gate (`invoke.ts`), so by the time `run` is called the only
195
- things left are the handler and `validateOutput` — and the second throws *after* the first has
196
- committed. The reservation is therefore SETTLED as a failure and the retry replays it under the
197
- first attempt's own code; `release()` is reserved for a pre-handler failure, of which this gate
198
- has none. Releasing there is what turned a rounding change in an `output:` schema into a second
199
- charge: `X_OUTPUT_INVALID` dropped the record and the client's automatic retry re-ran a handler
200
- that had already taken the money. A `settle` that itself refuses leaves the record **in flight**
201
- for the same reason — a 409 the caller can act on beats a silent re-run. A store with no `fail`
202
- slot gets the same fail-closed treatment. `idempotency-failure.test.ts` drives the
203
- post-commit-throw path first, because that is the one that shipped.
204
- - **An idempotency record belongs to ONE CALLER, and a key that names no request is refused**
205
- (`As of 2026-08`). The namespace was the action name alone, so `idempotencyKeyFor` filed every
206
- caller's `k1` under one record: alice POSTed `charge`, bob POSTed `charge` with the same header
207
- and was handed alice's stored response — and with a *differing* payload bob got
208
- `X_IDEMPOTENCY_CONFLICT` instead, so any key he guessed was a key he could deny her. The key is
209
- now `JSON.stringify([action, actor.kind, actor.id, actor.orgId ?? null, key])`, and the encoding
210
- is a **JSON tuple, never a joined string**, for `readAuthority`'s reason: an actor id is app data,
211
- so under `a:b:c` an id of `alice:x` with key `y` is the same record as `alice` with key `x:y`.
212
- Separately, `req.header()` is `Headers.get()`, which answers `''` for `Idempotency-Key:` and
213
- never `null` — so a blank header was itself a shared key. It is `X_IDEMPOTENCY_KEY_INVALID`, a
214
- 4xx raised before the handler, and **not** read as "no key": the quiet reading loses the retry
215
- protection exactly when a client's key interpolation broke, and the double charge lands on the
216
- client's own automatic retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the same
217
- reason and uses `assert` because *its* empty key is the app's declaration, not a caller's header.
218
- What this does NOT close: an anonymous actor is one identity, so anonymous callers of a public
219
- idempotent action still share a key space — nothing at this tier can tell two apart, and keying
220
- on an IP or a cookie would break the retry the header exists to serve.
221
- - **The `idempotency-key` header also NAMES the write, on every action, idempotent or not.**
222
- `http.ts` runs `invoke` inside `withWriteOrigin(writeDigest(key))` (`@ultimat3/core`), so the
223
- rows the handler writes reach a channel's `records` frame carrying that digest, and the page
224
- that sent the key recognises its own echo (see `packages/realtime/CLAUDE.md`). It is a label and
225
- never a gate: a missing or blank header names nothing, and the blank-header refusal above still
226
- belongs to `def.idempotent` alone. `http.test.ts`, "the write a request names".
227
- - **Both stores FENCE a settlement on the reservation `id` AND on `in-flight`**, as
228
- `@ultimat3/jobs`' `SQL_ACK` fences on `id = $1 and state = 'running'`. A reservation whose window
229
- lapsed is reclaimed by the next caller (`on conflict … do update`), so a straggler from the first
230
- one used to overwrite a record it no longer owned and the next replay answered a retry with a
231
- value produced for a different request. Postgres fences in SQL and returns `key`, so the no-op is
232
- observable and logged; memory checks the id and status it holds. It is logged and never thrown —
233
- a settlement is post-commit, so raising there would turn a durable write into the caller's error.
234
- **The status alone was not enough**, which is why `settle(key, value, reservationId)` and
235
- `fail(key, failure, reservationId)` carry the id: a reclaimed record is `in-flight` AGAIN, so a
236
- straggler satisfied a status-only fence exactly and overwrote a LIVE reservation — and the
237
- replacement's own settle was then fenced out. Public API, changed in the 8.0.0 major; callers
238
- pass `reservation.record.id`, which `withIdempotency` already holds.
239
- - **A stored status is NARROWED, never cast.** `isIdempotencyStatus` decides, and an unknown word
240
- is `X_IDEMPOTENCY_STATUS_UNKNOWN` at `toRecord`. `row.status as IdempotencyStatus` let one
241
- through and `withIdempotency` has no branch for it: the record fell past `in-flight` and
242
- `failed` and answered `{ value: null, replayed: true }` — "this already ran, here is its result"
243
- — for a row nobody could read. The record was written by whatever build was deployed when the
244
- first attempt ran, which on a rolling deploy is not this one. Same rule, same column shape, as
245
- `@ultimat3/jobs`' `statusIn`.
246
- - **Where the idempotency records live is DECLARED, and refused at registration.**
247
- `IdempotencyStore.scope` says what a driver provides; `configureIdempotency({ scope })` says what
248
- the deployment requires; `assertIdempotencyScope` compares them inside `registerAction` — the
249
- funnel every registration path shares, and one that necessarily runs before a route is mounted.
250
- `'shared'` over a per-process store, or over a store declaring **no** scope, is
251
- `X_IDEMPOTENCY_NOT_SHARED` before the socket opens. The exact shape of `assertRateLimitScope`
252
- and `assertRouteBuckets`, and for the same reason: what cannot be shown to hold is not assumed
253
- to hold. The default is `'process'`, because one process is the only thing a framework can
254
- promise without being told — nothing here reads an environment to guess a replica count.
255
- - **The memory store is bounded, and the eviction order is part of the guarantee.** A key is
256
- caller-supplied, so its cardinality is the write rate: unbounded, 500 idempotent writes a second
257
- is 43M immortal entries a day. Expired records go for free (past the window a record answers as
258
- a missing one); past the cap, **`in-flight` records are the last to go** — one of those is the
259
- reservation that stops a concurrent duplicate from running the handler twice. That is the mirror
260
- of `memoryRateLimitStore` evicting the fullest bucket first; never swap either for an LRU.
261
- - **`postgresIdempotencyStore` is the shipped shared store, and it must be CALLED.** A mechanism
262
- built and never wired is the defect this package has shipped twice. It takes a structural
263
- `PgExecutor` — the same shape `@ultimat3/jobs`' pg driver declares, satisfied by `Bun.sql` and by
264
- a Tx — so there is no `action -> db` package edge and the app wires it:
265
- `setIdempotencyStore(postgresIdempotencyStore({ executor: Bun.sql }))` then
266
- `configureIdempotency({ scope: 'shared' })`, at boot, before `registerActions()`.
267
- `SQL_IDEMPOTENCY_TABLE` is applied the way `SQL_JOBS_TABLE` is. The reservation is ONE statement:
268
- a returned row always means this caller owns it, because the `do update` fires only for a row
269
- already outside the window.
270
- - **`deprecated:` is a compat WINDOW and versioning is deliberately not here.** One declaration,
271
- four projections: `Deprecation`/`Sunset` headers on every response *including the failures* the
272
- handler raises, a `rel="successor-version"` link derived through `derivePath` (never a second URL
273
- derivation), `deprecated: true` in the OpenAPI operation, and `deprecated_calls_total`. The
274
- headers are rendered ONCE at projection, so a date that cannot become one is
275
- `X_ACTION_DEPRECATION_INVALID` at mount rather than on the first request — the rule `toBucket`
276
- follows. Running two versions side by side is two deployments behind one ingress (axiom 7); a
277
- path prefix, a second registry or a version router would all be a ninth thing to maintain.
278
- `deprecation.ts` is TWINNED in `@ultimat3/query`, because both are tier 3 and the shared home is
279
- `@ultimat3/http` if it ever grows one — the same compromise `naming.ts` is ported under.
280
- - **The span wraps `execute` whole, and carries attributes.** It used to wrap `def.handle` alone
281
- and set nothing, so `def.row()` — the loader a row-level policy needs — ran inside no span at
282
- all: a 2s p99 reported 40ms and the missing 1.96s read as framework overhead. Attributes are
283
- chosen for bounded cardinality (surface, actor KIND, outcome, booleans) with one exception, the
284
- namespaced idempotency key, which is the single fact that joins a retry to the call it retries —
285
- and is never a metric label. `telemetry.test.ts` asserts the EXTENT structurally, by reading
286
- `currentSpan()` from inside `row:` and the policy predicate, not by timing: the test clock is
287
- frozen.
288
- - **`policyCapability` is a display label and `policyPermissions` is what a report matches on.**
289
- A composite renders as `and(post:publish, org:administer)`, which equals no permission string,
290
- so `x policy list` matching on `capability` reported every non-trivially-guarded action's
291
- permissions as *unenforced* — real grants shown as dead. `ActionDescriptor.permissions` is the
292
- flattened list from `@ultimat3/policy`'s `policyPermissions`, published beside `capability` and
293
- never instead of it. A `not()` clause contributes its inner permissions: the grant still
294
- participates in the decision, so omitting it would be the false statement.
295
- - **Both clients inject `traceparent`.** `@ultimat3/core`'s `traceparent()` existed with no caller
296
- in the repo, so every Ultimate-to-Ultimate hop began a fresh root trace on the far side. It is
297
- set BEFORE the caller's own headers so an explicit one still wins, and an incomplete span context
298
- (`spanId: ''`, which `currentSpanContext()` answers when a request context exists but no span
299
- does) sends nothing rather than `00-<trace>--01`. In a browser there is no ambient context, so
300
- a cross-origin call acquires no CORS preflight it did not already have.
301
- - **`ActionJobHandle` is not consumed by `@ultimat3/jobs`, and the file said it was until
302
- 2026-08.** The header read "`@ultimat3/jobs` consumes this shape, so enqueueing an existing
303
- action costs zero rewriting" — a claim no code supports. `'action-job'` occurs in four places,
304
- all inside this package (`job-handle.ts:16,30`, `job-handle.test.ts`, `facade.test.ts`), and
305
- `isJobHandle` (`packages/jobs/src/job.ts:256`) requires `kind === 'job'` **and** membership of a
306
- module-private `WeakMap` written only inside `job()` — so this is impossible in principle, not
307
- merely unwired. `JobHandle` shares two members with it. What the shape actually gives: `.job()`
308
- on the façade, `action:<name>` as a durable queue key, a payload-derived idempotency key, and an
309
- `invoke` that runs the one execution path under `surface: 'job'` — so an app that owns a queue
310
- can drive it by hand and still get the action's parse + policy. **The missing halves are
311
- `tenant` and `retry`**, both required on `JobDefinition` with deliberately no default, so "zero
312
- rewriting" was never reachable regardless of wiring. The bridge is one `job({ … })` call, and it
313
- belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. **It exists, `As of
314
- 2026-08`** — `agentJob()` in `@ultimat3/ai` (`agent-job.ts`), tier 4, which takes an `Action` and
315
- nothing agent-specific and supplies the two missing halves as its own options. So "nothing in the
316
- framework consumes it", which `job-handle.ts`'s header said until this was checked, is false: it
317
- has one consumer, and an app writes the same three lines.
318
- - An action has no `.def`. Inside the package read it with `defOf(target)`; outside,
319
- read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`.
320
- - **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased
321
- view — `listActions()`, `getAction(name)` — so a member missing from it is a projection the
322
- registry cannot reach: `.job()` was absent until 2026-08 and `getAction('publishPost')?.job()`
323
- was a type error against an object that has had the method since `facadeFor` bound it. `job()`
324
- erases because `ActionJobHandle`'s members are method-syntax (bivariant parameters) and its
325
- output erases to `unknown`; `ClientMethod` is a **function type**, so its input is
326
- contravariant and `(input: unknown) => …` is a supertype of no concrete action's method. Both
327
- halves are build errors in `type-pins.ts`, never a comment — and type claims go there, never in
328
- a `.test.ts`, because `tsconfig.json` excludes tests and `tsc` never reads one.
329
- - **The client keeps the server's error code and marks it remote.** A `problem+json` failure
330
- becomes `RemoteActionError` (`errors.ts`), which re-uses the code off the wire the way
331
- `ActionDeniedError` re-uses the policy decision's — and then says so, because the browser
332
- bundle never registered it: `name` marks it in a stack, `meta.origin: 'remote'` marks it in
333
- `--json`, the overlay and the error reporter. **It never synthesizes a docs URL.**
334
- a `…/errors/X_SIGNUP_CLOSED` invented for an app-declared code is a 404 dressed as
335
- documentation; the link is the server's own `docs`/`type` when it sent an `http(s)` one, this
336
- build's registered link when `hasErrorCode` knows the code, otherwise `ERROR_DOCS_URL`. Those
337
- last two agree by construction now that core resolves every code to one URL — `hasErrorCode`
338
- still separates them only for a package that declared its own `docs:`, which is why the branch
339
- stays. The
340
- code must be `X_SCREAMING_SNAKE` to be taken at all — `typeof code === 'string'` accepted `""`
341
- from a gateway — and anything else is core's `X_CLIENT_TRANSPORT_FAILED` (it was
342
- `RpcFailedError` until 21.0.0).
343
- `docs` and `type` travel to `remoteDocs` as an ordered pair, not `docs ?? type`: preference is
344
- not selection, and picking the preferred slot on presence alone let one `javascript:` string
345
- bury a perfectly good `type` the same response had already offered.
346
- - **MCP exposure is read through `isMcpExposed` from `@ultimat3/core`, in all three places.**
347
- `toMcpTools` builds the tool, `describeAction` publishes the manifest fact and
348
- `toOpenApiOperation` publishes `x-ultimate.mcpTool` — the last two fail-opened (`?? true`,
349
- `!== false`) until 2026-08, so an action with no `mcp` block was advertised as a tool by both
350
- contract artifacts and refused by the only surface that could serve one. A contract that
351
- disagrees with the runtime is worse than no contract; never spell the check inline again.
352
- - **An MCP tool's NAME is the export name verbatim, in all four places — `toToolName` is gone**
353
- (`As of 2026-08`). The same three readers that fail-opened on exposure also *derived* the name:
354
- `toToolName` snake_cased it for `toMcpTool`, for `x-ultimate.mcpTool` and for
355
- `describeAction().mcp.tool`, while `@ultimat3/mcp` has only ever served
356
- `primitive.mcp?.name ?? primitive.name`. So nine of the ten tool names
357
- `examples/dummy/openapi.json` published — `publish_post`, `create_post`, … — were names
358
- `tools/call` answers not-found for; the tenth, `summarize`, is single-word and so was already
359
- its own snake_case form, which is why a count of the wrong names is not a count of the rows. The
360
- DESCRIPTOR said the same: `x actions describe --json`, `x actions list --json`, the
361
- `actions.describe` dev MCP tool and the `/_x` Routes panel all read `.mcp.tool`. **Not the app
362
- manifest** — `ActionFact.mcp` is `{ expose, description? }` and `packages/manifest/src/sources.ts`
363
- copies only those two, so `x.manifest.json` has never carried a tool name and `grep '"tool"'` on
364
- either committed manifest finds none. Do not describe this defect as a manifest defect; the
365
- manifest's stake in the `mcp` block is `expose`, which is the invariant above. `ActionMcp`
366
- carries no `name:`, so
367
- the verbatim name is the whole rule and there is nothing left to derive; the helper is
368
- **deleted** rather than left exported, because a dead name-deriver is a second way to name a
369
- tool. `mcp-tool.test.ts`'s "one name per action, on every surface" asserts the three strings are
370
- one string AND that it is the verbatim name — the equality alone passes on the old behaviour,
371
- where all three agreed on the wrong name. **Neither package derives a tool name any more**:
372
- `@ultimat3/query` carried a same-named twin with the identical defect and deleted it in the same
373
- change, so `packages/query/src/naming.ts` derives PATHS only and its `src/index.ts` exports no
374
- such helper. Both are tier 3, so neither could import the other and each owned its own removal —
375
- which is why the rule is restated in both `CLAUDE.md`s rather than shared.
376
- - **The post-commit bust never fails the write it followed.** `cache.invalidates` fans out once the
377
- handler has committed, so `bustAfterCommit` — `cache-gate.ts`, the only caller of
378
- `invalidateTags` here — absorbs a fan-out that refuses and answers `undefined`: an undeclared tag
379
- (`X_CACHE_TAG_UNKNOWN`) must not turn a durable write into a failed action, and those entries
380
- expire by TTL. One dead tier is not that case; `invalidateTags` already reports it in
381
- `report.errors`. It logs through core's `logger`, never `ctx.logger` — an HTTP `Ctx` is a cast
382
- request context that carries none — and never renders the tags, because reading a malformed
383
- `invalidates` entry back is the second throw the guard exists to stop. **A replay skips the bust
384
- entirely:** no handler ran, the first call already busted these tags, and re-purging the CDN and
385
- re-queueing ISR per retry is work for a write nobody made.
386
- - **The policy contract test asserts `ActionDeniedError`, and it sends valid input to get there.**
387
- It sent `{}` and accepted any `UltimateError` until 2026-08, so every action with a required
388
- field failed `input:` before `guard()` ran and the assertion passed on `X_INPUT_INVALID` —
389
- including one whose policy was `allow()`. `sampleInput` builds the payload from `input:`'s own
390
- IR (required keys only) so the invocation reaches the policy; the class, not `X_FORBIDDEN`, is
391
- the assertion, because `ActionDeniedError` re-uses the policy decision's code and `can()`
392
- answers a null actor with `X_UNAUTHENTICATED`. **Only `X_INPUT_INVALID` becomes
393
- `X_CONTRACT_DRIFT`** — it is the one code `invoke` raises before `guard()` is reached, and
394
- `input:` is the one knob that answers it. Everything else keeps its own code and its own fix:
395
- saying `X_OUTPUT_INVALID … before its policy decided` named a stage nothing had checked and
396
- offered a fix that changes nothing, and it hid the `allow()` whose handler threw — the authz
397
- escape this assertion exists to catch. A non-`UltimateError` (a `row:` loader's own
398
- `TypeError`) is rethrown untouched too: its stack is the thing worth reading.
399
- `contract-test.contract.test.ts` drives all three assertions against actions built to fail them.
400
- **`X_AUDIT_SINK_MISSING` is the one code assertion 1 passes through as well**, for the same
401
- reason: `auditSinkFor` runs *before* `validateInput`, so "the schema accepted garbage" is a
402
- false statement about it and `tighten input:` is a fix that changes nothing. It is the only
403
- refusal that precedes the parse — a second one is a design mistake, not a second entry here.
404
- - **The audit seam ships the mechanism and none of the row.** `audit: true` on a declaration
405
- wraps `execute` — it never forks it — so a **denied** attempt is recorded, which is the whole
406
- reason this lives in the framework: `guard` throws before `handle`, so nothing an app writes
407
- around its own handler could ever see one. What reaches the sink is what `invoke` already holds
408
- (`at` from `ctx.now()`, the name, the mutator brand, the surface, the whole `ctx`, the parsed
409
- input, the namespaced idempotency key, `replayed`, the outcome, the failure code). What does
410
- **not** ship, ever: an audit entity, a retention policy, a hash chain, a subject index, or an
411
- opinion on what "who" means under impersonation — four apps model those four ways, so by axiom
412
- 8's own test they are business convention and shipping one makes three of them wrong.
413
- **"a storage backend" was on that list until 2026-08-24 and is off it**, because the list was
414
- answering a different question than it appeared to. What four apps model four ways is the ROW —
415
- which of their own facts it carries, how long they keep it, whether it chains. Where the record
416
- the FRAMEWORK already defines is put is not one of those: `x_audit`'s columns are the fields of
417
- `AuditRecord` and nothing else, which is the same relationship `idempotency-postgres.ts` has to
418
- `IdempotencyRecord` and `@ultimat3/http`'s `postgresRateLimitStore` to its `Bucket`. Leaving it
419
- off meant the only sink that shipped was a ring that drops, so the shortest edit clearing
420
- `X_AUDIT_SINK_MISSING` was `setAuditSink(memoryAuditSink())` — compliant in dev, silently
421
- amnesiac in production, which `docs/idea/20-large-app-readiness.md` scores as **Ship**. An app
422
- that wants columns of its own still writes its own sink; the seam is one method. `result` is absent for the same reason and one more: a handler's return is
423
- reachable from the handler itself, so shipping it would be this package deciding a row carries
424
- an after-image, which is `@ultimat3/admin`'s `diff` convention arriving one tier down.
425
- - **The audit vocabulary is `@ultimat3/admin`'s, shared by name and not by import.** `AuditOutcome`
426
- is the same three words (`allowed | denied | failed`) and `AuditSink` the same noun; `admin` is
427
- tier 5 and this is tier 3, so there is no edge to share them over. **Known duplicate**: admin's
428
- `AuditSink` writes a fixed `AuditEntry` requiring an `AdminActor` and a `permission`, and it is
429
- called only from `admin/crud.ts`, so an action outside `/admin` still produces nothing there.
430
- Unifying them means lifting the vocabulary into `@ultimat3/core` — the only tier both reach —
431
- and rebuilding `admin/audit.ts` on this seam. Not done here: `admin` is a shipped public API
432
- and its `AuditEntry` is a different shape.
433
- - **The memory sink DROPS, and both halves of that sentence are enforced.** It was a plain array
434
- with a `push` — the one memory implementation in the framework with no cap, beside five that
435
- have one (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`,
436
- `createTotpReplayGuard`, `createMemoryEventBus`) — and a record pins a whole `Ctx`, so at 50
437
- audited writes a second it is 4.3M immortal records a day and the pod OOMs holding the trail it
438
- was retaining. It is now a ring at `DEFAULT_MAX_AUDIT_RECORDS`, evicting the OLDEST (the
439
- direction `createMemoryEventBus` evicts in: refusing new writes would answer "nothing has
440
- happened since" for a process that has been serving all day). `dropped` is what makes "it drops"
441
- checkable in a running process instead of a sentence in a header — a non-zero count on a real
442
- deployment is the sink saying it is the wrong one. A `maxRecords` of `0`, negative or `NaN`
443
- falls back to the default: there is no spelling of "no bound", because that spelling was the bug.
444
- - **What a DURABLE sink may write down is decided in `audit-input.ts`, and it is two rules.**
445
- A record's `input` is the PARSED input, which is exactly where a password, a bearer token or a
446
- card number lives, so `postgresAuditSink` redacts it through `@ultimat3/core`'s `isRedactedKey`
447
- — the SAME table `defineEnv({ secret: true })` extends, never a copy of the list, because a copy
448
- is how a value that is `[redacted]` in a log line becomes plaintext in a table. `isSecret`
449
- redacts by VALUE beside it, for a credential travelling under a harmless name. The second rule
450
- is that the answer is always JSON-representable: a `bigint`, a `NaN`, a function and a cycle all
451
- become a NAMED marker rather than a throw, because `auditSettled` turns a sink throw into a
452
- failed invocation for a handler that has already committed — and because `JSON.stringify` over a
453
- cycle takes ~4.6s in Bun 1.4 before it raises, so leaving the detection to the serializer stalls
454
- the audited path either way. `toJSON` is never called: it is app code in the frame that owes the
455
- caller a record.
456
- - **The `Ctx` is never walked, and never will be.** `createContext` spreads every installed
457
- service ONTO the context object and an HTTP surface's value is a `RequestContext` carrying the
458
- request's own `Authorization` and `Cookie`, so a projection that iterated it would write an
459
- app's database clients and its caller's credentials into an audit table. `postgresAuditSink`
460
- reads an allow-list of framework-owned fields (`requestId`, `traceId`, `locale`, `tz`,
461
- `buildId`, `role`, and the actor's `id`/`kind`/`orgId`/`onBehalfOf`) and nothing else.
462
- `failure.error` is not among them — the row keeps `failure.code`, because a throwable's stack is
463
- worth reading and is not worth storing, and rendering one into a column is the trap
464
- `renderThrowable` exists for.
465
- - **`x_audit` ships no purge, and it is the only framework table that does not.**
466
- `x_idempotency` and `x_rate_limit` both ship one because a stale row there is meaningless; a
467
- stale audit row IS the record, and "how long" is a legal answer that is seven years for one app
468
- and thirty days for the next. Shipping a `delete` would be shipping one of those answers.
469
- - **`SQL_AUDIT_INSERT` is positional, so its parameter order is pinned by a test and not by a
470
- type.** `audit-parity.test.ts` names every column once and compares both sinks' answer for every
471
- string field with a DIFFERENT value per field — two columns holding the same word cannot catch a
472
- slip, and a `locale` in the `tz` slot type-checks perfectly. Proven by mutation: the first draft
473
- of that test did NOT catch a swapped `locale`/`tz` and was widened until it did.
474
- - **A sink may not silently swallow, and the two failure policies are deliberate opposites.**
475
- `X_AUDIT_SINK_MISSING` is raised *before* the input parse, so an audited action nothing can
476
- record refuses with no committed write behind it — there is deliberately no logger-backed
477
- default sink, because a line nobody stores satisfies the declaration while recording nothing.
478
- A sink that refuses an **allowed** record fails the invocation (`X_AUDIT_SINK_FAILED`), which
479
- is the inverse of `cache-gate.ts`'s absorb-and-log: a dropped cache entry expires by TTL and
480
- the stack heals itself, while nothing ever re-derives an audit row that was never written. It
481
- is post-commit all the same, so the cause says the handler already committed rather than
482
- implying a rollback. **Its `fix:` branches on `record.idempotencyKey !== null` — the
483
- INVOCATION's fact, never the declaration's `idempotent`.** A retry replays instead of re-running
484
- only when this call reserved a record, and `invoke` reads
485
- `def.idempotent === true ? (options.idempotencyKey ?? null) : null`, so a non-idempotent action
486
- and an idempotent one whose caller sent no header collapse to the same `null`. The unqualified
487
- "retry with the same Idempotency-Key" told a caller to apply a committed write twice — an
488
- axiom-4 violation dressed as a fix line. Requiring `idempotent: true` at declaration was the
489
- other candidate and was rejected: it would not have made the message true (the header is still
490
- the caller's), and it would force the idempotency store on every app that wants only an audit
491
- trail — "which writes must be retry-safe" is the app's call, not this package's.
492
- `meta.replayable` carries the same fact to `--json`, and `audit.test.ts` pins both branches so
493
- the text cannot drift back. A sink that refuses a **denied or failed** record is logged as
494
- `audit.sink.failed` and the original error still reaches the caller — `X_AUDIT_SINK_FAILED`
495
- there would hide the `X_FORBIDDEN` and would answer a probing client differently depending on
496
- whether the audit backend was up, which is an oracle.
497
- - **`auditSettled` sits outside the `catch`, not inside it.** Inside, its own
498
- `X_AUDIT_SINK_FAILED` fell into the failure branch and wrote a *second* record saying the
499
- action `failed` — for a handler that had committed. An audit trail lying about a write is
500
- worse than no audit trail; `audit.test.ts` counts the records a refusing sink was offered.
501
- - **`auditOutcomeFor` is TOTAL, because both callers ask inside a `catch`.**
502
- `error instanceof ActionDeniedError` runs a `Proxy`'s `getPrototypeOf` trap, and the two call
503
- sites are `execute`'s span attribute and the one place the `failed` record is produced — so a
504
- handler throwing such a value made the probe throw *from the frame holding the app's error*,
505
- and the caller got a `TypeError` in place of its own throwable. It fails closed to `failed`: a
506
- value that refuses to be examined is not evidence of a policy denial. Same rule as core's
507
- `isThrownError` / `isUltimateError`, and the same defect `@ultimat3/http`'s `finalize.ts` and
508
- `factsOf` carried; `audit.test.ts` asserts the caller's throwable by IDENTITY, which is the only
509
- assertion that catches a replacement.
510
- - **`json-schema.ts`'s refusal names `introspect()`, never a `toJsonSchema` member.**
511
- `SchemaProvider` declares no such member and `toJsonSchema()` calls `introspect()`
512
- unconditionally, so the old `fix:` told a reader to implement an API that does not exist —
513
- axiom 4 inverted, and invisible to `x verify`'s `errors` step, which checks a fix line's shape
514
- and never whether the API it names is real. The guard is `normalizeJsonSchema`, exported from
515
- the module for its own test and absent from `src/index.ts` exactly as `sortSchema` is; the
516
- shipped `toJsonSchema` cannot reach it today (it returns an object literal on every path), so
517
- the test drives the guard directly rather than pretending a converter can be swapped.
518
- - **A lookup table is read with `Object.hasOwn`, never with the index alone.** `IRREGULAR[word]`
519
- in `naming.ts` and `BY_FORMAT[node.format]` in `sample-input.ts` both read the prototype chain:
520
- `splitWords` lowercases, which keeps `toString` and `hasOwnProperty` out of reach, but
521
- `constructor` is already lowercase and survived — so `pluralize('constructor')` answered the
522
- `Object` FUNCTION where its return type says `string`, `derivePath('addConstructor')` mounted the
523
- action at `/api/function Object() { [native code] }/add` and published that as its OpenAPI path
524
- and `tags`, and a provider emitting `format: 'constructor'` put a function in the payload the
525
- policy contract test invokes with. Both keys are caller- or provider-supplied, which is the whole
526
- test for whether this applies. Same discriminator `packages/flags/src/subject.ts:75` uses.
527
- - App code reaches a projection through the action (`publishPost.tool()`), never through
528
- `.def` and never by importing the projection function. `facade.ts` is where a new method
529
- is bound; the projection itself keeps living in its own file.
530
- - A mutator projects the three names it was authored with — `.local`, `.server`, `.conflict` —
531
- plus every action façade member, through `named()` and registration alike. No aliases: the
532
- old `.applyLocal` is gone, not deprecated.
533
- - `mutator.server()` calls the action's own callable, so it lands in `invoke` like every other
534
- surface. Reaching the declared `server` from there is the second execution path this package
535
- exists to prevent. `.local()` is the one member that skips the core — it never leaves the
536
- client, so there is nothing to authorize.
537
- - Registration names the action the app exported, in place — `import { publishPost }` is
538
- projectable after boot. Naming an already-named action is the only case that twins.
539
- - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an action file imports
540
- one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
541
- access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
542
- - `defineApi` is the app's registration call; `registerActions` is what it composes. It reaches
543
- `@ultimat3/query`'s and `@ultimat3/jobs`' registries through core's registrar table
544
- (`primitiveRegistrar('query' | 'job' | 'task')`), never a sideways import — and throws
545
- `X_REGISTRAR_MISSING` rather than skipping a kind whose registrar is absent, because a silent
546
- skip drops every primitive of that kind.
547
- - **`jobs` and `tasks` belong in the same call, for the same reason `queries` do.** The export
548
- name becomes the job's durable queue key; a job module nothing hands over keeps `job()`'s
549
- positional `anonymous-job-<n>`, on every queue row and in the manifest. A definition with its
550
- own `name:` keeps it — that rule lives in `@ultimat3/jobs`, not here. Jobs register before
551
- tasks: a task descriptor lists the jobs it enqueues by name.
552
- - `defineApi`'s returned maps are built from the **registrar's own results**, never from the
553
- modules' exports. A feature module exports helpers next to its primitives; copying every export
554
- would seat one in `Api['actions']` as a client method nothing serves, and let two modules'
555
- same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the
556
- same filter, so `rpc<Api['actions']>()` offers only what registered.
557
- - `rpc` is the only name for the map-wide typed client. There is no `createClient` alias.
558
- - **Flight control is `@ultimat3/core`'s, and this package RE-EXPORTS it.** `client-flight.ts` and
559
- `client-wire.ts` shipped here and in the other tier-3 client package as byte-identical copies —
560
- 288 and 85 lines — kept in step by a `client-twin.test.ts` in each. A test that makes drift LOUD
561
- is not the same as a file that cannot drift, and this package's own thesis is that duplication is
562
- the defect. Both files import nothing but tier 0, which was always the argument for where they
563
- belong; the one blocker was `isJsonObject`, now `@ultimat3/core`'s `json-object.ts`, re-exported
564
- from `./stable` here. `createClientFlight`, `DEFAULT_CLIENT_RETRY`, `isTransientFailure`,
565
- `isSuperseded`, `ClientFlight`, `ClientFlightOptions`, `ClientRetry`, `FlightKeyOptions`,
566
- `FlightPlan` and `WireAnswer` are all still importable from `@ultimat3/action` — the same names,
567
- and now literally the same objects the other package exports. Never re-declare one here; the
568
- fix for anything wrong with the pipeline is an edit in `packages/core/src/client-flight.ts`.
569
- - **Every mechanism underneath the flight is `@ultimat3/core`'s, the pipeline included.**
570
- `createSingleFlight` for dedup, `createFence`/`isSuperseded` for supersession, `createFlightGate`
571
- for the ceiling, `retry` + `backoffDelay` for the schedule, `isRetryableStatus` for the status
572
- table, `X_TIMEOUT` for the deadline — and `createClientFlight`, which composes them. This package
573
- declares NO new error code for any of it; never add a second curve, a second fence or a private
574
- retry loop here, and `bun run flight-copies` is what says so.
575
- - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default, and the inversion must
576
- survive** (`As of 2026-08-23`). `retryDecision` sends a throw nobody classified again until the
577
- attempts run out; `@ultimat3/ai` and `@ultimat3/db` each refused the executor outright over it.
578
- The client keeps the executor and supplies a predicate instead: a declared
579
- `retryable`/`retry-after`, plus a dispatch that produced no response at all (`fetch` rejecting
580
- with a plain `TypeError`), and nothing else — a caller's own `AbortError` and a foreign value are
581
- terminal. The loop is stopped by RESOLVING to a private sentinel rather than by throwing, so the
582
- original value still reaches the caller unwrapped, which is the property `retry`'s own header
583
- promises. It lives in `packages/core/src/client-flight.ts` now; the tests that pin it from this
584
- side still drive it through this package's own client.
585
- - **`ClientFlight` is a TYPE inside `client.ts` and never a value.** That erasure is the entire
586
- tree-shaking story: a caller who wants a plain typed call must not pay for the fence, the dedup
587
- map or the retry loop. Never import `createClientFlight` for a VALUE from `client.ts` —
588
- `ClientFlight` and `ClientRetry` are `import type` from `@ultimat3/core` and must stay that way.
589
- **Measured, `bun build --target=browser --minify`, one entry importing from `@ultimat3/action`,
590
- `As of 2026-09-22`:**
51
+ - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse output. A second
52
+ execution path is the one unforgivable change here.
53
+ - **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
54
+ `@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
55
+ `invoke-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
56
+ - The declaration never leaves `invoke.ts`: `defOf`/`stashDef` are never re-exported from
57
+ `src/index.ts` (`index.test.ts`). An action has no `.def`; outside, read `.input`/`.output`/
58
+ `.policy`/`.mcp` or `describe()`. App code reaches a projection through the action
59
+ (`publishPost.tool()`); new methods are bound in `facade.ts`.
60
+ - **`toRoute` sets `enforcedBy: 'handler'`** — `invoke` is the one evaluation and the only one holding
61
+ the row `def.row` loaded; `meta.policy` stays set. `http.test.ts` counts exactly one evaluation.
62
+ - **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, via
63
+ `policy-gate.ts`), never the root combinator — never a copy here (`@ultimat3/query` needs the same
64
+ answer).
65
+ - Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a denial becomes
66
+ `ActionDeniedError`, keeping the policy's code. `policy-gate.ts` is the only RUNTIME edge to
67
+ `@ultimat3/policy` (`errors.ts`'s `import type { SurfaceDenial }` erases).
68
+ - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
69
+ - **`registerAction` guards the derived PATH as well as the name** (`X_ACTION_PATH_DUPLICATE`; a
70
+ second index cleared by `resetRegistry`).
71
+ - Registration names the action the app exported, in place. Naming an already-named action is the
72
+ only case that twins.
73
+ - A mutator projects `.local`, `.server`, `.conflict` plus every action member — no aliases.
74
+ `mutator.server()` calls the action's own callable (lands in `invoke`); `.local()` never leaves the
75
+ client.
76
+ - **`AnyAction` projects every surface, `client()` excepted** (`ClientMethod` is contravariant); both
77
+ halves are build errors in `type-pins.ts`.
78
+ - **`defineApi` is the app's registration call**, reaching query/jobs/tasks through core's
79
+ registrar table (`X_REGISTRAR_MISSING`, never a silent skip). `jobs` and `tasks` belong in the same
80
+ call (the export name is the durable queue key); jobs register before tasks. The returned maps are
81
+ built from the registrars' results, never the modules' exports.
82
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts` asserts identity).
83
+ - **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
84
+ codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
85
+ `audit` off unless declared.
86
+ - **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
87
+ `sample-input.ts`) — caller- or provider-supplied keys.
88
+
89
+ ## Invariants — the wire
90
+
91
+ - **Every browser call goes through core's `clientTransport`**: `client.ts` hands it the method, the
92
+ URL (core's `actionPath`; `naming.ts` re-exports core's path helpers), body, headers, signal, key,
93
+ flight and `retry`. Only action-specific hooks ride in: `onResponse` (build-id check,
94
+ `X_CONTRACT_DRIFT`) and `decodeError` (`RemoteActionError`, else core's
95
+ `X_CLIENT_TRANSPORT_FAILED`). `X_RPC_FAILED` stays registered, thrown by nothing since 21.0.0.
96
+ - **A retried mutation needs an `Idempotency-Key`**: without one `retry` narrows to one attempt,
97
+ silently (never a refusal). A fence never aborts a write (`abortable: false`), and `client.ts`
98
+ never calls `flight.keyFor`.
99
+ - **The record envelope is derived from the output schema, per ACTION** (`carriesRecords` =
100
+ entity's `hasEntityRows`, once in `toRoute`): `{ data, records }` under `x-ultimate-records: 1` on
101
+ every answer. HTTP-only. An output with no entity row is byte-identical (`record-wire.test.ts`).
102
+ - **`conflict` is core's `ConflictPolicy`, over ROWS**; `custom(merge)` builds
103
+ `{ kind: 'custom', merge(localRow, serverRow) }`; core's `resolveConflict` is the one resolver.
104
+ - **`X_INPUT_INVALID` carries the rejections twice**: the line in `cause`
105
+ (`formatIssues(issues).join('; ')`, pinned by `validate.test.ts`) and `meta.issues` structured.
106
+ The rendering stays out of `InputInvalidError` (browser-reachable). **`toValidationIssues`** keeps
107
+ four members, never a library's raw issue (it may carry the value). `issuesFromWire` rebuilds
108
+ member by member, all-or-nothing, bounded by `MAX_WIRE_ISSUES`. **`X_OUTPUT_INVALID` keeps the line alone.**
109
+ - **The client keeps the server's code and marks it remote** (`RemoteActionError`, `meta.origin:
110
+ 'remote'`), only for an `X_SCREAMING_SNAKE` code. It never synthesizes a docs URL: the server's
111
+ `http(s)` `docs`/`type` (as an ordered pair), else this build's registered link, else
112
+ `ERROR_DOCS_URL`.
113
+ - **Both clients inject `traceparent`** before the caller's headers; an incomplete span sends nothing.
114
+ - `serializeOpenApi` output is byte-stable: sorted keys, sorted registry, no clock. `client.ts` stays
115
+ free of server imports.
116
+ - **`stable.ts` holds the DOCUMENT form only** (`stableStringify` → `openapi.json`, re-read by
117
+ `json-schema.ts`). The HASH form is core's `canonicalJson`/`fingerprint` (injective: tags
118
+ `NaN`/`±Infinity`/`-0`, `Date`, `Map`, `Set`); `stable.test.ts` pins byte-equality for ordinary
119
+ payloads.
120
+ - **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (re-exported; raises
121
+ `X_RATE_LIMIT_INVALID`). Never restore a local copy; never use `@ultimat3/render`'s `tagKeys`.
122
+ - **`rateLimit:` reaches the limiter**: `toRoute` sets `meta.rateLimit` AND `meta.rateLimitBucket`
123
+ (`toBucket`), which `@ultimat3/http`'s `withRouteBuckets` registers. The computed rate must be
124
+ finite and `limit` at least one token.
125
+ - **`deprecated:` is a compat WINDOW** — headers on every response including failures, a
126
+ `rel="successor-version"` link via `derivePath`, `deprecated: true` in OpenAPI,
127
+ `deprecated_calls_total`. Rendered ONCE at projection (`X_ACTION_DEPRECATION_INVALID` at mount).
128
+ Twinned in `@ultimat3/query`.
129
+ - **The span wraps `execute` whole** (including `def.row()`), with bounded attributes plus the
130
+ namespaced idempotency key (never a metric label). `telemetry.test.ts`.
131
+ - **`policyCapability` is a display label; `policyPermissions` (`ActionDescriptor.permissions`) is
132
+ what a report matches on** (a `not()` clause contributes its permissions).
133
+ - **MCP exposure reads core's `isMcpExposed`, in all three places** (`toMcpTools`, `describeAction`,
134
+ `x-ultimate.mcpTool`). **A tool's NAME is the export name verbatim everywhere** — `toToolName` is
135
+ deleted; `mcp-tool.test.ts` asserts one verbatim name across surfaces. `ActionFact.mcp` in the app
136
+ manifest carries only `{ expose, description? }`.
137
+ - **`ActionJobHandle` is not consumed by `@ultimat3/jobs`** (`isJobHandle` needs `job()`'s private
138
+ map). `.job()` gives `action:<name>` as a queue key, a payload-derived idempotency key and an
139
+ `invoke` under `surface: 'job'`. The bridge is `agentJob()` in `@ultimat3/ai`, or an app's own
140
+ `job({ … })` supplying `tenant` and `retry`.
141
+
142
+ ## Invariants — flight control
143
+
144
+ - **Flight control is `@ultimat3/core`'s, re-exported** — the same objects `@ultimat3/query`
145
+ exports. Fix the pipeline in `packages/core/src/client-flight.ts`. No new code, curve, fence or
146
+ retry loop here (`bun run flight-copies`).
147
+ - **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** (a caller's `AbortError`
148
+ is terminal). Must survive.
149
+ - **`ClientFlight` is a TYPE inside `client.ts`, never a value.** Measured,
150
+ `bun build --target=browser --minify`, one entry importing from `@ultimat3/action` — **the ONE
151
+ table for these figures** (`packages/core/CLAUDE.md` points here):
591
152
 
592
153
  | Entry | before (HEAD `98d16d84`) | onto `clientTransport` | trace headers moved to core's outbound slot | As of 2026-09-23 |
593
154
  |---|---|---|---|---|
594
155
  | `rpc` | 18,097 B | 23,007 B | 18,119 B | 19,074 B |
595
156
  | `rpc` + `createClientFlight` | 23,903 B | 28,823 B | not measured | 25,197 B |
596
157
 
597
- This is the ONE table for these figures; `packages/core/CLAUDE.md` points here. The slot column
598
- is the `outbound-headers.ts` change (core's `traceHeaders()` left the browser path; its own
599
- "before" read 23,164 B, the transport column re-measured on a later tree). The last column is
600
- one entry importing `packages/action/src/index.ts` by path, same flags.
158
+ Net against the pre-transport figure: +977 B, about the envelope decoder's size.
159
+ - **The `sideEffects` array is load-bearing**: `errors.ts` runs `registerErrorCodes` at import. Never `false`.
601
160
 
602
- The 14,759 B this file used to quote does not reproduce on the tree it was checked against
603
- (18,097 B); the numbers above are both measured the same way, same day. The growth is +4,910 B,
604
- of which the envelope decoder is ~1,020 B (`decodeRecordEnvelope` added to a
605
- `UltimateError`+`problemOf`+`traceHeaders` entry: 13,059 → 14,079 B). The rest is core's
606
- transport graph — the scope fence, the dispatch module, and core's shared problem decoder, which
607
- now answers a body with no framework code (`X_CLIENT_TRANSPORT_FAILED`) behind `decodeError`. That is OVER the plan's bound ("must not grow beyond the envelope decoder") and is
608
- reported, not hidden. After the slot moved telemetry out, the net against the pre-transport figure
609
- is +977 B (18,097 → 19,074 B, As of 2026-09-23), about the envelope decoder's size.
610
- - **The `sideEffects` array is what makes the barrel shakable, and it is load-bearing** (`As of
611
- 2026-08-23`). Declaring nothing meant a bundler had to assume every module ran at import, so
612
- `import { rpc } from '@ultimat3/action'` was 43,104 B and `import { queryClient } from
613
- '@ultimat3/query'` was 40,859 B — three times the deep-import cost, through the ONLY specifier
614
- the `exports` map offers. The arrays are the ones `bun run scripts/side-effects.ts --explain
615
- --json` measures, and they must stay that: `errors.ts` runs `registerErrorCodes` at import in both
616
- packages, and query's `registry.ts` runs `registerPrimitiveRegistrar('query', …)` — drop either
617
- and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
618
- - **A retried mutation is gated on an `Idempotency-Key`, and the gate is silent narrowing rather
619
- than a refusal** (`As of 2026-08-23`). `CallOptions.retry` is honoured only alongside
620
- `idempotencyKey`; without one the call is narrowed to a single attempt. A second POST with no key
621
- is a second WRITE, and nothing at this seam can tell a lost answer from a lost request. A refusal
622
- was the other candidate and was rejected: `retry:` may be set once on the flight for a whole
623
- client, and turning every keyless call in an app into a thrown error would make the flight
624
- un-installable. `client-flight.test.ts` pins both halves, and the header on every attempt.
625
- - **A fence never aborts a write, and `client.ts` never calls `flight.keyFor`.** The first because
626
- closing a mutation's socket does not un-commit it — it only destroys the one chance the caller had
627
- of learning whether it landed, so `abortable: false` is unconditional and the caller still gets
628
- `X_SUPERSEDED` for the ANSWER. The second is how "a mutation may never join another mutation" is
629
- enforced: there is no dedup path to reach from here, even with a principal installed.
630
- - **`registerAction` guards the derived PATH as well as the name.** `X_ACTION_DUPLICATE` only ever
631
- asked about the name, so `archiveOrder` and `archiveOrders` — one route, by `pluralize`'s
632
- deliberate "a trailing `s` is already plural" rule — both registered and both projected: the
633
- router table seated whichever came last and the other was unreachable over HTTP while its
634
- OpenAPI operation and MCP tool still advertised it. `paths` is a second index, cleared by
635
- `resetRegistry` with the first, and the refusal is `X_ACTION_PATH_DUPLICATE`.
636
- - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
637
- - `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock.
638
- - `client.ts` stays free of server imports — it is bundled into the browser.
639
- - Authz goes through `enforce(surface, policy, { input, actor, ctx })` from
640
- `@ultimat3/policy`; a returned denial becomes `ActionDeniedError`, which keeps the
641
- policy's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) and carries the surface
642
- denial. `policy-gate.ts` is the only file with a **runtime** edge to the policy package;
643
- `errors.ts` also imports it, `import type { SurfaceDenial }`, which `verbatimModuleSyntax`
644
- erases — so there is still exactly one place authz is evaluated.
161
+ ## Invariants — idempotency
162
+
163
+ - **Everything `withIdempotency`'s `run` throws is treated as possibly-committed**: the reservation
164
+ is SETTLED as a failure and a retry replays it; `release()` is only for a pre-handler failure. A
165
+ `settle` that refuses leaves the record in flight. `idempotency-failure.test.ts`.
166
+ - **A record belongs to ONE CALLER**: the key is
167
+ `JSON.stringify([action, actor.kind, actor.id, actor.orgId ?? null, key])` — a JSON tuple, never a
168
+ joined string. A blank header is `X_IDEMPOTENCY_KEY_INVALID` (4xx, before the handler), never "no
169
+ key". Anonymous callers still share one key space.
170
+ - **The `idempotency-key` header also NAMES the write, on every action**: `http.ts` runs `invoke`
171
+ inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
172
+ never a gate.
173
+ - **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
174
+ `settle(key, value, reservationId)` / `fail(key, failure, reservationId)`. A fenced no-op is logged,
175
+ never thrown.
176
+ - **A stored status is NARROWED** (`isIdempotencyStatus`; unknown is
177
+ `X_IDEMPOTENCY_STATUS_UNKNOWN`), never cast.
178
+ - **Where records live is DECLARED and refused at registration**: `IdempotencyStore.scope` vs
179
+ `configureIdempotency({ scope })`, compared by `assertIdempotencyScope` in `registerAction`
180
+ (`X_IDEMPOTENCY_NOT_SHARED`). Default `'process'`.
181
+ - **The memory store is bounded; `in-flight` records are the last evicted.** Never an LRU.
182
+ - **`postgresIdempotencyStore` is the shared store** over a structural `PgExecutor` (no `action -> db`
183
+ edge); the reservation is ONE `insert … on conflict` statement. The CLI boot installs it.
184
+
185
+ ## Invariants — cache and audit
186
+
187
+ - **The post-commit bust never fails the write** — `cache-gate.ts` (the only `invalidateTags` caller)
188
+ absorbs a refusing fan-out and logs through core's `logger`, never rendering the tags. A replay
189
+ skips the bust.
190
+ - **The policy contract test asserts `ActionDeniedError` and sends valid input** (`sampleInput` from
191
+ the input IR). Only `X_INPUT_INVALID` (and `X_AUDIT_SINK_MISSING`, the one refusal before the parse)
192
+ becomes `X_CONTRACT_DRIFT`; everything else keeps its own code; a non-`UltimateError` is rethrown.
193
+ `contract-test.contract.test.ts`.
194
+ - **The audit seam ships the mechanism and none of the row**: `audit: true` wraps `execute`, so a
195
+ DENIED attempt is recorded. No audit entity, retention, hash chain or "who" convention. The
196
+ vocabulary matches `@ultimat3/admin`'s by name (tier 5, no edge); admin's `AuditSink` is a known
197
+ duplicate that unifying would need core for.
198
+ - **The memory sink DROPS** — a ring at `DEFAULT_MAX_AUDIT_RECORDS`, oldest first, counting `dropped`;
199
+ no spelling of "unbounded".
200
+ - **A durable sink writes what `audit-input.ts` allows**: input redacted through core's
201
+ `isRedactedKey` (the table `defineEnv({ secret: true })` extends) plus `isSecret` by value, and
202
+ always JSON-representable (named markers, cycle detection; `toJSON` never called).
203
+ - **The `Ctx` is never walked**: `postgresAuditSink` reads an allow-list (`requestId`, `traceId`,
204
+ `locale`, `tz`, `buildId`, `role`, actor `id`/`kind`/`orgId`/`onBehalfOf`) and keeps
205
+ `failure.code`, never the error.
206
+ - **`x_audit` ships no purge**, deliberately. **`SQL_AUDIT_INSERT` is positional**, pinned by
207
+ `audit-parity.test.ts` with a distinct value per field.
208
+ - **The two failure policies are opposites**: `X_AUDIT_SINK_MISSING` before the input parse (no
209
+ logger-backed default sink); a sink refusing an ALLOWED record is `X_AUDIT_SINK_FAILED`, whose `fix:`
210
+ branches on `record.idempotencyKey !== null` (`meta.replayable`); a sink refusing a denied or failed
211
+ record is logged (`audit.sink.failed`) and the original error still reaches the caller.
212
+ - **`auditSettled` sits outside the `catch`**, and **`auditOutcomeFor` is TOTAL** (fails closed to
213
+ `failed`); `audit.test.ts` asserts the caller's throwable by identity.
214
+ - **`json-schema.ts`'s refusal names `introspect()`** (`normalizeJsonSchema`, test-only export).
645
215
 
646
216
  ## Commands
647
217
 
@@ -649,3 +219,5 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
649
219
  bun test packages/action
650
220
  bun run typecheck
651
221
  ```
222
+
223
+ Why each rule above is shaped the way it is: [`docs/history/action.md`](../../docs/history/action.md).
package/README.md CHANGED
@@ -623,7 +623,7 @@ never a pass — the assertion says which code got in the way and names `input:`
623
623
  | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor })` at boot |
624
624
  | `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 |
625
625
  | `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 |
626
- | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
626
+ | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --only contract` |
627
627
  | `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 |
628
628
  | `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
629
629
  | `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `setAuditSink(yourSink)` at boot |
@@ -645,6 +645,33 @@ cannot read is dropped whole rather than half-kept, leaving `cause` (which still
645
645
  rejection) as the answer. It is exported for the island that posts with a plain `fetch` and holds
646
646
  the body itself.
647
647
 
648
+ ### Error classes
649
+
650
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
651
+ a job boundary the class is gone and the `code` is what survives — match on that.
652
+
653
+ | Class | Code | Declared in |
654
+ |---|---|---|
655
+ | `ActionDeniedError` | the policy denial's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`, …), kept on `.denial` | `src/errors.ts` |
656
+ | `ActionDeprecationInvalidError` | `X_ACTION_DEPRECATION_INVALID` | `src/errors.ts` |
657
+ | `ActionDuplicateError` | `X_ACTION_DUPLICATE` | `src/errors.ts` |
658
+ | `ActionForeignError` | `X_ACTION_FOREIGN` | `src/errors.ts` |
659
+ | `ActionPathDuplicateError` | `X_ACTION_PATH_DUPLICATE` | `src/errors.ts` |
660
+ | `ActionPolicyMissingError` | `X_ACTION_POLICY_MISSING` | `src/errors.ts` |
661
+ | `ActionUnregisteredError` | `X_ACTION_UNREGISTERED` | `src/errors.ts` |
662
+ | `AuditSinkFailedError` | `X_AUDIT_SINK_FAILED` | `src/errors.ts` |
663
+ | `AuditSinkMissingError` | `X_AUDIT_SINK_MISSING` | `src/errors.ts` |
664
+ | `ContractDriftError` | `X_CONTRACT_DRIFT` | `src/errors.ts` |
665
+ | `IdempotencyConflictError` | `X_IDEMPOTENCY_CONFLICT` | `src/errors-idempotency.ts` |
666
+ | `IdempotencyKeyInvalidError` | `X_IDEMPOTENCY_KEY_INVALID` | `src/errors-idempotency.ts` |
667
+ | `IdempotencyNotSharedError` | `X_IDEMPOTENCY_NOT_SHARED` | `src/errors-idempotency.ts` |
668
+ | `IdempotencyReplayedFailureError` | `X_IDEMPOTENCY_REPLAYED_FAILURE` | `src/errors-idempotency.ts` |
669
+ | `IdempotencyStatusUnknownError` | `X_IDEMPOTENCY_STATUS_UNKNOWN` | `src/errors-idempotency.ts` |
670
+ | `InputInvalidError` | `X_INPUT_INVALID` | `src/errors.ts` |
671
+ | `OutputInvalidError` | `X_OUTPUT_INVALID` | `src/errors.ts` |
672
+ | `RemoteActionError` | the code the server sent, verbatim — `meta.origin: 'remote'` | `src/errors.ts` |
673
+ | `RpcFailedError` | `X_RPC_FAILED` | `src/errors.ts` |
674
+
648
675
  ## Boundaries
649
676
 
650
677
  Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `entity`, `policy`, `http`. Never imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "21.0.0",
3
+ "version": "22.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",
@@ -34,11 +34,11 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/cache": "21.0.0",
38
- "@ultimat3/core": "21.0.0",
39
- "@ultimat3/entity": "21.0.0",
40
- "@ultimat3/http": "21.0.0",
41
- "@ultimat3/policy": "21.0.0",
42
- "@ultimat3/schema": "21.0.0"
37
+ "@ultimat3/cache": "22.0.0",
38
+ "@ultimat3/core": "22.0.0",
39
+ "@ultimat3/entity": "22.0.0",
40
+ "@ultimat3/http": "22.0.0",
41
+ "@ultimat3/policy": "22.0.0",
42
+ "@ultimat3/schema": "22.0.0"
43
43
  }
44
44
  }
@@ -75,8 +75,3 @@ export function renderDeprecation(
75
75
  };
76
76
  return { ok: true, headers, meta };
77
77
  }
78
-
79
- /** Set on a response that already exists, so a redirect and a problem document carry them too. */
80
- export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
81
- for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
82
- }
package/src/http.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  import { tagKeys } from '@ultimat3/cache';
9
9
  import {
10
10
  isMcpExposed,
11
- isUltimateError,
12
11
  RECORDS_OPENAPI_HEADER,
13
12
  recordEnvelopeSchema,
14
13
  withWriteOrigin,
@@ -18,10 +17,10 @@ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
18
17
  // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
19
18
  // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
20
19
  // a copy here would be a second answer to "what does this limit mean" for the read half.
21
- import { json, problem, redirect, takeRedirect, toBucket } from '@ultimat3/http';
20
+ import { json, redirect, takeRedirect, toBucket } from '@ultimat3/http';
22
21
  import type { ActionRateLimit, AnyAction } from './action';
23
22
  import type { Deprecation } from './deprecation';
24
- import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
23
+ import { recordDeprecatedCall, renderDeprecation } from './deprecation';
25
24
  import { ActionDeprecationInvalidError } from './errors';
26
25
  import { actionName, defOf, invoke } from './invoke';
27
26
  import {
@@ -66,51 +65,50 @@ export function toRoute(target: AnyAction): Route {
66
65
  const enveloped = carriesRecords(target.output);
67
66
 
68
67
  const handler = async (req: UltimateRequest): Promise<Response> => {
69
- if (sunsetting !== undefined) recordDeprecatedCall('action', name);
70
- try {
71
- // The pipeline already parsed and size-capped the body; parsing it again here
72
- // would be a second, differently-behaved parser for the same bytes.
73
- const raw = await req.bodyRaw();
74
- const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
75
- let replayed = false;
76
- // The header NAMES the write whatever the declaration, idempotent or not: a page sends one
77
- // with every mutation, and the `records` frames its rows produce carry the digest so that
78
- // page can tell its own echo from somebody else's change (`@ultimat3/core`'s write origin).
79
- const result = await withWriteOrigin(await writeOriginOf(req), () =>
80
- invoke(target, raw, {
81
- surface: 'http',
82
- idempotencyKey: key,
83
- onReplay: () => {
84
- replayed = true;
85
- },
86
- }),
87
- );
88
- // The one thing an action's return value cannot say. `setRedirect()` inside the handler
89
- // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
90
- // 200 this used to always return is a header browsers ignore, so a JS-less form left the
91
- // reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
92
- // fact, and the MCP tool and the job handle share none of it.
93
- const to = takeRedirect(req.ctx);
94
- const response =
95
- to !== undefined
96
- ? redirect(to.location, to.status)
97
- : enveloped
98
- ? recordResponse(target.output, result)
99
- : json(result);
100
- if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
101
- // On the failure path too, below: a client polling a deprecated endpoint that is currently
102
- // 403ing still has to learn the endpoint is going away. Announcing it only on 200 hides the
103
- // sunset from exactly the callers most likely to be stale.
104
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
105
- return response;
106
- } catch (error) {
107
- // Framework errors carry their own code, status and fix line; anything else is
108
- // a bug and belongs to the server's error boundary, not to this route.
109
- if (!isUltimateError(error)) throw error;
110
- const response = problem(error);
111
- if (sunsetting !== undefined) applyHeaders(response, sunsetting);
112
- return response;
68
+ if (sunsetting !== undefined) {
69
+ recordDeprecatedCall('action', name);
70
+ // On the CONTEXT, before anything can fail: the pipeline's `response` stage merges
71
+ // `ctx.headers` into whatever answers — this handler's 200 and the `error-map` stage's
72
+ // problem or error page alike. A client polling a deprecated endpoint that is currently
73
+ // 403ing still has to learn the endpoint is going away.
74
+ for (const [header, value] of Object.entries(sunsetting)) req.ctx.headers.set(header, value);
113
75
  }
76
+ // No `catch`: an error — framework or not — takes the path every route's error takes. This
77
+ // used to answer `problem(error)` itself, so the `error-map` stage never saw it: no
78
+ // `onError`/`reportError` on a 5xx, no HTML error page or sign-in redirect for a browser, no
79
+ // `requestId`, no `Retry-After`.
80
+ //
81
+ // The pipeline already parsed and size-capped the body; parsing it again here
82
+ // would be a second, differently-behaved parser for the same bytes.
83
+ const raw = await req.bodyRaw();
84
+ const key = def.idempotent === true ? req.header(IDEMPOTENCY_HEADER) : null;
85
+ let replayed = false;
86
+ // The header NAMES the write whatever the declaration, idempotent or not: a page sends one
87
+ // with every mutation, and the `records` frames its rows produce carry the digest so that
88
+ // page can tell its own echo from somebody else's change (`@ultimat3/core`'s write origin).
89
+ const result = await withWriteOrigin(await writeOriginOf(req), () =>
90
+ invoke(target, raw, {
91
+ surface: 'http',
92
+ idempotencyKey: key,
93
+ onReplay: () => {
94
+ replayed = true;
95
+ },
96
+ }),
97
+ );
98
+ // The one thing an action's return value cannot say. `setRedirect()` inside the handler
99
+ // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
100
+ // 200 this used to always return is a header browsers ignore, so a JS-less form left the
101
+ // reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
102
+ // fact, and the MCP tool and the job handle share none of it.
103
+ const to = takeRedirect(req.ctx);
104
+ const response =
105
+ to !== undefined
106
+ ? redirect(to.location, to.status)
107
+ : enveloped
108
+ ? recordResponse(target.output, result)
109
+ : json(result);
110
+ if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
111
+ return response;
114
112
  };
115
113
 
116
114
  const meta: RouteMeta = {
package/src/invoke.ts CHANGED
@@ -253,7 +253,10 @@ async function perform(
253
253
  if (outcome.replayed) options.onReplay?.();
254
254
  trace.replayed = outcome.replayed;
255
255
  wrote = !outcome.replayed;
256
- value = outcome.value;
256
+ // A replay is parsed like a first call. The memory store hands back the live value and the
257
+ // postgres store `JSON.parse(JSON.stringify(v))` — a `Date` on the first call and a `string`
258
+ // on every retry served from Postgres — so the output schema is the one answer to its shape.
259
+ value = outcome.replayed ? validateOutput(def.output, outcome.value, name) : outcome.value;
257
260
  }
258
261
  // Only for a run that actually happened, and only through the gate. A replay ran no handler
259
262
  // and changed nothing the first call had not already busted — re-busting per retry re-purges
package/src/mutator.ts CHANGED
@@ -7,8 +7,17 @@
7
7
 
8
8
  import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
9
9
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
- import type { Action, ActionCache, ActionDef, ActionDescriptor, ActionMcp } from './action';
10
+ import type {
11
+ Action,
12
+ ActionCache,
13
+ ActionDef,
14
+ ActionDescriptor,
15
+ ActionMcp,
16
+ ActionRateLimit,
17
+ ActionRowArgs,
18
+ } from './action';
11
19
  import { action, isAction } from './action';
20
+ import type { Deprecation } from './deprecation';
12
21
  import { assertConflictClock } from './mutator-clock';
13
22
  import type { ActionPolicy } from './policy-gate';
14
23
 
@@ -71,13 +80,26 @@ export function custom<TRow extends object = Row>(
71
80
  return { kind: 'custom', merge: merge as unknown as (local: Row, server: Row) => Row };
72
81
  }
73
82
 
74
- export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> {
83
+ export interface MutatorDef<
84
+ TInput extends StandardSchemaV1,
85
+ TOutput extends StandardSchemaV1,
86
+ TRow = unknown,
87
+ > {
75
88
  readonly input: TInput;
76
89
  readonly output: TOutput;
77
- readonly policy: ActionPolicy;
90
+ readonly policy: ActionPolicy<TRow>;
78
91
  readonly cache?: ActionCache;
79
92
  readonly mcp?: ActionMcp;
93
+ /** Same key, same meaning as an action's — a mutator IS an action. */
94
+ readonly rateLimit?: ActionRateLimit;
95
+ /** Same key, same meaning as an action's: `Deprecation`/`Sunset` on every response. */
96
+ readonly deprecated?: Deprecation;
80
97
  readonly idempotent?: boolean;
98
+ /**
99
+ * The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
100
+ * the action until 2026-09-23, so such a policy received `row === null` and denied every call.
101
+ */
102
+ row?(args: ActionRowArgs<TInput>): TRow | null | Promise<TRow | null>;
81
103
  /**
82
104
  * Record every attempt through the installed `AuditSink`. Same key, same meaning as an
83
105
  * action's — a mutator IS an action, so it inherits the seam rather than getting a second one.
@@ -128,15 +150,21 @@ export interface Mutator<
128
150
  named(name: string): Mutator<TInput, TOutput>;
129
151
  }
130
152
 
131
- export function mutator<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
132
- def: MutatorDef<TInput, TOutput>,
133
- ): Mutator<TInput, TOutput> {
134
- const actionDef: ActionDef<TInput, TOutput> = {
153
+ export function mutator<
154
+ TInput extends StandardSchemaV1,
155
+ TOutput extends StandardSchemaV1,
156
+ TRow = unknown,
157
+ >(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
158
+ const row = def.row;
159
+ const actionDef: ActionDef<TInput, TOutput, TRow> = {
135
160
  input: def.input,
136
161
  output: def.output,
137
162
  policy: def.policy,
138
163
  ...(def.cache === undefined ? {} : { cache: def.cache }),
139
164
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
165
+ ...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
166
+ ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
167
+ ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
140
168
  ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
141
169
  ...(def.audit === undefined ? {} : { audit: def.audit }),
142
170
  handle: ({ input, ctx }) => def.server(ctx, input),