@ultimat3/action 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/action.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * The `action` primitive: one server-authoritative mutation, declared once.
3
- * Every projection in this package (route, OpenAPI, client, MCP tool, job
4
- * handle, contract tests) reads this declaration — none of them re-declare it.
3
+ * Every projection in this package (route, OpenAPI, client, job handle, contract
4
+ * tests) reads this declaration — none of them re-declare it. The MCP tool is
5
+ * `@ultimat3/mcp`'s projection (`toolFrom`), one tier up, of the same declaration.
5
6
  */
6
7
 
7
8
  import type { CacheTag } from '@ultimat3/cache';
8
9
  import { tagKeys } from '@ultimat3/cache';
9
- import type { Actor, Ctx } from '@ultimat3/core';
10
+ import type { Actor, Ctx, Deprecation, McpExposureDeclaration } from '@ultimat3/core';
10
11
  import { isMcpExposed } from '@ultimat3/core';
12
+ import type { RateLimitDecision } from '@ultimat3/http';
13
+ import { policyCapability, policyPermissions } from '@ultimat3/policy';
11
14
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
12
15
  import type { ClientMethod, ClientOptions } from './client';
13
16
  import type { ContractTest, ContractTestOptions } from './contract-test';
14
- import type { Deprecation } from './deprecation';
15
17
  import { facadeFor } from './facade';
16
18
  import type { OpenApiOperation } from './http';
17
19
  import type { ActionHttp } from './http-path';
@@ -21,76 +23,18 @@ import { actionName, defOf, hasDef, invoke, stashDef } from './invoke';
21
23
  import type { ActionJobHandle } from './job-handle';
22
24
  import type { JsonSchemaObject } from './json-schema';
23
25
  import { jsonSchemaOf } from './json-schema';
24
- import type { McpToolDescriptor } from './mcp-tool';
25
- import {
26
- type ActionPolicy,
27
- policyCapability,
28
- policyPermissions,
29
- type Surface,
30
- } from './policy-gate';
26
+ import type { ActionPolicy, Surface } from './policy-gate';
31
27
 
32
28
  export interface ActionCache {
33
29
  /** Tags dropped from every cache tier after the handler settles. */
34
30
  readonly invalidates: readonly CacheTag[];
35
31
  }
36
32
 
37
- export interface ActionMcp {
38
- /** Opt-in: only a literal `true` makes the action a tool. Silence exposes nothing. */
39
- readonly expose: boolean;
40
- /**
41
- * Contract text, NOT UI text — deliberately outside `t()`. It becomes the OpenAPI
42
- * operation `summary` (`toOpenApiOperation`), and `buildOpenApi`'s bytes are what
43
- * `x verify` diffs for contract drift. Resolving it through the ambient, request-scoped
44
- * translator would make `openapi.json` depend on whichever locale happened to be active
45
- * when it was generated, which is exactly the determinism that file's header forbids.
46
- * Localised agent-facing text needs a separate, locale-resolved projection; there is no
47
- * second field for it here until that exists, because two ways to describe one tool is
48
- * the drift axiom 1 rejects.
49
- */
50
- readonly description?: string;
51
- /**
52
- * Roles that may SEE the projected tool. A CATALOG audience, never an authz rule — the
53
- * `policy` above still decides every call, and this list decides nothing about one. Omitted
54
- * means every caller may enumerate it.
55
- *
56
- * Fail-closed where it lands (`@ultimat3/mcp`): a caller whose role is not named — including
57
- * one carrying no role at all — gets the answer an ABSENT tool gets, never `Forbidden`.
58
- * Forbidden would confirm the tool exists, which turns the catalog into something an agent
59
- * can enumerate by probing names.
60
- *
61
- * A plain role list, never a predicate: a declared fact has to stay static and serialisable.
62
- * A surface deriving visibility from something richer (`@ultimat3/admin` derives it from the
63
- * actor's admin permissions) hands `@ultimat3/mcp` a predicate instead.
64
- */
65
- readonly visibleTo?: readonly string[];
66
- /**
67
- * The tool's display name in an MCP client's UI (`title`, MCP 2025-06-18). Contract text, like
68
- * `description`. Omitted: none is published and the client shows the tool name.
69
- */
70
- readonly title?: string;
71
- /**
72
- * MCP tool annotations, overriding what `@ultimat3/mcp` derives key by key. An action derives
73
- * `readOnlyHint: false`, `destructiveHint: true` and `idempotentHint` from `idempotent`. Hints for
74
- * a client's confirmation UI — the policy decides every call whatever they say.
75
- *
76
- * ```ts
77
- * mcp: { expose: true, annotations: { destructiveHint: false, openWorldHint: true } },
78
- * ```
79
- */
80
- readonly annotations?: McpAnnotationHints;
81
- }
82
-
83
33
  /**
84
- * MCP's four tool hints, as the spec spells them. Declared here (tier 3) and in `@ultimat3/query`
85
- * because `@ultimat3/mcp` (tier 4) is the reader and cannot be imported; its `McpToolAnnotations`
86
- * is the same shape.
34
+ * An action's `mcp` block: core's ONE `McpExposureDeclaration`, minus the list whitelist an action
35
+ * has no list to compose over. Derived, never restated — `type-pins.ts` holds it to core's.
87
36
  */
