@ultimat3/action 23.0.0 → 25.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.
@@ -0,0 +1,51 @@
1
+ // @ultimat3/action's error TITLES, registered at import — the one module `sideEffects` names, so
2
+ // `errors.ts` (the classes) is unlisted and a chunk carries it only where it constructs a class:
3
+ // Bun >= 1.4.1 honours the array and keeps a listed module wherever the barrel reaches it.
4
+ // `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
5
+ // its title from the registry; `errors.ts` and its two siblings import it bare as well.
6
+
7
+ import { registerErrorCodes } from '@ultimat3/core';
8
+
9
+ /**
10
+ * Titles for the framework-wide code table — every one of them owned by this package.
11
+ * `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
12
+ * enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
13
+ * Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
14
+ */
15
+ export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
16
+ X_ACTION_DUPLICATE: 'two actions are registered under one name',
17
+ X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
18
+ X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
19
+ X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
20
+ X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
21
+ X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
22
+ X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
23
+ X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
24
+ X_CONTRACT_DRIFT: 'client and server disagree about the contract',
25
+ X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
26
+ X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
27
+ X_IDEMPOTENCY_NOT_SHARED:
28
+ 'idempotency is declared fleet-wide and the installed store is per-process',
29
+ X_IDEMPOTENCY_REPLAYED_FAILURE:
30
+ 'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
31
+ X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
32
+ X_INPUT_INVALID: 'input failed schema validation',
33
+ X_MUTATOR_CLOCK_MISSING:
34
+ "a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
35
+ X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
36
+ X_RPC_FAILED: 'an RPC call failed without a problem+json body',
37
+ X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
38
+ X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
39
+ X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
40
+ X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
41
+ X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
42
+ X_IDEMPOTENCY_RESERVATION_LOST:
43
+ "an idempotent action's reservation was taken over before its transaction could settle it",
44
+ X_IDEMPOTENT_REPLAY_REDACTED: 'an idempotent replay would return a field redacted at rest',
45
+ };
46
+
47
+ // One unconditional call: a presence guard would turn "another package claims one of these codes"
48
+ // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
49
+ registerErrorCodes(
50
+ Object.fromEntries(Object.entries(ACTION_ERROR_TITLES).map(([code, title]) => [code, { title }])),
51
+ );
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The refusals of the HTTP declaration an app makes about its actions: a pinned `http.path`, the
3
3
  * app's `pathStyle`, and the `openapi` block. Split from `errors.ts` at its ceiling; the codes and
4
- * titles stay registered there, one registry.
4
+ * titles are registered in `error-titles.ts`, one registry, imported bare so constructing one of
5
+ * these registers its title first.
5
6
  */
6
7
 
7
8
  import { renderCauseValue, UltimateError } from '@ultimat3/core';
9
+ import './error-titles';
8
10
 
9
11
  export class ActionHttpPathInvalidError extends UltimateError {
10
12
  constructor(input: { name: string; path: unknown; reason: string }) {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * The five idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
3
- * stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
2
+ * The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
3
+ * stable code, exactly as there — the codes and their titles stay in `error-titles.ts`'s one
4
4
  * `registerErrorCodes` call, because a second registration is how two modules end up deciding a
5
- * title by load order.
5
+ * title by load order. Imported bare, so constructing one of these registers its title first.
6
6
  */
7
7
  import { renderCauseValue, UltimateError } from '@ultimat3/core';
8
+ import './error-titles';
8
9
  // Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
9
10
  // cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
10
11
  import type { IdempotencyFailure } from './idempotency';
@@ -77,14 +78,14 @@ export class IdempotencyNotSharedError extends UltimateError {
77
78
  // positional form is `unsafe`), so that line compiled and would have thrown on the first
78
79
  // reservation. The framework's own boot already installs this store; a host booting the
79
80
  // framework itself wraps the client it opened.
80
- 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' })",
81
+ 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 }) }, origin: () => client, reclaimAfterMs: requestDeadlineMs })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
81
82
  meta: { storeScope: storeScope ?? null },
