@ultimat3/action 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,7 @@ imports one package for the primitive and its schemas, never two.
42
42
  import { action, t } from '@ultimat3/action';
43
43
 
44
44
  export const publishPost = action({
45
- input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
45
+ input: t.object({ postId: t.uuid, orgId: t.uuid, notify: t.boolean.default(true) }),
46
46
  output: PostView,
47
47
  policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
48
48
  cache: { invalidates: [tag.post, tag.feed] },
@@ -50,7 +50,7 @@ export const publishPost = action({
50
50
  idempotent: true,
51
51
  async handle({ input, ctx }) {
52
52
  const post = await ctx.posts.publish(input.postId);
53
- if (input.notify) await notifySubscribers.enqueue({ postId: post.id });
53
+ if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId });
54
54
  return post;
55
55
  },
56
56
  });
@@ -75,21 +75,33 @@ export const api = defineApi({
75
75
  export type Api = typeof api;
76
76
  ```
77
77
 
78
+ Six keys, all optional:
79
+
78
80
  | Key | Goes to | Why |
79
81
  |---|---|---|
80
82
  | `actions` | the action registry | the primitive |
81
83
  | `mutators` | the action registry | a mutator IS an action, on the same authz path |
82
84
  | `llm` | the action registry | `llm()` returns an action, not a ninth primitive |
83
85
  | `queries` | `@ultimat3/query`'s registry, via core's registrar table | `query` is on this tier, so importing it here would be a build error |
86
+ | `jobs` | `@ultimat3/jobs`' registry, the same way | the export name becomes the durable queue key — a job row names the handle, not a counter |
87
+ | `tasks` | `@ultimat3/jobs`' registry, the same way | handing a task over is what names its cron after its export |
88
+
89
+ Jobs register **before** tasks: a task's descriptor lists the jobs it enqueues by name, so the
90
+ other order would read the queue keys one boot step before they were assigned. `Api` carries all
91
+ four maps back — `api.actions`, `api.queries`, `api.jobs`, `api.tasks` — keyed by the name
92
+ registration stamped.
84
93
 
85
94
  Names come from **export names** — that is what makes the path, the tool name and the
86
95
  OpenAPI `operationId` derivable everywhere without a second declaration. Registration
87
96
  stamps the name onto the action the module exported, so the binding you imported is the
88
97
  one that projects; a projection attempted before boot is `X_ACTION_UNREGISTERED`. Two
89
- features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging.
98
+ features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging, and two
99
+ names deriving one route collide with `X_ACTION_PATH_DUPLICATE` — `pluralize` leaves a trailing
100
+ `s` alone, so `archiveOrder` and `archiveOrders` are two exports and one `POST /api/orders/archive`.
90
101
 
91
- `registerActions` / `registerQueries` are what `defineApi` composes. An app calling them
92
- directly is a second path.
102
+ `registerActions` is what `defineApi` composes for the three action-shaped keys; the other three
103
+ go through core's `primitiveRegistrar(kind)`, because this package may not import `@ultimat3/query`
104
+ or `@ultimat3/jobs` sideways. An app calling either directly is a second path.
93
105
 
94
106
  ## Call it — `rpc`
95
107
 
@@ -107,7 +119,7 @@ a page's module graph free of any edge to a feature's implementation.
107
119
  ## Path derivation
108
120
 
109
121
  First camelCase word is the verb; the rest is the resource, last word pluralized,
110
- kebab-cased. The **MCP tool name is not derived** — it is the export name verbatim, because
122
+ kebab-cased. The **MCP tool name is not derived at all** — it is the export name verbatim, because
111
123
  that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
112
124
 
113
125
  | Action | Route | MCP tool |
@@ -117,6 +129,18 @@ that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
117
129
  | `likePost` | `POST /api/posts/like` | `likePost` |
118
130
  | `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
119
131
 
132
+ **One name, four surfaces** — `.tool().name`, `openapi.json`'s `x-ultimate.mcpTool`,
133
+ `describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the
134
+ `actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
135
+ serves. It was two until 2026-08: a `toToolName()` here snake_cased the first three to
136
+ `publish_post` while the server answered only `publishPost`, so an agent that read the published
137
+ contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second
138
+ derivation is a second name. `mcp-tool.test.ts`'s "one name per action, on every surface" is what
139
+ keeps it that way.
140
+
141
+ `x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so
142
+ the manifest never carried a tool name and was never wrong about one.
143
+
120
144
  ## One invocation core
121
145
 
122
146
  `invoke()` is the only execution path: **parse input → evaluate policy → handle →
@@ -135,6 +159,11 @@ no `.def`. A second authz path cannot be written without deleting that store.
135
159
  | handle | whatever the handler throws |
136
160
  | parse output | `X_OUTPUT_INVALID` — and fields the schema never declared are dropped |
137
161
 
162
+ `cache: { invalidates }` fans out **after** the handler commits, so it never fails it: a
163
+ fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
164
+ `action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
165
+ call busts nothing; the first call already did.
166
+
138
167
  Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
139
168
  no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
140
169
 
@@ -185,25 +214,257 @@ LocalTables { posts: PostRow } }`.
185
214
  name-sorted, and reads no clock, env or random source — same registry ⇒ same bytes ⇒
186
215
  `x verify` can diff the spec and fail on `X_CONTRACT_DRIFT`.
187
216
 
188
- `idempotent: true` + an `Idempotency-Key` header replays the first response
189
- (`x-ultimate-replayed: 1`); a duplicate still in flight, or a reused key with a new
190
- payload, is `X_IDEMPOTENCY_CONFLICT`. Store is swappable via `setIdempotencyStore()`.
217
+ ## `rateLimit:` is the enforced limit
218
+
219
+ ```ts
220
+ rateLimit: { limit: 5, windowMs: 600_000 }, // 5 held, one back every two minutes
221
+ ```
222
+
223
+ One declaration, three places it lands: the bucket the limiter runs on (named after the action,
224
+ registered by `@ultimat3/http`'s `withRouteBuckets` when the route is mounted), the
225
+ `ratelimit-limit` header the caller reads, and `x-ultimate.rateLimit` in the OpenAPI operation.
226
+ `toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
227
+ — so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
228
+ beside `Bucket` and the limiter maths, and is re-exported here: `@ultimat3/query` needs the same
229
+ conversion and is the same tier, so a copy in either package would be a second answer for the
230
+ other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. An action that declares nothing
231
+ stays on the `default` bucket. An app that also configures `http.rateLimit.buckets.<actionName>`
232
+ with **different** numbers is `X_RATE_LIMIT_BUCKET_CONFLICT` at boot: neither source wins, because
233
+ the loser would go on being read as enforced.
234
+
235
+ ## `idempotent:` — and where its records live
236
+
237
+ `idempotent: true` + an `Idempotency-Key` header replays the first **outcome**
238
+ (`x-ultimate-replayed: 1`); a duplicate still in flight, or a reused key with a new payload, is
239
+ `X_IDEMPOTENCY_CONFLICT`.
240
+
241
+ **A record belongs to one caller.** The key is namespaced by action *and* by actor
242
+ (`idempotencyKeyFor`), so two callers sending the same header value hold two records — the same
243
+ value under one action used to be one shared record, which replayed one caller's response to
244
+ another. A **blank** `Idempotency-Key:` is `X_IDEMPOTENCY_KEY_INVALID`, never read as "no key":
245
+ `Headers.get()` answers `''` and not `null`, so a blank header was itself a shared key, and the
246
+ quiet reading — run without idempotency — loses the retry protection exactly when a client's key
247
+ interpolation broke. Omit the header to run un-keyed; the published `maxLength: 255` is enforced
248
+ by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
249
+ public idempotent action still share a key space: a UUID key is what keeps them apart.
250
+
251
+ **A failed first attempt is replayed too, not re-run.** `guard()` and the input parse both happen
252
+ *before* the idempotency gate, so everything it can see throw is post-authorization and possibly
253
+ post-commit: a handler that took the money and then failed its own `output:` schema is the case.
254
+ The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
255
+ code. Releasing it there is what made idempotency the cause of a double charge.
256
+
257
+ **Where the records live is declared, and refused at registration.** The default store is process
258
+ memory — bounded, swept on a 24h window, and `scope: 'process'`. An app on more than one replica
259
+ must say so and bring a store that can keep it, or the retry that lands on another replica finds
260
+ no record and runs the handler again:
261
+
262
+ ```ts
263
+ // boot, before registerActions()
264
+ import {
265
+ configureIdempotency,
266
+ postgresIdempotencyStore,
267
+ setIdempotencyStore,
268
+ } from '@ultimat3/action';
269
+
270
+ setIdempotencyStore(postgresIdempotencyStore({ executor: Bun.sql }));
271
+ configureIdempotency({ scope: 'shared' });
272
+ ```
273
+
274
+ `configureIdempotency({ scope: 'shared' })` over a per-process store — or over a store that
275
+ declares no scope at all — is `X_IDEMPOTENCY_NOT_SHARED` at `registerAction`, before the socket
276
+ opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is: `x db up` in
277
+ development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
278
+ .purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
279
+
280
+ **A plain mutating `route` can use the same gate.** `withIdempotency`, `IDEMPOTENCY_HEADER`,
281
+ `idempotencyKeyFor` and `getIdempotencyStore` are all public, so a route that is not an action
282
+ reserves and replays through the one implementation rather than growing a second:
283
+
284
+ ```ts
285
+ const key = req.header(IDEMPOTENCY_HEADER);
286
+ // No header is the caller declining idempotency; a BLANK one is not, and
287
+ // `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
288
+ if (key === null) return json(await refund(input));
289
+ const outcome = await withIdempotency(
290
+ getIdempotencyStore(),
291
+ // Namespaced by action AND actor: otherwise two routes share one caller's key, and two
292
+ // callers share one record.
293
+ idempotencyKeyFor('refundCharge', key, req.ctx.actor),
294
+ input,
295
+ () => refund(input),
296
+ );
297
+ ```
298
+
299
+ A `query` has none and never will: a read has nothing to be idempotent about.
300
+
301
+ ## `deprecated:` — a compat window, not a version
302
+
303
+ ```ts
304
+ deprecated: { since: '2026-08-01T00:00:00Z', sunset: '2026-12-31T23:59:59Z', replacedBy: 'searchOrders' },
305
+ ```
306
+
307
+ Four things at once: `Deprecation: @1754006400` (RFC 9745) and `Sunset: Wed, 31 Dec 2026 …`
308
+ (RFC 8594) on **every** response including the failures, `link: </api/orders/search>;
309
+ rel="successor-version"`, `deprecated: true` plus `x-ultimate.deprecation` in the OpenAPI
310
+ operation, and a `deprecated_calls_total{primitive,name}` counter — which is the only way to
311
+ answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
312
+ `X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
313
+
314
+ **Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
315
+ action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
316
+ no router feature, no path prefix and no second registry. What ships is the window: a date, a
317
+ successor, and a number.
318
+
319
+ ## Audit — the seam, not the row
320
+
321
+ `audit: true` on any `action` or `mutator` sends **every attempt** — allowed, denied and failed
322
+ — to the installed `AuditSink`. Opt-in per declaration, never a global switch: a login and a
323
+ price change are not the same event, and the framework is not the thing that knows which of
324
+ them your business has to keep.
325
+
326
+ ```ts
327
+ import { setAuditSink } from '@ultimat3/action';
328
+
329
+ setAuditSink({
330
+ async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
331
+ });
332
+ ```
333
+
334
+ What the framework supplies is what it genuinely knows:
335
+
336
+ | Field | |
337
+ |---|---|
338
+ | `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
339
+ | `action` / `mutator` | the registered name, and which primitive it was |
340
+ | `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
341
+ | `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
342
+ | `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
343
+ | `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
344
+ | `outcome` | `allowed` \| `denied` \| `failed` |
345
+ | `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
346
+
347
+ What it does **not** supply: an audit entity, a schema, a retention policy, a storage backend, a
348
+ hash chain, a subject index, or an opinion on what "who" means under impersonation. Four apps
349
+ model those four ways; shipping one would make three of them wrong.
350
+
351
+ A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
352
+ so nothing you could write around your own handler would ever see one. That is the reason this
353
+ lives in the framework and the row does not.
354
+
355
+ **Failure is loud, both ways.** `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`,
356
+ raised before the input parse — the one audit failure with no committed write behind it. A sink
357
+ that refuses a **successful** record is `X_AUDIT_SINK_FAILED`: the deliberate opposite of the
358
+ cache tier's `bestEffort`, because a dropped cache entry expires by TTL and the stack heals
359
+ itself while nothing ever re-derives an audit row that was never written. It is post-commit all
360
+ the same, and the error says so.
361
+
362
+ **Its `fix:` branches, because only one of the two is ever true.** Retrying is safe exactly when
363
+ *this invocation* went through the idempotency store — then the settled record replays and the
364
+ audit row is re-attempted without re-running the handler. It did not when the action is not
365
+ `idempotent`, **and it did not when the action is `idempotent` but the caller sent no
366
+ `Idempotency-Key`**: `invoke` reads `def.idempotent === true ? (options.idempotencyKey ?? null)
367
+ : null`, so both collapse to the same `null`. In that case the error says *do not retry* and
368
+ names the edit — telling a caller to re-run a committed mutator is worse than saying nothing.
369
+ `meta.replayable` carries the same fact to `--json`.
370
+
371
+ A sink that refuses a **denied or failed** record is logged as
372
+ `audit.sink.failed` and the original error still reaches the caller: answering
373
+ `X_AUDIT_SINK_FAILED` there would hide the `X_FORBIDDEN` from whoever has to act on it.
374
+
375
+ Your house rule goes in a wrapper, not in a config option — the same shape as `tenantEntity()`:
376
+
377
+ ```ts
378
+ // apps/web/shared/base/audited-mutator.ts — the app's convention, written once
379
+ import { mutator, type MutatorDef } from '@ultimat3/action';
380
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
381
+
382
+ /** Every write in this app is recorded and retryable — declared once, not at forty call sites. */
383
+ export const auditedMutator = <I extends StandardSchemaV1, O extends StandardSchemaV1>(
384
+ def: MutatorDef<I, O>,
385
+ ) => mutator({ ...def, audit: true, idempotent: true });
386
+ ```
387
+
388
+ Nothing downstream can tell the difference: `isMutator()` is structural and `registerActions`
389
+ names the object in place, so every projection, the manifest and admin CRUD work on it exactly
390
+ as on a hand-written one.
391
+
392
+ The row it produces is the app's, and so is every question the framework refused to answer —
393
+ which fields, whose tenant, chained or not, kept how long:
394
+
395
+ ```ts
396
+ setAuditSink({
397
+ async write(record) {
398
+ const { ctx } = record; // the services a sink needs to write a row
399
+ const prev = await chainHead(ctx); // hash-chained: the app's choice
400
+ await ctx.db.auditRows.insert({
401
+ orgId: orgOf(ctx.actor), // tenancy: derived from the actor
402
+ subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
403
+ actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
404
+ at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
405
+ prevHash: prev, hash: await sha256(prev, record),
406
+ });
407
+ },
408
+ });
409
+ ```
410
+
411
+ ## Contract tests
412
+
413
+ `publishPost.contract()` returns three assertions. Run them; they throw `X_CONTRACT_DRIFT`.
414
+
415
+ | Assertion | Holds when |
416
+ |---|---|
417
+ | input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
418
+ | policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
419
+ | OpenAPI document contains its operation | the derived path is in `buildOpenApi()` |
420
+
421
+ The denial assertion sends an input synthesized from `input:`'s own schema — required keys
422
+ only, formats included — because a payload the schema rejects never reaches a policy. It
423
+ asserts the denial, not `X_FORBIDDEN`: a denial carries the policy decision's own code, and
424
+ `can()` answers a null actor with `X_UNAUTHENTICATED`.
425
+
426
+ ```ts
427
+ publishPost.contract({
428
+ garbage: 42, // what the input schema must reject
429
+ input: { postId, orgId }, // when the synthesized one cannot fit
430
+ ctx: myCtx, // default: an anonymous context
431
+ })
432
+ ```
433
+
434
+ Pass `input:` when the schema carries a constraint the IR cannot invert (a bare `pattern`) or
435
+ when `row:` needs an id that resolves. Anything thrown *before* the policy decides is drift,
436
+ never a pass — the assertion says which code got in the way and names `input:` as the fix.
191
437
 
192
438
  ## Errors
193
439
 
194
440
  | Code | When | Fix |
195
441
  |---|---|---|
196
442
  | `X_ACTION_DUPLICATE` | two actions registered under one name | rename one export |
443
+ | `X_ACTION_PATH_DUPLICATE` | two actions derive one HTTP path (`archiveOrder` / `archiveOrders`) | rename one export |
197
444
  | `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
445
+ | `X_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely. Owned by `@ultimat3/http`, which owns the conversion | make both positive, or delete the block |
446
+ | `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
198
447
  | `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
199
448
  | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
449
+ | `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
450
+ | `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor: Bun.sql })` at boot |
451
+ | `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 |
200
452
  | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
