@ultimat3/action 24.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,140 @@
1
+ /**
2
+ * One job: the copy of an idempotent answer that may be kept AT REST — every value under a key
3
+ * core's `isRedactedKey` names, every `Secret` box, and everything the walk cannot vouch for
4
+ * replaced by `[redacted]`. The rule is core's table, the one `audit-input.ts` and the logger ask.
5
+ */
6
+ import { isRedactedKey, isSecret, REDACTED } from '@ultimat3/core';
7
+
8
+ /**
9
+ * How deep the walk inspects. Past it the subtree is REDACTED, never kept: what was not looked at
10
+ * cannot be shown to hold no credential, and the record of it outlives the request by a day. An
11
+ * output schema deep enough to reach this is a recursive one, and its replay is refused rather
12
+ * than served with a credential in it.
13
+ */
14
+ export const IDEMPOTENCY_REDACT_MAX_DEPTH = 32;
15
+
16
+ export interface RestingAnswer {
17
+ /** What the store may write down. The ORIGINAL reference when nothing was redacted. */
18
+ readonly value: unknown;
19
+ /** True when anything was replaced — the record's flag, so a replay never scans for a string. */
20
+ readonly redacted: boolean;
21
+ }
22
+
23
+ interface WalkState {
24
+ redacted: boolean;
25
+ }
26
+
27
+ /**
28
+ * The answer as it may rest. Plain data — objects whose prototype is `Object.prototype` or
29
+ * `null`, arrays, `Date`, primitives — with no redacted key comes back as the SAME reference, so
30
+ * the common case stores and replays exactly what it did before.
31
+ *
32
+ * **Fail closed on everything else.** The Postgres store writes `JSON.stringify(value)`, which
33
+ * runs app code this walk never saw: a `toJSON` (on a class, a plain object, an array, even a
34
+ * function) may return `{ apiKey }`, and a getter read a second time may return something else.
35
+ * So a value with a callable `toJSON`, a `Map`, a `Set`, a class instance or a `Date` subclass is
36
+ * `[redacted]` and the record flagged — its replay is refused, which is the honest answer for an
37
+ * answer this walk could not judge. An own getter is read ONCE and the subtree copied, so the
38
+ * serializer reads the value that was judged, not a fresh one. `toJSON` is never called. A CYCLE
39
+ * is cut the same way — its back-reference becomes `[redacted]` — so both stores keep one
40
+ * serializable copy; a value repeated as siblings is not a cycle and is kept.
41
+ */
42
+ export function restingAnswer(value: unknown): RestingAnswer {
43
+ const state: WalkState = { redacted: false };
44
+ const out = walk(value, 0, new Set(), state);
45
+ return { value: out, redacted: state.redacted };
46
+ }
47
+
48
+ function redact(state: WalkState): string {
49
+ state.redacted = true;
50
+ return REDACTED;
51
+ }
52
+
53
+ /** `JSON.stringify` asks for `toJSON` on any object — a function included. */
54
+ function hasToJson(value: object): boolean {
55
+ return typeof (value as { toJSON?: unknown }).toJSON === 'function';
56
+ }
57
+
58
+ /** A `Date` whose `toJSON` is the built-in one: its own prototype, no own override. */
59
+ function isPlainDate(value: object): boolean {
60
+ return Object.getPrototypeOf(value) === Date.prototype && !Object.hasOwn(value, 'toJSON');
61
+ }
62
+
63
+ function isPlainObject(value: object): boolean {
64
+ const proto: unknown = Object.getPrototypeOf(value);
65
+ return proto === Object.prototype || proto === null;
66
+ }
67
+
68
+ function walk(value: unknown, depth: number, ancestors: Set<object>, state: WalkState): unknown {
69
+ if (typeof value === 'function') return hasToJson(value) ? redact(state) : value;
70
+ if (typeof value !== 'object' || value === null) return value;
71
+ if (isSecret(value)) return redact(state);
72
+ if (isPlainDate(value)) return value;
73
+ // A back-reference fails closed: kept, `JSON.stringify` throws in the Postgres store after the
74
+ // handler committed, while the memory store would accept it — two answers for one record.
75
+ if (ancestors.has(value)) return redact(state);
76
+ const array = Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
77
+ if (hasToJson(value) || !(array || isPlainObject(value))) return redact(state);
78
+ if (depth >= IDEMPOTENCY_REDACT_MAX_DEPTH) return redact(state);
79
+ ancestors.add(value);
80
+ try {
81
+ return array
82
+ ? walkArray(value as readonly unknown[], depth, ancestors, state)
83
+ : walkObject(value, depth, ancestors, state);
84
+ } finally {
85
+ ancestors.delete(value);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * One read of an own property: a data property's value, or its getter called exactly once.
91
+ * `copy` says the subtree must be copied for the read to be the one the serializer sees.
92
+ */
93
+ function readOnce(target: object, key: string | number): { value: unknown; copy: boolean } {
94
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
95
+ if (descriptor === undefined) return { value: undefined, copy: false };
96
+ if (descriptor.get !== undefined) return { value: descriptor.get.call(target), copy: true };
97
+ return { value: descriptor.value, copy: false };
98
+ }
99
+
100
+ function walkArray(
101
+ value: readonly unknown[],
102
+ depth: number,
103
+ ancestors: Set<object>,
104
+ state: WalkState,
105
+ ): unknown {
106
+ let copy: unknown[] | undefined;
107
+ // Indexed, not `forEach`: a hole is visited (as `undefined`) and kept in its place.
108
+ for (let index = 0; index < value.length; index += 1) {
109
+ const read = readOnce(value, index);
110
+ const next = walk(read.value, depth + 1, ancestors, state);
111
+ if (read.copy || next !== read.value) copy ??= value.slice(0, index);
112
+ copy?.push(next);
113
+ }
114
+ return copy ?? value;
115
+ }
116
+
117
+ function walkObject(
118
+ value: object,
119
+ depth: number,
120
+ ancestors: Set<object>,
121
+ state: WalkState,
122
+ ): unknown {
123
+ let changed = false;
124
+ const out: Record<string, unknown> = {};
125
+ // OWN enumerable string keys: what `JSON.stringify` writes and what the Postgres store keeps.
126
+ for (const key of Object.keys(value)) {
127
+ const read = readOnce(value, key);
128
+ const item = read.value;
129
+ // The key decides before the value is walked, so a credential under a redacted name is never
130
+ // read further. `null` and `undefined` under one are KEPT: they hold no credential, and
131
+ // redacting them would refuse the replay of a `{ resetToken: null }` that never carried one.
132
+ const next =
133
+ item !== undefined && item !== null && isRedactedKey(key)
134
+ ? redact(state)
135
+ : walk(item, depth + 1, ancestors, state);
136
+ if (read.copy || next !== item) changed = true;
137
+ out[key] = next;
138
+ }
139
+ return changed ? out : value;
140
+ }
@@ -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();
@@ -230,7 +273,11 @@ export async function withIdempotency<T>(
230
273
  // Inside a transaction the shared store settles on that transaction's connection, so "settled"
231
274
  // and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
232
275
  // (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
233
- await store.settle(key, value, record.id);
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);
234
281
  return { value, replayed: false };
235
282
  }
236
283
 
package/src/index.ts CHANGED
@@ -7,44 +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
- /**
23
- * The SHARED store, and the only one an app running more than one replica may install. The
24
- * statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
25
- * by `x db up` in development and by the release-phase `ROLE=migrate` in production.
26
- */
27
- export type {
28
- ClientFlight,
29
- ClientFlightOptions,
30
- ClientRetry,
31
- FlightKeyOptions,
32
- FlightPlan,
33
- PgExecutor,
34
- WireAnswer,
35
- } from '@ultimat3/core';
36
- export {
37
- createClientFlight,
38
- DEFAULT_CLIENT_RETRY,
39
- isSuperseded,
40
- isTransientFailure,
41
- } from '@ultimat3/core';
42
- /**
43
- * `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
44
- * `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
45
- * 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`.
46
20
  */
47
- export { toBucket } from '@ultimat3/http';
21
+ export type { ClientFlight, ClientRetry, PgExecutor } from '@ultimat3/core';
48
22
  /** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
49
23
  export type { Infer } from '@ultimat3/schema';
50
24
  export { t } from '@ultimat3/schema';
@@ -60,7 +34,6 @@ export type {
60
34
  ActionRowArgs,
61
35
  AnyAction,
62
36
  InvokeOptions,
63
- McpAnnotationHints,
64
37
  McpDescriptorMeta,
65
38
  } from './action';
66
39
  export { action, describeAction, isAction } from './action';
@@ -79,25 +52,21 @@ export { apiDeclaration } from './api-declaration';
79
52
  /**
80
53
  * The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
81
54
  * and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
82
- * 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.
83
57
  */
84
- export type {
85
- AuditFailure,
86
- AuditOutcome,
87
- AuditRecord,
88
- AuditSink,
89
- } from './audit';
90
- export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
58
+ export type { AuditRecord, AuditSink } from './audit';
91
59
  export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
92
60
  export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
93
61
  export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
94
62
  /**
95
- * The DURABLE sink, and the only one an app that must keep its trail may install. The statements
96
- * are exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
97
- * 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`).
98
67
  */
99
68
  export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
100
- export { postgresAuditSink, SQL_AUDIT_INSERT, SQL_AUDIT_TABLE } from './audit-postgres';
69
+ export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
101
70
  export type {
102
71
  ActionLike,
103
72
  ActionMap,
@@ -112,14 +81,6 @@ export type { ContractTest, ContractTestOptions } from './contract-test';
112
81
  export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
113
82
  export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
114
83
  export { defineApi } from './define-api';
115
- /**
116
- * The compat window a retirement gets. Versioning itself is NOT here and never will be: two
117
- * versions of one action side by side is two deployments behind one ingress (axiom 7), not a
118
- * router feature. `renderDeprecation` is exported so a plain `route` can announce the same pair
119
- * of headers the action projection does.
120
- */
121
- export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
122
- export { recordDeprecatedCall, renderDeprecation } from './deprecation';
123
84
  export type { IdempotencyConflictReason, IdempotencyKeyProblem, RemoteFailure } from './errors';
124
85
  export {
125
86
  ActionDeniedError,
@@ -138,6 +99,7 @@ export {
138
99
  IdempotencyReplayedFailureError,
139
100
  IdempotencyReservationLostError,
140
101
  IdempotencyStatusUnknownError,
102
+ IdempotentReplayRedactedError,
141
103
  InputInvalidError,
142
104
  MutatorNotIdempotentError,
143
105
  OutputInvalidError,
@@ -151,8 +113,6 @@ export {
151
113
  } from './errors-http';
152
114
  export type { OpenApiOperation } from './http';
153
115
  export {
154
- BUILD_ID_HEADER,
155
- IDEMPOTENCY_HEADER,
156
116
  operationTagOf,
157
117
  REPLAYED_HEADER,
158
118
  toOpenApiOperation,
@@ -191,34 +151,27 @@ export {
191
151
  withIdempotency,
192
152
  } from './idempotency';
193
153
  export { idempotencyKeyFor, MAX_IDEMPOTENCY_KEY_LENGTH } from './idempotency-key';
194
- export type { MemoryIdempotencyStoreOptions } from './idempotency-memory';
154
+ export type {
155
+ MemoryIdempotencyStore,
156
+ MemoryIdempotencyStoreOptions,
157
+ } from './idempotency-memory';
195
158
  export {
196
159
  DEFAULT_IDEMPOTENCY_WINDOW_MS,
197
160
  DEFAULT_MAX_IDEMPOTENCY_KEYS,
198
- MemoryIdempotencyStore,
161
+ memoryIdempotencyStore,
199
162
  } from './idempotency-memory';
200
163
  export type {
201
164
  PostgresIdempotencyStore,
202
165
  PostgresIdempotencyStoreOptions,
203
166
  } from './idempotency-postgres';
204
- export {
205
- postgresIdempotencyStore,
206
- SQL_IDEMPOTENCY_FAIL,
207
- SQL_IDEMPOTENCY_GET,
208
- SQL_IDEMPOTENCY_PURGE,
209
- SQL_IDEMPOTENCY_RELEASE,
210
- SQL_IDEMPOTENCY_RESERVE,
211
- SQL_IDEMPOTENCY_SETTLE,
212
- SQL_IDEMPOTENCY_TABLE,
213
- } 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';
214
169
  /** The one execution path. `defOf` stays unexported — that is the enforcement. */
215
170
  export { actionName, invoke } from './invoke';
216
171
  export type { ActionJobHandle } from './job-handle';
217
172
  export { toJobHandle } from './job-handle';
218
173
  export type { JsonSchemaObject } from './json-schema';
219
174
  export { jsonSchemaOf } from './json-schema';
220
- export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
221
- export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
222
175
  export type {
223
176
  LocalTable,
224
177
  LocalTableName,
@@ -230,7 +183,7 @@ export type {
230
183
  } from './mutator';
231
184
  export { custom, isMutator, mutator } from './mutator';
232
185
  export type { ActionPath } from './naming';
233
- export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming';
186
+ export { derivePath, inputSchemaName, outputSchemaName } from './naming';
234
187
  export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
235
188
  export { buildOpenApi, serializeOpenApi } from './openapi';
236
189
  export {
@@ -246,19 +199,12 @@ export {
246
199
  export { explainActionPathMiss } from './path-style-miss';
247
200
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
248
201
  /**
249
- * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
250
- * `admitsAnonymous` is `@ultimat3/policy`'s, re-exported here beside them: it is what `toRoute`
251
- * derives `meta.auth` from, so a plain `route` sets that field from the same walk rather than
252
- * 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.
253
206
  */
254
- export {
255
- actorOf,
256
- admitsAnonymous,
257
- guard,
258
- guardBeforeInput,
259
- policyCapability,
260
- policyPermissions,
261
- } from './policy-gate';
207
+ export { guardAction, guardActionBeforeInput } from './policy-gate';
262
208
  export {
263
209
  actionHttpPath,
264
210
  configureActionPathStyle,
@@ -267,7 +213,7 @@ export {
267
213
  listActions,
268
214
  registerAction,
269
215
  registerActions,
270
- resetRegistry,
216
+ resetActions,
271
217
  } from './registry';
272
218
  export { requestDeadlineMs } from './request-deadline';
273
219
  /**
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;
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';