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