@ultimat3/action 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -34,6 +34,8 @@ export interface MemoryAuditSinkOptions {
34
34
  * here: a non-zero count on a real deployment is the sink saying it is the wrong one.
35
35
  */
36
36
  export interface MemoryAuditSink extends AuditSink {
37
+ /** Synchronous here: the window is an array, so nothing is left for a caller to await. */
38
+ write(record: AuditRecord): void;
37
39
  /** The retained window, oldest first. A copy — the log cannot be mutated through it. */
38
40
  records(): readonly AuditRecord[];
39
41
  /** Retained right now — the bound, observable. */
@@ -44,7 +46,7 @@ export interface MemoryAuditSink extends AuditSink {
44
46
  }
45
47
 
46
48
  /**
47
- * 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
48
50
  * of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
49
51
  * happened since" for a process that has been serving all day, and the most recent attempts are
50
52
  * the ones anyone reading `x dev` is looking at.
@@ -5,11 +5,10 @@
5
5
  * production. Statements are spelled out so an agent can run the exact one it saw in a log.
6
6
  */
7
7
 
8
- import type { Actor } from '@ultimat3/core';
9
- import { uuid } from '@ultimat3/core';
8
+ import type { Actor, PgExecutor } 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
- import type { PgExecutor } from './idempotency-postgres';
13
12
 
14
13
  /**
15
14
  * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, and
@@ -35,6 +34,10 @@ import type { PgExecutor } from './idempotency-postgres';
35
34
  * a different answer per app — seven years for one, thirty days for the next — so shipping a
36
35
  * `delete` would be shipping one of those answers. The table grows until the app prunes or
37
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.
38
41
  */
39
42
  export const SQL_AUDIT_TABLE = `
40
43
  create table if not exists x_audit (
@@ -62,6 +65,8 @@ create table if not exists x_audit (
62
65
  recorded_at timestamptz not null default now()
63
66
  );
64
67
 
68
+ alter table x_audit add column if not exists primitive text not null default 'action';
69
+
65
70
  create index if not exists x_audit_at_idx on x_audit (at desc);
66
71
 
67
72
  create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
@@ -80,11 +85,11 @@ export const SQL_AUDIT_INSERT = `
80
85
  insert into x_audit (
81
86
  id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
82
87
  actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
83
- request_id, trace_id, locale, tz, build_id, role, input
88
+ request_id, trace_id, locale, tz, build_id, role, input, primitive
84
89
  ) values (
85
90
  $1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
86
91
  $10, $11, $12, $13, $14,
87
- $15, $16, $17, $18, $19, $20, $21::jsonb
92
+ $15, $16, $17, $18, $19, $20, $21::jsonb, $22
88
93
  )
89
94
  `;
90
95
 
@@ -112,7 +117,7 @@ export interface PostgresAuditSink extends AuditSink {
112
117
  * `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
113
118
  *
114
119
  * What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
115
- * 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
116
121
  * an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
117
122
  * `Cookie` headers — so a projection that walked it would write an app's database clients and its
118
123
  * caller's credentials into an audit table. An app that wants more columns writes its own
@@ -127,9 +132,11 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
127
132
  const onBehalfOf = onBehalfOfOf(actor);
128
133
  const input = auditableInput(record.input);
129
134
  await exec.query(SQL_AUDIT_INSERT, [
130
- uuid(),
135
+ uuidV7(),
131
136
  at === null ? null : at.toISOString(),
132
- 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,
133
140
  record.mutator,
134
141
  record.surface,
135
142
  record.outcome,
@@ -150,6 +157,7 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
150
157
  // `undefined` in means a parse that never produced an input, which is a NULL column and
151
158
  // not the four characters `JSON.stringify(undefined)` does not produce either.
152
159
  input === undefined ? null : JSON.stringify(input),
160
+ record.primitive,
153
161
  ]);
154
162
  },
155
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/cache-gate.ts CHANGED
@@ -1,22 +1,41 @@
1
1
  /**
2
2
  * The post-commit cache bust — the only file in this package that calls `invalidateTags`.
3
- * The handler has already committed by the time it runs, so a fan-out that refuses degrades to
4
- * one logged failure instead of a failed action: the stale entries expire by TTL, and the caller
5
- * keeps the write it already made.
3
+ * Post-COMMIT, not post-handler: inside a transaction it waits for the root commit. A fan-out that
4
+ * refuses degrades to one logged failure instead of a failed action — the stale entries expire by
5
+ * TTL, and the caller keeps the write it already made.
6
6
  */
7
7
 
8
8
  import type { CacheTag, InvalidationReport } from '@ultimat3/cache';
9
9
  import { invalidateTags } from '@ultimat3/cache';
10
10
  import { logger } from '@ultimat3/core';
11
+ import { type CommitScope, openCommitScope } from './tx-scope';
11
12
 
12
13
  /**
13
- * Fan `tags` out and never throw. `undefined` back means the fan-out itself refused and nothing
14
- * was cleared — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits. One dead tier
15
- * is not that case: `invalidateTags` absorbs those into `report.errors` and still reports.
14
+ * Fan `tags` out and never throw. `undefined` back means nothing was cleared BY THIS CALL: the
15
+ * fan-out refused — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits — or it was
16
+ * deferred. One dead tier is not that case: `invalidateTags` absorbs those into `report.errors`.
17
+ *
18
+ * Inside a transaction (`scope`, the ambient one unless a caller passes its own) the handler's rows
19
+ * are not durable when it returns. Busting there let a concurrent read refill the cache with the
20
+ * pre-commit rows, which then stayed for the TTL — and a rollback busted for a write nobody made.
21
+ * So the bust is handed to the ROOT commit, as `@ultimat3/entity`'s row observer hands its report:
22
+ * `onCommit` never fires for a transaction that rolled back or whose COMMIT was lost.
16
23
  */
17
24
  export async function bustAfterCommit(
18
25
  action: string,
19
26
  tags: readonly CacheTag[],
27
+ scope: CommitScope | undefined = openCommitScope(),
28
+ ): Promise<InvalidationReport | undefined> {
29
+ if (scope === undefined) return bust(action, tags);
30
+ // Not awaited, and it cannot be: a commit effect is synchronous and runs after the transaction
31
+ // is durable. `bust` never rejects, so there is no rejection for the missing await to lose.
32
+ scope.onCommit(() => void bust(action, tags));
33
+ return undefined;
34
+ }
35
+
36
+ async function bust(
37
+ action: string,
38
+ tags: readonly CacheTag[],
20
39
  ): Promise<InvalidationReport | undefined> {
21
40
  try {
22
41
  return await invalidateTags(tags);
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,12 +5,14 @@
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
+ import { toOpenApiOperation } from './http';
12
+ import { servedActionRoute } from './http-path';
11
13
  import { actionName, invoke } from './invoke';
12
- import { derivePath } from './naming';
13
14
  import { buildOpenApi } from './openapi';
15
+ import { listActions } from './registry';
14
16
  import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
15
17
  import { validateInput } from './validate';
16
18
 
@@ -34,7 +36,7 @@ export interface ContractTestOptions {
34
36
 
35
37
  /** A context whose actor is core's anonymous actor — what a signed-out caller has. */
36
38
  export function anonymousCtx(): Ctx {
37
- return createContext({});
39
+ return ctxOf({});
38
40
  }
39
41
 
40
42
  export function contractTestsFor(
@@ -74,20 +76,54 @@ export function contractTestsFor(
74
76
  },
75
77
  {
76
78
  name: `${name}: OpenAPI document contains its operation`,
77
- run: async () => {
78
- const document = buildOpenApi({ actions: [target] });
79
- const path = derivePath(name).path;
80
- if (document.paths[path] === undefined) {
81
- throw new ContractDriftError(
82
- `OpenAPI document has no entry for ${path}`,
83
- 'x verify --json # the contract suite is a step of it',
84
- );
85
- }
86
- },
79
+ run: async () => assertDocumented(target, name),
87
80
  },
88
81
  ];
89
82
  }
90
83
 
84
+ /**
85
+ * Against the document the APP publishes — the whole registry, plus this action when a test drives
86
+ * a `.named()` twin nothing seated — never one built from this action alone: that one holds its
87
+ * own path by construction, so the assertion could not fail. What can go wrong is in the rest of
88
+ * the registry: a second action deriving the same route, whose operation is the one published.
89
+ */
90
+ function assertDocumented(target: AnyAction, name: string): void {
91
+ const seated = listActions();
92
+ const actions = seated.some((other) => other.name === name) ? seated : [...seated, target];
93
+ const { path } = servedActionRoute(name);
94
+ const published = buildOpenApi({ actions }).paths[path];
95
+ const owner = operationIdAt(published);
96
+ if (owner === toOpenApiOperation(target).operationId) return;
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(
111
+ owner === undefined
112
+ ? `OpenAPI document has no entry for ${path}`
113
+ : `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
114
+ owner === undefined
115
+ ? 'x verify --json # the contract suite is a step of it'
116
+ : `x actions describe ${renderFixShellArg(name, '<action>')} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
117
+ );
118
+ }
119
+
120
+ /** `paths[path].post.operationId`, read off a value typed `unknown` — narrowed, never cast. */
121
+ function operationIdAt(entry: unknown): string | undefined {
122
+ if (!isJsonObject(entry) || !isJsonObject(entry['post'])) return undefined;
123
+ const id = entry['post']['operationId'];
124
+ return typeof id === 'string' ? id : undefined;
125
+ }
126
+
91
127
  /**
92
128
  * A `pattern` cannot be inverted, so the framework cannot build a payload for
93
129
  * `t.string.pattern(...)` — but the pattern IS in the IR, so it knows that BEFORE it invokes.
@@ -152,7 +188,7 @@ async function expectDenied(
152
188
  if (error instanceof ActionDeniedError) return;
153
189
  // `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
154
190
  // output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
155
- // 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
156
192
  // could not decide), and `input:` is the knob that answers it. Any other
157
193
  // code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
158
194
  // `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather