@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/src/errors.ts CHANGED
@@ -2,10 +2,22 @@
2
2
  * Every failure @ultimat3/action can produce, one subclass per stable code so
3
3
  * callers `instanceof` a specific failure instead of string-matching a message.
4
4
  */
5
- import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ assertNever,
7
+ ERROR_DOCS_BASE,
8
+ errorDocsUrl,
9
+ hasErrorCode,
10
+ registerErrorCodes,
11
+ UltimateError,
12
+ } from '@ultimat3/core';
6
13
  import type { SurfaceDenial } from '@ultimat3/policy';
14
+ // Type-only: `idempotency.ts` imports the error classes below, and a runtime edge here would
15
+ // close the cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
16
+ import type { IdempotencyFailure } from './idempotency';
7
17
 
8
- const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
18
+ // Core's spelling, aliased never a second one. A local template drifts from what
19
+ // `x errors explain` prints the moment `ERROR_DOCS_BASE` moves.
20
+ const docs = errorDocsUrl;
9
21
 
10
22
  /**
11
23
  * Titles for the framework-wide code table — every one of them owned by this package.
@@ -15,11 +27,20 @@ const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
15
27
  */
16
28
  const OWNED_TITLES: Readonly<Record<string, string>> = {
17
29
  X_ACTION_DUPLICATE: 'two actions are registered under one name',
30
+ X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
31
+ X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
32
+ X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
33
+ X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
18
34
  X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
19
35
  X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
20
36
  X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
21
37
  X_CONTRACT_DRIFT: 'client and server disagree about the contract',
22
38
  X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
39
+ X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
40
+ X_IDEMPOTENCY_NOT_SHARED:
41
+ 'idempotency is declared fleet-wide and the installed store is per-process',
42
+ X_IDEMPOTENCY_REPLAYED_FAILURE:
43
+ 'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
23
44
  X_INPUT_INVALID: 'input failed schema validation',
24
45
  X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
25
46
  X_RPC_FAILED: 'an RPC call failed without a problem+json body',
@@ -119,12 +140,36 @@ export class ActionDuplicateError extends UltimateError {
119
140
  }
120
141
  }
121
142
 
