@ultimat3/action 1.1.0 → 2.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 +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +17 -10
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
- App code reaches a projection through the action (`publishPost.tool()`), never through
|
|
336
|
+
`.def` and never by importing the projection function. `facade.ts` is where a new method
|
|
337
|
+
is bound; the projection itself keeps living in its own file.
|
|
338
|
+
- A mutator projects the three names it was authored with — `.local`, `.server`, `.conflict` —
|
|
339
|
+
plus every action façade member, through `named()` and registration alike. No aliases: the
|
|
340
|
+
old `.applyLocal` is gone, not deprecated.
|
|
341
|
+
- `mutator.server()` calls the action's own callable, so it lands in `invoke` like every other
|
|
342
|
+
surface. Reaching the declared `server` from there is the second execution path this package
|
|
343
|
+
exists to prevent. `.local()` is the one member that skips the core — it never leaves the
|
|
344
|
+
client, so there is nothing to authorize.
|
|
345
|
+
- Registration names the action the app exported, in place — `import { publishPost }` is
|
|
346
|
+
projectable after boot. Naming an already-named action is the only case that twins.
|
|
347
|
+
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so an action file imports
|
|
348
|
+
one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on every
|
|
349
|
+
access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
350
|
+
- `defineApi` is the app's registration call; `registerActions` is what it composes. It reaches
|
|
351
|
+
`@ultimat3/query`'s and `@ultimat3/jobs`' registries through core's registrar table
|
|
352
|
+
(`primitiveRegistrar('query' | 'job' | 'task')`), never a sideways import — and throws
|
|
353
|
+
`X_REGISTRAR_MISSING` rather than skipping a kind whose registrar is absent, because a silent
|
|
354
|
+
skip drops every primitive of that kind.
|
|
355
|
+
- **`jobs` and `tasks` belong in the same call, for the same reason `queries` do.** The export
|
|
356
|
+
name becomes the job's durable queue key; a job module nothing hands over keeps `job()`'s
|
|
357
|
+
positional `anonymous-job-<n>`, on every queue row and in the manifest. A definition with its
|
|
358
|
+
own `name:` keeps it — that rule lives in `@ultimat3/jobs`, not here. Jobs register before
|
|
359
|
+
tasks: a task descriptor lists the jobs it enqueues by name.
|
|
360
|
+
- `defineApi`'s returned maps are built from the **registrar's own results**, never from the
|
|
361
|
+
modules' exports. A feature module exports helpers next to its primitives; copying every export
|
|
362
|
+
would seat one in `Api['actions']` as a client method nothing serves, and let two modules'
|
|
363
|
+
same-named helpers overwrite each other with no `X_ACTION_DUPLICATE` to raise. The type does the
|
|
364
|
+
same filter, so `rpc<Api['actions']>()` offers only what registered.
|
|
365
|
+
- `rpc` is the only name for the map-wide typed client. There is no `createClient` alias.
|
|
366
|
+
- **`registerAction` guards the derived PATH as well as the name.** `X_ACTION_DUPLICATE` only ever
|
|
367
|
+
asked about the name, so `archiveOrder` and `archiveOrders` — one route, by `pluralize`'s
|
|
368
|
+
deliberate "a trailing `s` is already plural" rule — both registered and both projected: the
|
|
369
|
+
router table seated whichever came last and the other was unreachable over HTTP while its
|
|
370
|
+
OpenAPI operation and MCP tool still advertised it. `paths` is a second index, cleared by
|
|
371
|
+
`resetRegistry` with the first, and the refusal is `X_ACTION_PATH_DUPLICATE`.
|
|
372
|
+
- No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
|
|
373
|
+
- `serializeOpenApi` output must be byte-stable: sorted keys, sorted registry, no clock.
|
|
374
|
+
- `client.ts` stays free of server imports — it is bundled into the browser.
|
|
375
|
+
- Authz goes through `enforce(surface, policy, { input, actor, ctx })` from
|
|
376
|
+
`@ultimat3/policy`; a returned denial becomes `ActionDeniedError`, which keeps the
|
|
377
|
+
policy's own code (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) and carries the surface
|
|
378
|
+
denial. `policy-gate.ts` is the only file with a **runtime** edge to the policy package;
|
|
379
|
+
`errors.ts` also imports it, `import type { SurfaceDenial }`, which `verbatimModuleSyntax`
|
|
380
|
+
erases — so there is still exactly one place authz is evaluated.
|
|
381
|
+
|
|
382
|
+
## Commands
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
bun test packages/action
|
|
386
|
+
bun run typecheck
|
|
387
|
+
```
|