@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.
@@ -9,8 +9,10 @@ import {
9
9
  IdempotencyConflictError,
10
10
  IdempotencyNotSharedError,
11
11
  IdempotencyReplayedFailureError,
12
+ IdempotentReplayRedactedError,
12
13
  } from './errors';
13
- import { MemoryIdempotencyStore } from './idempotency-memory';
14
+ import { memoryIdempotencyStore } from './idempotency-memory';
15
+ import { restingAnswer } from './idempotency-redact';
14
16
 
15
17
  /**
16
18
  * Where a store's records live. Declared by the driver, never inferred — the same rule
@@ -68,6 +70,12 @@ export interface IdempotencyRecord {
68
70
  readonly value: unknown;
69
71
  /** Present exactly when `status === 'failed'` — what the replay re-throws. */
70
72
  readonly failure?: IdempotencyFailure | undefined;
73
+ /**
74
+ * True when `value` had a field redacted at rest (`idempotency-redact.ts`). Its replay is
75
+ * `X_IDEMPOTENT_REPLAY_REDACTED`, decided by this flag — never by scanning the value for
76
+ * `[redacted]`, a string a legitimate answer may hold. Absent or false: replayed as stored.
77
+ */
78
+ readonly redacted?: boolean | undefined;
71
79
  /** Epoch milliseconds. The store's own dedupe window is measured from here. */
72
80
  readonly createdAt: number;
73
81
  }
