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