143
+ /**
144
+ * Two distinct action names, one derived route. `X_ACTION_DUPLICATE` guards the NAME; nothing
145
+ * guarded the path, so `archiveOrder` and `archiveOrders` both registered, both projected to
146
+ * `POST /api/orders/archive`, and whichever the router seated last silently shadowed the other —
147
+ * while the shadowed action's OpenAPI operation and MCP tool went on advertising it.
148
+ */
149
+ export class ActionPathDuplicateError extends UltimateError {
150
+ constructor(input: { name: string; existing: string; path: string }) {
151
+ super({
152
+ code: 'X_ACTION_PATH_DUPLICATE',
153
+ cause: `actions "${input.name}" and "${input.existing}" both derive ${input.path}`,
154
+ fix: `rename one export so the two derive different paths — x actions list --json prints every derived route`,
155
+ docs: docs('X_ACTION_PATH_DUPLICATE'),
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The action's NAME goes in the cause and the permission's SHAPE goes in the fix — the two are not
162
+ * interchangeable. `can()` takes `resource:verb` (`Permission` is `` `${string}:${string}` ``), so
163
+ * the `can('<the action name>')` this used to emit was a snippet that could not compile, and
164
+ * `assertPermission` would refuse it once the app declared its own set. Same split
165
+ * `@ultimat3/policy`'s own `policyMissing()` makes, and the same repair `X_QUERY_POLICY_MISSING` took.
166
+ */
122
167
  export class ActionPolicyMissingError extends UltimateError {
123
168
  constructor(name: string) {
124
169
  super({
125
170
  code: 'X_ACTION_POLICY_MISSING',
126
171
  cause: `action "${name}" was registered without a policy`,
127
- fix: `add \`policy: can('${name}')\` to the action definition in the file that exports it`,
172
+ fix: `add \`policy: can('<resource>:<verb>')\` to the action() that exports "${name}" — a permission your definePermissions() call declares, never the action's own name — or \`allow('<resource>:<verb>')\` if the operation is genuinely public`,
128
173
  docs: docs('X_ACTION_POLICY_MISSING'),
129
174
  });
130
175
  }
@@ -176,6 +221,179 @@ export class IdempotencyConflictError extends UltimateError {
176
221
  }
177
222
  }
178
223
 
224
+ export type IdempotencyKeyProblem = 'empty' | 'too-long';
225
+
226
+ /**
227
+ * The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
228
+ * answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
229
+ * every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
230
+ * because a client whose key interpolation produced nothing would lose the protection silently
231
+ * and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
232
+ * same reason; it uses `assert` because the empty key there is the app's own declaration, while
233
+ * this one is a caller's header and therefore a 4xx.
234
+ *
235
+ * The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
236
+ * A contract that disagrees with the runtime is worse than no contract.
237
+ */
238
+ export class IdempotencyKeyInvalidError extends UltimateError {
239
+ constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
240
+ super({
241
+ code: 'X_IDEMPOTENCY_KEY_INVALID',
242
+ cause:
243
+ problem === 'empty'
244
+ ? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
245
+ : `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
246
+ fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
247
+ docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
248
+ meta: { action, problem, length },
249
+ });
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
255
+ * registration, before a socket opens, because the failure it replaces is silent and expensive:
256
+ * a per-process store under `replicas: 3` means the retry that lands on another replica finds no
257
+ * record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
258
+ * An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
259
+ * to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
260
+ */
261
+ export class IdempotencyNotSharedError extends UltimateError {
262
+ constructor(storeScope: string | undefined) {
263
+ super({
264
+ code: 'X_IDEMPOTENCY_NOT_SHARED',
265
+ cause:
266
+ storeScope === undefined
267
+ ? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
268
+ : `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
269
+ // NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
270
+ // positional form is `unsafe`), so that line compiled and would have thrown on the first
271
+ // reservation. The framework's own boot already installs this store; a host booting the
272
+ // framework itself wraps the client it opened.
273
+ fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
274
+ docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
275
+ meta: { storeScope: storeScope ?? null },
276
+ });
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
282
+ * and the input parse both run before the idempotency gate, so everything the gate can see throw
283
+ * is post-authorization and possibly post-commit — a handler that took the money and then failed
284
+ * its own `output:` schema is the case this exists for. Releasing the reservation there let the
285
+ * client's automatic retry charge a second time, which made idempotency the cause of the double
286
+ * charge it exists to prevent.
287
+ *
288
+ * The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
289
+ * the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
290
+ * is the code only when the original throw carried none of its own.
291
+ */
292
+ export class IdempotencyReplayedFailureError extends UltimateError {
293
+ /** The recorded first attempt, so a caller reads the original code without parsing a message. */
294
+ readonly failure: IdempotencyFailure;
295
+
296
+ constructor(key: string, failure: IdempotencyFailure | undefined) {
297
+ const recorded: IdempotencyFailure = failure ?? {
298
+ code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
299
+ cause: 'the first attempt under this key failed and the store kept no detail of it',
300
+ fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
301
+ };
302
+ super({
303
+ code: recorded.code,
304
+ cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
305
+ fix: recorded.fix,
306
+ ...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
307
+ // `replayed` is what tells an operator this is not a second execution: nothing ran here.
308
+ meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
309
+ });
310
+ this.failure = recorded;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
316
+ * declaration is converted, so the route and the OpenAPI operation refuse the same value — the
317
+ * same shape as `@ultimat3/http`'s `X_RATE_LIMIT_INVALID`, and for the same reason: a `Sunset` that
318
+ * renders `Invalid Date` is a contract statement no client can act on.
319
+ */
320
+ export class ActionDeprecationInvalidError extends UltimateError {
321
+ constructor(action: string, field: string, value: string) {
322
+ super({
323
+ code: 'X_ACTION_DEPRECATION_INVALID',
324
+ cause: `action "${action}" declares deprecated.${field} as "${value}", which is not a date`,
325
+ fix: `edit \`deprecated: { ${field}: … }\` on ${action} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
326
+ docs: docs('X_ACTION_DEPRECATION_INVALID'),
327
+ meta: { action, field, value },
328
+ });
329
+ }
330
+ }
331
+
332
+ /** What the wire said, once `client.ts` has decided the body really is a problem document. */
333
+ export interface RemoteFailure {
334
+ /** The action that was called — `meta` carries it, so a report names the call, not just a code. */
335
+ readonly action: string;
336
+ readonly status: number;
337
+ /** The server's code, kept verbatim: matching on it is why problem+json carries one. */
338
+ readonly code: string;
339
+ readonly cause: string;
340
+ readonly fix: string;
341
+ /**
342
+ * The links the server offered, most specific first. Never synthesized from the code, and
343
+ * never trusted for being present — the first one that is an absolute HTTP(S) URL wins, so
344
+ * a `javascript:` in the preferred slot cannot suppress a usable link behind it.
345
+ */
346
+ readonly docs?: readonly (string | undefined)[] | undefined;
347
+ }
348
+
349
+ /** A link, not a string the server happened to put in a field the overlay renders as an href. */
350
+ const ABSOLUTE_HTTP_URL = /^https?:\/\//;
351
+
352
+ /**
353
+ * The docs link, or the honest absence of one. `UltimateError` resolves an unregistered code to
354
+ * `https://ultimate.dev/errors/<code>`, and a code the SERVER owns — `X_SIGNUP_CLOSED`, declared
355
+ * by the app through `registerErrorStatus` — is exactly the kind this bundle never registered:
356
+ * that URL is a 404 dressed as documentation, printed under `docs:` as if the framework wrote the
357
+ * page. So: the server's own link when it sent a resolvable one, this build's registered link
358
+ * when it knows the code, and otherwise the index — a page that exists.
359
+ *
360
+ * `sent` is ordered, not singular: a server that fills `docs` with a `javascript:` URI still
361
+ * sent RFC-9457's `type`, and taking the first *resolvable* candidate means the unusable one
362
+ * costs nothing. Testing only the preferred slot would have dropped a valid link on the floor.
363
+ */
364
+ function remoteDocs(code: string, sent: readonly (string | undefined)[] = []): string | undefined {
365
+ const link = sent.find((value) => value !== undefined && ABSOLUTE_HTTP_URL.test(value));
366
+ if (link !== undefined) return link;
367
+ // `undefined` lets the constructor resolve the REGISTERED descriptor, whose docs a package
368
+ // may have declared as something other than the default URL.
369
+ return hasErrorCode(code) ? undefined : ERROR_DOCS_BASE;
370
+ }
371
+
372
+ /**
373
+ * A failure the SERVER raised, rebuilt from `application/problem+json` in the browser. The code
374
+ * is the server's — this package invents none, the same rule `ActionDeniedError` follows for a
375
+ * policy decision — but a code the server owns is one this bundle may never have registered, so
376
+ * the error says where it came from rather than passing as locally declared: `name` marks it in
377
+ * a stack trace and `meta.origin` marks it in `--json`, the dev overlay and the error reporter.
378
+ * `RpcFailedError` stays the answer when no framework code came back at all.
379
+ */
380
+ export class RemoteActionError extends UltimateError {
381
+ override readonly name = 'RemoteActionError';
382
+ /** The status that carried it — typed, so a caller never digs it back out of `meta`. */
383
+ readonly status: number;
384
+
385
+ constructor(failure: RemoteFailure) {
386
+ super({
387
+ code: failure.code,
388
+ cause: failure.cause,
389
+ fix: failure.fix,
390
+ docs: remoteDocs(failure.code, failure.docs),
391
+ meta: { origin: 'remote', action: failure.action, status: failure.status },
392
+ });
393
+ this.status = failure.status;
394
+ }
395
+ }
396
+
179
397
  /** The client got a non-`problem+json` failure — a proxy, not our server, answered. */
180
398
  export class RpcFailedError extends UltimateError {
181
399
  constructor(name: string, status: number) {
@@ -188,6 +406,61 @@ export class RpcFailedError extends UltimateError {
188
406
  }
189
407
  }
190
408
 
409
+ /**
410
+ * `audit: true` with no sink installed. Raised before the input parse, so an action nobody can
411
+ * record has made no write to be inconsistent about — the one audit failure that costs nothing.
412
+ * There is deliberately no logger-backed default sink to fall back on: a line nobody stores
413
+ * satisfies the declaration while recording nothing, which is the silent pass this code exists
414
+ * to turn into a stop.
415
+ */
416
+ export class AuditSinkMissingError extends UltimateError {
417
+ constructor(action: string) {
418
+ super({
419
+ code: 'X_AUDIT_SINK_MISSING',
420
+ cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
421
+ fix: "call setAuditSink(yourSink) from '@ultimat3/action' at boot, before registerActions()",
422
+ docs: docs('X_AUDIT_SINK_MISSING'),
423
+ });
424
+ }
425
+ }
426
+
427
+ /**
428
+ * The sink refused a record for an attempt that SUCCEEDED. The opposite call to the cache tier's
429
+ * `bestEffort`, and for the opposite reason: a dropped cache entry expires by TTL and the stack
430
+ * self-heals, while nothing ever re-derives an audit row that was never written. So the caller is
431
+ * told rather than left believing the operation was recorded.
432
+ *
433
+ * The cause says what the fix cannot undo: the handler already committed.
434
+ *
435
+ * **The fix branches, because only one of the two is true.** "Retry with the same
436
+ * Idempotency-Key" is safe exactly when this invocation went through the idempotency store —
437
+ * the settled record is then replayed and the record is re-attempted without re-running the
438
+ * handler. It did not when the action is not `idempotent`, and it did not when the action IS
439
+ * `idempotent` and the caller sent no key: `invoke` reads
440
+ * `def.idempotent === true ? (options.idempotencyKey ?? null) : null`, so both collapse to the
441
+ * same `null`. Telling either one to retry instructs a caller to apply a committed write twice
442
+ * — the worst possible advice for a mutator, and an axiom-4 violation dressed as a fix line.
443
+ * `replayable` is therefore the invocation's own fact (`record.idempotencyKey !== null`), never
444
+ * the declaration's: requiring `idempotent: true` at declaration would not have made the
445
+ * original message true, since a caller may still omit the header.
446
+ */
447
+ export class AuditSinkFailedError extends UltimateError {
448
+ constructor(action: string, sourceError: unknown, replayable: boolean) {
449
+ super({
450
+ code: 'X_AUDIT_SINK_FAILED',
451
+ cause: `"${action}" ran and its audit sink refused the record, so the change is unrecorded — the handler had already committed`,
452
+ fix: replayable
453
+ ? `fix the sink installed by setAuditSink, then retry with the same Idempotency-Key — the replay re-records without re-running the handler`
454
+ : `fix the sink installed by setAuditSink, then reconcile this one change by hand — do NOT retry: ${action} ran with no Idempotency-Key, so a second call runs the committed handler again. Add \`idempotent: true\` and send the header to make retries safe`,
455
+ docs: docs('X_AUDIT_SINK_FAILED'),
456
+ // Read by `--json` and the error reporter: whether a retry is safe is the one decision an
457
+ // operator makes here, so it is a field and not only a sentence.
458
+ meta: { action, replayable },
459
+ sourceError,
460
+ });
461
+ }
462
+ }
463
+
191
464
  export class ContractDriftError extends UltimateError {
192
465
  constructor(cause: string, fix: string) {
193
466
  super({ code: 'X_CONTRACT_DRIFT', cause, fix, docs: docs('X_CONTRACT_DRIFT') });
package/src/http.ts CHANGED
@@ -5,10 +5,17 @@
5
5
  * there is no way to mount an action without them.
6
6
  */
7
7
 
8
- import { isUltimateError } from '@ultimat3/core';
8
+ import { tagKeys } from '@ultimat3/cache';
9
+ import { isMcpExposed, isUltimateError } from '@ultimat3/core';
9
10
  import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
10
- import { json, problem } from '@ultimat3/http';
11
+ // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
12
+ // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
13
+ // a copy here would be a second answer to "what does this limit mean" for the read half.
14
+ import { json, problem, redirect, takeRedirect, toBucket } from '@ultimat3/http';
11
15
  import type { ActionRateLimit, AnyAction } from './action';
16
+ import type { Deprecation } from './deprecation';
17
+ import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
18
+ import { ActionDeprecationInvalidError } from './errors';
12
19
  import { actionName, defOf, invoke } from './invoke';
13
20
  import {
14
21
  derivePath,
@@ -17,12 +24,11 @@ import {
17
24
  PROBLEM_SCHEMA_NAME,
18
25
  schemaRef,
19
26
  toOperationId,
20
- toToolName,
21
27
  } from './naming';
22
28
  import { policyCapability } from './policy-gate';
23
- import { tagKeys } from './tags';
24
29
 
25
- /** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.buildId`. */
30
+ /** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
31
+ * CLIENT's claim, never `ctx.buildId`, which is the build this process serves. */
26
32
  export const BUILD_ID_HEADER = 'x-ultimate-build';
27
33
  export const IDEMPOTENCY_HEADER = 'idempotency-key';
28
34
  export const REPLAYED_HEADER = 'x-ultimate-replayed';
@@ -36,8 +42,12 @@ export function toRoute(target: AnyAction): Route {
36
42
  const name = actionName(target);
37
43
  const { path, resource } = derivePath(name);
38
44
  const def = defOf(target);
45
+ // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
46
+ // not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
47
+ const sunsetting = deprecationHeadersFor(name, def.deprecated);
39
48
 
40
49
  const handler = async (req: UltimateRequest): Promise<Response> => {
50
+ if (sunsetting !== undefined) recordDeprecatedCall('action', name);
41
51
  try {
42
52
  // The pipeline already parsed and size-capped the body; parsing it again here
43
53
  // would be a second, differently-behaved parser for the same bytes.
@@ -51,14 +61,26 @@ export function toRoute(target: AnyAction): Route {
51
61
  replayed = true;
52
62
  },
53
63
  });
54
- const response = json(result);
64
+ // The one thing an action's return value cannot say. `setRedirect()` inside the handler
65
+ // is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
66
+ // 200 this used to always return is a header browsers ignore, so a JS-less form left the
67
+ // reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
68
+ // fact, and the MCP tool and the job handle share none of it.
69
+ const to = takeRedirect(req.ctx);
70
+ const response = to === undefined ? json(result) : redirect(to.location, to.status);
55
71
  if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
72
+ // On the failure path too, below: a client polling a deprecated endpoint that is currently
73
+ // 403ing still has to learn the endpoint is going away. Announcing it only on 200 hides the
74
+ // sunset from exactly the callers most likely to be stale.
75
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
56
76
  return response;
57
77
  } catch (error) {
58
78
  // Framework errors carry their own code, status and fix line; anything else is
59
79
  // a bug and belongs to the server's error boundary, not to this route.
60
- if (isUltimateError(error)) return problem(error);
61
- throw error;
80
+ if (!isUltimateError(error)) throw error;
81
+ const response = problem(error);
82
+ if (sunsetting !== undefined) applyHeaders(response, sunsetting);
83
+ return response;
62
84
  }
63
85
  };
64
86
 
@@ -76,7 +98,12 @@ export function toRoute(target: AnyAction): Route {
76
98
  input: def.input,
77
99
  cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
78
100
  tags: [resource],
79
- ...(def.rateLimit === undefined ? {} : { rateLimit: name }),
101
+ // Name AND numbers. The name alone selected a bucket the limiter's table never held, so
102
+ // `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
103
+ // numbers ride along and `withRouteBuckets` registers them at construction.
104
+ ...(def.rateLimit === undefined
105
+ ? {}
106
+ : { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
80
107
  ...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
81
108
  };
82
109
 
@@ -87,6 +114,9 @@ export interface OpenApiOperation {
87
114
  readonly operationId: string;
88
115
  readonly tags: readonly string[];
89
116
  readonly summary: string;
117
+ /** OpenAPI's own flag. Absent — not `false` — when nothing is deprecated, so the spec bytes
118
+ * of an app that deprecates nothing are unchanged and `x verify`'s contract diff stays quiet. */
119
+ readonly deprecated?: boolean;
90
120
  readonly parameters: readonly Record<string, unknown>[];
91
121
  readonly requestBody: Record<string, unknown>;
92
122
  readonly responses: Record<string, unknown>;
@@ -99,10 +129,12 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
99
129
  const def = defOf(target);
100
130
  const path = derivePath(name);
101
131
  const idempotent = def.idempotent === true;
132
+ const deprecation = deprecationMetaFor(name, def.deprecated);
102
133
  return {
103
134
  operationId: toOperationId(name),
104
135
  tags: [path.resource],
105
136
  summary: def.mcp?.description ?? name,
137
+ ...(deprecation === undefined ? {} : { deprecated: true }),
106
138
  parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
107
139
  requestBody: {
108
140
  required: true,
@@ -121,14 +153,59 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
121
153
  capability: policyCapability(def.policy),
122
154
  idempotent,
123
155
  invalidates: tagKeys(def.cache?.invalidates ?? []),
124
- mcpTool: def.mcp?.expose === false ? null : toToolName(name),
125
- rateLimit: rateLimitMeta(def.rateLimit),
156
+ // The tool name an agent would call, or `null` when there is no tool. `!== false` here
157
+ // advertised one for every action, so an agent reading the spec asked for a tool the MCP
158
+ // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. The NAME was
159
+ // the second half of the same defect: `toToolName` published `publish_post` while
160
+ // `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
161
+ // exist. Verbatim, and never derived — this is a published contract, not a label.
162
+ mcpTool: isMcpExposed(def.mcp) ? name : null,
163
+ rateLimit: rateLimitMeta(name, def.rateLimit),
164
+ // The dates as data, beside the boolean flag OpenAPI defines: `deprecated: true` says an
165
+ // operation is going away and nothing else, so a client that wants to plan the migration
166
+ // has to read the sunset out of prose. Absent, not null, for the same byte-stability
167
+ // reason `deprecated` is.
168
+ ...(deprecation === undefined ? {} : { deprecation }),
126
169
  },
127
170
  };
128
171
  }
129
172
 
130
- function rateLimitMeta(limit: ActionRateLimit | undefined): Record<string, number> | null {
131
- return limit === undefined ? null : { limit: limit.limit, windowMs: limit.windowMs };
173
+ /**
174
+ * The headers this action's `deprecated:` block renders to, or nothing. The successor's URL comes
175
+ * from `derivePath` — the same derivation the route and the client use, never a second one.
176
+ */
177
+ function deprecationHeadersFor(
178
+ name: string,
179
+ deprecated: Deprecation | undefined,
180
+ ): Readonly<Record<string, string>> | undefined {
181
+ if (deprecated === undefined) return undefined;
182
+ const successor =
183
+ deprecated.replacedBy === undefined ? undefined : derivePath(deprecated.replacedBy).path;
184
+ const rendered = renderDeprecation(deprecated, successor);
185
+ if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
186
+ return rendered.headers;
187
+ }
188
+
189
+ /** The same declaration as spec data. Validated through the same render, so the two agree. */
190
+ function deprecationMetaFor(
191
+ name: string,
192
+ deprecated: Deprecation | undefined,
193
+ ): Readonly<Record<string, string>> | undefined {
194
+ if (deprecated === undefined) return undefined;
195
+ const rendered = renderDeprecation(deprecated, undefined);
196
+ if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
197
+ return rendered.meta;
198
+ }
199
+
200
+ function rateLimitMeta(
201
+ name: string,
202
+ limit: ActionRateLimit | undefined,
203
+ ): Record<string, number> | null {
204
+ if (limit === undefined) return null;
205
+ // Validated through the same call the route makes, so the spec cannot publish a pair the
206
+ // limiter would have refused to run on.
207
+ toBucket(name, limit);
208
+ return { limit: limit.limit, windowMs: limit.windowMs };
132
209
  }
133
210
 
134
211
  const IDEMPOTENCY_PARAMETER: Record<string, unknown> = {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What an idempotency record is filed under: the action, the CALLER, and the caller's key —
3
+ * one JSON tuple, so no part of it can spell another. A key scoped to the action name alone was a
4
+ * key space every caller shared, and a blank header is refused rather than read as no key at all.
5
+ */
6
+ import type { Actor } from '@ultimat3/core';
7
+ import { IdempotencyKeyInvalidError } from './errors';
8
+
9
+ /**
10
+ * The bound the OpenAPI operation has always published for the `Idempotency-Key` parameter. It is
11
+ * enforced here so the spec and the runtime say the same thing — and because the key is
12
+ * caller-chosen, which makes its length the caller's choice of how much store to occupy.
13
+ */
14
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
15
+
16
+ /**
17
+ * Keys are namespaced per action AND per caller: the same key under two actions is two keys, and
18
+ * the same key from two callers is two keys.
19
+ *
20
+ * The caller half is the fix for a real replay across identities — alice POSTs `charge` with a
21
+ * key, bob POSTs `charge` with the same key and was handed alice's stored response; with a
22
+ * differing payload bob got `X_IDEMPOTENCY_CONFLICT` instead, so any key he guessed was a key he
23
+ * could deny her.
24
+ *
25
+ * **The encoding is a JSON tuple and never a joined string**, the reasoning `@ultimat3/query`'s
26
+ * `readAuthority` states: an actor id is app data, so a caller who can choose one can spell
27
+ * whatever separator the key uses. Under `${action}:${id}:${key}`, id `alice:x` with key `y` and
28
+ * id `alice` with key `x:y` are one record. Fixed arity plus JSON escaping means no value can
29
+ * move a boundary.
30
+ *
31
+ * The limit it does NOT close: an anonymous actor has no identity to narrow to, so every
32
+ * anonymous caller of a public idempotent action still shares one key space. Nothing at this tier
33
+ * can tell two of them apart, and narrowing to something that is not identity (an IP, a session
34
+ * cookie) would break the retry it exists to serve.
35
+ */
36
+ export function idempotencyKeyFor(actionName: string, key: string, actor: Actor): string {
37
+ if (key.trim().length === 0) {
38
+ throw new IdempotencyKeyInvalidError(actionName, 'empty', key.length);
39
+ }
40
+ if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
41
+ throw new IdempotencyKeyInvalidError(actionName, 'too-long', key.length);
42
+ }
43
+ // The same three fields every other caller-scoped key in the framework is built from
44
+ // (`readAuthority`, `scopeKey`): kind, id, org. Two of them alone is one identity too few —
45
+ // a `service` and a `user` may hold the same id, and one actor's org is what a tenant sees.
46
+ return JSON.stringify([actionName, actor.kind, actor.id, actor.orgId ?? null, key]);
47
+ }