@ultimat3/action 1.2.0 → 3.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 ADDED
@@ -0,0 +1,404 @@
1
+ # @ultimat3/action
2
+
3
+ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
4
+
5
+ ## Boundary
6
+
7
+ - May import: `core`, `schema` (t0), `cache`, `i18n`, `time` (t1), `entity`, `policy`, `http` (t2).
8
+ - Never import: `query`, `jobs`, `realtime` (sideways), or any tier 4-5 package.
9
+ - Never re-implement authz, validation or caching — call `policy`, `schema`, `cache`.
10
+
11
+ ## Files
12
+
13
+ | File | Job |
14
+ |---|---|
15
+ | `action.ts` | the primitive: `action()`, `describeAction`, the registry-facing name stamp |
16
+ | `invoke.ts` | **the one execution path** + the private declaration store `handle` lives in |
17
+ | `facade.ts` | the fluent surface — binds each projection to the action, re-implements none |
18
+ | `mutator.ts` | action + optimistic `.local` twin + authoritative `.server` + `.conflict` |
19
+ | `registry.ts` | export-name registration, collisions, `describeActions()` |
20
+ | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call |
21
+ | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
22
+ | `openapi.ts` | deterministic OpenAPI 3.1 document |
23
+ | `client.ts` | typed RPC client (browser-safe: no server imports) |
24
+ | `mcp-tool.ts` | MCP descriptor, same `invoke` |
25
+ | `job-handle.ts` | the `.job()` projection: an action as a queueable payload. **Not** consumed by `@ultimat3/jobs` — see Invariants |
26
+ | `contract-test.ts` | assertions `x g action` emits |
27
+ | `sample-input.ts` | a value `input:` accepts, from its own IR — what makes the policy assertion reach a policy |
28
+ | `idempotency.ts` | the store SEAM: types, the installed-store slot, the scope declaration + `assertIdempotencyScope`, and `withIdempotency` — the replay-or-run gate |
29
+ | `idempotency-key.ts` | the namespaced key — action + actor + the caller's key, as one JSON tuple — and the refusal of one that names no request |
30
+ | `idempotency-memory.ts` | the process default: bounded, swept, `scope: 'process'` |
31
+ | `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
32
+ | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
33
+ | `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
34
+ | `cache-gate.ts` | the post-commit bust — **the only** file that calls `invalidateTags` |
35
+ | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the memory sink, the installed-sink store |
36
+ | `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
37
+ | `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
38
+ | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers |
39
+
40
+ ## Invariants
41
+
42
+ - Every surface goes through `invoke`: parse input, evaluate policy, handle, parse
43
+ output. Adding a second execution path is the one unforgivable change here.
44
+ - **An explicit `ctx` is INSTALLED, never merely passed** (`As of 2026-08`). `invoke` entered
45
+ `runWithContext` only when `options.actor` was supplied; given `options.ctx` alone it called
46
+ `core(target, raw, options.ctx, options)` directly. So `guard()` decided about that actor while
47
+ everything reading the AMBIENT context — above all `@ultimat3/entity`'s tenant guard, which
48
+ derives from `tryUseContext()` and not from the ctx it is handed — saw a different identity, or
49
+ none: `writeAnywhere.job().invoke(input, ctxAsOrgA)` wrote a row naming org B while the identical
50
+ call under an ambient context was `X_TENANCY_ACTOR_MISMATCH`. Absent a `ctx` this reinstalls the
51
+ ambient one, a no-op on every path that already worked. The twin fix is `@ultimat3/query`'s
52
+ `asActor`, and `invoke-context.test.ts` asserts an EQUALITY between the three spellings of one
53
+ caller — ambient, `options.actor`, `options.ctx` — because three independent expectations are
54
+ exactly what let this ship.
55
+ - The declaration never leaves `invoke.ts`. `defOf`/`stashDef` are internal and must
56
+ never be re-exported from `src/index.ts` — that absence is the enforcement, and
57
+ `index.test.ts` is what makes it one.
58
+ - **`toRoute` sets `enforcedBy: 'handler'`, so the HTTP pipeline's authz stage stands down.**
59
+ `invoke` is the route's single evaluation and the only one holding the row `def.row` loaded;
60
+ a stage deciding first would decide the same policy from `row: null`, deny the row's own
61
+ author, and never reach the evaluation that had the row. `meta.policy` stays set — dropping
62
+ it would read as "this action is unguarded" in `x routes` and the manifest. `http.test.ts`
63
+ drives a row-level action over the real pipeline and counts the evaluations: exactly one.
64
+ - **`stable.ts` holds TWO serializers, and they are not one function.** `stableStringify` is the
65
+ DOCUMENT form: `serializeOpenApi` publishes it as `openapi.json` and `json-schema.ts` re-reads it
66
+ with `JSON.parse`, so a non-finite number has to be `null` — the bare token `NaN` would make a
67
+ published spec unparseable. `canonicalJson` is the HASH form `fingerprint` is taken over, and it
68
+ must be INJECTIVE: `NaN`, `±Infinity` and JSON `null` all encoded as `'null'` and `String(-0)` is
69
+ `"0"`, so four distinct inputs shared one `requestHash` — one caller handed another's stored
70
+ response on replay — and one job dedupe key. That is why the fix `@ultimat3/query` made in its own
71
+ `stable.ts` could not simply be copied here; it needed the split first. Ordinary payloads are
72
+ byte-identical between the two, so no idempotency record and no enqueued job moved.
73
+ `stable.test.ts` pins both duties, including a `JSON.parse` of the document form.
74
+ - **`tagKeys` is `@ultimat3/cache`'s, not this package's — moved 2026-08.** `packages/action/src/tags.ts`
75
+ and `packages/query/src/tags.ts` were byte-identical, and both packages are tier 3, so neither can
76
+ import the other and a copy in either is a second answer for the other. `tagKey` went with it: it
77
+ was `serializeTag` under a second name with zero call sites. Same shape as `toBucket`. Never
78
+ restore a local one, and never reach for `@ultimat3/render`'s same-named `tagKeys` — that one
79
+ preserves declaration order and is a different function.
80
+ - **`toBucket` is `@ultimat3/http`'s, not this package's — moved 2026-08.** http owns `Bucket` and
81
+ the limiter maths, and `@ultimat3/query` needs the identical conversion while being the same
82
+ tier as this package, so a copy in either is a second answer to "what does this limit mean" for
83
+ the other. It is re-exported from `src/index.ts` so an action file still reaches it through one
84
+ import, and it raises http's `X_RATE_LIMIT_INVALID`; `X_ACTION_RATE_LIMIT_INVALID` is gone with
85
+ the copy. Never restore a local one.
86
+ - **`rateLimit:` reaches the limiter, not only the spec.** `toRoute` sets `meta.rateLimit` (the
87
+ bucket name) **and** `meta.rateLimitBucket` (`toBucket(name, def.rateLimit)`), and
88
+ `@ultimat3/http`'s `withRouteBuckets` registers the second under the first. Until 2026-08 only
89
+ the name was set, so `bucketFor` fell through to `default` — 120 burst / 2 per second for an
90
+ action that declared 5, with the declared numbers published in `x-ultimate.rateLimit` all the
91
+ same: looser in practice than what the author wrote, which is the dangerous direction. `toBucket`
92
+ is the **only** conversion between `{ limit, windowMs }` and `{ capacity, refillPerSecond }`, and
93
+ both projections that read the declaration call it, so the spec cannot publish a pair the limiter
94
+ refuses. http's `X_RATE_LIMIT_INVALID` covers all three checks, and the third is the one that is
95
+ easy to miss: the **computed** rate, not just the two declared halves. `windowMs: 0` is an
96
+ infinite refill, and so is `{ limit: Number.MAX_VALUE, windowMs: 1 }` — two finite positive
97
+ numbers whose division enforces nothing. `limit` must also be at least one whole token, or the
98
+ first caller is already refused and the endpoint is closed rather than limited.
99
+ - **Everything `withIdempotency`'s `run` throws is treated as possibly-committed.** `guard()` and
100
+ `validateInput` both run before the gate (`invoke.ts`), so by the time `run` is called the only
101
+ things left are the handler and `validateOutput` — and the second throws *after* the first has
102
+ committed. The reservation is therefore SETTLED as a failure and the retry replays it under the
103
+ first attempt's own code; `release()` is reserved for a pre-handler failure, of which this gate
104
+ has none. Releasing there is what turned a rounding change in an `output:` schema into a second
105
+ charge: `X_OUTPUT_INVALID` dropped the record and the client's automatic retry re-ran a handler
106
+ that had already taken the money. A `settle` that itself refuses leaves the record **in flight**
107
+ for the same reason — a 409 the caller can act on beats a silent re-run. A store with no `fail`
108
+ slot gets the same fail-closed treatment. `idempotency-failure.test.ts` drives the
109
+ post-commit-throw path first, because that is the one that shipped.
110
+ - **An idempotency record belongs to ONE CALLER, and a key that names no request is refused**
111
+ (`As of 2026-08`). The namespace was the action name alone, so `idempotencyKeyFor` filed every
112
+ caller's `k1` under one record: alice POSTed `charge`, bob POSTed `charge` with the same header
113
+ and was handed alice's stored response — and with a *differing* payload bob got
114
+ `X_IDEMPOTENCY_CONFLICT` instead, so any key he guessed was a key he could deny her. The key is
115
+ now `JSON.stringify([action, actor.kind, actor.id, actor.orgId ?? null, key])`, and the encoding
116
+ is a **JSON tuple, never a joined string**, for `readAuthority`'s reason: an actor id is app data,
117
+ so under `a:b:c` an id of `alice:x` with key `y` is the same record as `alice` with key `x:y`.
118
+ Separately, `req.header()` is `Headers.get()`, which answers `''` for `Idempotency-Key:` and
119
+ never `null` — so a blank header was itself a shared key. It is `X_IDEMPOTENCY_KEY_INVALID`, a
120
+ 4xx raised before the handler, and **not** read as "no key": the quiet reading loses the retry
121
+ protection exactly when a client's key interpolation broke, and the double charge lands on the
122
+ client's own automatic retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the same
123
+ reason and uses `assert` because *its* empty key is the app's declaration, not a caller's header.
124
+ What this does NOT close: an anonymous actor is one identity, so anonymous callers of a public
125
+ idempotent action still share a key space — nothing at this tier can tell two apart, and keying
126
+ on an IP or a cookie would break the retry the header exists to serve.
127
+ - **Both stores FENCE a settlement on `in-flight`**, as `@ultimat3/jobs`' `SQL_ACK` fences on
128
+ `state = 'running'`. A reservation whose window lapsed is reclaimed by the next caller
129
+ (`on conflict … do update`), so a straggler from the first one used to overwrite a record it no
130
+ longer owned and the next replay answered a retry with a value produced for a different request.
131
+ Postgres fences in SQL and returns `key`, so the no-op is observable and logged; memory checks
132
+ the status it holds. It is logged and never thrown — a settlement is post-commit, so raising
133
+ there would turn a durable write into the caller's error. The fence is on the STATUS only: the
134
+ reservation's own `id` would close the last case (a straggler landing while the replacement is
135
+ still in flight) and cannot be checked, because `IdempotencyStore.settle(key, value)` is public
136
+ API and does not carry it.
137
+ - **Where the idempotency records live is DECLARED, and refused at registration.**
138
+ `IdempotencyStore.scope` says what a driver provides; `configureIdempotency({ scope })` says what
139
+ the deployment requires; `assertIdempotencyScope` compares them inside `registerAction` — the
140
+ funnel every registration path shares, and one that necessarily runs before a route is mounted.
141
+ `'shared'` over a per-process store, or over a store declaring **no** scope, is
142
+ `X_IDEMPOTENCY_NOT_SHARED` before the socket opens. The exact shape of `assertRateLimitScope`
143
+ and `assertRouteBuckets`, and for the same reason: what cannot be shown to hold is not assumed
144
+ to hold. The default is `'process'`, because one process is the only thing a framework can
145
+ promise without being told — nothing here reads an environment to guess a replica count.
146
+ - **The memory store is bounded, and the eviction order is part of the guarantee.** A key is
147
+ caller-supplied, so its cardinality is the write rate: unbounded, 500 idempotent writes a second
148
+ is 43M immortal entries a day. Expired records go for free (past the window a record answers as
149
+ a missing one); past the cap, **`in-flight` records are the last to go** — one of those is the
150
+ reservation that stops a concurrent duplicate from running the handler twice. That is the mirror
151
+ of `memoryRateLimitStore` evicting the fullest bucket first; never swap either for an LRU.
152
+ - **`postgresIdempotencyStore` is the shipped shared store, and it must be CALLED.** A mechanism
153
+ built and never wired is the defect this package has shipped twice. It takes a structural
154
+ `PgExecutor` — the same shape `@ultimat3/jobs`' pg driver declares, satisfied by `Bun.sql` and by
155
+ a Tx — so there is no `action -> db` package edge and the app wires it:
156
+ `setIdempotencyStore(postgresIdempotencyStore({ executor: Bun.sql }))` then
157
+ `configureIdempotency({ scope: 'shared' })`, at boot, before `registerActions()`.
158
+ `SQL_IDEMPOTENCY_TABLE` is applied the way `SQL_JOBS_TABLE` is. The reservation is ONE statement:
159
+ a returned row always means this caller owns it, because the `do update` fires only for a row
160
+ already outside the window.
161
+ - **`deprecated:` is a compat WINDOW and versioning is deliberately not here.** One declaration,
162
+ four projections: `Deprecation`/`Sunset` headers on every response *including the failures* the
163
+ handler raises, a `rel="successor-version"` link derived through `derivePath` (never a second URL
164
+ derivation), `deprecated: true` in the OpenAPI operation, and `deprecated_calls_total`. The
165
+ headers are rendered ONCE at projection, so a date that cannot become one is
166
+ `X_ACTION_DEPRECATION_INVALID` at mount rather than on the first request — the rule `toBucket`
167
+ follows. Running two versions side by side is two deployments behind one ingress (axiom 7); a
168
+ path prefix, a second registry or a version router would all be a ninth thing to maintain.
169
+ `deprecation.ts` is TWINNED in `@ultimat3/query`, because both are tier 3 and the shared home is
170
+ `@ultimat3/http` if it ever grows one — the same compromise `naming.ts` is ported under.
171
+ - **The span wraps `execute` whole, and carries attributes.** It used to wrap `def.handle` alone
172
+ and set nothing, so `def.row()` — the loader a row-level policy needs — ran inside no span at
173
+ all: a 2s p99 reported 40ms and the missing 1.96s read as framework overhead. Attributes are
174
+ chosen for bounded cardinality (surface, actor KIND, outcome, booleans) with one exception, the
175
+ namespaced idempotency key, which is the single fact that joins a retry to the call it retries —
176
+ and is never a metric label. `telemetry.test.ts` asserts the EXTENT structurally, by reading
177
+ `currentSpan()` from inside `row:` and the policy predicate, not by timing: the test clock is
178
+ frozen.
179
+ - **`policyCapability` is a display label and `policyPermissions` is what a report matches on.**
180
+ A composite renders as `and(post:publish, org:administer)`, which equals no permission string,
181
+ so `x policy list` matching on `capability` reported every non-trivially-guarded action's
182
+ permissions as *unenforced* — real grants shown as dead. `ActionDescriptor.permissions` is the
183
+ flattened list from `@ultimat3/policy`'s `policyPermissions`, published beside `capability` and
184
+ never instead of it. A `not()` clause contributes its inner permissions: the grant still
185
+ participates in the decision, so omitting it would be the false statement.
186
+ - **Both clients inject `traceparent`.** `@ultimat3/core`'s `traceparent()` existed with no caller
187
+ in the repo, so every Ultimate-to-Ultimate hop began a fresh root trace on the far side. It is
188
+ set BEFORE the caller's own headers so an explicit one still wins, and an incomplete span context
189
+ (`spanId: ''`, which `currentSpanContext()` answers when a request context exists but no span
190
+ does) sends nothing rather than `00-<trace>--01`. In a browser there is no ambient context, so
191
+ a cross-origin call acquires no CORS preflight it did not already have.
192
+ - **`ActionJobHandle` is not consumed by `@ultimat3/jobs`, and the file said it was until
193
+ 2026-08.** The header read "`@ultimat3/jobs` consumes this shape, so enqueueing an existing
194
+ action costs zero rewriting" — a claim no code supports. `'action-job'` occurs in four places,
195
+ all inside this package (`job-handle.ts:16,30`, `job-handle.test.ts`, `facade.test.ts`), and
196
+ `isJobHandle` (`packages/jobs/src/job.ts:256`) requires `kind === 'job'` **and** membership of a
197
+ module-private `WeakMap` written only inside `job()` — so this is impossible in principle, not
198
+ merely unwired. `JobHandle` shares two members with it. What the shape actually gives: `.job()`
199
+ on the façade, `action:<name>` as a durable queue key, a payload-derived idempotency key, and an
200
+ `invoke` that runs the one execution path under `surface: 'job'` — so an app that owns a queue
201
+ can drive it by hand and still get the action's parse + policy. **The missing halves are
202
+ `tenant` and `retry`**, both required on `JobDefinition` with deliberately no default, so "zero
203
+ rewriting" was never reachable regardless of wiring. The bridge is one `job({ … })` call, and it
204
+ belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. Not built here — that is
205
+ code, and this was a comment correction.
206
+ - An action has no `.def`. Inside the package read it with `defOf(target)`; outside,
207
+ read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`.
208
+ - **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased
209
+ view — `listActions()`, `getAction(name)` — so a member missing from it is a projection the
210
+ registry cannot reach: `.job()` was absent until 2026-08 and `getAction('publishPost')?.job()`
211
+ was a type error against an object that has had the method since `facadeFor` bound it. `job()`
212
+ erases because `ActionJobHandle`'s members are method-syntax (bivariant parameters) and its
213
+ output erases to `unknown`; `ClientMethod` is a **function type**, so its input is
214
+ contravariant and `(input: unknown) => …` is a supertype of no concrete action's method. Both
215
+ halves are build errors in `type-pins.ts`, never a comment — and type claims go there, never in
216
+ a `.test.ts`, because `tsconfig.json` excludes tests and `tsc` never reads one.
217
+ - **The client keeps the server's error code and marks it remote.** A `problem+json` failure
218
+ becomes `RemoteActionError` (`errors.ts`), which re-uses the code off the wire the way
219
+ `ActionDeniedError` re-uses the policy decision's — and then says so, because the browser
220
+ bundle never registered it: `name` marks it in a stack, `meta.origin: 'remote'` marks it in
221
+ `--json`, the overlay and the error reporter. **It never synthesizes a docs URL.**
222
+ `https://ultimate.dev/errors/X_SIGNUP_CLOSED` for an app-declared code is a 404 dressed as
223
+ documentation; the link is the server's own `docs`/`type` when it sent an `http(s)` one, this
224
+ build's registered link when `hasErrorCode` knows the code, otherwise `ERROR_DOCS_BASE`. The
225
+ code must be `X_SCREAMING_SNAKE` to be taken at all — `typeof code === 'string'` accepted `""`
226
+ from a gateway — and anything else is `RpcFailedError`, which is what that code means.
227
+ `docs` and `type` travel to `remoteDocs` as an ordered pair, not `docs ?? type`: preference is
228
+ not selection, and picking the preferred slot on presence alone let one `javascript:` string
229
+ bury a perfectly good `type` the same response had already offered.
230
+ - **MCP exposure is read through `isMcpExposed` from `@ultimat3/core`, in all three places.**
231
+ `toMcpTools` builds the tool, `describeAction` publishes the manifest fact and
232
+ `toOpenApiOperation` publishes `x-ultimate.mcpTool` — the last two fail-opened (`?? true`,
233
+ `!== false`) until 2026-08, so an action with no `mcp` block was advertised as a tool by both
234
+ contract artifacts and refused by the only surface that could serve one. A contract that
235
+ disagrees with the runtime is worse than no contract; never spell the check inline again.
236
+ - **An MCP tool's NAME is the export name verbatim, in all four places — `toToolName` is gone**
237
+ (`As of 2026-08`). The same three readers that fail-opened on exposure also *derived* the name:
238
+ `toToolName` snake_cased it for `toMcpTool`, for `x-ultimate.mcpTool` and for
239
+ `describeAction().mcp.tool`, while `@ultimat3/mcp` has only ever served
240
+ `primitive.mcp?.name ?? primitive.name`. So nine of the ten tool names
241
+ `examples/dummy/openapi.json` published — `publish_post`, `create_post`, … — were names
242
+ `tools/call` answers not-found for; the tenth, `summarize`, is single-word and so was already
243
+ its own snake_case form, which is why a count of the wrong names is not a count of the rows. The
244
+ DESCRIPTOR said the same: `x actions describe --json`, `x actions list --json`, the
245
+ `actions.describe` dev MCP tool and the `/_x` Routes panel all read `.mcp.tool`. **Not the app
246
+ manifest** — `ActionFact.mcp` is `{ expose, description? }` and `packages/manifest/src/sources.ts`
247
+ copies only those two, so `x.manifest.json` has never carried a tool name and `grep '"tool"'` on
248
+ either committed manifest finds none. Do not describe this defect as a manifest defect; the
249
+ manifest's stake in the `mcp` block is `expose`, which is the invariant above. `ActionMcp`
250
+ carries no `name:`, so
251
+ the verbatim name is the whole rule and there is nothing left to derive; the helper is
252
+ **deleted** rather than left exported, because a dead name-deriver is a second way to name a
253
+ tool. `mcp-tool.test.ts`'s "one name per action, on every surface" asserts the three strings are
254
+ one string AND that it is the verbatim name — the equality alone passes on the old behaviour,
255
+ where all three agreed on the wrong name. **Neither package derives a tool name any more**:
256
+ `@ultimat3/query` carried a same-named twin with the identical defect and deleted it in the same
257
+ change, so `packages/query/src/naming.ts` derives PATHS only and its `src/index.ts` exports no
258
+ such helper. Both are tier 3, so neither could import the other and each owned its own removal —
259
+ which is why the rule is restated in both `CLAUDE.md`s rather than shared.
260
+ - **The post-commit bust never fails the write it followed.** `cache.invalidates` fans out once the
261
+ handler has committed, so `bustAfterCommit` — `cache-gate.ts`, the only caller of
262
+ `invalidateTags` here — absorbs a fan-out that refuses and answers `undefined`: an undeclared tag
263
+ (`X_CACHE_TAG_UNKNOWN`) must not turn a durable write into a failed action, and those entries
264
+ expire by TTL. One dead tier is not that case; `invalidateTags` already reports it in
265
+ `report.errors`. It logs through core's `logger`, never `ctx.logger` — an HTTP `Ctx` is a cast
266
+ request context that carries none — and never renders the tags, because reading a malformed
267
+ `invalidates` entry back is the second throw the guard exists to stop. **A replay skips the bust
268
+ entirely:** no handler ran, the first call already busted these tags, and re-purging the CDN and
269
+ re-queueing ISR per retry is work for a write nobody made.
270
+ - **The policy contract test asserts `ActionDeniedError`, and it sends valid input to get there.**
271
+ It sent `{}` and accepted any `UltimateError` until 2026-08, so every action with a required
272
+ field failed `input:` before `guard()` ran and the assertion passed on `X_INPUT_INVALID` —
273
+ including one whose policy was `allow()`. `sampleInput` builds the payload from `input:`'s own
274
+ IR (required keys only) so the invocation reaches the policy; the class, not `X_FORBIDDEN`, is
275
+ the assertion, because `ActionDeniedError` re-uses the policy decision's code and `can()`
276
+ answers a null actor with `X_UNAUTHENTICATED`. **Only `X_INPUT_INVALID` becomes
277
+ `X_CONTRACT_DRIFT`** — it is the one code `invoke` raises before `guard()` is reached, and
278
+ `input:` is the one knob that answers it. Everything else keeps its own code and its own fix:
279
+ saying `X_OUTPUT_INVALID … before its policy decided` named a stage nothing had checked and
280
+ offered a fix that changes nothing, and it hid the `allow()` whose handler threw — the authz
281
+ escape this assertion exists to catch. A non-`UltimateError` (a `row:` loader's own
282
+ `TypeError`) is rethrown untouched too: its stack is the thing worth reading.
283
+ `contract-test.contract.test.ts` drives all three assertions against actions built to fail them.
284
+ **`X_AUDIT_SINK_MISSING` is the one code assertion 1 passes through as well**, for the same
285
+ reason: `auditSinkFor` runs *before* `validateInput`, so "the schema accepted garbage" is a
286
+ false statement about it and `tighten input:` is a fix that changes nothing. It is the only
287
+ refusal that precedes the parse — a second one is a design mistake, not a second entry here.
288
+ - **The audit seam ships the mechanism and none of the row.** `audit: true` on a declaration
289
+ wraps `execute` — it never forks it — so a **denied** attempt is recorded, which is the whole
290
+ reason this lives in the framework: `guard` throws before `handle`, so nothing an app writes
291
+ around its own handler could ever see one. What reaches the sink is what `invoke` already holds
292
+ (`at` from `ctx.now()`, the name, the mutator brand, the surface, the whole `ctx`, the parsed
293
+ input, the namespaced idempotency key, `replayed`, the outcome, the failure code). What does
294
+ **not** ship, ever: an audit entity, a schema, a retention policy, a storage backend, a hash
295
+ chain, a subject index, or an opinion on what "who" means under impersonation — four apps model
296
+ those four ways, so by axiom 8's own test they are business convention and shipping one makes
297
+ three of them wrong. `result` is absent for the same reason and one more: a handler's return is
298
+ reachable from the handler itself, so shipping it would be this package deciding a row carries
299
+ an after-image, which is `@ultimat3/admin`'s `diff` convention arriving one tier down.
300
+ - **The audit vocabulary is `@ultimat3/admin`'s, shared by name and not by import.** `AuditOutcome`
301
+ is the same three words (`allowed | denied | failed`) and `AuditSink` the same noun; `admin` is
302
+ tier 5 and this is tier 3, so there is no edge to share them over. **Known duplicate**: admin's
303
+ `AuditSink` writes a fixed `AuditEntry` requiring an `AdminActor` and a `permission`, and it is
304
+ called only from `admin/crud.ts`, so an action outside `/admin` still produces nothing there.
305
+ Unifying them means lifting the vocabulary into `@ultimat3/core` — the only tier both reach —
306
+ and rebuilding `admin/audit.ts` on this seam. Not done here: `admin` is a shipped public API
307
+ and its `AuditEntry` is a different shape.
308
+ - **A sink may not silently swallow, and the two failure policies are deliberate opposites.**
309
+ `X_AUDIT_SINK_MISSING` is raised *before* the input parse, so an audited action nothing can
310
+ record refuses with no committed write behind it — there is deliberately no logger-backed
311
+ default sink, because a line nobody stores satisfies the declaration while recording nothing.
312
+ A sink that refuses an **allowed** record fails the invocation (`X_AUDIT_SINK_FAILED`), which
313
+ is the inverse of `cache-gate.ts`'s absorb-and-log: a dropped cache entry expires by TTL and
314
+ the stack heals itself, while nothing ever re-derives an audit row that was never written. It
315
+ is post-commit all the same, so the cause says the handler already committed rather than
316
+ implying a rollback. **Its `fix:` branches on `record.idempotencyKey !== null` — the
317
+ INVOCATION's fact, never the declaration's `idempotent`.** A retry replays instead of re-running
318
+ only when this call reserved a record, and `invoke` reads
319
+ `def.idempotent === true ? (options.idempotencyKey ?? null) : null`, so a non-idempotent action
320
+ and an idempotent one whose caller sent no header collapse to the same `null`. The unqualified
321
+ "retry with the same Idempotency-Key" told a caller to apply a committed write twice — an
322
+ axiom-4 violation dressed as a fix line. Requiring `idempotent: true` at declaration was the
323
+ other candidate and was rejected: it would not have made the message true (the header is still
324
+ the caller's), and it would force the idempotency store on every app that wants only an audit
325
+ trail — "which writes must be retry-safe" is the app's call, not this package's.
326
+ `meta.replayable` carries the same fact to `--json`, and `audit.test.ts` pins both branches so
327
+ the text cannot drift back. A sink that refuses a **denied or failed** record is logged as
328
+ `audit.sink.failed` and the original error still reaches the caller — `X_AUDIT_SINK_FAILED`
329
+ there would hide the `X_FORBIDDEN` and would answer a probing client differently depending on
330
+ whether the audit backend was up, which is an oracle.
331
+ - **`auditSettled` sits outside the `catch`, not inside it.** Inside, its own
332
+ `X_AUDIT_SINK_FAILED` fell into the failure branch and wrote a *second* record saying the
333
+ action `failed` — for a handler that had committed. An audit trail lying about a write is
334
+ worse than no audit trail; `audit.test.ts` counts the records a refusing sink was offered.
335
+ - **`auditOutcomeFor` is TOTAL, because both callers ask inside a `catch`.**
336
+ `error instanceof ActionDeniedError` runs a `Proxy`'s `getPrototypeOf` trap, and the two call
337
+ sites are `execute`'s span attribute and the one place the `failed` record is produced — so a
338
+ handler throwing such a value made the probe throw *from the frame holding the app's error*,
339
+ and the caller got a `TypeError` in place of its own throwable. It fails closed to `failed`: a
340
+ value that refuses to be examined is not evidence of a policy denial. Same rule as core's
341
+ `isThrownError` / `isUltimateError`, and the same defect `@ultimat3/http`'s `finalize.ts` and
342
+ `factsOf` carried; `audit.test.ts` asserts the caller's throwable by IDENTITY, which is the only
343
+ assertion that catches a replacement.
344
+ - **`json-schema.ts`'s refusal names `introspect()`, never a `toJsonSchema` member.**
345
+ `SchemaProvider` declares no such member and `toJsonSchema()` calls `introspect()`
346
+ unconditionally, so the old `fix:` told a reader to implement an API that does not exist —
347
+ axiom 4 inverted, and invisible to `x verify`'s `errors` step, which checks a fix line's shape
348
+ and never whether the API it names is real. The guard is `normalizeJsonSchema`, exported from
349
+ the module for its own test and absent from `src/index.ts` exactly as `sortSchema` is; the
350
+ shipped `toJsonSchema` cannot reach it today (it returns an object literal on every path), so
351
+ the test drives the guard directly rather than pretending a converter can be swapped.
352
+ - App code reaches a projection through the action (`publishPost.tool()`), never through
353
+ `.def` and never by importing the projection function. `facade.ts` is where a new method
354
+ is bound; the projection itself keeps living in its own file.
355
+ - A mutator projects the three names it was authored with — `.local`, `.server`, `.conflict` —
356
+ plus every action façade member, through `named()` and registration alike. No aliases: the
357
+ old `.applyLocal` is gone, not deprecated.
358
+ - `mutator.server()` calls the action's own callable, so it lands in `invoke` like every other
359
+ surface. Reaching the declared `server` from there is the second execution path this package
360
+ exists to prevent. `.local()` is the one member that skips the core — it never leaves the
361
+ client, so there is nothing to authorize.
362
+ - Registration names the action the app exported, in place — `import { publishPost }` is
363
+ projectable after boot. Naming an already-named action is the only case that twins.
364
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an action file imports
365
+ one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
366
+ access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
367
+ - `defineApi` is the app's registration call; `registerActions` is what it composes. It reaches
368
+ `@ultimat3/query`'s and `@ultimat3/jobs`' registries through core's registrar table
369
+ (`primitiveRegistrar('query' | 'job' | 'task')`), never a sideways import — and throws
370
+ `X_REGISTRAR_MISSING` rather than skipping a kind whose registrar is absent, because a silent
371
+ skip drops every primitive of that kind.
372
+ - **`jobs` and `tasks` belong in the same call, for the same reason `queries` do.** The export
373
+ name becomes the job's durable queue key; a job module nothing hands over keeps `job()`'s
374
+ positional `anonymous-job-<n>`, on every queue row and in the manifest. A definition with its
375
+ own `name:` keeps it — that rule lives in `@ultimat3/jobs`, not here. Jobs register before
376
+ tasks: a task descriptor lists the jobs it enqueues by name.
377
+ - `defineApi`'s returned maps are built from the **registrar's own results**, never from the
378
+ modules' exports. A feature module exports helpers next to its primitives; copying every export
379
+ would seat one in `Api['actions']` as a client method nothing serves, and let two modules'
380
+ same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the
381
+ same filter, so `rpc<Api['actions']>()` offers only what registered.
382
+ - `rpc` is the only name for the map-wide typed client. There is no `createClient` alias.
383
+ - **`registerAction` guards the derived PATH as well as the name.** `X_ACTION_DUPLICATE` only ever
384
+ asked about the name, so `archiveOrder` and `archiveOrders` — one route, by `pluralize`'s
385
+ deliberate "a trailing `s` is already plural" rule — both registered and both projected: the
386
+ router table seated whichever came last and the other was unreachable over HTTP while its
387
+ OpenAPI operation and MCP tool still advertised it. `paths` is a second index, cleared by
388
+ `resetRegistry` with the first, and the refusal is `X_ACTION_PATH_DUPLICATE`.
389
+ - No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
390
+ - `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock.
391
+ - `client.ts` stays free of server imports — it is bundled into the browser.
392
+ - Authz goes through `enforce(surface, policy, { input, actor, ctx })` from
393
+ `@ultimat3/policy`; a returned denial becomes `ActionDeniedError`, which keeps the
394
+ policy's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) and carries the surface
395
+ denial. `policy-gate.ts` is the only file with a **runtime** edge to the policy package;
396
+ `errors.ts` also imports it, `import type { SurfaceDenial }`, which `verbatimModuleSyntax`
397
+ erases — so there is still exactly one place authz is evaluated.
398
+
399
+ ## Commands
400
+
401
+ ```
402
+ bun test packages/action
403
+ bun run typecheck
404
+ ```