201
- | `X_RPC_FAILED` | non-`problem+json` failure reached the client | check the gateway |
453
+ | `X_RPC_FAILED` | non-`problem+json` failure, or a body naming no `X_` code | check the gateway |
202
454
  | `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
455
+ | `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `setAuditSink(yourSink)` at boot |
456
+ | `X_AUDIT_SINK_FAILED` | the sink refused the record for an attempt that **succeeded** | fix the sink — then retry the same `Idempotency-Key` if this call carried one, else reconcile by hand |
203
457
 
204
458
  Denials re-throw the policy layer's own codes (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) —
205
459
  this package never invents an authz code.
206
460
 
461
+ The client does the same with the server's: a `problem+json` failure comes back as a
462
+ `RemoteActionError` keeping the code the server sent, marked `meta.origin: 'remote'` because
463
+ the browser bundle may never have registered it, and linked only to a page that exists — the
464
+ server's own `docs`/`type` when it sent an `http(s)` one, this build's registered link when it
465
+ knows the code, otherwise the error index. A per-code URL is never synthesized for a code
466
+ nothing here declares.
467
+
207
468
  ## Boundaries
208
469
 
209
470
  Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "1.2.0",
3
+ "version": "3.0.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,10 +31,10 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/cache": "1.2.0",
34
- "@ultimat3/core": "1.2.0",
35
- "@ultimat3/http": "1.2.0",
36
- "@ultimat3/policy": "1.2.0",
37
- "@ultimat3/schema": "1.2.0"
34
+ "@ultimat3/cache": "3.0.0",
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/http": "3.0.0",
37
+ "@ultimat3/policy": "3.0.0",
38
+ "@ultimat3/schema": "3.0.0"
38
39
  }
