@ultimat3/action 24.0.0 → 25.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 +65 -39
- package/README.md +124 -41
- package/package.json +10 -10
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +3 -3
- package/src/audit-postgres.ts +15 -6
- package/src/audit.ts +7 -106
- package/src/client.ts +18 -6
- package/src/contract-test.ts +18 -6
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +21 -3
- package/src/errors.ts +40 -62
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +29 -10
- package/src/idempotency-postgres.ts +27 -12
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +69 -9
- package/src/index.ts +33 -87
- package/src/invoke.ts +54 -19
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +2 -1
- package/src/mutator.ts +1 -2
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +2 -2
- package/src/sample-input.ts +35 -6
- package/src/stable.ts +0 -7
- package/src/transition.ts +23 -3
- package/src/tx-scope.ts +1 -1
- package/src/type-pins.ts +103 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -95
- package/src/wire-headers.ts +0 -20
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @ultimat3/action
|
|
2
2
|
|
|
3
|
-
Owns the `action` + `mutator` primitives and
|
|
3
|
+
Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP tool is
|
|
4
|
+
`@ultimat3/mcp`'s one projection (O-tool, 25.0.0).
|
|
4
5
|
|
|
5
6
|
## Boundary
|
|
6
7
|
|
|
@@ -27,57 +28,59 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
27
28
|
| `http-path.ts` | where an action is served: the app's `pathStyle` + a per-action `http.path` pin, read lazily by every projection |
|
|
28
29
|
| `path-style-miss.ts` | `explainActionPathMiss` — `@ultimat3/http`'s `hooks.explainMiss`: a POST under the style this app does not serve is `X_CONTRACT_DRIFT` naming the one it does |
|
|
29
30
|
| `api-declaration.ts` | `defineApi({ http, openapi })` held process-wide for the boot and `x manifest` |
|
|
30
|
-
| `errors-http.ts` | the three HTTP-declaration refusals
|
|
31
|
+
| `errors-http.ts` | the three HTTP-declaration refusals |
|
|
32
|
+
| `error-titles.ts` | every code's title, the one `registerErrorCodes` |
|
|
31
33
|
| `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
|
|
32
34
|
| `record-wire.ts` | the record envelope on the HTTP projection: `carriesRecords` (from the output schema), the enveloped 200, and its OpenAPI shape. Server-only — `client.ts` never imports it |
|
|
33
35
|
| `wire-issues.ts` | the ONE reader of a problem document's `issues` member — an untrusted array back into `@ultimat3/schema`'s `ValidationIssue` shape |
|
|
34
36
|
| `transition.ts` | `transition()`: a MUTATOR factory over one entity column's state machine. Declares no error code — entity's three propagate |
|
|
35
|
-
| — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`,
|
|
36
|
-
| `wire-headers.ts` | `BUILD_ID_HEADER` + `IDEMPOTENCY_HEADER`, and nothing else. Their own module so `client.ts` can name them without importing `http.ts` |
|
|
37
|
-
| `mcp-tool.ts` | MCP descriptor, same `invoke` |
|
|
37
|
+
| — | opt-in flight control is **`@ultimat3/core`**'s `client-flight.ts` + `client-wire.ts`, imported from core — `src/index.ts` re-exports only the `ClientFlight`/`ClientRetry` types. There is no local copy and must not be one |
|
|
38
38
|
| `job-handle.ts` | the `.job()` projection: an action as a queueable payload. **Not** consumed by `@ultimat3/jobs` — see Invariants |
|
|
39
39
|
| `contract-test.ts` | assertions `x g action` emits |
|
|
40
40
|
| `sample-input.ts` | a value `input:` accepts, from its own IR — what makes the policy assertion reach a policy |
|
|
41
41
|
| `idempotency.ts` | the store SEAM: types, the installed-store slot, the scope declaration + `assertIdempotencyScope`, and `withIdempotency` — the replay-or-run gate |
|
|
42
42
|
| `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 |
|
|
43
43
|
| `idempotency-memory.ts` | the process default: bounded, swept, `scope: 'process'` |
|
|
44
|
+
| `idempotency-redact.ts` | `restingAnswer` — the copy of an answer a store may keep: core's `isRedactedKey` + `isSecret`, the SAME reference when nothing is redacted |
|
|
44
45
|
| `idempotency-postgres.ts` | the SHARED store — one table, one `insert … on conflict` |
|
|
45
|
-
| `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
|
|
46
46
|
| `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
|
|
47
47
|
| `cache-gate.ts` | the post-COMMIT bust — **the only** file that calls `invalidateTags` |
|
|
48
48
|
| `tx-scope.ts` | the open transaction — **the only** file that imports `@ultimat3/db` (`tx-scope.test.ts`): `openCommitScope` (the bust) and `liveTransaction` (the settle) |
|
|
49
|
+
| `rate-limit-gate.ts` | **the one place** a declared `rateLimit:` is spent, and the installed-store slot |
|
|
49
50
|
| `request-deadline.ts` | `requestDeadlineMs` — the app's `requestTimeoutMs`, resolved by http's own `defineHttpConfig` |
|
|
50
|
-
| `audit.ts` | the audit seam
|
|
51
|
+
| `audit.ts` | the audit seam's TYPES, re-exported from `@ultimat3/core` (shared with `query`); the slot (`setAuditSink`) is core's alone |
|
|
51
52
|
| `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
|
|
52
53
|
| `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
|
|
53
54
|
| `audit-input.ts` | what may be written DOWN: an `input` redacted through core's table and made JSON-representable on every path |
|
|
54
55
|
| `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
|
|
55
56
|
| `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
|
|
56
|
-
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer
|
|
57
|
+
| `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer (core's `isJsonObject` is imported from core, never re-exported) — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
|
|
57
58
|
|
|
58
59
|
## Invariants — execution and authz
|
|
59
60
|
|
|
60
|
-
- Every surface goes through `invoke`: the policy's actor half (`
|
|
61
|
+
- Every surface goes through `invoke`: the policy's actor half (`guardActionBeforeInput`, 403 before the
|
|
61
62
|
parse), parse input, evaluate policy, handle, parse output. A second
|
|
62
63
|
execution path is the one unforgivable change here.
|
|
63
64
|
- **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
|
|
64
|
-
`@ultimat3/entity`'s tenant guard reads) must be the identity `
|
|
65
|
+
`@ultimat3/entity`'s tenant guard reads) must be the identity `guardAction()` decided about.
|
|
65
66
|
`invoke-context.test.ts` asserts ambient, `options.actor` and `options.ctx` are one caller.
|
|
66
67
|
- The declaration never leaves `invoke.ts`: `defOf`/`stashDef` are never re-exported from
|
|
67
68
|
`src/index.ts` (`index.test.ts`). An action has no `.def`; outside, read `.input`/`.output`/
|
|
68
69
|
`.policy`/`.mcp` or `describe()`. App code reaches a projection through the action
|
|
69
|
-
(`publishPost.
|
|
70
|
+
(`publishPost.openapi()`); new methods are bound in `facade.ts`. **No `.tool()`**: it was a second
|
|
71
|
+
MCP projection beside mcp's `toolFrom` (`index.test.ts`, `type-pins.ts`).
|
|
70
72
|
- **`toRoute` sets `enforcedBy: 'handler'`** — `invoke` is the one evaluation and the only one holding
|
|
71
73
|
the row `def.row` loaded; `meta.policy` stays set. `http.test.ts` counts exactly one evaluation.
|
|
72
|
-
- **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree,
|
|
73
|
-
|
|
74
|
+
- **`meta.auth` comes from `@ultimat3/policy`'s `admitsAnonymous`** (a walk of the tree, imported
|
|
75
|
+
from `@ultimat3/policy` by `http.ts`; 25.0.0 dropped this package's re-export, `index.test.ts`
|
|
76
|
+
pins the absence), never the root combinator — never a copy here (`@ultimat3/query` needs the same
|
|
74
77
|
answer).
|
|
75
78
|
- Authz goes through `enforce(surface, policy, { input, actor, ctx })`; a denial becomes
|
|
76
79
|
`ActionDeniedError`, keeping the policy's code. `policy-gate.ts` is the only RUNTIME edge to
|
|
77
80
|
`@ultimat3/policy` (`errors.ts`'s `import type { SurfaceDenial }` erases).
|
|
78
81
|
- No policy at registration → `X_ACTION_POLICY_MISSING`. No exceptions, no flag.
|
|
79
82
|
- **`registerAction` guards the derived PATH as well as the name** (`X_ACTION_PATH_DUPLICATE`; a
|
|
80
|
-
second index cleared by `
|
|
83
|
+
second index cleared by `resetActions`).
|
|
81
84
|
- **A path is the app's `pathStyle` or the action's pin, never a third answer** (`http-path.ts`).
|
|
82
85
|
`'resource'` stays the default (changing it moves every URL). `defineApi` sets the style FIRST and
|
|
83
86
|
`configureActionPathStyle` re-derives every seated action (the module scan may have seated them
|
|
@@ -117,7 +120,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
117
120
|
- **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
|
|
118
121
|
codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
|
|
119
122
|
`audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
|
|
120
|
-
`t.uuid
|
|
123
|
+
`t.uuid`; `row` goes to `mutator()` as declared, never loaded here (#687).
|
|
121
124
|
- **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
|
|
122
125
|
`sample-input.ts`) — caller- or provider-supplied keys.
|
|
123
126
|
|
|
@@ -152,11 +155,21 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
152
155
|
`json-schema.ts`). The HASH form is core's `canonicalJson`/`fingerprint` (injective: tags
|
|
153
156
|
`NaN`/`±Infinity`/`-0`, `Date`, `Map`, `Set`); `stable.test.ts` pins byte-equality for ordinary
|
|
154
157
|
payloads.
|
|
155
|
-
- **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (
|
|
156
|
-
`X_RATE_LIMIT_INVALID`). Never restore a local copy
|
|
157
|
-
- **`rateLimit:`
|
|
158
|
-
(`
|
|
159
|
-
|
|
158
|
+
- **`tagKeys` is `@ultimat3/cache`'s and `toBucket` is `@ultimat3/http`'s** (imported, never
|
|
159
|
+
re-exported; `toBucket` raises `X_RATE_LIMIT_INVALID`). Never restore a local copy.
|
|
160
|
+
- **`rateLimit:` is spent ONCE, inside `invoke`** (`rate-limit-gate.ts`), so HTTP, MCP, the agent
|
|
161
|
+
tool and `.job()` share one bucket; `surface: 'server'` spends nothing. `toRoute` sets
|
|
162
|
+
`rateLimitedBy: 'handler'` and NO `meta.rateLimit`/`rateLimitBucket` — the stage spends neither
|
|
163
|
+
the action's bucket (the old HTTP-only point) nor `default`. Key `action:<name>|<subject>`.
|
|
164
|
+
HTTP gets `RateLimit-*` via `onRateLimit` → http's `publishRateLimit`. The store is
|
|
165
|
+
`@ultimat3/http`'s installed one, adopted by the boot on every role. Spent after
|
|
166
|
+
`guardActionBeforeInput`, BEFORE the parse and `def.row()` (an input/row-denied flood is refused). A key
|
|
167
|
+
the idempotency store holds spends nothing (replay); a reservation `withIdempotency` CREATES that
|
|
168
|
+
the peek did not pay for spends in `beforeRun`, released on refusal. A job run is charged to its
|
|
169
|
+
org, else `job:unattributed`. A surface's `clientAddress` is inherited by nested `invoke`s
|
|
170
|
+
(`withCallerAddress`); a `'job'` invoke opens a fresh frame (`withJobFrame`) so no visitor's
|
|
171
|
+
address follows work into the worker, and calls nested in it are the job's.
|
|
172
|
+
`rate-limit-surfaces.test.ts`. The computed rate must be finite and `limit` at least one token.
|
|
160
173
|
- **`deprecated:` is a compat WINDOW** — headers on every response including failures, a
|
|
161
174
|
`rel="successor-version"` link via `derivePath`, `deprecated: true` in OpenAPI,
|
|
162
175
|
`deprecated_calls_total`. Rendered ONCE at projection (`X_ACTION_DEPRECATION_INVALID` at mount).
|
|
@@ -164,11 +177,11 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
164
177
|
- **The span wraps `execute` whole** (including `def.row()`), with bounded attributes plus the
|
|
165
178
|
namespaced idempotency key (never a metric label). `telemetry.test.ts`.
|
|
166
179
|
- **`policyCapability` is a display label; `policyPermissions` (`ActionDescriptor.permissions`) is
|
|
167
|
-
what a report matches on** (a `not()` clause contributes its permissions).
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
what a report matches on** (a `not()` clause contributes its permissions). Both are
|
|
181
|
+
`@ultimat3/policy`'s, imported from there.
|
|
182
|
+
- **MCP exposure is core's `isMcpExposed`** (`describeAction`, `x-ultimate.mcpTool`); **a tool's
|
|
183
|
+
NAME is the export name verbatim** (`mcp-surface.test.ts`). `ActionFact.mcp` in the app manifest
|
|
184
|
+
carries only `{ expose, description? }`.
|
|
172
185
|
- **`ActionJobHandle` is not consumed by `@ultimat3/jobs`** (`isJobHandle` needs `job()`'s private
|
|
173
186
|
map). `.job()` gives `action:<name>` as a queue key, a payload-derived idempotency key and an
|
|
174
187
|
`invoke` under `surface: 'job'`. The bridge is `agentJob()` in `@ultimat3/ai`, or an app's own
|
|
@@ -176,24 +189,25 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
176
189
|
|
|
177
190
|
## Invariants — flight control
|
|
178
191
|
|
|
179
|
-
- **Flight control is `@ultimat3/core`'s, re-exported** —
|
|
180
|
-
|
|
181
|
-
retry loop here (`bun run flight-copies`).
|
|
192
|
+
- **Flight control is `@ultimat3/core`'s, and NOT re-exported** — its values are imported from
|
|
193
|
+
core (`X_HELPER_COPY` refuses a re-export); only the types `ClientFlight`/`ClientRetry`. Fix the
|
|
194
|
+
pipeline in `packages/core/src/client-flight.ts`. No new code, curve, fence or retry loop here (`bun run flight-copies`).
|
|
182
195
|
- **`isTransientFailure` INVERTS `retryDecision`'s unclassified default** (a caller's `AbortError`
|
|
183
196
|
is terminal). Must survive.
|
|
184
197
|
- **`ClientFlight` is a TYPE inside `client.ts`, never a value.** Measured,
|
|
185
198
|
`bun build --target=browser --minify`, one entry importing from `@ultimat3/action` — **the ONE
|
|
186
199
|
table for these figures** (`packages/core/CLAUDE.md` points here):
|
|
187
200
|
|
|
188
|
-
| Entry |
|
|
189
|
-
|
|
190
|
-
| `rpc` |
|
|
191
|
-
| `rpc` + `
|
|
201
|
+
| Entry | `As of 2026-10-01` |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `rpc` | 19,671 B (+181 B for the document's path-style stamp) |
|
|
204
|
+
| `rpc` + core's `clientFlight` | 25,954 B |
|
|
192
205
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
Earlier columns (pre-transport 18,097 B, +977 B net for the envelope decoder) are in git history.
|
|
207
|
+
- **`sideEffects` is `["./src/error-titles.ts"]`**, never `false` (drops its bare imports): the
|
|
208
|
+
titles ride into every barrel chunk (`problemError` decodes by code), `errors.ts`'s classes do
|
|
209
|
+
not. An http refusal needs no http module: core's decoders take its title off the body and its
|
|
210
|
+
`retry-after` off the header (`rpc-refusal.test.ts`).
|
|
197
211
|
|
|
198
212
|
## Invariants — idempotency
|
|
199
213
|
|
|
@@ -208,8 +222,17 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
208
222
|
inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
|
|
209
223
|
never a gate.
|
|
210
224
|
- **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
|
|
211
|
-
`settle(key, value, reservationId)` / `fail(key, failure, reservationId)
|
|
212
|
-
never thrown.
|
|
225
|
+
`settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)` /
|
|
226
|
+
`release(key, reservationId)`. A fenced no-op is never thrown.
|
|
227
|
+
- **An answer rests REDACTED** (#591): `withIdempotency` passes the store `restingAnswer(value)`
|
|
228
|
+
(core's `isRedactedKey` + `isSecret` — never a second rule) and `redacted` as `settle`'s
|
|
229
|
+
REQUIRED 4th argument; both stores keep it (`x_idempotency.redacted`, memory's record field).
|
|
230
|
+
A flagged replay is `X_IDEMPOTENT_REPLAY_REDACTED`, decided by the FLAG, never by scanning for
|
|
231
|
+
`[redacted]`. Plain data is the same reference, replayed as before; anything else that
|
|
232
|
+
`JSON.stringify` would run app code on (`toJSON`, `Map`/`Set`, class instances, `Date`
|
|
233
|
+
subclasses) is redacted FAIL-CLOSED, and a getter is read once. `keepsRedaction: true` is a
|
|
234
|
+
required store declaration (`type-pins.ts`: a pre-25 store is a build error — a required
|
|
235
|
+
parameter alone is not). A reclaim and a `fail` reset the flag.
|
|
213
236
|
- **A stored status is NARROWED** (`isIdempotencyStatus`; unknown is
|
|
214
237
|
`X_IDEMPOTENCY_STATUS_UNKNOWN`), never cast.
|
|
215
238
|
- **Where records live is DECLARED and refused at registration**: `IdempotencyStore.scope` vs
|
|
@@ -248,6 +271,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
248
271
|
DENIED attempt is recorded. No audit entity, retention, hash chain or "who" convention. The
|
|
249
272
|
vocabulary matches `@ultimat3/admin`'s by name (tier 5, no edge); admin's `AuditSink` is a known
|
|
250
273
|
duplicate that unifying would need core for.
|
|
274
|
+
- **Records carry `name` + `primitive`, required** (`type-pins.ts`); no `action` alias since 25.0.0.
|
|
275
|
+
The `x_audit.action` column holds `name`.
|
|
251
276
|
- **The memory sink DROPS** — a ring at `DEFAULT_MAX_AUDIT_RECORDS`, oldest first, counting `dropped`;
|
|
252
277
|
no spelling of "unbounded".
|
|
253
278
|
- **A durable sink writes what `audit-input.ts` allows**: input redacted through core's
|
|
@@ -257,7 +282,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
257
282
|
`locale`, `tz`, `buildId`, `role`, actor `id`/`kind`/`orgId`/`onBehalfOf`) and keeps
|
|
258
283
|
`failure.code`, never the error.
|
|
259
284
|
- **`x_audit` ships no purge**, deliberately. **`SQL_AUDIT_INSERT` is positional**, pinned by
|
|
260
|
-
`audit-parity.test.ts` with a distinct value per field.
|
|
285
|
+
`audit-parity.test.ts` with a distinct value per field. Only `*_TABLE` DDL is exported
|
|
286
|
+
(`X_SQL_EXPORT_UNREAD`).
|
|
261
287
|
- **The two failure policies are opposites**: `X_AUDIT_SINK_MISSING` before the input parse (no
|
|
262
288
|
logger-backed default sink); a sink refusing an ALLOWED record is `X_AUDIT_SINK_FAILED`, whose `fix:`
|
|
263
289
|
branches on `record.idempotencyKey !== null` (`meta.replayable`); a sink refusing a denied or failed
|
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# @ultimat3/action ⚡
|
|
2
2
|
|
|
3
|
-
One declaration → six artifacts.
|
|
3
|
+
One declaration → six artifacts. Five are projected here; the MCP tool is `@ultimat3/mcp`'s
|
|
4
|
+
(tier 4), from the same declaration.
|
|
4
5
|
|
|
5
6
|
| # | Artifact | Reach it with | Guarantees |
|
|
6
7
|
|---|---|---|---|
|
|
7
8
|
| 1 | HTTP route `POST /api/<resource>/<verb>` | `toRoute(publishPost)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional |
|
|
8
9
|
| 2 | OpenAPI 3.1 operation + document | `publishPost.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` |
|
|
9
10
|
| 3 | Typed RPC client | `publishPost.client({ baseUrl })` / `rpc<Api['actions']>()` | server typo = compile error in Solid |
|
|
10
|
-
| 4 | MCP tool | `publishPost
|
|
11
|
+
| 4 | MCP tool | `toolFrom(publishPost)` from `@ultimat3/mcp` — what `tools/list` serves | *identical* policy evaluation to the route: it calls this package's `invoke` with `surface: 'mcp'` |
|
|
11
12
|
| 5 | Job handle | `publishPost.job()` | enqueue durable work, no rewrite |
|
|
12
13
|
| 6 | Contract tests | `publishPost.contract()` | garbage rejected, anonymous denied, spec present |
|
|
13
14
|
|
|
@@ -22,16 +23,17 @@ publishPost.output // the declared output schema
|
|
|
22
23
|
publishPost.policy // the one policy object
|
|
23
24
|
publishPost.mcp // { expose, description }, as declared
|
|
24
25
|
await publishPost.as(actor, { postId }) // run as someone, one execution path
|
|
25
|
-
publishPost.tool() // MCP descriptor
|
|
26
26
|
publishPost.openapi() // OpenAPI operation
|
|
27
27
|
publishPost.client({ baseUrl }) // typed RPC method
|
|
28
28
|
publishPost.job() // durable-work handle
|
|
29
29
|
publishPost.contract() // the three generated assertions
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
There is no `.tool()` (`As of 25.0.0`): it was a second MCP projection built a tier below the
|
|
33
|
+
one `@ultimat3/mcp` serves, and the two disagreed. The tool an agent is shown is
|
|
34
|
+
`toolFrom(publishPost)`, and its every call lands in this package's `invoke` — the one
|
|
35
|
+
policy object, so an MCP call cannot reach a different authz path. `.as()` keeps the
|
|
36
|
+
surrounding context whole and swaps only the actor: impersonation, not a second context.
|
|
35
37
|
|
|
36
38
|
## Declare
|
|
37
39
|
|
|
@@ -133,7 +135,7 @@ returned none, so one operation has one wire shape. Every other action's body is
|
|
|
133
135
|
what it was before the envelope existed, and its OpenAPI operation is unchanged; an enveloped one
|
|
134
136
|
documents `data`, `records` and `removed`, and the header, generated from the declaration.
|
|
135
137
|
|
|
136
|
-
### Flight control — `
|
|
138
|
+
### Flight control — `clientFlight`, opt-in
|
|
137
139
|
|
|
138
140
|
Same object as `@ultimat3/query`'s, installed the same way (`rpc({ baseUrl, flight })`), and the
|
|
139
141
|
write half is the half made of refusals:
|
|
@@ -154,13 +156,14 @@ declare const orderId: string;
|
|
|
154
156
|
await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { attempts: 3 } });
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
`
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
`clientFlight` is **`@ultimat3/core`'s**, and imported from there — `import {
|
|
160
|
+
clientFlight } from '@ultimat3/core'`. It shipped as a byte-identical copy here and in
|
|
161
|
+
`@ultimat3/query` (both tier 3, neither may import the other); the copies are gone, and since 25.0.0
|
|
162
|
+
so are the re-exports: one value, one import path (`X_HELPER_COPY`, `bun run flight-copies`). The
|
|
163
|
+
types `ClientFlight` and `ClientRetry` stay re-exported, because `rpc`'s options name them.
|
|
161
164
|
|
|
162
165
|
Importing `rpc` alone from this package is **19,671 B** minified for the browser; adding
|
|
163
|
-
`
|
|
166
|
+
`clientFlight` (measured through this barrel, before 25.0.0 moved the import to core) is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
|
|
164
167
|
what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
|
|
165
168
|
keeps the second number off the first caller's bill.
|
|
166
169
|
|
|
@@ -252,14 +255,14 @@ Under the default style:
|
|
|
252
255
|
| `likePost` | `POST /api/posts/like` | `likePost` |
|
|
253
256
|
| `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
|
|
254
257
|
|
|
255
|
-
**One name,
|
|
258
|
+
**One name, three surfaces** — `openapi.json`'s `x-ultimate.mcpTool`,
|
|
256
259
|
`describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the
|
|
257
260
|
`actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
|
|
258
|
-
serves. It was two until 2026-08: a `toToolName()` here snake_cased the
|
|
261
|
+
serves. It was two until 2026-08: a `toToolName()` here snake_cased the published ones to
|
|
259
262
|
`publish_post` while the server answered only `publishPost`, so an agent that read the published
|
|
260
263
|
contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second
|
|
261
|
-
derivation is a second name. `mcp-
|
|
262
|
-
|
|
264
|
+
derivation is a second name. `mcp-surface.test.ts` here and `@ultimat3/mcp`'s
|
|
265
|
+
`cross-surface.test.ts` are what keep it that way.
|
|
263
266
|
|
|
264
267
|
`x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so
|
|
265
268
|
the manifest never carried a tool name and was never wrong about one.
|
|
@@ -277,7 +280,7 @@ no `.def`. A second authz path cannot be written without deleting that store.
|
|
|
277
280
|
|
|
278
281
|
| Stage | Failure |
|
|
279
282
|
|---|---|
|
|
280
|
-
| policy, actor half (`
|
|
283
|
+
| policy, actor half (`guardActionBeforeInput`) | `X_UNAUTHENTICATED` / `X_FORBIDDEN` for a caller the policy refuses whatever the input — never a 400 describing the schema of an operation they may not call |
|
|
281
284
|
| parse input | `X_INPUT_INVALID` |
|
|
282
285
|
| evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
|
|
283
286
|
| handle | whatever the handler throws |
|
|
@@ -308,8 +311,8 @@ no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FORE
|
|
|
308
311
|
|
|
309
312
|
## mutator = action + local twin
|
|
310
313
|
|
|
311
|
-
`mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets
|
|
312
|
-
|
|
314
|
+
`mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets every
|
|
315
|
+
projection an action has; it adds `local(tx, input)` for the optimistic write and a `conflict`
|
|
313
316
|
strategy for the rebase.
|
|
314
317
|
|
|
315
318
|
```ts
|
|
@@ -407,6 +410,7 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
|
|
|
407
410
|
| `id` is the entity's **key**, read off `output.id` | a uuid key stays `t.uuid` (a malformed one is `X_INPUT_INVALID` before a database reads it); a `text()` key keeps its length and a `bigint()` key its digits pattern — never a fixed `t.uuid`, which refused every move on an entity keyed by anything else. An `output` that does not carry the key as `id` passes the key's schema as `id:`; with neither, the id is a uuid |
|
|
408
411
|
| `idempotent: true`, always | a move replayed under its `Idempotency-Key` answers the first outcome, not an `X_STATE_CONFLICT` for a move that already happened |
|
|
409
412
|
| `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
|
|
413
|
+
| a row-level `policy` gets its row from **`row`** | the action's own loader, handed through: `row: ({ input, ctx }) => posts(ctx).find(input.id)`, then `can<TransitionValues<S>, Post>('post:move', ({ actor, row }) => row !== null && row.authorId === actor?.id)`. Loaded once, after the input parse, before the guard and the statement; the factory reads no row itself. Omitted, the rule sees `row: null` and must fail closed. **Check-then-act**, as every row-policy action's: the read is unlocked and outside any transaction, and the move's predicate is `id` + `from` only, so a concurrent change to a column the rule read (an `authorId` reassigned in between) is not seen. Where that matters, write an `action` whose handler opens `withTransaction`, reads the row `for update`, decides and calls `Table.transition` inside it |
|
|
410
414
|
| `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
|
|
411
415
|
|
|
412
416
|
`table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
|
|
@@ -427,17 +431,43 @@ import type { ActionRateLimit } from '@ultimat3/action';
|
|
|
427
431
|
const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
|
|
428
432
|
```
|
|
429
433
|
|
|
430
|
-
One declaration,
|
|
431
|
-
|
|
432
|
-
`
|
|
434
|
+
One declaration, one bucket, every surface. `invoke` spends it — so the HTTP route, the MCP tool,
|
|
435
|
+
the in-app agent's tool call and `.job()` draw on the **same** bucket, and the third call to a
|
|
436
|
+
`limit: 2` action is refused `X_RATE_LIMITED` whichever door it came through. A direct in-process
|
|
437
|
+
call (`surface: 'server'` — a service, a seed, a test) is app code, not a caller, and spends nothing.
|
|
438
|
+
|
|
439
|
+
| Fact | Answer |
|
|
440
|
+
|---|---|
|
|
441
|
+
| whose bucket | the caller's: actor → org → connection address (`@ultimat3/http`'s `rateLimitSpends`). An anonymous caller with no address (MCP, a job) shares one bucket |
|
|
442
|
+
| where it is counted | `@ultimat3/http`'s installed store (`installRateLimitStore()`) — the slot `@ultimat3/query` spends from too; the boot fills it with the SAME instance it gives `httpServer({ rateLimitStore })`, on every role. Default: one process' memory |
|
|
443
|
+
| what HTTP answers | `RateLimit-*` from the bucket closest to refusing, and on a refusal `429` + `Retry-After` — the pipeline's own shape |
|
|
444
|
+
| a retry | an admitted attempt spends one token; a refused one spends nothing. A job refused `X_RATE_LIMITED` is rescheduled for its `Retry-After` WITHOUT counting an attempt, so a backlog is delayed, never dead-lettered |
|
|
445
|
+
| an idempotent replay | spends nothing: the stored answer is served, never a 429 |
|
|
446
|
+
| who a job run is | its actor, else its tenant (`org:<orgId>`); a run with neither shares one `job:unattributed` bucket, never the anonymous callers' |
|
|
447
|
+
| OpenAPI | `x-ultimate.rateLimit`, and with `defineApi({ openapi })` the `RateLimit-*` headers and `429` |
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
451
|
+
import { installRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
|
|
452
|
+
|
|
453
|
+
declare const executor: PgExecutor; // the pool this boot already opened
|
|
454
|
+
|
|
455
|
+
// Boot, before the first call — the one store every surface and every replica counts in.
|
|
456
|
+
// `x dev` and a container boot do this themselves, and `httpServer({ rateLimitStore })` adopts
|
|
457
|
+
// the store it is handed; a process with no server (a worker of your own) installs it here.
|
|
458
|
+
const rateLimitStore = postgresRateLimitStore({ executor });
|
|
459
|
+
installRateLimitStore(rateLimitStore);
|
|
460
|
+
```
|
|
461
|
+
|
|
433
462
|
`toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
|
|
434
463
|
— so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
|
|
435
|
-
beside `Bucket` and the limiter maths, and is re-exported here
|
|
436
|
-
conversion and is the same tier, so a
|
|
437
|
-
other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
464
|
+
beside `Bucket` and the limiter maths, and is imported from there — never re-exported here (25.0.0
|
|
465
|
+
dropped that second path): `@ultimat3/query` needs the same conversion and is the same tier, so a
|
|
466
|
+
copy in either package would be a second answer for the other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. The route says
|
|
467
|
+
`rateLimitedBy: 'handler'`, so the pipeline's stage spends no caller bucket for it — not `default`,
|
|
468
|
+
which would cap an action declaring more than it — and only the tenant allowance. An
|
|
469
|
+
`http.rateLimit.buckets.<actionName>` entry is a different table: it neither conflicts with nor
|
|
470
|
+
loosens what the action declared.
|
|
441
471
|
|
|
442
472
|
## `idempotent:` — and where its records live
|
|
443
473
|
|
|
@@ -455,12 +485,47 @@ interpolation broke. Omit the header to run un-keyed; the published `maxLength:
|
|
|
455
485
|
by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
|
|
456
486
|
public idempotent action still share a key space: a UUID key is what keeps them apart.
|
|
457
487
|
|
|
458
|
-
**A failed first attempt is replayed too, not re-run.** `
|
|
488
|
+
**A failed first attempt is replayed too, not re-run.** `guardAction()` and the input parse both happen
|
|
459
489
|
*before* the idempotency gate, so everything it can see throw is post-authorization and possibly
|
|
460
490
|
post-commit: a handler that took the money and then failed its own `output:` schema is the case.
|
|
461
491
|
The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
|
|
462
492
|
code. Releasing it there is what made idempotency the cause of a double charge.
|
|
463
493
|
|
|
494
|
+
**A credential in the answer is never kept at rest.** The record lives a day, and a mutator is
|
|
495
|
+
always idempotent — so a "create an API key" mutator would otherwise leave the plaintext key in
|
|
496
|
+
`x_idempotency.value`. On settle, every value under a key `@ultimat3/core`'s `isRedactedKey` names
|
|
497
|
+
(the table the logger and the audit row use, extended by `defineEnv({ secret: true })`) and every
|
|
498
|
+
`Secret` box is stored as `[redacted]`, at any depth, and the record is flagged `redacted`. The
|
|
499
|
+
first caller still gets the whole answer. A replay of a flagged record is
|
|
500
|
+
`X_IDEMPOTENT_REPLAY_REDACTED` (409) rather than `[redacted]` served as the answer: the first call
|
|
501
|
+
RAN, so read its effect with a query — a fresh key runs the mutation again. An answer with no such
|
|
502
|
+
key is stored and replayed exactly as before — plain data (plain objects, arrays, `Date`,
|
|
503
|
+
primitives) comes back as the same reference. **What the walk cannot vouch for is redacted too**:
|
|
504
|
+
a value with a `toJSON` method, a `Map`, a `Set`, a class instance or a `Date` subclass, because
|
|
505
|
+
`JSON.stringify` would run app code the walk never saw; an own getter is read once and the stored
|
|
506
|
+
copy holds that read. A custom `IdempotencyStore` receives the redacted copy, must declare
|
|
507
|
+
`keepsRedaction: true` (a store without it is a compile error), and must keep `settle`'s required
|
|
508
|
+
fourth argument and hand it back as `IdempotencyRecord.redacted`:
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
import type { IdempotencyStore } from '@ultimat3/action';
|
|
512
|
+
|
|
513
|
+
// A store that wraps another — a metrics or tracing decorator — passes the flag through.
|
|
514
|
+
export function counted(inner: IdempotencyStore, onSettle: () => void): IdempotencyStore {
|
|
515
|
+
return {
|
|
516
|
+
scope: inner.scope,
|
|
517
|
+
keepsRedaction: true,
|
|
518
|
+
reserve: (key, requestHash) => inner.reserve(key, requestHash),
|
|
519
|
+
settle: (key, value, reservationId, redacted) => {
|
|
520
|
+
onSettle();
|
|
521
|
+
return inner.settle(key, value, reservationId, redacted);
|
|
522
|
+
},
|
|
523
|
+
release: (key, reservationId) => inner.release(key, reservationId),
|
|
524
|
+
get: (key) => inner.get(key),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
464
529
|
**The stored payload fingerprint is keyed.** `requestHash` is `keyedFingerprint(input, …)` from
|
|
465
530
|
`@ultimat3/core`: `h1:<key id>:<HMAC-SHA-256/128>` under a key derived from the app's signing
|
|
466
531
|
secret (`ULTIMATE_CURSOR_SECRET` / `configureCursorSigning`), never a bare hash — the row lives a
|
|
@@ -511,15 +576,16 @@ opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is
|
|
|
511
576
|
development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
|
|
512
577
|
.purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
|
|
513
578
|
|
|
514
|
-
**A plain mutating `route` can use the same gate.** `withIdempotency`, `
|
|
515
|
-
`
|
|
516
|
-
|
|
579
|
+
**A plain mutating `route` can use the same gate.** `withIdempotency`, `idempotencyKeyFor` and
|
|
580
|
+
`getIdempotencyStore` are public here and `IDEMPOTENCY_HEADER` is `@ultimat3/core`'s (imported from
|
|
581
|
+
core — 25.0.0 dropped this package's re-export, and `BUILD_ID_HEADER`'s with it), so a route that is
|
|
582
|
+
not an action reserves and replays through the one implementation rather than growing a second:
|
|
517
583
|
|
|
518
584
|
```ts
|
|
519
585
|
const key = req.header(IDEMPOTENCY_HEADER);
|
|
520
586
|
// No header is the caller declining idempotency; a BLANK one is not, and
|
|
521
587
|
// `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
|
|
522
|
-
if (key === null) return
|
|
588
|
+
if (key === null) return jsonResponse(await refund(input));
|
|
523
589
|
const outcome = await withIdempotency(
|
|
524
590
|
getIdempotencyStore(),
|
|
525
591
|
// Namespaced by action AND actor: otherwise two routes share one caller's key, and two
|
|
@@ -536,6 +602,14 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
|
|
|
536
602
|
so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
|
|
537
603
|
reservation.
|
|
538
604
|
|
|
605
|
+
`release` takes it too (`As of 2026-10-07`): it is the pre-handler cleanup when `beforeRun` refuses,
|
|
606
|
+
and keyed alone it DELETED whatever record the key held — a replacement's reservation, or its
|
|
607
|
+
settled answer, so the next retry re-ran a handler that had already committed. A release that
|
|
608
|
+
matches nothing does nothing (`idempotency-release.test.ts`, both stores). **A custom store's
|
|
609
|
+
`release(key, reservationId)` must delete only the record whose id is `reservationId` and whose
|
|
610
|
+
status is `in-flight`** — one that ignores the second argument reopens the double-run window, and
|
|
611
|
+
type-checking cannot catch it: a one-parameter function still satisfies the signature.
|
|
612
|
+
|
|
539
613
|
**Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
|
|
540
614
|
`idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
|
|
541
615
|
inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
|
|
@@ -561,7 +635,7 @@ A `query` has none and never will: a read has nothing to be idempotent about.
|
|
|
561
635
|
## `deprecated:` — a compat window, not a version
|
|
562
636
|
|
|
563
637
|
```ts
|
|
564
|
-
import type { Deprecation } from '@ultimat3/
|
|
638
|
+
import type { Deprecation } from '@ultimat3/core';
|
|
565
639
|
|
|
566
640
|
// The `deprecated:` key of an `action()`.
|
|
567
641
|
const deprecated: Deprecation = {
|
|
@@ -578,6 +652,9 @@ operation, and a `deprecated_calls_total{primitive,name}` counter — which is t
|
|
|
578
652
|
answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
|
|
579
653
|
`X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
|
|
580
654
|
|
|
655
|
+
`Deprecation`, `renderDeprecation` and `recordDeprecatedCall` are `@ultimat3/core`'s and are
|
|
656
|
+
imported from there — this package does not re-export them (`As of 25.0.0`).
|
|
657
|
+
|
|
581
658
|
**Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
|
|
582
659
|
action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
|
|
583
660
|
no router feature, no path prefix and no second registry. What ships is the window: a date, a
|
|
@@ -591,7 +668,7 @@ price change are not the same event, and the framework is not the thing that kno
|
|
|
591
668
|
them your business has to keep.
|
|
592
669
|
|
|
593
670
|
```ts
|
|
594
|
-
import { setAuditSink } from '@ultimat3/
|
|
671
|
+
import { setAuditSink } from '@ultimat3/core';
|
|
595
672
|
|
|
596
673
|
setAuditSink({
|
|
597
674
|
async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
|
|
@@ -603,14 +680,19 @@ What the framework supplies is what it genuinely knows:
|
|
|
603
680
|
| Field | |
|
|
604
681
|
|---|---|
|
|
605
682
|
| `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
|
|
606
|
-
| `
|
|
607
|
-
| `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
|
|
683
|
+
| `name` / `primitive` / `mutator` | the registered name; `'action'` (or `'query'` for an audited read); and whether `mutator()` built it. `name` and `primitive` are required, and the `action` alias of `name` is gone (`As of 25.0.0`) — `postgresAuditSink` still files `name` in the `x_audit.action` column |
|
|
684
|
+
| `surface` | `server` \| `http` \| `mcp` \| `job` (and `live` on a read) — the same price change over MCP is not the same event |
|
|
608
685
|
| `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
|
|
609
686
|
| `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
|
|
610
687
|
| `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
|
|
611
688
|
| `outcome` | `allowed` \| `denied` \| `failed` |
|
|
612
689
|
| `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
|
|
613
690
|
|
|
691
|
+
**One contract, one sink, shared with reads.** `AuditRecord`, `AuditSink` and the installed slot
|
|
692
|
+
are `@ultimat3/core`'s, and so is the one way to install it: `setAuditSink` from `@ultimat3/core`
|
|
693
|
+
(this package re-exports the two types, never the slot — `As of 25.0.0`) installs the sink `query({ audit: true })` writes to as well (`@ultimat3/query`'s README). A sink
|
|
694
|
+
tells the two apart by `record.primitive`.
|
|
695
|
+
|
|
614
696
|
What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
|
|
615
697
|
or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
|
|
616
698
|
one would make three of them wrong.
|
|
@@ -629,7 +711,8 @@ is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the res
|
|
|
629
711
|
|
|
630
712
|
```ts
|
|
631
713
|
// apps/web/server.ts — the app owns the connection, so the app installs the sink
|
|
632
|
-
import { postgresAuditSink
|
|
714
|
+
import { postgresAuditSink } from '@ultimat3/action';
|
|
715
|
+
import { setAuditSink } from '@ultimat3/core';
|
|
633
716
|
import { db } from '@ultimat3/db';
|
|
634
717
|
|
|
635
718
|
const client = db();
|
|
@@ -651,7 +734,7 @@ the actor, the correlation ids, the idempotency key — and the parsed `input` a
|
|
|
651
734
|
through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
|
|
652
735
|
value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
|
|
653
736
|
boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
|
|
654
|
-
itself (`
|
|
737
|
+
itself (`ctxOf` spreads every installed service onto it, and an HTTP surface's is a
|
|
655
738
|
`RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
|
|
656
739
|
a failure — the row keeps `failure.code`, never the throwable.
|
|
657
740
|
|
|
@@ -659,7 +742,7 @@ The table has **no purge**, deliberately, and it is the one framework table that
|
|
|
659
742
|
stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
|
|
660
743
|
legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
|
|
661
744
|
|
|
662
|
-
A denial is recorded because `invoke` wraps the whole path — `
|
|
745
|
+
A denial is recorded because `invoke` wraps the whole path — `guardAction` throws **before** `handle`,
|
|
663
746
|
so nothing you could write around your own handler would ever see one. That is the reason this
|
|
664
747
|
lives in the framework and the row does not.
|
|
665
748
|
|
|
@@ -710,7 +793,7 @@ setAuditSink({
|
|
|
710
793
|
const prev = await chainHead(ctx); // hash-chained: the app's choice
|
|
711
794
|
await ctx.db.auditRows.insert({
|
|
712
795
|
orgId: orgOf(ctx.actor), // tenancy: derived from the actor
|
|
713
|
-
subjectId: subjectOf(record.
|
|
796
|
+
subjectId: subjectOf(record.name, record.input), // queryable by subject: the app's index
|
|
714
797
|
actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
|
|
715
798
|
at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
|
|
716
799
|
prevHash: prev, hash: await sha256(prev, record),
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
7
7
|
"sideEffects": [
|
|
8
|
-
"./src/
|
|
8
|
+
"./src/error-titles.ts"
|
|
9
9
|
],
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -28,19 +28,19 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/db": "
|
|
41
|
-
"@ultimat3/entity": "
|
|
42
|
-
"@ultimat3/http": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/cache": "25.1.0",
|
|
39
|
+
"@ultimat3/core": "25.1.0",
|
|
40
|
+
"@ultimat3/db": "25.1.0",
|
|
41
|
+
"@ultimat3/entity": "25.1.0",
|
|
42
|
+
"@ultimat3/http": "25.1.0",
|
|
43
|
+
"@ultimat3/policy": "25.1.0",
|
|
44
|
+
"@ultimat3/schema": "25.1.0"
|
|
45
45
|
}
|
|
46
46
|
}
|