@@ -91,6 +99,14 @@ export interface IdempotencyStore {
91
99
  * is one immortal entry per write, forever.
92
100
  */
93
101
  readonly windowMs?: number | undefined;
102
+ /**
103
+ * The store's declaration that it keeps `settle`'s `redacted` flag and hands it back as
104
+ * `IdempotencyRecord.redacted` (#591). REQUIRED, and a literal: a store written before answers
105
+ * were redacted at rest drops the flag and would replay `[redacted]` as the answer, and a
106
+ * required parameter alone cannot refuse it — TypeScript accepts an implementation with fewer
107
+ * parameters. Without this member, such a store is a build error (`type-pins.ts`).
108
+ */
109
+ readonly keepsRedaction: true;
94
110
  /** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
95
111
  reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
96
112
  /**
@@ -104,8 +120,13 @@ export interface IdempotencyStore {
104
120
  * exactly, overwrote a live reservation, and the replacement's own settle was then fenced out —
105
121
  * so the retry replayed a value produced for a different request. A settlement that matches no
106
122
  * record is logged, never thrown: it lands after the handler has committed.
123
+ *
124
+ * `value` is already the RESTING copy — `withIdempotency` redacts before it calls, so no store
125
+ * ever receives a credential. `redacted` says it did, and a store MUST keep it and hand it back
126
+ * as `IdempotencyRecord.redacted`: one that drops it replays `[redacted]` as the answer. Required
127
+ * of every caller, so a store that wraps another cannot drop it on the way through.
107
128
  */
108
- settle(key: string, value: unknown, reservationId: string): Promise<void>;
129
+ settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void>;
109
130
  /**
110
131
  * Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
111
132
  * have committed. Fenced on the same reservation id as `settle`, for the same case — a
@@ -136,7 +157,7 @@ export interface IdempotencyConfig {
136
157
  export const DEFAULT_IDEMPOTENCY_CONFIG: IdempotencyConfig = Object.freeze({ scope: 'process' });
137
158
 
138
159
  let config: IdempotencyConfig = DEFAULT_IDEMPOTENCY_CONFIG;
139
- let defaultStore: IdempotencyStore = new MemoryIdempotencyStore();
160
+ let defaultStore: IdempotencyStore = memoryIdempotencyStore();
140
161
 
141
162
  /** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
142
163
  export function configureIdempotency(next: IdempotencyConfig): void {
@@ -158,7 +179,7 @@ export function getIdempotencyStore(): IdempotencyStore {
158
179
  /** Test-only. A process configures its idempotency once at boot and never reconfigures it. */
159
180
  export function resetIdempotency(): void {
160
181
  config = DEFAULT_IDEMPOTENCY_CONFIG;
161
- defaultStore = new MemoryIdempotencyStore();
182
+ defaultStore = memoryIdempotencyStore();
162
183
  }
163
184
 
164
185
  /**
@@ -190,19 +211,30 @@ export interface IdempotentOutcome<T> {
190
211
  * X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
191
212
  * flight.
192
213
  *
193
- * **Everything `run()` throws is treated as possibly-committed.** `guard()` and `validateInput`
214
+ * **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `validateInput`
194
215
  * both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
195
216
  * things left are the handler and `validateOutput` — and the second of those throws *after* the
196
217
  * first has committed. Releasing the reservation there is what turned a rounding change in an
197
218
  * output schema into a second charge: `X_OUTPUT_INVALID` dropped the record, and the client's
198
219
  * automatic retry re-ran a handler that had already taken the money. So the failure is SETTLED
199
- * and replayed, and `release` is reserved for a pre-handler failure — of which this gate has none.
220
+ * and replayed, and `release` is reserved for a pre-handler failure — `beforeRun` is the only one.
200
221
  */
222
+ export interface WithIdempotencyOptions {
223
+ /**
224
+ * Called only for a reservation this call CREATED, before `run` — `invoke`'s rate-limit spend
225
+ * for a key its pre-parse peek took for a replay (a stale record reclaimed in between). A throw
226
+ * here is a PRE-handler failure, so the reservation is released, never settled: a refusal is not
227
+ * the key's outcome, and a retry once the bucket refills must still be able to run.
228
+ */
229
+ readonly beforeRun?: () => Promise<void>;
230
+ }
231
+
201
232
  export async function withIdempotency<T>(
202
233
  store: IdempotencyStore,
203
234
  key: string,
204
235
  input: unknown,
205
236
  run: () => Promise<T>,
237
+ options: WithIdempotencyOptions = {},
206
238
  ): Promise<IdempotentOutcome<T>> {
207
239
  const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
208
240
  const { record, created } = await store.reserve(key, requestHash);
@@ -210,9 +242,20 @@ export async function withIdempotency<T>(
210
242
  if (!created) {
211
243
  if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
212
244
  if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
245
+ // The stored value lost a credential at rest: serving it would hand `[redacted]` back as the
246
+ // answer, so the replay is refused — and, like any replay, spends and runs nothing.
247
+ if (record.redacted === true) throw new IdempotentReplayRedactedError(key);
213
248
  // The stored value is the previous return of this very handler.
214
249
  return { value: record.value as T, replayed: true };
215
250
  }
251
+ if (options.beforeRun !== undefined) {
252
+ try {
253
+ await options.beforeRun();
254
+ } catch (error) {
255
+ await store.release(key);
256
+ throw error;
257
+ }
258
+ }
216
259
  let value: T;
217
260
  try {
218
261
  value = await run();
@@ -226,7 +269,15 @@ export async function withIdempotency<T>(
226
269
  //
227
270
  // `record.id` is THIS reservation's, so a straggler from an attempt whose window has since
228
271
  // lapsed cannot land on the replacement that reclaimed the key.
229
- await store.settle(key, value, record.id);
272
+ //
273
+ // Inside a transaction the shared store settles on that transaction's connection, so "settled"
274
+ // and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
275
+ // (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
276
+ //
277
+ // The CALLER gets `value` whole; the store gets the resting copy, so an answer carrying a
278
+ // credential is never written down in plaintext for the window (#591).
279
+ const resting = restingAnswer(value);
280
+ await store.settle(key, resting.value, record.id, resting.redacted);
230
281
  return { value, replayed: false };
231
282
  }
232
283
 
package/src/index.ts CHANGED
@@ -7,38 +7,18 @@
7
7
  * framework died; there is exactly one here, structurally.
8
8
  */
9
9
 
10
+ // Imported bare, and the one module `sideEffects` lists: a browser that rebuilds a SERVER's refusal
11
+ // (`problemError`, by code) constructs nothing from `errors.ts`, and still has to render the title
12
+ // this package registered. `SIDE_EFFECTS_ANCHORS` carries the argument and `bun run side-effects`
13
+ // enforces it.
14
+ import './error-titles';
15
+
10
16
  /**
11
- * Flight control for the typed client, and OPT-IN by construction: `client.ts` names `ClientFlight`
12
- * as a TYPE only, so a caller that never mentions `createClientFlight` pays nothing for the fence,
13
- * the dedup map or the retry loop. Every mechanism underneath is `@ultimat3/core`'s — one fence,
14
- * one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
15
- * itself: it shipped as a byte-identical copy here and in `@ultimat3/query`, and
16
- * two tier-3 packages may not import each other, so the one copy lives at tier 0.
17
- *
18
- * Re-exported rather than re-declared, so every name is importable from this package exactly as
19
- * before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
20
- * flight, and it should not cost a second import.
21
- */
22
- export type {
23
- ClientFlight,
24
- ClientFlightOptions,
25
- ClientRetry,
26
- FlightKeyOptions,
27
- FlightPlan,
28
- WireAnswer,
29
- } from '@ultimat3/core';
30
- export {
31
- createClientFlight,
32
- DEFAULT_CLIENT_RETRY,
33
- isSuperseded,
34
- isTransientFailure,
35
- } from '@ultimat3/core';
36
- /**
37
- * `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
38
- * `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
39
- * here, not re-implemented, so an action file still reaches it through one import.
17
+ * Types only: the barrel's own declarations name them. Flight control's VALUES are imported from
18
+ * `@ultimat3/core`, which holds the one pipeline for action and query alike; a value re-export
19
+ * here is `X_HELPER_COPY`.
40
20
  */
41
- export { toBucket } from '@ultimat3/http';
21
+ export type { ClientFlight, ClientRetry, PgExecutor } from '@ultimat3/core';
42
22
  /** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
43
23
  export type { Infer } from '@ultimat3/schema';
44
24
  export { t } from '@ultimat3/schema';
@@ -54,7 +34,6 @@ export type {
54
34
  ActionRowArgs,
55
35
  AnyAction,
56
36
  InvokeOptions,
57
- McpAnnotationHints,
58
37
  McpDescriptorMeta,
59
38
  } from './action';
60
39
  export { action, describeAction, isAction } from './action';
@@ -73,25 +52,21 @@ export { apiDeclaration } from './api-declaration';
73
52
  /**
74
53
  * The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
75
54
  * and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
76
- * absence is what keeps it one.
55
+ * absence is what keeps it one. The record and the sink are `@ultimat3/core`'s, re-exported as
56
+ * TYPES only because this barrel's sinks name them; the slot (`setAuditSink`) is core's alone.
77
57
  */
78
- export type {
79
- AuditFailure,
80
- AuditOutcome,
81
- AuditRecord,
82
- AuditSink,
83
- } from './audit';
84
- export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
58
+ export type { AuditRecord, AuditSink } from './audit';
85
59
  export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
86
60
  export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
87
61
  export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
88
62
  /**
89
- * The DURABLE sink, and the only one an app that must keep its trail may install. The statements
90
- * are exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
91
- * boot, never by an app migration.
63
+ * The DURABLE sink, and the only one an app that must keep its trail may install. The table's DDL
64
+ * is exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
65
+ * boot, never by an app migration. The insert is not: it is the sink's own statement, and an
66
+ * export with no reader outside this package is `X_SQL_EXPORT_UNREAD` (`bun run sql-export-readers`).
92
67
  */
93
68
  export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
94
- export { postgresAuditSink, SQL_AUDIT_INSERT, SQL_AUDIT_TABLE } from './audit-postgres';
69
+ export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
95
70
  export type {
96
71
  ActionLike,
97
72
  ActionMap,
@@ -106,14 +81,6 @@ export type { ContractTest, ContractTestOptions } from './contract-test';
106
81
  export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
107
82
  export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
108
83
  export { defineApi } from './define-api';
109
- /**
110
- * The compat window a retirement gets. Versioning itself is NOT here and never will be: two
111
- * versions of one action side by side is two deployments behind one ingress (axiom 7), not a
112
- * router feature. `renderDeprecation` is exported so a plain `route` can announce the same pair
113
- * of headers the action projection does.
114
- */
115
- export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
116
- export { recordDeprecatedCall, renderDeprecation } from './deprecation';
117
84
  export type { IdempotencyConflictReason, IdempotencyKeyProblem, RemoteFailure } from './errors';
118
85
  export {
119
86
  ActionDeniedError,
@@ -130,8 +97,11 @@ export {
130
97
  IdempotencyKeyInvalidError,
131
98
  IdempotencyNotSharedError,
132
99
  IdempotencyReplayedFailureError,
100
+ IdempotencyReservationLostError,
133
101
  IdempotencyStatusUnknownError,
102
+ IdempotentReplayRedactedError,
134
103
  InputInvalidError,
104
+ MutatorNotIdempotentError,
135
105
  OutputInvalidError,
136
106
  RemoteActionError,
137
107
  RpcFailedError,
@@ -143,8 +113,6 @@ export {
143
113
  } from './errors-http';
144
114
  export type { OpenApiOperation } from './http';
145
115
  export {
146
- BUILD_ID_HEADER,
147
- IDEMPOTENCY_HEADER,
148
116
  operationTagOf,
149
117
  REPLAYED_HEADER,
150
118
  toOpenApiOperation,
@@ -183,40 +151,27 @@ export {
183
151
  withIdempotency,
184
152
  } from './idempotency';
185
153
  export { idempotencyKeyFor, MAX_IDEMPOTENCY_KEY_LENGTH } from './idempotency-key';
186
- export type { MemoryIdempotencyStoreOptions } from './idempotency-memory';
154
+ export type {
155
+ MemoryIdempotencyStore,
156
+ MemoryIdempotencyStoreOptions,
157
+ } from './idempotency-memory';
187
158
  export {
188
159
  DEFAULT_IDEMPOTENCY_WINDOW_MS,
189
160
  DEFAULT_MAX_IDEMPOTENCY_KEYS,
190
- MemoryIdempotencyStore,
161
+ memoryIdempotencyStore,
191
162
  } from './idempotency-memory';
192
- /**
193
- * The SHARED store, and the only one an app running more than one replica may install. The
194
- * statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
195
- * by `x db up` in development and by the release-phase `ROLE=migrate` in production.
196
- */
197
163
  export type {
198
- PgExecutor,
199
164
  PostgresIdempotencyStore,
200
165
  PostgresIdempotencyStoreOptions,
201
166
  } from './idempotency-postgres';
202
- export {
203
- postgresIdempotencyStore,
204
- SQL_IDEMPOTENCY_FAIL,
205
- SQL_IDEMPOTENCY_GET,
206
- SQL_IDEMPOTENCY_PURGE,
207
- SQL_IDEMPOTENCY_RELEASE,
208
- SQL_IDEMPOTENCY_RESERVE,
209
- SQL_IDEMPOTENCY_SETTLE,
210
- SQL_IDEMPOTENCY_TABLE,
211
- } from './idempotency-postgres';
167
+ /** The table's DDL alone: the boot applies it. The store's own statements stay its own. */
168
+ export { postgresIdempotencyStore, SQL_IDEMPOTENCY_TABLE } from './idempotency-postgres';
212
169
  /** The one execution path. `defOf` stays unexported — that is the enforcement. */
213
170
  export { actionName, invoke } from './invoke';
214
171
  export type { ActionJobHandle } from './job-handle';
215
172
  export { toJobHandle } from './job-handle';
216
173
  export type { JsonSchemaObject } from './json-schema';
217
- export { jsonSchemaOf, mcpSchemaOf } from './json-schema';
218
- export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
219
- export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
174
+ export { jsonSchemaOf } from './json-schema';
220
175
  export type {
221
176
  LocalTable,
222
177
  LocalTableName,
@@ -228,7 +183,7 @@ export type {
228
183
  } from './mutator';
229
184
  export { custom, isMutator, mutator } from './mutator';
230
185
  export type { ActionPath } from './naming';
231
- export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming';
186
+ export { derivePath, inputSchemaName, outputSchemaName } from './naming';
232
187
  export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
233
188
  export { buildOpenApi, serializeOpenApi } from './openapi';
234
189
  export {
@@ -244,19 +199,12 @@ export {
244
199
  export { explainActionPathMiss } from './path-style-miss';
245
200
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
246
201
  /**
247
- * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
248
- * `admitsAnonymous` is `@ultimat3/policy`'s, re-exported here beside them: it is what `toRoute`
249
- * derives `meta.auth` from, so a plain `route` sets that field from the same walk rather than
250
- * re-reading the root combinator.
202
+ * The one authz gate, named after what it guards. The display label (`policyCapability`), what a
203
+ * report MATCHES on (`policyPermissions`) and what `toRoute` derives `meta.auth` from
204
+ * (`admitsAnonymous`) are `@ultimat3/policy`'s, and the anonymous → `null` mapping (`actorOf`) is
205
+ * `@ultimat3/core`'s — imported from there, so `query` reads the same walk through one import path.
251
206
  */
252
- export {
253
- actorOf,
254
- admitsAnonymous,
255
- guard,
256
- guardBeforeInput,
257
- policyCapability,
258
- policyPermissions,
259
- } from './policy-gate';
207
+ export { guardAction, guardActionBeforeInput } from './policy-gate';
260
208
  export {
261
209
  actionHttpPath,
262
210
  configureActionPathStyle,
@@ -265,8 +213,9 @@ export {
265
213
  listActions,
266
214
  registerAction,
267
215
  registerActions,
268
- resetRegistry,
216
+ resetActions,
269
217
  } from './registry';
218
+ export { requestDeadlineMs } from './request-deadline';
270
219
  /**
271
220
  * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
272
221
  * a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
package/src/invoke.ts CHANGED
@@ -1,19 +1,20 @@
1
1
  /**
2
- * The one invocation core: parse input, evaluate policy, run the handler, parse
3
- * output. The declaration lives in this module's private store, so `handle` is
2
+ * The one invocation core: ask who is calling, spend the rate limit, parse input, evaluate
3
+ * policy, run the handler, parse output. The declaration lives in this module's private store, so `handle` is
4
4
  * unreachable from anywhere else — HTTP, MCP, jobs and `.as()` hand `invoke` a
5
5
  * payload, and none of them can become a second execution path.
6
6
  *
7
7
  * `audit: true` wraps that path, it never forks it: `execute` is the same body either way, and
8
8
  * the audited branch only observes it. Wrapping rather than hooking is what lets a DENIED attempt
9
- * be recorded at all — `guard` throws before `handle`, so nothing an app writes around its own
9
+ * be recorded at all — `guardAction` throws before `handle`, so nothing an app writes around its own
10
10
  * handler could ever see one.
11
11
  */
12
12
 
13
13
  import type { Ctx } from '@ultimat3/core';
14
14
  import {
15
+ actorOf,
15
16
  anonymousActor,
16
- createContext,
17
+ ctxOf,
17
18
  isUltimateError,
18
19
  runWithContext,
19
20
  tryUseContext,
@@ -34,7 +35,8 @@ import { bustAfterCommit } from './cache-gate';
34
35
  import { ActionForeignError, ActionUnregisteredError } from './errors';
35
36
  import { getIdempotencyStore, withIdempotency } from './idempotency';
36
37
  import { idempotencyKeyFor } from './idempotency-key';
37
- import { actorOf, guard, guardBeforeInput } from './policy-gate';
38
+ import { guardAction, guardActionBeforeInput } from './policy-gate';
39
+ import { spendActionLimit, withCallerAddress, withJobFrame } from './rate-limit-gate';
38
40
  import { parsedOrNothing, validateInput, validateOutput } from './validate';
39
41
 
40
42
  /**
@@ -77,6 +79,15 @@ export function invoke(
77
79
  raw: unknown,
78
80
  options: InvokeOptions = {},
79
81
  ): Promise<unknown> {
82
+ // The address a surface handed over becomes the one every nested call inherits — an agent's
83
+ // tool calls are keyed by the visitor who asked, not shared by every anonymous visitor.
84
+ // A job run opens a fresh frame instead: no visitor's address follows work into the worker.
85
+ return options.surface === 'job'
86
+ ? withJobFrame(() => invokeAs(target, raw, options))
87
+ : withCallerAddress(options.clientAddress, () => invokeAs(target, raw, options));
88
+ }
89
+
90
+ function invokeAs(target: AnyAction, raw: unknown, options: InvokeOptions): Promise<unknown> {
80
91
  if (options.actor === undefined) {
81
92
  // INSTALLED, never only handed over. An explicit `ctx` used to be passed to `core` and to
82
93
  // nothing else, so everything downstream that reads the ambient context — most importantly
@@ -96,7 +107,7 @@ export function invoke(
96
107
  const run = (): Promise<unknown> => core(target, raw, useContext(), options);
97
108
  const base = options.ctx ?? tryUseContext();
98
109
  return base === undefined
99
- ? runWithContext(createContext(patch), run)
110
+ ? runWithContext(ctxOf(patch), run)
100
111
  : runWithContext(base, () => withChildContext(patch, run));
101
112
  }
102
113
 
@@ -128,7 +139,8 @@ async function core(
128
139
  const draft = {
129
140
  // When the attempt began, from the context's clock — never `new Date()`.
130
141
  at: ctx.now(),
131
- action: name,
142
+ name,
143
+ primitive: 'action',
132
144
  // The brand `mutator()` stamps, read structurally — the same read `describeAction` makes,
133
145
  // and for the same reason: importing `isMutator` would point this module at the one that
134
146
  // imports it, for a check that needs the brand and not the predicate.
@@ -163,7 +175,7 @@ async function core(
163
175
  /**
164
176
  * The span covers the WHOLE invocation, not just `handle`. Wrapping the handler alone reported
165
177
  * 40ms for an action whose p99 was 2s, because `def.row()` — the loader a row-level policy needs
166
- * — ran outside it, along with the input parse and `guard()`. The 1.96s was inside no span at
178
+ * — ran outside it, along with the input parse and `guardAction()`. The 1.96s was inside no span at
167
179
  * all, so the only reading available was "framework overhead" and the only fix was hand
168
180
  * instrumentation. One span, one extent, and the attributes that make it answerable.
169
181
  *
@@ -219,15 +231,42 @@ async function perform(
219
231
  const surface = options.surface ?? 'server';
220
232
  // Who is asking is decided before what they sent is read: a caller the policy refuses whatever
221
233
  // the input is gets 403/401 here, not a 400 whose issues describe this action's input schema.
222
- // Only the actor half runs — a predicate over `input` or `row` waits for `guard` below.
234
+ // Only the actor half runs — a predicate over `input` or `row` waits for `guardAction` below.
223
235
  try {
224
- guardBeforeInput(def.policy, { actor: actorOf(ctx), ctx, action: name }, surface);
236
+ guardActionBeforeInput(def.policy, { actor: actorOf(ctx), ctx, action: name }, surface);
225
237
  } catch (denial) {
226
238
  // The audit record still names what was attempted — the denial is already decided, so parsing
227
239
  // now tells the CALLER nothing, and an unparseable payload is a record with no input.
228
240
  if (def.audit === true) trace.input = await parsedOrNothing(def.input, raw);
229
241
  throw denial;
230
242
  }
243
+ // The ONE place a declared `rateLimit:` is spent, so every surface draws on the same bucket.
244
+ // HERE — after the actor half (a refused caller learns that, not a 429), BEFORE the parse, the
245
+ // row load and `guardAction`: spent later, a flood of invalid input (400) or row-denied calls (403)
246
+ // was never refused, and every 429 had already paid for a row load.
247
+ const spend = (): Promise<void> =>
248
+ spendActionLimit(name, def.rateLimit, ctx, {
249
+ surface,
250
+ clientAddress: options.clientAddress,
251
+ onRateLimit: options.onRateLimit,
252
+ });
253
+ const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
254
+ const store = key === null ? undefined : (options.store ?? getIdempotencyStore());
255
+ // The namespaced key, not the caller's: the same key under two actions — or from two callers
256
+ // — is two keys, and an audit row keyed on the raw header would collide across both. The
257
+ // ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
258
+ if (key !== null) trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
259
+ // A replay is its stored answer and spends nothing — a 429 in its place told a client its
260
+ // committed write had not happened. A key the store already holds is a replay (or a duplicate in
261
+ // flight, answered 409); only a key it does not hold spends here. The peek can be stale both
262
+ // ways, so `withIdempotency` meters a reservation it CREATED that this peek did not pay for.
263
+ const spentEarly =
264
+ trace.idempotencyKey === null ||
265
+ store === undefined ||
266
+ def.rateLimit === undefined ||
267
+ (await store.get(trace.idempotencyKey)) === undefined;
268
+ if (spentEarly) await spend();
269
+
231
270
  const input = await validateInput(def.input, raw, name);
232
271
  trace.input = input;
233
272
  // The one place a row-level rule gets its row. Once per invocation, never per row:
@@ -236,7 +275,7 @@ async function perform(
236
275
  // action with no loader hands the rule `null` — unchanged, and never a silent allow,
237
276
  // because a rule that reads `row` has to decide what `null` means.
238
277
  const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
239
- guard(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
278
+ guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
240
279
 
241
280
  // Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
242
281
  // one shape on the wire, first call and every retry. No span of its own: `execute` above holds
@@ -246,18 +285,14 @@ async function perform(
246
285
  return validateOutput(def.output, produced, name);
247
286
  };
248
287
 
249
- const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
250
288
  let value: unknown;
251
289
  let wrote = true;
252
- if (key === null) {
290
+ if (store === undefined || trace.idempotencyKey === null) {
253
291
  value = await run();
254
292
  } else {
255
- const store = options.store ?? getIdempotencyStore();
256
- // The namespaced key, not the caller's: the same key under two actions — or from two callers
257
- // — is two keys, and an audit row keyed on the raw header would collide across both. The
258
- // ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
259
- trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
260
- const outcome = await withIdempotency(store, trace.idempotencyKey, input, run);
293
+ const outcome = await withIdempotency(store, trace.idempotencyKey, input, run, {
294
+ ...(spentEarly ? {} : { beforeRun: spend }),
295
+ });
261
296
  if (outcome.replayed) options.onReplay?.();
262
297
  trace.replayed = outcome.replayed;
263
298
  wrote = !outcome.replayed;
@@ -268,8 +303,9 @@ async function perform(
268
303
  }
269
304
  // Only for a run that actually happened, and only through the gate. A replay ran no handler
270
305
  // and changed nothing the first call had not already busted — re-busting per retry re-purges
271
- // the CDN and re-queues ISR for a write nobody made. And the bust is post-commit either way,
272
- // so `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
306
+ // the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
307
+ // invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
308
+ // `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
273
309
  // durable write into a failed action.
274
310
  if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
275
311
  return value;
package/src/job-handle.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Projection 5: an action as durable work — its input schema, a payload-derived
3
3
  * idempotency key, and an `invoke` that runs the action's one execution path under
4
- * `surface: 'job'`, so a queued run gets the same validation and policy evaluation
5
- * as the HTTP call. `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any
6
- * `Action` and composes `job()` around this shape — and an app may write the same three lines.
4
+ * `surface: 'job'`, so a queued run takes the same validation and policy path as the HTTP call —
5
+ * under the worker's system authority: the enqueuer is `enqueuedBy`, attribution only, and never
6
+ * authorises the run. An action that acts for a user takes that user's id and re-authorises it.
7
+ * `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any `Action` and
8
+ * composes `job()` around this shape — and an app may write the same three lines.
7
9
  */
8
10
  import type { Ctx } from '@ultimat3/core';
9
11
  import { fingerprint } from '@ultimat3/core';
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Standard Schema -> JSON Schema, normalized to a plain record. OpenAPI, MCP
3
- * descriptors and the manifest all need the same object, produced the same way.
2
+ * Standard Schema -> JSON Schema, normalized to a plain record, for OpenAPI and the manifest. An
3
+ * MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
4
4
  */
5
5
 
6
+ import { isJsonObject } from '@ultimat3/core';
6
7
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
- import { SchemaUnsupportedError, toJsonSchema, toMcpInputSchema } from '@ultimat3/schema';
8
- import { isJsonObject, stableStringify } from './stable';
8
+ import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
9
+ import { stableStringify } from './stable';
9
10
 
10
11
  export type JsonSchemaObject = Record<string, unknown>;
11
12
 
@@ -23,13 +24,8 @@ export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
23
24
  return normalizeJsonSchema(() => toJsonSchema(schema));
24
25
  }
25
26
 
26
- /** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
27
- export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
28
- return normalizeJsonSchema(() => toMcpInputSchema(schema));
29
- }
30
-
31
27
  /**
32
- * The narrowing both projections share, and the refusal it earns: a converter that answered with
28
+ * The narrowing `jsonSchemaOf` applies, and the refusal it earns: a converter that answered with
33
29
  * something that is not a JSON object is the same failure by a quieter route, so it gets the same
34
30
  * shipped code rather than a permissive node. Exported for its own test and nothing else — it is
35
31
  * absent from `src/index.ts`, exactly as `sortSchema` is.
package/src/mutator.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * contract tests for free, and its authz is the same single evaluation.
6
6
  */
7
7
 
8
- import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
8
+ import type { ConflictPolicy, Ctx, Deprecation, Row } from '@ultimat3/core';
9
9
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
10
  import type {
11
11
  Action,
@@ -17,7 +17,7 @@ import type {
17
17
  ActionRowArgs,
18
18
  } from './action';
19
19
  import { action, isAction } from './action';
20
- import type { Deprecation } from './deprecation';
20
+ import { MutatorNotIdempotentError } from './errors';
21
21
  import type { ActionHttp } from './http-path';
22
22
  import { assertConflictClock } from './mutator-clock';
23
23
  import type { ActionPolicy } from './policy-gate';
@@ -97,7 +97,14 @@ export interface MutatorDef<
97
97
  readonly deprecated?: Deprecation;
98
98
  /** Same key, same meaning as an action's: `http: { path }` pins the URL. */
99
99
  readonly http?: ActionHttp;
100
- readonly idempotent?: boolean;
100
+ /**
101
+ * REQUIRED, and only `true`. A mutator's write is replayed under one `Idempotency-Key` —
102
+ * `useMutation`'s retry, the offline queue's drain after a dropped response — and the server
103
+ * reads that key only for an idempotent action. Declared rather than defaulted because the
104
+ * replay is answered from the idempotency store, and which store that is (`configureIdempotency`)
105
+ * is the app's decision to see. Omitted: a compile error, and `X_MUTATOR_NOT_IDEMPOTENT`.
106
+ */
107
+ readonly idempotent: true;
101
108
  /**
102
109
  * The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
103
110
  * the action until 2026-09-23, so such a policy received `row === null` and denied every call.
@@ -158,6 +165,9 @@ export function mutator<
158
165
  TOutput extends StandardSchemaV1,
159
166
  TRow = unknown,
160
167
  >(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
168
+ // First, and for a caller the compiler never saw: a replay that runs the server half twice is
169
+ // silent, so the declaration is refused where it is written rather than on the second POST.
170
+ if (def.idempotent !== true) throw new MutatorNotIdempotentError();
161
171
  const row = def.row;
162
172
  const actionDef: ActionDef<TInput, TOutput, TRow> = {
163
173
  input: def.input,
@@ -169,7 +179,7 @@ export function mutator<
169
179
  ...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
170
180
  ...(def.http === undefined ? {} : { http: def.http }),
171
181
  ...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
172
- ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
182
+ idempotent: true,
173
183
  ...(def.audit === undefined ? {} : { audit: def.audit }),
174
184
  handle: ({ input, ctx }) => def.server(ctx, input),
175
185
  };
package/src/naming.ts CHANGED
@@ -11,10 +11,9 @@ import { resolveActionRoute } from './http-path';
11
11
 
12
12
  /**
13
13
  * The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
14
- * spec derive one URL from one function. Re-exported by name so `@ultimat3/action`'s public
15
- * `derivePath` / `pluralize` / `splitWords` / `ActionPath` keep resolving; never re-declared here.
14
+ * spec derive one URL from one function; `pluralize` and `splitWords` are imported from core by
15
+ * whoever needs them, never re-exported here (`X_HELPER_COPY`).
16
16
  */
17
- export { pluralize, splitWords } from '@ultimat3/core';
18
17
 
19
18
  export type ActionPath = ActionRoute;
20
19