39
40
  }
package/src/action.ts CHANGED
@@ -5,10 +5,13 @@
5
5
  */
6
6
 
7
7
  import type { CacheTag } from '@ultimat3/cache';
8
+ import { tagKeys } from '@ultimat3/cache';
8
9
  import type { Actor, Ctx } from '@ultimat3/core';
10
+ import { isMcpExposed } from '@ultimat3/core';
9
11
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
12
  import type { ClientMethod, ClientOptions } from './client';
11
13
  import type { ContractTest, ContractTestOptions } from './contract-test';
14
+ import type { Deprecation } from './deprecation';
12
15
  import { facadeFor } from './facade';
13
16
  import type { OpenApiOperation } from './http';
14
17
  import type { IdempotencyStore } from './idempotency';
@@ -17,9 +20,13 @@ import type { ActionJobHandle } from './job-handle';
17
20
  import type { JsonSchemaObject } from './json-schema';
18
21
  import { jsonSchemaOf } from './json-schema';
19
22
  import type { McpToolDescriptor } from './mcp-tool';
20
- import { derivePath, toToolName } from './naming';
21
- import { type ActionPolicy, policyCapability, type Surface } from './policy-gate';
22
- import { tagKeys } from './tags';
23
+ import { derivePath } from './naming';
24
+ import {
25
+ type ActionPolicy,
26
+ policyCapability,
27
+ policyPermissions,
28
+ type Surface,
29
+ } from './policy-gate';
23
30
 
24
31
  export interface ActionCache {
25
32
  /** Tags dropped from every cache tier after the handler settles. */
@@ -84,8 +91,32 @@ export interface ActionDef<
84
91
  readonly cache?: ActionCache;
85
92
  readonly mcp?: ActionMcp;
86
93
  readonly rateLimit?: ActionRateLimit;
94
+ /**
95
+ * On its way out. Declared here and projected everywhere at once: `Deprecation` and `Sunset`
96
+ * response headers (RFC 9745 / RFC 8594), a `rel="successor-version"` link when `replacedBy`
97
+ * names one, `deprecated: true` in the OpenAPI operation, and a
98
+ * `deprecated_calls_total{name}` counter — which is the only way to answer "is anyone
99
+ * still calling it?" before deleting it.
100
+ *
101
+ * **Versioning is deliberately NOT here.** Running `v1` and `v2` of one action side by side is
102
+ * two deployments behind one ingress, which is axiom 7's answer and costs this package no
103
+ * router feature; see the README. What ships is the compat WINDOW: a date, a successor, and a
104
+ * number.
105
+ */
106
+ readonly deprecated?: Deprecation;
87
107
  /** Marks the action safe to retry with an `Idempotency-Key`. */
88
108
  readonly idempotent?: boolean;
109
+ /**
110
+ * Record every attempt at this action through the installed `AuditSink` — allowed, denied and
111
+ * failed alike. Opt-in per declaration and never a global switch: a login and a price change
112
+ * are not the same event, and the framework is not the thing that knows which of them an app
113
+ * has to keep. `true` with no sink installed is `X_AUDIT_SINK_MISSING`, refused before the
114
+ * input parse.
115
+ *
116
+ * What the sink DOES with the record — which fields survive, how long, hash-chained or not,
117
+ * indexed by subject or not — is the app's, and this package ships none of it.
118
+ */
119
+ readonly audit?: boolean;
89
120
  /**
90
121
  * Loads the row a row-level `policy` decides about, once per invocation, after the
91
122
  * input parse and before the guard. This is the async half authz is not allowed to
@@ -136,13 +167,28 @@ export interface ActionDescriptor {
136
167
  readonly resource: string;
137
168
  readonly method: 'POST';
138
169
  readonly path: string;
170
+ /** The policy's DISPLAY label. A composite renders as `and(a:b, c:d)` — never a permission. */
139
171
  readonly capability: string;
172
+ /**
173
+ * Every permission the policy tree references, flattened. This is the field a compliance
174
+ * report matches a grant against: `capability` is a label, so `x policy list` reported every
175
+ * composite-guarded action's permissions as unenforced — real grants shown as dead.
176
+ */
177
+ readonly permissions: readonly string[];
140
178
  readonly input: JsonSchemaObject;
141
179
  readonly output: JsonSchemaObject;
142
180
  readonly invalidates: readonly string[];
143
181
  readonly idempotent: boolean;
182
+ /**
183
+ * Whether every attempt reaches the audit sink. Published here for the same reason
184
+ * `idempotent` is: "which of these writes leave a trail" is a question an agent asks of
185
+ * `x actions list --json`, and a fact nothing publishes is a fact nobody can check.
186
+ */
187
+ readonly audited: boolean;
144
188
  readonly mcp: McpDescriptorMeta;
145
189
  readonly rateLimit: ActionRateLimit | null;
190
+ /** Published so `x actions list --json` can answer "what is retiring, and when". */
191
+ readonly deprecated: Deprecation | null;
146
192
  }