88
- export interface McpAnnotationHints {
89
- readonly readOnlyHint?: boolean;
90
- readonly destructiveHint?: boolean;
91
- readonly idempotentHint?: boolean;
92
- readonly openWorldHint?: boolean;
93
- }
37
+ export type ActionMcp = Omit<McpExposureDeclaration, 'listParams'>;
94
38
 
95
39
  export interface ActionRateLimit {
96
40
  readonly limit: number;
@@ -180,6 +124,19 @@ export interface InvokeOptions {
180
124
  readonly idempotencyKey?: string | null;
181
125
  readonly store?: IdempotencyStore;
182
126
  readonly onReplay?: () => void;
127
+ /**
128
+ * The caller's connection address, which is the only rate-limit subject an anonymous caller has
129
+ * (`actor > org > address`). The HTTP projection passes the request's; a surface with none
130
+ * leaves it out, and every anonymous caller there shares one bucket — refused together rather
131
+ * than unlimited.
132
+ */
133
+ readonly clientAddress?: string | null;
134
+ /**
135
+ * Hands over the `rateLimit:` decision before a refusal is thrown — how the HTTP projection
136
+ * answers with `RateLimit-*` and `Retry-After`. Called only for a declared limit on a surface
137
+ * that spends one.
138
+ */
139
+ readonly onRateLimit?: (decision: RateLimitDecision) => void;
183
140
  }
184
141
 
185
142
  export interface McpDescriptorMeta {
@@ -258,7 +215,6 @@ export interface AnyAction {
258
215
  named(name: string): AnyAction;
259
216
  /** Run as this actor. Same `invoke` core, only the context's actor changes. */
260
217
  as(actor: Actor | null, input: unknown, options?: InvokeOptions): Promise<unknown>;
261
- tool(): McpToolDescriptor;
262
218
  openapi(): OpenApiOperation;
263
219
  /**
264
220
  * The durable-work shape, erased. `listActions()` and `getAction(name)` hand back this view
@@ -302,7 +258,7 @@ export interface Action<
302
258
  /** The fluent half of an action: lifted declaration plus one method per projection. */
303
259
  export type ActionFacade<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> = Pick<
304
260
  Action<TInput, TOutput>,
305
- 'input' | 'output' | 'policy' | 'mcp' | 'as' | 'tool' | 'openapi' | 'client' | 'job' | 'contract'
261
+ 'input' | 'output' | 'policy' | 'mcp' | 'as' | 'openapi' | 'client' | 'job' | 'contract'
306
262
  >;
307
263
 
308
264
  export function action<
@@ -327,7 +283,7 @@ export function isAction(value: unknown): value is AnyAction {
327
283
  /**
328
284
  * Stamp the export name onto the action the app declared, rather than handing back a
329
285
  * differently-named copy of it. `import { publishPost } from './actions'` is then the
330
- * action that projects — `publishPost.tool()` after boot, with nothing to remember.
286
+ * action that projects — `publishPost.openapi()` after boot, with nothing to remember.
331
287
  * Naming twice is the one case that still needs a twin: one object, one name, forever.
332
288
  */
333
289
  export function nameAction<A extends AnyAction>(target: A, name: string): A {
@@ -117,7 +117,7 @@ export function apiDeclaration(): ApiDeclaration {
117
117
  return declared;
118
118
  }
119
119
 
120
- /** Test seam, called by `resetRegistry`. */
120
+ /** Test seam, called by `resetActions`. */
121
121
  export function resetApiDeclaration(): void {
122
122
  declared = {};
123
123
  }
package/src/audit-gate.ts CHANGED
@@ -4,9 +4,8 @@
4
4
  * an attempt that already failed do not want the same answer.
5
5
  */
6
6
 
7
- import { isUltimateError, logger } from '@ultimat3/core';
7
+ import { getAuditSink, isUltimateError, logger } from '@ultimat3/core';
8
8
  import type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from './audit';
9
- import { getAuditSink } from './audit';
10
9
  import { ActionDeniedError, AuditSinkFailedError, AuditSinkMissingError } from './errors';
11
10
 
12
11
  /**
@@ -63,7 +62,7 @@ export async function auditSettled(sink: AuditSink, record: AuditRecord): Promis
63
62
  try {
64
63
  await sink.write(record);
65
64
  } catch (error) {
66
- throw new AuditSinkFailedError(record.action, error, record.idempotencyKey !== null);
65
+ throw new AuditSinkFailedError(record.name, error, record.idempotencyKey !== null);
67
66
  }
68
67
  }
69
68
 
@@ -84,7 +83,7 @@ export async function auditThrew(sink: AuditSink, record: AuditRecord): Promise<
84
83
  // the same reason `cache-gate.ts` gives. Never the record — rendering an input the sink just
85
84
  // choked on is the second throw this branch exists to prevent.
86
85
  logger.error('audit.sink.failed', {
87
- action: record.action,
86
+ action: record.name,
88
87
  outcome: record.outcome,
89
88
  error,
90
89
  });
@@ -11,8 +11,8 @@ import type { AuditRecord, AuditSink } from './audit';
11
11
  * input — so its cost is the request's, not a row's: at 50 audited writes a second an unbounded
12
12
  * array is 4.3M immortal records a day and the pod dies holding the trail it was retaining. This
13
13
  * sink was the one memory implementation in the framework with no cap, beside five that have one
14
- * (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`, `createTotpReplayGuard`,
15
- * `createMemoryEventBus`).
14
+ * (`memoryRateLimitStore`, `memoryIdempotencyStore`, `concurrencyLimiter`, `memoryTotpReplayGuard`,
15
+ * `memoryEventBus`).
16
16
  */
17
17
  export const DEFAULT_MAX_AUDIT_RECORDS = 1_000;
18
18
 
@@ -46,7 +46,7 @@ export interface MemoryAuditSink extends AuditSink {
46
46
  }
47
47
 
48
48
  /**
49
- * The OLDEST goes, which is the same direction `createMemoryEventBus` evicts in and the opposite
49
+ * The OLDEST goes, which is the same direction `memoryEventBus` evicts in and the opposite
50
50
  * of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
51
51
  * happened since" for a process that has been serving all day, and the most recent attempts are
52
52
  * the ones anyone reading `x dev` is looking at.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Actor, PgExecutor } from '@ultimat3/core';
9
- import { uuid } from '@ultimat3/core';
9
+ import { uuidV7 } from '@ultimat3/core';
10
10
  import type { AuditRecord, AuditSink } from './audit';
11
11
  import { auditableInput } from './audit-input';
12
12
 
@@ -34,6 +34,10 @@ import { auditableInput } from './audit-input';
34
34
  * a different answer per app — seven years for one, thirty days for the next — so shipping a
35
35
  * `delete` would be shipping one of those answers. The table grows until the app prunes or
36
36
  * partitions it, and that is stated rather than solved.
37
+ *
38
+ * **`primitive` arrived by `alter`, with a default of `'action'`.** The sink is shared with
39
+ * `query({ audit: true })` since plan 101 sweep 10b, so a row has to say whether a read or a write
40
+ * acted; every row written before that was an action's, which is exactly what the default says.
37
41
  */
38
42
  export const SQL_AUDIT_TABLE = `
39
43
  create table if not exists x_audit (
@@ -61,6 +65,8 @@ create table if not exists x_audit (
61
65
  recorded_at timestamptz not null default now()
62
66
  );
63
67
 
68
+ alter table x_audit add column if not exists primitive text not null default 'action';
69
+
64
70
  create index if not exists x_audit_at_idx on x_audit (at desc);
65
71
 
66
72
  create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
@@ -79,11 +85,11 @@ export const SQL_AUDIT_INSERT = `
79
85
  insert into x_audit (
80
86
  id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
81
87
  actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
82
- request_id, trace_id, locale, tz, build_id, role, input
88
+ request_id, trace_id, locale, tz, build_id, role, input, primitive
83
89
  ) values (
84
90
  $1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
85
91
  $10, $11, $12, $13, $14,
86
- $15, $16, $17, $18, $19, $20, $21::jsonb
92
+ $15, $16, $17, $18, $19, $20, $21::jsonb, $22
87
93
  )
88
94
  `;
89
95
 
@@ -111,7 +117,7 @@ export interface PostgresAuditSink extends AuditSink {
111
117
  * `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
112
118
  *
113
119
  * What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
114
- * is not tidiness: `createContext` spreads every installed service onto the context object, and on
120
+ * is not tidiness: `ctxOf` spreads every installed service onto the context object, and on
115
121
  * an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
116
122
  * `Cookie` headers — so a projection that walked it would write an app's database clients and its
117
123
  * caller's credentials into an audit table. An app that wants more columns writes its own
@@ -126,9 +132,11 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
126
132
  const onBehalfOf = onBehalfOfOf(actor);
127
133
  const input = auditableInput(record.input);
128
134
  await exec.query(SQL_AUDIT_INSERT, [
129
- uuid(),
135
+ uuidV7(),
130
136
  at === null ? null : at.toISOString(),
131
- record.action,
137
+ // `name`, into the column the seam named before reads were audited: renaming it is a data
138
+ // migration for every installed table, and the value it holds is the same.
139
+ record.name,
132
140
  record.mutator,
133
141
  record.surface,
134
142
  record.outcome,
@@ -149,6 +157,7 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
149
157
  // `undefined` in means a parse that never produced an input, which is a NULL column and
150
158
  // not the four characters `JSON.stringify(undefined)` does not produce either.
151
159
  input === undefined ? null : JSON.stringify(input),
160
+ record.primitive,
152
161
  ]);
153
162
  },
154
163
  };
package/src/audit.ts CHANGED
@@ -1,109 +1,10 @@
1
1
  /**
2
- * The audit seam: THAT an action or mutator can be recorded at all. One `AuditSink`, installed
3
- * once, handed every fact `invoke` genuinely knows about an attempt — who acted, which primitive,
4
- * when, on which surface, with which idempotency key, and whether it was allowed, denied or
5
- * failed. What the ROW says — its fields, its retention, its hash chain, its subject index, what
6
- * "who" means under impersonation — is the app's, and this file declares none of it.
2
+ * The audit seam, as this package exposes it: THAT an action or mutator can be recorded at all.
3
+ * The contract — the record, the sink, the one installed-sink slot — moved to `@ultimat3/core`'s
4
+ * `audit.ts` so `@ultimat3/query` (a sibling this package cannot import) writes the same record
5
+ * into the same sink. The TYPES are re-exported for this package's own modules; the slot's VALUES
6
+ * (`getAuditSink`, `setAuditSink`, `resetAuditSink`) are imported from `@ultimat3/core` and never
7
+ * re-exported — a second import path for one value is `X_HELPER_COPY` (`bun run flight-copies`).
7
8
  */
8
9
 
9
- import type { Ctx } from '@ultimat3/core';
10
- import type { Surface } from './policy-gate';
11
-
12
- /**
13
- * The three things that can happen to an attempt. Deliberately the same three words
14
- * `@ultimat3/admin`'s `AuditEntry` uses — that package is tier 5 and this one is tier 3, so the
15
- * vocabulary is shared by name and not by import. `denied` is an authz refusal, `failed` is
16
- * everything else that threw, including an input that never parsed.
17
- */
18
- export type AuditOutcome = 'allowed' | 'denied' | 'failed';
19
-
20
- /** Why a non-`allowed` attempt ended. The framework classifies; it never renders. */
21
- export interface AuditFailure {
22
- /** The `X_*` code when an `UltimateError` ended it; `null` for anything else that threw. */
23
- readonly code: string | null;
24
- /** The thrown value, verbatim — its stack is the thing worth reading. */
25
- readonly error: unknown;
26
- }
27
-
28
- /**
29
- * One attempt, as the framework observed it. Every field here is something `invoke` already
30
- * holds; nothing on it is a guess about the business.
31
- *
32
- * `result` is deliberately absent. A handler's return value is reachable from the handler
33
- * itself, on the one outcome that has one — so shipping it would be the framework deciding the
34
- * row carries an after-image, which is the first field of an audit ENTITY. `input` is present
35
- * for the opposite reason: on a `denied` record the handler never ran, so nothing in app code
36
- * can recover what was attempted, and that is the record an auditor actually wants.
37
- */
38
- export interface AuditRecord {
39
- /**
40
- * When the attempt began, from `ctx.now()` — never `new Date()`. An instant, not a rendering:
41
- * serialising it (ISO, epoch, a Postgres `timestamptz`) is the app's decision, and it is the
42
- * app that knows the zone anything is displayed in.
43
- */
44
- readonly at: Date;
45
- /** The registered export name. A mutator carries the name of its action half. */
46
- readonly action: string;
47
- /** True when `mutator()` built it. Which primitive acted is a framework fact. */
48
- readonly mutator: boolean;
49
- /** Which projection ran it: a price change over `http` and one over `mcp` are not the same event. */
50
- readonly surface: Surface;
51
- /**
52
- * The context the attempt ran in — actor, `requestId`, `traceId`, locale, and the service bag
53
- * a sink needs to write a row at all. Carried whole rather than projected into `actorId` +
54
- * `requestId` fields, because choosing WHICH context facts an audit row keeps is precisely the
55
- * convention four apps modelled four ways.
56
- *
57
- * **A sink that PERSISTS must project it, and `audit-postgres.ts` is where that is done.**
58
- * `createContext` spreads every installed service onto this object and an HTTP surface's value
59
- * is a `RequestContext` carrying the caller's `Authorization` and `Cookie`, so writing it down
60
- * whole puts an app's database clients and its caller's credentials in a table. Whole here,
61
- * allow-listed there — the seam hands over everything and each sink decides what it keeps.
62
- */
63
- readonly ctx: Ctx;
64
- /**
65
- * The PARSED input, or `undefined` when the parse is what failed. Never the raw payload:
66
- * an unvalidated body is attacker-shaped, and handing one to a sink that writes it to a table
67
- * is how an audit trail becomes an injection surface.
68
- */
69
- readonly input: unknown;
70
- /** The namespaced key an `idempotent` action was retried under, or `null`. */
71
- readonly idempotencyKey: string | null;
72
- /** True when the response was replayed from an earlier settled record — a call, not a write. */
73
- readonly replayed: boolean;
74
- readonly outcome: AuditOutcome;
75
- /** Present exactly when `outcome !== 'allowed'`. */
76
- readonly failure: AuditFailure | null;
77
- }
78
-
79
- /**
80
- * Where a record goes. The app's implementation: a table, an append-only hash chain, an OTel
81
- * log, a queue. `@ultimat3/admin`'s `AuditSink` is the same noun one tier up, over its own
82
- * fixed entry type; this one carries no `AdminActor` and no `permission`, because an action
83
- * outside `/admin` has neither.
84
- *
85
- * A sink that throws is never swallowed — see `audit-gate.ts` for which failure wins.
86
- */
87
- export interface AuditSink {
88
- write(record: AuditRecord): Promise<void> | void;
89
- }
90
-
91
- /**
92
- * No default. A logger-backed default would satisfy `audit: true` with a line nobody stores,
93
- * which is the silent pass this seam exists to remove: an audited action with no sink installed
94
- * is `X_AUDIT_SINK_MISSING`, refused before the handler runs.
95
- */
96
- let installed: AuditSink | null = null;
97
-
98
- export function setAuditSink(sink: AuditSink): void {
99
- installed = sink;
100
- }
101
-
102
- export function getAuditSink(): AuditSink | null {
103
- return installed;
104
- }
105
-
106
- /** Test seam: back to "nothing installed", which restoring a literal cannot express. */
107
- export function resetAuditSink(): void {
108
- installed = null;
109
- }
10
+ export type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from '@ultimat3/core';
package/src/client.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * compile error in a Solid component — not a 404 at runtime.
5
5
  *
6
6
  * `ClientFlight` is a TYPE here and never a value: the fence, the retry loop and the deadline are
7
- * `@ultimat3/core`'s `client-flight.ts`, so a caller that never calls `createClientFlight` does
7
+ * `@ultimat3/core`'s `client-flight.ts`, so a caller that never calls `clientFlight` does
8
8
  * not pay a byte for any of them — an `import type` is erased and the value import would not be.
9
9
  * Dedup is deliberately unreachable from this file — a mutation may never join another mutation,
10
10
  * and the way that is guaranteed is that `keyFor` is never called here.
@@ -24,15 +24,16 @@ import type {
24
24
  } from '@ultimat3/core';
25
25
  import {
26
26
  actionPath,
27
+ BUILD_ID_HEADER,
27
28
  clientTransport,
28
29
  FRAMEWORK_CODE,
29
30
  isJsonObject,
30
31
  problemOf,
32
+ remoteTitleOf,
31
33
  } from '@ultimat3/core';
32
34
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
33
35
  import type { Action } from './action';
34
36
  import { ContractDriftError, RemoteActionError } from './errors';
35
- import { BUILD_ID_HEADER } from './wire-headers';
36
37
  import { issuesFromWire } from './wire-issues';
37
38
 
38
39
  /**
@@ -79,7 +80,7 @@ export interface ClientOptions {
79
80
  readonly buildId?: string;
80
81
  readonly headers?: Readonly<Record<string, string>>;
81
82
  /**
82
- * Opt-in flight control — `createClientFlight({ … })`. Absent, a call is one dispatch and
83
+ * Opt-in flight control — `clientFlight({ … })`. Absent, a call is one dispatch and
83
84
  * nothing else, which is what every caller written before this option existed already gets.
84
85
  */
85
86
  readonly flight?: ClientFlight;
@@ -132,8 +133,11 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
132
133
  const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`;
133
134
  const onResponse = (response: Response): void =>
134
135
  assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
135
- const decodeError = (status: number, text: string): UltimateError | undefined =>
136
- toUltimateError(text, status, name);
136
+ const decodeError = (
137
+ status: number,
138
+ text: string,
139
+ retryAfterSeconds?: number,
140
+ ): UltimateError | undefined => toUltimateError(text, status, name, retryAfterSeconds);
137
141
  // Erased at the wire seam; the response type is this action's by construction.
138
142
  return (input, callOptions = {}) =>
139
143
  clientTransport({
@@ -197,7 +201,12 @@ function assertSameBuild(
197
201
  * code a query gets for the same failure. `X_RPC_FAILED` was this branch until 21.0.0; the code
198
202
  * stays registered (shipped codes never change) and nothing in the framework throws it now.
199
203
  */
200
- function toUltimateError(text: string, status: number, name: string): UltimateError | undefined {
204
+ function toUltimateError(
205
+ text: string,
206
+ status: number,
207
+ name: string,
208
+ retryAfterSeconds: number | undefined,
209
+ ): UltimateError | undefined {
201
210
  // `problemOf` is total — a gateway's HTML, an empty body and a truncated stream all answer `{}`,
202
211
  // which carries no `code` and therefore falls through to the transport's decode.
203
212
  const body = problemOf(text);
@@ -207,6 +216,9 @@ function toUltimateError(text: string, status: number, name: string): UltimateEr
207
216
  action: name,
208
217
  status,
209
218
  code,
219
+ // Core's reader, so an island titles an http refusal it never loaded the table for.
220
+ title: remoteTitleOf(body['title']),
221
+ retryAfterSeconds,
210
222
  // Parsed, never taken: `body` is whatever answered the request. A list this build cannot read
211
223
  // is dropped rather than repaired, and `cause` below still carries every rejection in it.
212
224
  issues: issuesFromWire(body['issues']),
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
- import { createContext, isUltimateError, logger } from '@ultimat3/core';
8
+ import { ctxOf, isJsonObject, isUltimateError, logger, renderFixShellArg } from '@ultimat3/core';
9
9
  import type { AnyAction } from './action';
10
10
  import { ActionDeniedError, ContractDriftError } from './errors';
11
11
  import { toOpenApiOperation } from './http';
@@ -14,7 +14,6 @@ import { actionName, invoke } from './invoke';
14
14
  import { buildOpenApi } from './openapi';
15
15
  import { listActions } from './registry';
16
16
  import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
17
- import { isJsonObject } from './stable';
18
17
  import { validateInput } from './validate';
19
18
 
20
19
  export interface ContractTest {
@@ -37,7 +36,7 @@ export interface ContractTestOptions {
37
36
 
38
37
  /** A context whose actor is core's anonymous actor — what a signed-out caller has. */
39
38
  export function anonymousCtx(): Ctx {
40
- return createContext({});
39
+ return ctxOf({});
41
40
  }
42
41
 
43
42
  export function contractTestsFor(
@@ -95,13 +94,26 @@ function assertDocumented(target: AnyAction, name: string): void {
95
94
  const published = buildOpenApi({ actions }).paths[path];
96
95
  const owner = operationIdAt(published);
97
96
  if (owner === toOpenApiOperation(target).operationId) return;
98
- throw new ContractDriftError(
97
+ throw documentedDrift(path, owner, name);
98
+ }
99
+
100
+ /**
101
+ * The refusal when the published document does not name this action at its route. `name` is
102
+ * whatever `.named()` was handed — nothing validates its alphabet — so it is screened where it
103
+ * enters the `x actions describe` command (security audit of plan 101 sweep 1c).
104
+ */
105
+ export function documentedDrift(
106
+ path: string,
107
+ owner: string | undefined,
108
+ name: string,
109
+ ): ContractDriftError {
110
+ return new ContractDriftError(
99
111
  owner === undefined
100
112
  ? `OpenAPI document has no entry for ${path}`
101
113
  : `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
102
114
  owner === undefined
103
115
  ? 'x verify --json # the contract suite is a step of it'
104
- : `x actions describe ${name} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
116
+ : `x actions describe ${renderFixShellArg(name, '<action>')} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
105
117
  );
106
118
  }
107
119
 
@@ -176,7 +188,7 @@ async function expectDenied(
176
188
  if (error instanceof ActionDeniedError) return;
177
189
  // `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
178
190
  // output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
179
- // is what `validateInput` raises before `guard()` is reached (for a policy whose actor half
191
+ // is what `validateInput` raises before `guardAction()` is reached (for a policy whose actor half
180
192
  // could not decide), and `input:` is the knob that answers it. Any other
181
193
  // code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
182
194
  // `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
@@ -0,0 +1,51 @@
1
+ // @ultimat3/action's error TITLES, registered at import — the one module `sideEffects` names, so
2
+ // `errors.ts` (the classes) is unlisted and a chunk carries it only where it constructs a class:
3
+ // Bun >= 1.4.1 honours the array and keeps a listed module wherever the barrel reaches it.
4
+ // `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
5
+ // its title from the registry; `errors.ts` and its two siblings import it bare as well.
6
+
7
+ import { registerErrorCodes } from '@ultimat3/core';
8
+
9
+ /**
10
+ * Titles for the framework-wide code table — every one of them owned by this package.
11
+ * `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
12
+ * enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
13
+ * Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
14
+ */
15
+ export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
16
+ X_ACTION_DUPLICATE: 'two actions are registered under one name',
17
+ X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
18
+ X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
19
+ X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
20
+ X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
21
+ X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
22
+ X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
23
+ X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
24
+ X_CONTRACT_DRIFT: 'client and server disagree about the contract',
25
+ X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
26
+ X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
27
+ X_IDEMPOTENCY_NOT_SHARED:
28
+ 'idempotency is declared fleet-wide and the installed store is per-process',
29
+ X_IDEMPOTENCY_REPLAYED_FAILURE:
30
+ 'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
31
+ X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
32
+ X_INPUT_INVALID: 'input failed schema validation',
33
+ X_MUTATOR_CLOCK_MISSING:
34
+ "a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
35
+ X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
36
+ X_RPC_FAILED: 'an RPC call failed without a problem+json body',
37
+ X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
38
+ X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
39
+ X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
40
+ X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
41
+ X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
42
+ X_IDEMPOTENCY_RESERVATION_LOST:
43
+ "an idempotent action's reservation was taken over before its transaction could settle it",
44
+ X_IDEMPOTENT_REPLAY_REDACTED: 'an idempotent replay would return a field redacted at rest',
45
+ };
46
+
47
+ // One unconditional call: a presence guard would turn "another package claims one of these codes"
48
+ // from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
49
+ registerErrorCodes(
50
+ Object.fromEntries(Object.entries(ACTION_ERROR_TITLES).map(([code, title]) => [code, { title }])),
51
+ );
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The refusals of the HTTP declaration an app makes about its actions: a pinned `http.path`, the
3
3
  * app's `pathStyle`, and the `openapi` block. Split from `errors.ts` at its ceiling; the codes and
4
- * titles stay registered there, one registry.
4
+ * titles are registered in `error-titles.ts`, one registry, imported bare so constructing one of
5
+ * these registers its title first.
5
6
  */
6
7
 
7
8
  import { renderCauseValue, UltimateError } from '@ultimat3/core';
9
+ import './error-titles';
8
10
 
9
11
  export class ActionHttpPathInvalidError extends UltimateError {
10
12
  constructor(input: { name: string; path: unknown; reason: string }) {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
3
- * stable code, exactly as there — the codes and their titles stay in `errors.ts`'s one
3
+ * stable code, exactly as there — the codes and their titles stay in `error-titles.ts`'s one
4
4
  * `registerErrorCodes` call, because a second registration is how two modules end up deciding a
5
- * title by load order.
5
+ * title by load order. Imported bare, so constructing one of these registers its title first.
6
6
  */
7
7
  import { renderCauseValue, UltimateError } from '@ultimat3/core';
8
+ import './error-titles';
8
9
  // Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
9
10
  // cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
10
11
  import type { IdempotencyFailure } from './idempotency';
@@ -84,7 +85,7 @@ export class IdempotencyNotSharedError extends UltimateError {
84
85
  }
85
86
 
86
87
  /**
87
- * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
88
+ * The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guardAction()`
88
89
  * and the input parse both run before the idempotency gate, so everything the gate can see throw
89
90
  * is post-authorization and possibly post-commit — a handler that took the money and then failed
90
91
  * its own `output:` schema is the case this exists for. Releasing the reservation there let the
@@ -172,3 +173,20 @@ export class IdempotencyReservationLostError extends UltimateError {
172
173
  });
173
174
  }
174
175
  }
176
+
177
+ /**
178
+ * A replay of a settled answer that had a field redacted at rest. Refused, never served: the
179
+ * stored copy holds `[redacted]` where the first caller got a credential, and handing that back
180
+ * as "the answer" is a silent wrong value. The first attempt RAN — a fresh key runs it again, so
181
+ * the fix names reading its effect as the way to recover the result.
182
+ */
183
+ export class IdempotentReplayRedactedError extends UltimateError {
184
+ constructor(key: string) {
185
+ super({
186
+ code: 'X_IDEMPOTENT_REPLAY_REDACTED',
187
+ cause: `the answer stored for idempotency key "${key}" had a secret-named field redacted when it settled, so it cannot be replayed faithfully`,
188
+ fix: "x actions describe '<action>' --json # the call already ran and showed its secret once — read its effect with a query; a new Idempotency-Key runs it again (a second credential)",
189
+ meta: { key, replayed: true },
190
+ });
191
+ }
192
+ }