82
83
  });
83
84
  }
84
85
  }
85
86
 
86
87
  /**
87
- * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
88
+ * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guardAction()`
88
89
  * and the input parse both run before the idempotency gate, so everything the gate can see throw
89
90
  * is post-authorization and possibly post-commit — a handler that took the money and then failed
90
91
  * its own `output:` schema is the case this exists for. Releasing the reservation there let the
@@ -138,3 +139,54 @@ export class IdempotencyStatusUnknownError extends UltimateError {
138
139
  });
139
140
  }
140
141
  }
142
+
143
+ /**
144
+ * A mutator declared without `idempotent: true`. Refused at declaration: the client replays a
145
+ * mutator's write under one `Idempotency-Key` (a retry, an offline queue draining after a lost
146
+ * response), and `toRoute` reads that key only for an idempotent action — so the alternative is a
147
+ * toggle that flips back, with nothing in any log to say the handler ran twice.
148
+ */
149
+ export class MutatorNotIdempotentError extends UltimateError {
150
+ constructor() {
151
+ super({
152
+ code: 'X_MUTATOR_NOT_IDEMPOTENT',
153
+ cause:
154
+ 'a mutator is declared without idempotent: true, so a replay of its write under the same Idempotency-Key would run the server half a second time',
155
+ fix: 'add `idempotent: true` to the mutator definition',
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * A settlement inside the handler's transaction matched no record: the attempt ran past the
162
+ * request deadline, a retry reclaimed the key, and this one no longer owns it. THROWN, where the
163
+ * autocommit path only logs — nothing here has committed, so the throw rolls this attempt's
164
+ * writes back and the retry's are the only ones stored.
165
+ */
166
+ export class IdempotencyReservationLostError extends UltimateError {
167
+ constructor(key: string) {
168
+ super({
169
+ code: 'X_IDEMPOTENCY_RESERVATION_LOST',
170
+ cause: `idempotency key "${key}" was reclaimed by a retry while this attempt was still in flight past the request deadline, so its transaction is rolled back rather than committed beside the retry's`,
171
+ fix: 'resend this request with the same Idempotency-Key — the attempt that took the key over answers it, and this one was rolled back',
172
+ meta: { key },
173
+ });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * A replay of a settled answer that had a field redacted at rest. Refused, never served: the
179
+ * stored copy holds `[redacted]` where the first caller got a credential, and handing that back
180
+ * as "the answer" is a silent wrong value. The first attempt RAN — a fresh key runs it again, so
181
+ * the fix names reading its effect as the way to recover the result.
182
+ */
183
+ export class IdempotentReplayRedactedError extends UltimateError {
184
+ constructor(key: string) {
185
+ super({
186
+ code: 'X_IDEMPOTENT_REPLAY_REDACTED',
187
+ cause: `the answer stored for idempotency key "${key}" had a secret-named field redacted when it settled, so it cannot be replayed faithfully`,
188
+ fix: "x actions describe '<action>' --json # the call already ran and showed its secret once — read its effect with a query; a new Idempotency-Key runs it again (a second credential)",
189
+ meta: { key, replayed: true },
190
+ });
191
+ }
192
+ }
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
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
- * The idempotency five live in `errors-idempotency.ts` — this file reached the line ceiling —
4
+ * The idempotency failures live in `errors-idempotency.ts` — this file reached the line ceiling —
5
5
  * and are re-exported here, so `./errors` stays the one import path for all of them.
6
6
  */
7
7
  import {
@@ -9,15 +9,20 @@ import {
9
9
  deniedCallerFix,
10
10
  ERROR_DOCS_URL,
11
11
  hasErrorCode,
12
- registerErrorCodes,
13
12
  renderFixShellArg,
14
13
  retryForStatus,
15
14
  UltimateError,
15
+ withStatedDelay,
16
16
  } from '@ultimat3/core';
17
17
  import type { SurfaceDenial } from '@ultimat3/policy';
18
18
  import type { ValidationIssue } from '@ultimat3/schema';
19
+ // The titles are `error-titles.ts`, imported bare: this module is then side-effect-free and the
20
+ // array does not list it, so a chunk keeps only the classes it constructs, and a chunk that
21
+ // constructs one still registers every title first — anchored by use. The barrel imports it bare
22
+ // too, for the browser that rebuilds a SERVER's refusal by code and constructs nothing from here.
23
+ import './error-titles';
19
24
 
20
- // Re-exported, not re-declared: the five idempotency failures moved to their own file when this
25
+ // Re-exported, not re-declared: the idempotency failures moved to their own file when this
21
26
  // one reached the line ceiling, and every importer still reads them from `./errors`.
22
27
  export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
23
28
  export {
@@ -25,49 +30,12 @@ export {
25
30
  IdempotencyKeyInvalidError,
26
31
  IdempotencyNotSharedError,
27
32
  IdempotencyReplayedFailureError,
33
+ IdempotencyReservationLostError,
28
34
  IdempotencyStatusUnknownError,
35
+ IdempotentReplayRedactedError,
36
+ MutatorNotIdempotentError,
29
37
  } from './errors-idempotency';
30
38
 
31
- /**
32
- * Titles for the framework-wide code table — every one of them owned by this package.
33
- * `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
34
- * enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
35
- * Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
36
- */
37
- const OWNED_TITLES: Readonly<Record<string, string>> = {
38
- X_ACTION_DUPLICATE: 'two actions are registered under one name',
39
- X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
40
- X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
41
- X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
42
- X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
43
- X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
44
- X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
45
- X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
46
- X_CONTRACT_DRIFT: 'client and server disagree about the contract',
47
- X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
48
- X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
49
- X_IDEMPOTENCY_NOT_SHARED:
50
- 'idempotency is declared fleet-wide and the installed store is per-process',
51
- X_IDEMPOTENCY_REPLAYED_FAILURE:
52
- 'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
53
- X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
54
- X_INPUT_INVALID: 'input failed schema validation',
55
- X_MUTATOR_CLOCK_MISSING:
56
- "a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
57
- X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
58
- X_RPC_FAILED: 'an RPC call failed without a problem+json body',
59
- X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
60
- X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
61
- X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
62
- X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
63
- };
64
-
65
- // One unconditional call: a presence guard would turn "another package claims one of these codes"
66
- // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
67
- registerErrorCodes(
68
- Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
69
- );
70
-
71
39
  /** Thrown when a projection needs a name the action does not have yet. */
72
40
  export class ActionUnregisteredError extends UltimateError {
73
41
  constructor() {
@@ -124,7 +92,7 @@ export function denialReason(denial: SurfaceDenial): string {
124
92
  }
125
93
 
126
94
  /**
127
- * An authz denial, thrown by `guard()`. The code and reason come from the policy
95
+ * An authz denial, thrown by `guardAction()`. The code and reason come from the policy
128
96
  * decision — this package never invents an authz code — and the surface-shaped
129
97
  * denial rides along for projections that render it themselves.
130
98
  */
@@ -219,7 +187,7 @@ export class InputInvalidError extends UltimateError {
219
187
  super({
220
188
  code: 'X_INPUT_INVALID',
221
189
  cause: `input for ${subject} "${name}" failed validation: ${detail}`,
222
- fix: `x actions describe ${name} --json # prints the expected input schema`,
190
+ fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # prints the expected input schema`,
223
191
  // A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
224
192
  // body — and cannot run `x actions describe`.
225
193
  callerFix:
@@ -240,7 +208,7 @@ export class OutputInvalidError extends UltimateError {
240
208
  super({
241
209
  code: 'X_OUTPUT_INVALID',
242
210
  cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
243
- fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
211
+ fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # compare the handler's return against \`output:\``,
244
212
  });
245
213
  }
246
214
  }
@@ -289,6 +257,13 @@ export interface RemoteFailure {
289
257
  * so an island reads `error.meta.sessionId` where it used to run a regex over `cause`.
290
258
  */
291
259
  readonly meta?: Readonly<Record<string, unknown>> | undefined;
260
+ /**
261
+ * The document's `title`, already read by core's `remoteTitleOf` — used only when this bundle
262
+ * registered no title for `code`, which is every http refusal in an island.
263
+ */
264
+ readonly title?: string | undefined;
265
+ /** The delay the answer's `Retry-After` named, in seconds (core's `retryAfterSecondsOf`). */
266
+ readonly retryAfterSeconds?: number | undefined;
292
267
  }
293
268
 
294
269
  /** A link, not a string the server happened to put in a field the overlay renders as an href. */
@@ -341,19 +316,27 @@ export class RemoteActionError extends UltimateError {
341
316
  // this build may never have heard of. `UltimateError` otherwise fills `retry` from
342
317
  // `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
343
318
  // `terminal` on the one field the framework promises a client never has to infer.
344
- retry: retryForStatus(failure.code, failure.status),
345
- meta: {
346
- // The server's declared keys FIRST, so the four this class owns win a collision: a server
347
- // meta naming `status` would otherwise overwrite the one a report reads the HTTP status
348
- // from, and `origin: 'remote'` is what marks the code as one this bundle never declared.
349
- ...failure.meta,
350
- origin: 'remote',
351
- action: failure.action,
352
- status: failure.status,
353
- // Absent rather than `undefined`: `meta` is rendered into `--json` and the error reporter,
354
- // and a null member reads as "the server sent an empty list" rather than "it sent none".
355
- ...(failure.issues === undefined ? {} : { issues: failure.issues }),
356
- },
319
+ // A 429 or 503 that stated a delay is `retry-after` whoever registered the code, and the
320
+ // delay rides on `meta.retryAfterSeconds`, the one key core's `statedDelayMs` reads.
321
+ retry: retryForStatus(failure.code, failure.status, failure.retryAfterSeconds),
322
+ remoteTitle: failure.title,
323
+ // Core's `withStatedDelay`, the rule `problemError` applies too: a `retryAfterSeconds` off the
324
+ // body is dropped and only the header's is carried — the server never writes one there.
325
+ meta: withStatedDelay(
326
+ {
327
+ // The server's declared keys FIRST, so the four this class owns win a collision: a
328
+ // server meta naming `status` would otherwise overwrite the one a report reads the HTTP
329
+ // status from, and `origin: 'remote'` marks the code as one this bundle never declared.
330
+ ...failure.meta,
331
+ origin: 'remote',
332
+ action: failure.action,
333
+ status: failure.status,
334
+ // Absent rather than `undefined`: `meta` is rendered into `--json` and the error
335
+ // reporter, and a null member reads as "the server sent an empty list".
336
+ ...(failure.issues === undefined ? {} : { issues: failure.issues }),
337
+ },
338
+ failure.retryAfterSeconds,
339
+ ),
357
340
  });
358
341
  this.status = failure.status;
359
342
  }
@@ -370,7 +353,7 @@ export class RpcFailedError extends UltimateError {
370
353
  super({
371
354
  code: 'X_RPC_FAILED',
372
355
  cause: `${name} returned HTTP ${status} without a problem+json body`,
373
- fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
356
+ fix: `check the gateway in front of the app, then: x actions describe ${renderFixShellArg(name, '<action-name>')} --json`,
374
357
  retry: retryForStatus('X_RPC_FAILED', status),
375
358
  });
376
359
  }
@@ -388,7 +371,7 @@ export class AuditSinkMissingError extends UltimateError {
388
371
  super({
389
372
  code: 'X_AUDIT_SINK_MISSING',
390
373
  cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
391
- fix: "call setAuditSink(yourSink) from '@ultimat3/action' at boot, before registerActions()",
374
+ fix: "call setAuditSink(yourSink) from '@ultimat3/core' at boot, before registerActions()",
392
375
  });
393
376
  }
394
377
  }
package/src/facade.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The fluent surface: every projection reachable as a method on the action itself,
3
- * `publishPost.tool()` rather than `toMcpTool(publishPost)`, and every declared
4
- * field lifted off `def` so app code never reaches through `.def`. The projection
5
- * functions stay exported for the framework's own call sites — this file only
6
- * binds them to the action, it never re-implements one.
2
+ * The fluent surface: every projection this package owns reachable as a method on the action
3
+ * itself, `publishPost.openapi()` rather than `toOpenApiOperation(publishPost)`, and every
4
+ * declared field lifted off `def` so app code never reaches through `.def`. The projection
5
+ * functions stay exported for the framework's own call sites — this file only binds them to the
6
+ * action, it never re-implements one. There is no `.tool()`: the MCP tool is `@ultimat3/mcp`'s
7
+ * one projection (`toolFrom(publishPost)`), a tier this package cannot import.
7
8
  */
8
9
 
9
10
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
@@ -13,7 +14,6 @@ import { contractTestsFor } from './contract-test';
13
14
  import { toOpenApiOperation } from './http';
14
15
  import { actionName, invoke } from './invoke';
15
16
  import { toJobHandle } from './job-handle';
16
- import { toMcpTool } from './mcp-tool';
17
17
 
18
18
  /**
19
19
  * `self` is a thunk on purpose: the façade is attached while the action is still
@@ -33,7 +33,6 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
33
33
  // the output type is this action's by construction.
34
34
  as: (actor, input, options) =>
35
35
  invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
36
- tool: () => toMcpTool(self()),
37
36
  openapi: () => toOpenApiOperation(self()),
38
37
  client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
39
38
  job: () => toJobHandle(self()),
package/src/http-path.ts CHANGED
@@ -69,7 +69,7 @@ export function setActionPathStyle(next: unknown): ActionPathStyle {
69
69
  return style;
70
70
  }
71
71
 
72
- /** Test seam, beside `resetRegistry`. */
72
+ /** Test seam, beside `resetActions`. */
73
73
  /**
74
74
  * Forget which paths were handed out, keeping the style. `@ultimat3/testing` calls it between two
75
75
  * test files of one worker (22.7, no `--isolate`): a path an earlier FILE derived is not one this
package/src/http.ts CHANGED
@@ -6,10 +6,15 @@
6
6
  */
7
7
 
8
8
  import { tagKeys } from '@ultimat3/cache';
9
+ import type { Deprecation } from '@ultimat3/core';
9
10
  import {
11
+ IDEMPOTENCY_HEADER,
10
12
  isMcpExposed,
11
13
  RECORDS_OPENAPI_HEADER,
14
+ recordDeprecatedCall,
12
15
  recordEnvelopeSchema,
16
+ renderDeprecation,
17
+ useContext,
13
18
  withWriteOrigin,
14
19
  writeDigest,
15
20
  } from '@ultimat3/core';
@@ -17,10 +22,9 @@ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
17
22
  // `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
18
23
  // and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
19
24
  // a copy here would be a second answer to "what does this limit mean" for the read half.
20
- import { json, redirect, takeRedirect, toBucket } from '@ultimat3/http';
25
+ import { jsonResponse, publishRateLimit, redirect, takeRedirect, toBucket } from '@ultimat3/http';
26
+ import { admitsAnonymous, policyCapability, policyPermissions } from '@ultimat3/policy';
21
27
  import type { ActionRateLimit, AnyAction } from './action';
22
- import type { Deprecation } from './deprecation';
23
- import { recordDeprecatedCall, renderDeprecation } from './deprecation';
24
28
  import { ActionDeprecationInvalidError } from './errors';
25
29
  import { actionPathStyle, servedActionRoute } from './http-path';
26
30
  import { actionName, defOf, invoke } from './invoke';
@@ -31,16 +35,7 @@ import {
31
35
  schemaRef,
32
36
  toOperationId,
33
37
  } from './naming';
34
- import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
35
38
  import { carriesRecords, recordResponse } from './record-wire';
36
- import { IDEMPOTENCY_HEADER } from './wire-headers';
37
-
38
- /**
39
- * Re-exported, never re-declared. The two strings moved to `wire-headers.ts` so `client.ts` can
40
- * name them without pulling this file's server graph into a browser bundle; every importer of
41
- * `./http` still reads them from here, which is what kept the move from touching a call site.
42
- */
43
- export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
44
39
 
45
40
  export const REPLAYED_HEADER = 'x-ultimate-replayed';
46
41
 
@@ -90,6 +85,9 @@ function projectRoute(
90
85
  // Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
91
86
  // not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
92
87
  const sunsetting = deprecationHeadersFor(name, def.deprecated);
88
+ // Same rule, for a limit the limiter could not run on: refused at mount (`X_RATE_LIMIT_INVALID`),
89
+ // never on the first request that tries to spend it.
90
+ if (def.rateLimit !== undefined) toBucket(name, def.rateLimit);
93
91
  const enveloped = carriesRecords(target.output);
94
92
 
95
93
  const handler = async (req: UltimateRequest): Promise<Response> => {
@@ -121,6 +119,9 @@ function projectRoute(
121
119
  onReplay: () => {
122
120
  replayed = true;
123
121
  },
122
+ clientAddress: req.ctx.ip,
123
+ onRateLimit: (decision) =>
124
+ publishRateLimit(req.ctx, decision, useContext().now().getTime()),
124
125
  }),
125
126
  );
126
127
  // The one thing an action's return value cannot say. `setRedirect()` inside the handler
@@ -134,7 +135,7 @@ function projectRoute(
134
135
  ? redirect(to.location, to.status)
135
136
  : enveloped
136
137
  ? recordResponse(target.output, result)
137
- : json(result);
138
+ : jsonResponse(result);
138
139
  if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
139
140
  return response;
140
141
  };
@@ -154,6 +155,9 @@ function projectRoute(
154
155
  // stage deciding first would decide from `row: null` — a denial for the row's own
155
156
  // author, from an authz system that never saw the row.
156
157
  enforcedBy: 'handler',
158
+ // Which primitive this is, for `@ultimat3/http`'s bucket check: a `rateLimit.buckets.<name>`
159
+ // named after an action limits nothing since 25.0.0, and is refused rather than ignored.
160
+ primitive: 'action',
157
161
  // `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
158
162
  // Setting it hands the action's schema to the pipeline's `body` stage, which throws
159
163
  // `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
@@ -167,12 +171,11 @@ function projectRoute(
167
171
  // failing a plain `route.ts`'s own schema, where no primitive owns the input.
168
172
  cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
169
173
  tags: [operationTagOf(target)],
170
- // Name AND numbers. The name alone selected a bucket the limiter's table never held, so
171
- // `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
172
- // numbers ride along and `withRouteBuckets` registers them at construction.
173
- ...(def.rateLimit === undefined
174
- ? {}
175
- : { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
174
+ // `invoke` spends the declared bucket for every surface, so the stage spends no caller bucket
175
+ // for this route — not the action's (that was the second, HTTP-only enforcement point MCP,
176
+ // the agent tool and `.job()` never reached) and not `default` (a ceiling an action declaring
177
+ // more than it never had). The tenant allowance is still the stage's.
178
+ ...(def.rateLimit === undefined ? {} : { rateLimitedBy: 'handler' as const }),
176
179
  ...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
177
180
  };
178
181
 
@@ -254,7 +257,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
254
257
  invalidates: tagKeys(def.cache?.invalidates ?? []),
255
258
  // The tool name an agent would call, or `null` when there is no tool. `!== false` here
256
259
  // advertised one for every action, so an agent reading the spec asked for a tool the MCP
257
- // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. The NAME was
260
+ // catalog never listed — `isMcpExposed` is the same answer `@ultimat3/mcp` gives. The NAME was
258
261
  // the second half of the same defect: `toToolName` published `publish_post` while
259
262
  // `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
260
263
  // exist. Verbatim, and never derived — this is a published contract, not a label.