147
193
 
148
194
  /**
@@ -157,7 +203,9 @@ export interface AnyActionDef {
157
203
  readonly cache?: ActionCache;
158
204
  readonly mcp?: ActionMcp;
159
205
  readonly rateLimit?: ActionRateLimit;
206
+ readonly deprecated?: Deprecation;
160
207
  readonly idempotent?: boolean;
208
+ readonly audit?: boolean;
161
209
  row?(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
162
210
  handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
163
211
  }
@@ -177,6 +225,18 @@ export interface AnyAction {
177
225
  as(actor: Actor | null, input: unknown, options?: InvokeOptions): Promise<unknown>;
178
226
  tool(): McpToolDescriptor;
179
227
  openapi(): OpenApiOperation;
228
+ /**
229
+ * The durable-work shape, erased. `listActions()` and `getAction(name)` hand back this view
230
+ * and nothing else, so leaving `job()` off it meant the registry could project an action to
231
+ * every surface except the queue — `getAction('publishPost')?.job()` was a type error against
232
+ * an object that has had the method since `facadeFor` bound it.
233
+ *
234
+ * It erases where `client()` cannot: `ActionJobHandle`'s members are method-syntax, so their
235
+ * parameters stay bivariant, and its output erases to `unknown`. `ClientMethod` is a function
236
+ * type — contravariant input — and `(input: unknown) => …` is a supertype of nothing.
237
+ * `type-pins.ts` holds both halves of that as build errors.
238
+ */
239
+ job(): ActionJobHandle;
180
240
  contract(options?: ContractTestOptions): readonly ContractTest[];
