@ultimat3/action 29.3.2 → 30.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.
@@ -4,15 +4,29 @@
4
4
  * a value or a failure — and a concurrent duplicate is refused rather than run twice, because a
5
5
  * double charge is worse than a 409.
6
6
  */
7
- import { compareFingerprint, isUltimateError, keyedFingerprint, logger } from '@ultimat3/core';
7
+ import {
8
+ compareFingerprint,
9
+ finiteCount,
10
+ isUltimateError,
11
+ jsonbRefusal,
12
+ keyedFingerprint,
13
+ logger,
14
+ MAX_TIMER_MS,
15
+ runToCompletion,
16
+ tryUseContext,
17
+ } from '@ultimat3/core';
8
18
  import {
9
19
  IdempotencyConflictError,
10
20
  IdempotencyNotSharedError,
21
+ IdempotencyOutputUnserializableError,
11
22
  IdempotencyReplayedFailureError,
23
+ IdempotencyRunCeilingError,
12
24
  IdempotentReplayRedactedError,
13
25
  } from './errors';
14
26
  import { memoryIdempotencyStore } from './idempotency-memory';
15
27
  import { restingAnswer } from './idempotency-redact';
28
+ import { requestDeadlineMs } from './request-deadline';
29
+ import { carrySettleOnCommit } from './tx-scope';
16
30
 
17
31
  /**
18
32
  * Where a store's records live. Declared by the driver, never inferred — the same rule
@@ -153,6 +167,8 @@ export interface IdempotencyStore {
153
167
  */
154
168
  export interface IdempotencyConfig {
155
169
  readonly scope: IdempotencyScope;
170
+ /** How long a keyed run may go on, in ms. Default `idempotencyCeilingMs()`. */
171
+ readonly ceilingMs?: number | undefined;
156
172
  }
157
173
 
158
174
  /**
@@ -166,6 +182,10 @@ let defaultStore: IdempotencyStore = memoryIdempotencyStore();
166
182
 
167
183
  /** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
168
184
  export function configureIdempotency(next: IdempotencyConfig): void {
185
+ // At boot: unscreened, a bad value is refused by every keyed request instead.
186
+ if (next.ceilingMs !== undefined) {
187
+ finiteCount('configureIdempotency', 'ceilingMs', next.ceilingMs, 1);
188
+ }
169
189
  config = next;
170
190
  }
171
191
 
@@ -173,6 +193,21 @@ export function idempotencyConfig(): IdempotencyConfig {
173
193
  return config;
174
194
  }
175
195
 
196
+ /** The ceiling when the request deadline is `0`, "no deadline": there is nothing to double. */
197
+ const CEILING_WITHOUT_DEADLINE_MS = 60_000;
198
+
199
+ /**
200
+ * The hard ceiling on a keyed run: the declared one, else twice the request deadline (60 s by
201
+ * default). A handler the deadline's own socket answer has given up on gets as long again to finish;
202
+ * read per call because the app declares its deadline after the boot built its stores.
203
+ */
204
+ export function idempotencyCeilingMs(): number {
205
+ if (config.ceilingMs !== undefined) return config.ceilingMs;
206
+ const deadline = requestDeadlineMs();
207
+ // `ceil`: http's screen admits a fraction, and this one counts whole milliseconds.
208
+ return deadline > 0 ? Math.ceil(2 * deadline) : CEILING_WITHOUT_DEADLINE_MS;
209
+ }
210
+
176
211
  export function setIdempotencyStore(store: IdempotencyStore): void {
177
212
  defaultStore = store;
178
213
  }