181
241
  }
182
242
 
@@ -195,8 +255,10 @@ export interface Action<
195
255
  options?: InvokeOptions,
196
256
  ): Promise<InferOutput<TOutput>>;
197
257
  /**
198
- * Typed against this action's schemas, which is the whole point of bothso they
199
- * live here and not on the schema-erased `AnyAction` view.
258
+ * Typed against this action's schemas, which is the whole point of itand the reason
259
+ * `client()` lives here alone: its input sits in a contravariant position, so no erased
260
+ * spelling of it is assignable from a concrete one. `job()` is the narrowing of the erased
261
+ * view's, not a second declaration of it.
200
262
  */
201
263
  client(options: ClientOptions): ClientMethod<TInput, TOutput>;
202
264
  job(): ActionJobHandle<TInput, TOutput>;
@@ -281,15 +343,27 @@ export function describeAction(target: AnyAction): ActionDescriptor {
281
343
  method: 'POST',
282
344
  path: path.path,
283
345
  capability: policyCapability(def.policy),
346
+ // The flattened list, beside the label and never instead of it: one is read, one is matched.
347
+ permissions: policyPermissions(def.policy),
284
348
  input: jsonSchemaOf(def.input),
285
349
  output: jsonSchemaOf(def.output),
286
350
  invalidates: tagKeys(def.cache?.invalidates ?? []),
287
351
  idempotent: def.idempotent === true,
352
+ audited: def.audit === true,
288
353
  mcp: {
289
- expose: mcp?.expose ?? true,
290
- tool: toToolName(name),
354
+ // `isMcpExposed`, not `?? true`: this fact is what the manifest publishes and what the
355
+ // contract diff classifies, so it has to be the answer the tool projection actually gives.
356
+ // Fail-open here published every action as a tool no surface would ever serve, which made
357
+ // a first honest `expose: false` read as a withdrawn capability and demand a major bump.
358
+ expose: isMcpExposed(mcp),
359
+ // The export name verbatim, which is the only name `@ultimat3/mcp` will answer a
360
+ // `tools/call` for. A derived one made every descriptor reader — `x actions describe
361
+ // --json`, the `actions.describe` dev tool, the `/_x` panel — name a tool no surface
362
+ // serves. `x.manifest.json` is not among them: it copies `expose` and `description` only.
363
+ tool: name,
291
364
  description: mcp?.description ?? null,
292
365
  },
293
366
  rateLimit: def.rateLimit ?? null,
367
+ deprecated: def.deprecated ?? null,
294
368
  };
295
369
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The only file in this package that calls an `AuditSink`, and the one place the "a sink may not
3
+ * silently swallow" rule is spelled. Two failure policies, because an attempt that succeeded and
4
+ * an attempt that already failed do not want the same answer.
5
+ */
6
+
7
+ import { isUltimateError, logger } from '@ultimat3/core';
8
+ import type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from './audit';
9
+ import { getAuditSink } from './audit';
10
+ import { ActionDeniedError, AuditSinkFailedError, AuditSinkMissingError } from './errors';
11
+
12
+ /**
13
+ * Resolved before the input parse, never after: an audited action that nothing can record must
14
+ * refuse while it has still made no change. This is the only audit failure with no committed
15
+ * write behind it, which is exactly why it is checked first.
16
+ */
17
+ export function auditSinkFor(action: string): AuditSink {
18
+ const sink = getAuditSink();
19
+ if (sink === null) throw new AuditSinkMissingError(action);
20
+ return sink;
21
+ }
22
+
23
+ /**
24
+ * An authz refusal is `denied`; everything else that threw is `failed`, an unparsed input
25
+ * included.
26
+ *
27
+ * TOTAL, the same rule core's `isThrownError` states: `instanceof` runs a `Proxy`'s
28
+ * `getPrototypeOf` trap, and both callers ask this question inside a `catch` that is still
29
+ * holding the app's own error — `execute`'s span attribute and the one place the `failed` record
30
+ * is produced. A probe that threw there REPLACED the caller's throwable with its own `TypeError`,
31
+ * so an app catching by `instanceof` upstream stopped matching. A value that refuses to be
32
+ * examined is not evidence of a policy denial, so it fails closed to `failed`.
33
+ */
34
+ export function auditOutcomeFor(error: unknown): AuditOutcome {
35
+ try {
36
+ return error instanceof ActionDeniedError ? 'denied' : 'failed';
37
+ } catch {
38
+ return 'failed';
39
+ }
40
+ }
41
+
42
+ export function auditFailureFor(error: unknown): AuditFailure {
43
+ return { code: isUltimateError(error) ? error.code : null, error };
44
+ }
45
+
46
+ /**
47
+ * The record for an attempt that SUCCEEDED. A sink that refuses fails the invocation — the
48
+ * deliberate opposite of `bustAfterCommit`, which absorbs its own failure because a stale cache
49
+ * entry expires by TTL and the stack heals itself. Nothing heals a missing audit row: the only
50
+ * process that held the facts has returned. So "if it isn't logged, it didn't happen" is enforced
51
+ * on the caller rather than on a log line nobody reads.
52
+ *
53
+ * It is post-commit all the same, and the error says so instead of pretending the write was
54
+ * rolled back. That is the honest half of the choice: what the caller gains is being TOLD.
55
+ *
56
+ * Whether a retry is SAFE is the record's own `idempotencyKey`, not the declaration's
57
+ * `idempotent`. The key is non-null exactly when this invocation went through the idempotency
58
+ * store, which is the one condition under which a retry replays instead of re-running a handler
59
+ * that has already committed — and it is null both for a non-idempotent action and for an
60
+ * idempotent one whose caller sent no header.
61
+ */
62
+ export async function auditSettled(sink: AuditSink, record: AuditRecord): Promise<void> {
63
+ try {
64
+ await sink.write(record);
65
+ } catch (error) {
66
+ throw new AuditSinkFailedError(record.action, error, record.idempotencyKey !== null);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The record for an attempt that THREW. The sink's failure is reported and the original error is
72
+ * the one the caller gets — replacing a denial with `X_AUDIT_SINK_FAILED` would hide the
73
+ * `X_FORBIDDEN` from whoever has to act on it, and would answer a probing client differently
74
+ * depending on whether the audit backend happened to be up, which is an oracle.
75
+ *
76
+ * Never a silent swallow: the log line is the same shape every other subsystem writes, so
77
+ * `audit.sink.failed` is one alert rule over every action in the app.
78
+ */
79
+ export async function auditThrew(sink: AuditSink, record: AuditRecord): Promise<void> {
80
+ try {
81
+ await sink.write(record);
82
+ } catch (error) {
83
+ // Core's logger, not `ctx.logger`: an HTTP `Ctx` is a cast request context that carries none,
84
+ // the same reason `cache-gate.ts` gives. Never the record — rendering an input the sink just
85
+ // choked on is the second throw this branch exists to prevent.
86
+ logger.error('audit.sink.failed', {
87
+ action: record.action,
88
+ outcome: record.outcome,
89
+ error,
90
+ });
91
+ }
92
+ }