@@ -216,7 +251,7 @@ export interface IdempotentOutcome<T> {
216
251
  * X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
217
252
  * flight.
218
253
  *
219
- * **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `validateInput`
254
+ * **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `parseActionInput`
220
255
  * both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
221
256
  * things left are the handler and `validateOutput` — and the second of those throws *after* the
222
257
  * first has committed. Releasing the reservation there is what turned a rounding change in an
@@ -232,6 +267,8 @@ export interface WithIdempotencyOptions {
232
267
  * the key's outcome, and a retry once the bucket refills must still be able to run.
233
268
  */
234
269
  readonly beforeRun?: () => Promise<void>;
270
+ /** This call's ceiling, in ms, over `idempotencyCeilingMs()`. Past it the failure is settled. */
271
+ readonly ceilingMs?: number | undefined;
235
272
  }
236
273
 
237
274
  export async function withIdempotency<T>(
@@ -241,6 +278,12 @@ export async function withIdempotency<T>(
241
278
  run: () => Promise<T>,
242
279
  options: WithIdempotencyOptions = {},
243
280
  ): Promise<IdempotentOutcome<T>> {
281
+ const ceilingMs = finiteCount(
282
+ 'withIdempotency',
283
+ 'ceilingMs',
284
+ options.ceilingMs ?? idempotencyCeilingMs(),
285
+ 1,
286
+ );
244
287
  const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
245
288
  const { record, created } = await store.reserve(key, requestHash);
246
289
  if (record.requestHash !== requestHash) assertSamePayload(key, record.requestHash, input);
@@ -269,9 +312,15 @@ export async function withIdempotency<T>(
269
312
  throw error;
270
313
  }
271
314
  }
315
+ // A run under a key is OWED its end: the orphan finishing is what makes the replay answer success.
316
+ // An aborted `ctx.signal` would fail its vendor call, `settleFailure` would run, and the key would
317
+ // replay a failure. The COMMIT of a transaction around this gate sits outside the scope below, so
318
+ // that root is marked too, and the settle rides it. A reclaimed reservation still fences its own.
319
+ // The escape removes every bound the request had, so the ceiling below is the one it has.
320
+ carrySettleOnCommit();
272
321
  let value: T;
273
322
  try {
274
- value = await run();
323
+ value = await runWithinCeiling(key, ceilingMs, run);
275
324
  } catch (error) {
276
325
  await settleFailure(store, key, record.id, error);
277
326
  throw error;
@@ -290,10 +339,66 @@ export async function withIdempotency<T>(
290
339
  // The CALLER gets `value` whole; the store gets the resting copy, so an answer carrying a
291
340
  // credential is never written down in plaintext for the window (#591).
292
341
  const resting = restingAnswer(value);
342
+ if (!jsonRepresentable(resting.value)) {
343
+ const refusal = new IdempotencyOutputUnserializableError(key);
344
+ await settleFailure(store, key, record.id, refusal);
345
+ throw refusal;
346
+ }
293
347
  await store.settle(key, resting.value, record.id, resting.redacted);
294
348
  return { value, replayed: false };
295
349
  }
296
350
 
351
+ /**
352
+ * Whether a store can write this down. Tested HERE so both stores answer alike: memory holds any
353
+ * value, and Postgres throws from its own `JSON.stringify`. A resting copy has no `toJSON` left to
354
+ * run, so this calls no app code. `undefined` passes and rests as `null` (JSON has no `undefined`):
355
+ * the replay goes through the action's `output` schema, and that schema answers for its shape.
356
+ * Text `jsonb` refuses counts too: Postgres alone would raise it, after the handler ran.
357
+ */
358
+ function jsonRepresentable(value: unknown): boolean {
359
+ try {
360
+ const text = JSON.stringify(value);
361
+ return text === undefined || jsonbRefusal(text) === undefined;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * `run` in `runToCompletion`'s scope, under a signal this ceiling aborts. ABORTS before it rejects,
369
+ * as a job's step timeout does: the failure about to be settled is what a retry replays, so a handler
370
+ * still going must be told. One that ignores it is let go of: the in-flight count and the drain stop
371
+ * waiting, and a transaction around the gate rolls back on this throw, so its statements then refuse.
372
+ */
373
+ function runWithinCeiling<T>(key: string, ceilingMs: number, run: () => Promise<T>): Promise<T> {
374
+ const ceiling = new AbortController();
375
+ const startedAt = tryUseContext()?.now().getTime();
376
+ const bound = {
377
+ signal: ceiling.signal,
378
+ ...(startedAt === undefined ? {} : { deadlineAt: startedAt + ceilingMs }),
379
+ };
380
+ // Past what a timer holds `setTimeout` arms ~1 ms, and the longest ceiling killed every handler.
381
+ const delayMs = Math.min(ceilingMs, MAX_TIMER_MS);
382
+ return new Promise<T>((resolve, reject) => {
383
+ const timer = setTimeout(() => {
384
+ const failure = new IdempotencyRunCeilingError(key, ceilingMs);
385
+ ceiling.abort(failure);
386
+ reject(failure);
387
+ }, delayMs);
388
+ // `async`: a `run` that throws synchronously takes the same path, and clears the timer.
389
+ (async () => runToCompletion(run, bound))().then(
390
+ (value) => {
391
+ clearTimeout(timer);
392
+ resolve(value);
393
+ },
394
+ (cause: unknown) => {
395
+ clearTimeout(timer);
396
+ reject(cause);
397
+ },
398
+ );
399
+ });
400
+ }
401
+
297
402
  /**
298
403
  * A stored fingerprint that is not this request's exact string. A legacy unkeyed one (a row
299
404
  * written before keying) is still checked exactly. One keyed under a secret this process does not
package/src/index.ts CHANGED
@@ -31,12 +31,16 @@ export type {
31
31
  ActionHandlerArgs,
32
32
  ActionMcp,
33
33
  ActionRateLimit,
34
+ ActionRateLimitFact,
34
35
  ActionRowArgs,
35
36
  AnyAction,
36
37
  InvokeOptions,
37
38
  McpDescriptorMeta,
39
+ StreamAction,
40
+ StreamActionDef,
38
41
  } from './action';
39
42
  export { action, describeAction, isAction } from './action';
43
+ export { chunksRead } from './action-stream';
40
44
  /**
41
45
  * The app's API as a whole — `defineApi({ http: { pathStyle, mounts }, openapi })` — read back by
42
46
  * the boot (bearer mounts) and `x manifest` (the complete and per-mount documents).
@@ -67,6 +71,12 @@ export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
67
71
  */
68
72
  export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
69
73
  export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
74
+ export type {
75
+ AuditSubjectRef,
76
+ AuditSubjectTarget,
77
+ AuditSubjectTargetOptions,
78
+ } from './audit-subject-target';
79
+ export { AUDIT_SUBJECT_COLUMNS, auditSubjectTarget } from './audit-subject-target';
70
80
  export type {
71
81
  ActionLike,
72
82
  ActionMap,
@@ -75,6 +85,7 @@ export type {
75
85
  ClientMethod,
76
86
  ClientOptions,
77
87
  FetchLike,
88
+ StreamClientMethod,
78
89
  } from './client';
79
90
  export { rpc } from './client';
80
91
  export type { ContractTest, ContractTestOptions } from './contract-test';
@@ -96,8 +107,10 @@ export {
96
107
  IdempotencyConflictError,
97
108
  IdempotencyKeyInvalidError,
98
109
  IdempotencyNotSharedError,
110
+ IdempotencyOutputUnserializableError,
99
111
  IdempotencyReplayedFailureError,
100
112
  IdempotencyReservationLostError,
113
+ IdempotencyRunCeilingError,
101
114
  IdempotencyStatusUnknownError,
102
115
  IdempotentReplayRedactedError,
103
116
  InputInvalidError,
@@ -107,21 +120,37 @@ export {
107
120
  RpcFailedError,
108
121
  } from './errors';
109
122
  export {
123
+ ActionDeprecationSuccessorInvalidError,
110
124
  ActionHttpPathInvalidError,
111
125
  ActionPathStyleInvalidError,
112
126
  OpenApiConfigInvalidError,
113
127
  } from './errors-http';
128
+ export type { MeterExceededInput, MeterField } from './errors-meter';
129
+ export { MeterExceededError, MeterInvalidError } from './errors-meter';
130
+ export { ActionStreamInvalidError } from './errors-stream';
131
+ export type {
132
+ ExerciseActorOptions,
133
+ ExerciseOffOptions,
134
+ ExerciseOptions,
135
+ ExerciseReport,
136
+ } from './exercise';
137
+ export { exerciseAction } from './exercise';
114
138
  export type { OpenApiOperation } from './http';
115
139
  export {
116
140
  operationTagOf,
117
- REPLAYED_HEADER,
118
141
  toOpenApiOperation,
119
142
  toPostBinding,
120
143
  toRoute,
121
144
  } from './http';
122
145
  /** `http: { path }` on an action pins its URL; the app's `pathStyle` derives every other one. */
123
- export type { ActionHttp } from './http-path';
124
- export { actionPathStyle, forgetHandedOutActionPaths } from './http-path';
146
+ export type { ActionHttp, ActionHttpMethod } from './http-path';
147
+ export {
148
+ ACTION_HTTP_METHODS,
149
+ actionHttpPinOf,
150
+ actionPathStyle,
151
+ forgetHandedOutActionPaths,
152
+ } from './http-path';
153
+ export { EVENT_STREAM } from './http-stream';
125
154
  /**
126
155
  * The idempotency seam. `withIdempotency` and `IDEMPOTENCY_HEADER` are both public, so a plain
127
156
  * mutating `route` can reserve-and-replay exactly as an action does — `idempotencyKeyFor` is the
@@ -144,6 +173,7 @@ export {
144
173
  DEFAULT_IDEMPOTENCY_CONFIG,
145
174
  getIdempotencyStore,
146
175
  IDEMPOTENCY_STATUSES,
176
+ idempotencyCeilingMs,
147
177
  idempotencyConfig,
148
178
  isIdempotencyStatus,
149
179
  resetIdempotency,
@@ -172,6 +202,27 @@ export type { ActionJobHandle } from './job-handle';
172
202
  export { toJobHandle } from './job-handle';
173
203
  export type { JsonSchemaObject } from './json-schema';
174
204
  export { jsonSchemaOf } from './json-schema';
205
+ /**
206
+ * The meter seam: `meter: { name, period, limit, warnAt, onWarn }` on an action, drawn inside
207
+ * `invoke` on every surface. The draw itself (`meter-gate.ts`) stays unexported — one caller.
208
+ * `MeterWarning` is the schema an app's warning `job()` declares as its input.
209
+ */
210
+ export type {
211
+ ActionMeter,
212
+ ActionMeterFact,
213
+ MeterLimitArgs,
214
+ MeterPeriod,
215
+ MeterWarnEnqueueOptions,
216
+ MeterWarnTarget,
217
+ ResolvedLimit,
218
+ } from './meter';
219
+ export { METER_PERIODS, MeterWarning } from './meter';
220
+ export type { MemoryMeterStoreOptions } from './meter-memory';
221
+ export { DEFAULT_MAX_METER_KEYS, memoryMeterStore } from './meter-memory';
222
+ export type { PostgresMeterStoreOptions } from './meter-postgres';
223
+ export { postgresMeterStore, SQL_METER_TABLE } from './meter-postgres';
224
+ export type { MeterDraw, MeterDrawResult, MeterKey, MeterStore } from './meter-store';
225
+ export { getMeterStore, resetMeterStore, setMeterStore } from './meter-store';
175
226
  export type {
176
227
  LocalTable,
177
228
  LocalTableName,
@@ -216,6 +267,20 @@ export {
216
267
  resetActions,
217
268
  } from './registry';
218
269
  export { requestDeadlineMs } from './request-deadline';
270
+ /**
271
+ * What a named tool passes on its way to `invoke`, on either tool surface. `@ultimat3/mcp`'s
272
+ * `mcpConfirmations` registers one and `@ultimat3/ai`'s adapter reads it: one tier down from both.
273
+ */
274
+ export type { ToolGate, ToolGateCall } from './tool-gate';
275
+ export {
276
+ admitActionCaller,
277
+ asCallerContext,
278
+ gatedToolNames,
279
+ knownToolNames,
280
+ noteToolNames,
281
+ registerToolGate,
282
+ toolGateFor,
283
+ } from './tool-gate';
219
284
  /**
220
285
  * A mutator FACTORY, never a ninth primitive: `transition()` returns a `mutator`, so a move through
221
286
  * a state machine inherits the route, the OpenAPI operation, the typed client, the MCP tool, the job
@@ -229,6 +294,12 @@ export type {
229
294
  TransitionValues,
230
295
  } from './transition';
231
296
  export { transition } from './transition';
297
+ /**
298
+ * `invoke`'s input parse and its `X_INPUT_INVALID` refusal, alone. Exported so a caller that must
299
+ * hold something else to an action's input — an agent eval's tool double (`@ultimat3/ai`) — runs
300
+ * this one check instead of a copy that drifts.
301
+ */
302
+ export { parseActionInput } from './validate';
232
303
  /**
233
304
  * The one reader of a problem document's `issues` member. Exported because the typed client is not
234
305
  * the only caller that meets one: an island that posts with a plain `fetch` — which is what
package/src/invoke.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * policy, draw the meter, 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
  *
@@ -15,13 +15,19 @@ import {
15
15
  actorOf,
16
16
  anonymousActor,
17
17
  ctxOf,
18
+ failureCaptureActive,
18
19
  isUltimateError,
20
+ REDACTED,
21
+ recordFailureInput,
19
22
  runWithContext,
20
23
  tryUseContext,
21
24
  useContext,
22
25
  withChildContext,
26
+ withFailureCapture,
23
27
  withSpan,
24
28
  } from '@ultimat3/core';
29
+ import { statusFor } from '@ultimat3/http';
30
+ import { maskSensitive } from '@ultimat3/schema';
25
31
  import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
26
32
  import type { AuditRecord } from './audit';
27
33
  import {
@@ -32,12 +38,14 @@ import {
32
38
  auditThrew,
33
39
  } from './audit-gate';
34
40
  import { bustAfterCommit } from './cache-gate';
41
+ import { judgedHandler } from './declared-errors';
35
42
  import { ActionForeignError, ActionUnregisteredError } from './errors';
36
43
  import { getIdempotencyStore, withIdempotency } from './idempotency';
37
44
  import { idempotencyKeyFor } from './idempotency-key';
45
+ import { drawMeter } from './meter-gate';
38
46
  import { guardAction, guardActionBeforeInput } from './policy-gate';
39
47
  import { spendActionLimit, withCallerAddress, withJobFrame } from './rate-limit-gate';
40
- import { parsedOrNothing, validateInput, validateOutput } from './validate';
48
+ import { parseActionInput, parsedOrNothing, validateOutput } from './validate';
41
49
 
42
50
  /**
43
51
  * Private on purpose. `@ultimat3/action` exports no way to read this back, which
@@ -63,6 +71,18 @@ export function defOf(target: AnyAction): AnyActionDef {
63
71
  return def;
64
72
  }
65
73
 
74
+ /**
75
+ * How many times each declaration's `handle` has started, keyed like `DECLARATIONS`. Read only by
76
+ * `exercise.ts` — never re-exported — so "zero runs reached the handler" is countable without a
77
+ * second execution path: a contract that never got past policy or parse is its own red.
78
+ */
79
+ const HANDLER_REACHED = new WeakMap<AnyActionDef, number>();
80
+
81
+ /** Starts of `target`'s handler so far, across every surface. Off the barrel on purpose. */
82
+ export function handlerReached(target: AnyAction): number {
83
+ return HANDLER_REACHED.get(defOf(target)) ?? 0;
84
+ }
85
+
66
86
  /** Projections need a stable name; an unregistered action has none yet. */
67
87
  export function actionName(target: AnyAction): string {
68
88
  if (target.name.length === 0) throw new ActionUnregisteredError();
@@ -190,6 +210,20 @@ async function execute(
190
210
  ctx: Ctx,
191
211
  options: InvokeOptions,
192
212
  trace: InvokeTrace,
213
+ ): Promise<unknown> {
214
+ // A 5xx keeps a replayable bundle when `failureCapture` is on (`x repro`); off, `run` runs bare.
215
+ return withFailureCapture('action', name, statusFor, () =>
216
+ traced(def, name, raw, ctx, options, trace),
217
+ );
218
+ }
219
+
220
+ async function traced(
221
+ def: AnyActionDef,
222
+ name: string,
223
+ raw: unknown,
224
+ ctx: Ctx,
225
+ options: InvokeOptions,
226
+ trace: InvokeTrace,
193
227
  ): Promise<unknown> {
194
228
  return withSpan(`action.${name}`, async (span) => {
195
229
  span.setAttributes({
@@ -237,7 +271,9 @@ async function perform(
237
271
  } catch (denial) {
238
272
  // The audit record still names what was attempted — the denial is already decided, so parsing
239
273
  // now tells the CALLER nothing, and an unparseable payload is a record with no input.
240
- if (def.audit === true) trace.input = await parsedOrNothing(def.input, raw);
274
+ if (def.audit === true) {
275
+ trace.input = maskSensitive(def.input, await parsedOrNothing(def.input, raw), REDACTED);
276
+ }
241
277
  throw denial;
242
278
  }
243
279
  // The ONE place a declared `rateLimit:` is spent, so every surface draws on the same bucket.
@@ -260,15 +296,21 @@ async function perform(
260
296
  // committed write had not happened. A key the store already holds is a replay (or a duplicate in
261
297
  // flight, answered 409); only a key it does not hold spends here. The peek can be stale both
262
298
  // ways, so `withIdempotency` meters a reservation it CREATED that this peek did not pay for.
263
- const spentEarly =
299
+ // The same peek serves the row rule below, so an action that needs neither makes no read here.
300
+ const peeked =
264
301
  trace.idempotencyKey === null ||
265
302
  store === undefined ||
266
- def.rateLimit === undefined ||
267
- (await store.get(trace.idempotencyKey)) === undefined;
303
+ (def.rateLimit === undefined && def.row === undefined)
304
+ ? undefined
305
+ : await store.get(trace.idempotencyKey);
306
+ const spentEarly = def.rateLimit === undefined || peeked === undefined;
268
307
  if (spentEarly) await spend();
269
308
 
270
- const input = await validateInput(def.input, raw, name);
271
- trace.input = input;
309
+ const input = await parseActionInput(def.input, raw, name);
310
+ if (failureCaptureActive()) recordFailureInput(maskSensitive(def.input, input, REDACTED));
311
+ // The audit record's copy, `.sensitive()` fields masked by THIS action's own schema — never a
312
+ // key table, so a `cardPan` flagged here changes nothing another action records or stores.
313
+ if (def.audit === true) trace.input = maskSensitive(def.input, input, REDACTED);
272
314
  // The one place a row-level rule gets its row. Once per invocation, never per row:
273
315
  // that asymmetry is what lets the predicate stay synchronous, so a live query can
274
316
  // re-evaluate the same policy per subscriber without a query per change event. An
@@ -276,39 +318,73 @@ async function perform(
276
318
  // because a rule that reads `row` has to decide what `null` means.
277
319
  // The SAME `input` object reaches `def.row` here and `def.handle` below, by reference:
278
320
  // `transition()` keys what its rule read on it (`transition-observe.ts`, #702).
279
- const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
280
- guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
321
+ const judgeRow = async (): Promise<void> => {
322
+ // `row` and `handle` are the two places `errors:` judges a throw (`declared-errors.ts`).
323
+ const load = def.row;
324
+ const row =
325
+ load === undefined
326
+ ? null
327
+ : ((await judgedHandler(def, name, () => load.call(def, { input, ctx }))) ?? null);
328
+ guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
329
+ };
330
+ // A finished record is this key's outcome, and the key is this actor's (`idempotency-key.ts`).
331
+ // The row it decided about may be the row it deleted: a rule asked again judges `null`, and a
332
+ // committed write answers its own retry with a refusal. So the loader and the rule wait, and
333
+ // `withIdempotency` answers: the replay, or the conflict for another payload. Who is asking was
334
+ // already decided, per call, by `guardActionBeforeInput` above: a revoked `can()` is refused
335
+ // here too. In flight or absent, nothing has been decided yet, so the rule decides.
336
+ const answered = def.row !== undefined && peeked !== undefined && peeked.status !== 'in-flight';
337
+ if (!answered) await judgeRow();
281
338
 
282
339
  // Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
283
340
  // one shape on the wire, first call and every retry. No span of its own: `execute` above holds
284
341
  // the one that covers this invocation, and a second here would only re-time its tail.
342
+ let started = false;
285
343
  const run = async (): Promise<unknown> => {
286
- const produced = await Promise.resolve(def.handle({ input, ctx }));
344
+ started = true;
345
+ HANDLER_REACHED.set(def, (HANDLER_REACHED.get(def) ?? 0) + 1);
346
+ // Read here, not closed over: under a key this runs in `runToCompletion`'s scope, and a handler
347
+ // passing the outer `ctx.signal` onward would fail its vendor call and settle a failure.
348
+ const produced = await judgedHandler(def, name, () => def.handle({ input, ctx: useContext() }));
287
349
  return validateOutput(def.output, produced, name);
288
350
  };
351
+ // The meter is drawn LAST before the handler, on every surface `surface: 'server'` included: a
352
+ // call refused by the policy, the parse or the rate limit used none of the allowance. Under a key
353
+ // it is drawn only for a reservation this call CREATED (`beforeRun`), so a replay — of a success
354
+ // or of a settled failure — draws nothing, and a refusal releases the key for a retry next period.
355
+ const metered = (): Promise<void> =>
356
+ def.meter === undefined ? Promise.resolve() : drawMeter(name, def.meter, ctx);
357
+ // The peek can be stale: a record that lapsed after it leaves this call a FRESH reservation, and
358
+ // the handler must not run on a rule it skipped. `beforeRun` runs only for a created reservation,
359
+ // and a refusal there releases it. Spend first, in the order a first call takes them.
360
+ const beforeRun = async (): Promise<void> => {
361
+ if (!spentEarly) await spend();
362
+ if (answered) await judgeRow();
363
+ await metered();
364
+ };
289
365
 
290
- let value: unknown;
291
- let wrote = true;
292
- if (store === undefined || trace.idempotencyKey === null) {
293
- value = await run();
294
- } else {
366
+ try {
367
+ if (store === undefined || trace.idempotencyKey === null) {
368
+ await metered();
369
+ return await run();
370
+ }
295
371
  const outcome = await withIdempotency(store, trace.idempotencyKey, input, run, {
296
- ...(spentEarly ? {} : { beforeRun: spend }),
372
+ ...(spentEarly && !answered && def.meter === undefined ? {} : { beforeRun }),
297
373
  });
298
374
  if (outcome.replayed) options.onReplay?.();
299
375
  trace.replayed = outcome.replayed;
300
- wrote = !outcome.replayed;
301
376
  // A replay is parsed like a first call. The memory store hands back the live value and the
302
377
  // postgres store `JSON.parse(JSON.stringify(v))` — a `Date` on the first call and a `string`
303
378
  // on every retry served from Postgres — so the output schema is the one answer to its shape.
304
- value = outcome.replayed ? validateOutput(def.output, outcome.value, name) : outcome.value;
379
+ return outcome.replayed ? validateOutput(def.output, outcome.value, name) : outcome.value;
380
+ } finally {
381
+ // Only for a handler that actually started, and only through the gate. A replay ran no handler
382
+ // and changed nothing the first call had not already busted — re-busting per retry re-purges
383
+ // the CDN and re-queues ISR for a write nobody made. A handler that THREW is busted too: it may
384
+ // have committed (`withIdempotency` records the same throw for that reason), and skipping the
385
+ // bust hid that write behind a stale cache for the TTL. The bust is post-COMMIT either way —
386
+ // invoked inside a transaction, it waits for the root commit and is dropped by a rollback — and
387
+ // `bustAfterCommit` never throws, so it cannot replace this call's answer or its failure.
388
+ if (started && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
305
389
  }
306
- // Only for a run that actually happened, and only through the gate. A replay ran no handler
307
- // and changed nothing the first call had not already busted — re-busting per retry re-purges
308
- // the CDN and re-queues ISR for a write nobody made. And the bust is post-COMMIT either way —
309
- // invoked inside a transaction, it waits for the root commit and is dropped by a rollback — so
310
- // `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
311
- // durable write into a failed action.
312
- if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
313
- return value;
314
390
  }
@@ -15,7 +15,7 @@ export type JsonSchemaObject = Record<string, unknown>;
15
15
  * `{ type: 'object', additionalProperties: true }` so that "a missing OpenAPI detail must not
16
16
  * break a deploy" — which inverts axiom 3: the deploy succeeded and every caller was lied to.
17
17
  * `toJsonSchema` throws exactly when the spec must not claim anything, and the same schema
18
- * still fails `validateInput` on every payload, so the OpenAPI component and the MCP
18
+ * still fails `parseActionInput` on every payload, so the OpenAPI component and the MCP
19
19
  * `inputSchema` were advertising "any object accepted" for an endpoint that accepts none.
20
20
  * `registerAction` calls this at boot (`assertProjectable`), so a registered action can never
21
21
  * reach a projection that throws.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Where a declared `meter:` is drawn: once per call, inside `invoke`, on EVERY surface — HTTP, MCP,
3
+ * a job, an in-process call — after the policy admitted it and before the handler starts. A
4
+ * refusal is `X_METER_EXCEEDED`; a crossed `warnAt` enqueues the app's `onWarn` once per period.
5
+ */
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import { isAnonymous, logger } from '@ultimat3/core';
9
+ import { MeterExceededError, MeterInvalidError } from './errors-meter';
10
+ import type { ActionMeter, MeterWarning, ResolvedLimit } from './meter';
11
+ import { isMeterLimit } from './meter';
12
+ import { meterPeriodKey, meterWindow } from './meter-period';
13
+ import type { MeterKey, MeterStore } from './meter-store';
14
+ import { getMeterStore } from './meter-store';
15
+ import { inJobFrame, UNATTRIBUTED_JOB } from './rate-limit-gate';
16
+
17
+ /**
18
+ * Whose counter: the tenant, else the signed-in actor, else ONE shared counter per population —
19
+ * refused together rather than unlimited, the rate limiter's rule for a caller with nothing to key
20
+ * on. A job run carries its org on the worker's anonymous actor (`jobRunActor`), so it counts
21
+ * there; a tenantless one counts as `job:unattributed`, never on the anonymous visitors' counter.
22
+ */
23
+ export function meterSubject(ctx: Ctx): string {
24
+ const orgId = ctx.actor.orgId;
25
+ if (typeof orgId === 'string' && orgId !== '') return `org:${orgId}`;
26
+ if (!isAnonymous(ctx.actor)) return `actor:${ctx.actor.id}`;
27
+ return inJobFrame() ? UNATTRIBUTED_JOB : 'anonymous';
28
+ }
29
+
30
+ /** A call-time limit, checked: `NaN`, a fraction or a negative is the app's bug, never allow-all. */
31
+ export async function resolveMeterLimit(
32
+ meter: string,
33
+ limit: ResolvedLimit,
34
+ ctx: Ctx,
35
+ ): Promise<number> {
36
+ const resolved: unknown = typeof limit === 'function' ? await limit({ ctx }) : limit;
37
+ if (!isMeterLimit(resolved)) {
38
+ throw new MeterInvalidError(meter, 'limit', 'a whole number of calls, 0 or more', resolved);
39
+ }
40
+ return resolved;
41
+ }
42
+
43
+ /** Draw one call from `meter` for this caller, or throw `X_METER_EXCEEDED`. */
44
+ export async function drawMeter(
45
+ action: string,
46
+ meter: ActionMeter,
47
+ ctx: Ctx,
48
+ store: MeterStore = getMeterStore(),
49
+ ): Promise<void> {
50
+ const limit = await resolveMeterLimit(meter.name, meter.limit, ctx);
51
+ const nowMs = ctx.now().getTime();
52
+ const window = meterWindow(meter.period, nowMs);
53
+ const key: MeterKey = {
54
+ subject: meterSubject(ctx),
55
+ meter: meter.name,
56
+ period: meterPeriodKey(meter.period),
57
+ };
58
+ const drawn = await store.draw({ ...key, periodStartMs: window.startMs, limit });
59
+ if (!drawn.allowed) {
60
+ throw new MeterExceededError({
61
+ action,
62
+ meter: meter.name,
63
+ period: meter.period,
64
+ limit,
65
+ resetsAtMs: window.endMs,
66
+ nowMs,
67
+ });
68
+ }
69
+ // After the increment landed, never before: a warning for a draw that was refused is a warning
70
+ // about usage that never happened.
71
+ if (meter.warnAt === undefined || meter.onWarn === undefined || drawn.warned) return;
72
+ if (drawn.used < Math.ceil(meter.warnAt * limit)) return;
73
+ if (!(await store.claimWarning(key, window.startMs))) return;
74
+ const warning: MeterWarning = {
75
+ action,
76
+ meter: meter.name,
77
+ orgId: ctx.actor.orgId ?? null,
78
+ actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
79
+ used: drawn.used,
80
+ limit,
81
+ periodStart: new Date(window.startMs).toISOString(),
82
+ resetsAt: new Date(window.endMs).toISOString(),
83
+ };
84
+ try {
85
+ // Outside the caller's transaction: the claim above is already durable, and a warning staged
86
+ // in a transaction that rolls back would be a claim nothing ever delivers.
87
+ await meter.onWarn.enqueue(warning, {
88
+ outbox: false,
89
+ ...(warning.orgId === null ? {} : { tenantId: warning.orgId }),
90
+ });
91
+ } catch (error) {
92
+ // The call is owed its answer: its draw counted and the handler is about to run, so a warning
93
+ // that could not be queued never fails it. The claim goes back, so the next draw retries.
94
+ logger.error('action.meter.warn-failed', { action, meter: meter.name, error });
95
+ await store.releaseWarning(key, window.startMs).catch((releaseError: unknown) => {
96
+ logger.error('action.meter.warn-release-failed', {
97
+ action,
98
+ meter: meter.name,
99
+ error: releaseError,
100
+ });
101
+ });
102
+ }
103
+ }