@ultimat3/action 29.3.2 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/openapi.ts CHANGED
@@ -7,10 +7,11 @@
7
7
  * registry, and nothing here reads the clock, the environment or a random source.
8
8
  */
9
9
 
10
+ import { ERROR_RETRY_KINDS, templateParams, toOpenApiPath } from '@ultimat3/core';
10
11
  import type { AnyAction } from './action';
11
12
  import { operationTagOf, toOpenApiOperation } from './http';
12
- import { servedActionRoute } from './http-path';
13
- import { actionName } from './invoke';
13
+ import { methodOfHttp, servedActionRoute } from './http-path';
14
+ import { actionName, defOf } from './invoke';
14
15
  import { type JsonSchemaObject, jsonSchemaOf, sortSchema } from './json-schema';
15
16
  import { inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
16
17
  import { listActions } from './registry';
@@ -52,8 +53,16 @@ export function buildOpenApi(options: BuildOpenApiOptions = {}): OpenApiDocument
52
53
  for (const target of actions) {
53
54
  const name = actionName(target);
54
55
  const { path } = servedActionRoute(name);
55
- paths[path] = { post: toOpenApiOperation(target) };
56
- schemas[inputSchemaName(name)] = sortSchema(jsonSchemaOf(target.input));
56
+ const documented = toOpenApiPath(path);
57
+ // Merged, never replaced: REST's two verbs on one URL are two operations of one path item.
58
+ const item = Object.hasOwn(paths, documented) ? (paths[documented] as object) : {};
59
+ const verb = methodOfHttp(defOf(target).http).toLowerCase();
60
+ paths[documented] = { ...item, [verb]: toOpenApiOperation(target) };
61
+ // The component IS the request body, so a `:param`'s key leaves it: the URL carries it.
62
+ schemas[inputSchemaName(name)] = withoutKeys(
63
+ sortSchema(jsonSchemaOf(target.input)),
64
+ templateParams(path),
65
+ );
57
66
  schemas[outputSchemaName(name)] = sortSchema(jsonSchemaOf(target.output));
58
67
  tags.add(operationTagOf(target));
59
68
  }
@@ -72,6 +81,27 @@ export function serializeOpenApi(document: OpenApiDocument): string {
72
81
  return `${stableStringify(document, 2)}\n`;
73
82
  }
74
83
 
84
+ /** `schema` minus `keys` from `properties` and `required`; the same object when `keys` is empty. */
85
+ function withoutKeys(schema: JsonSchemaObject, keys: readonly string[]): JsonSchemaObject {
86
+ if (keys.length === 0) return schema;
87
+ const properties = schema['properties'];
88
+ const required = schema['required'];
89
+ const kept =
90
+ typeof properties === 'object' && properties !== null
91
+ ? Object.fromEntries(Object.entries(properties).filter(([key]) => !keys.includes(key)))
92
+ : properties;
93
+ const { required: _dropped, ...rest } = schema;
94
+ const still = Array.isArray(required)
95
+ ? required.filter((key: unknown) => typeof key !== 'string' || !keys.includes(key))
96
+ : [];
97
+ return {
98
+ ...rest,
99
+ ...(kept === undefined ? {} : { properties: kept }),
100
+ // Omitted when nothing is left, as `jsonSchemaOf` omits it for an all-optional object.
101
+ ...(still.length === 0 ? {} : { required: still }),
102
+ };
103
+ }
104
+
75
105
  function compareByName(a: AnyAction, b: AnyAction): number {
76
106
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
77
107
  }
@@ -95,6 +125,8 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
95
125
  cause: { type: 'string' },
96
126
  fix: { type: 'string' },
97
127
  docs: { type: 'string', format: 'uri' },
128
+ // The class a client maps to an exception. On every document, hidden 5xx included.
129
+ retry: { type: 'string', enum: [...ERROR_RETRY_KINDS] },
98
130
  requestId: { type: 'string' },
99
131
  // Absent when the failure produced none — never `[]`, which would claim "validated clean".
100
132
  issues: {
@@ -107,6 +139,9 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
107
139
  expected: { type: 'string' },
108
140
  received: { type: 'string' },
109
141
  message: { type: 'string' },
142
+ // `SCHEMA_RULES` is the vocabulary; typed `string` because it grows (add, never rename).
143
+ rule: { type: 'string' },
144
+ params: { type: 'object' },
110
145
  },
111
146
  },
112
147
  },
@@ -49,7 +49,12 @@ export function withJobFrame<T>(fn: () => T): T {
49
49
  * `ip:unknown` let a flood of unaddressed anonymous requests park every tenantless job, and the
50
50
  * reverse. Still ONE bucket for all such runs, deliberately: unlimited is the abuse this exists for.
51
51
  */
52
- const UNATTRIBUTED_JOB = 'job:unattributed';
52
+ export const UNATTRIBUTED_JOB = 'job:unattributed';
53
+
54
+ /** Whether this call runs inside a job run, nested or not — the one answer both gates key on. */
55
+ export function inJobFrame(): boolean {
56
+ return callerFrame.get()?.job === true;
57
+ }
53
58
 
54
59
  /**
55
60
  * Namespaced, never the bare action name: the HTTP pipeline still spends its `default` bucket
@@ -85,7 +90,7 @@ export async function spendActionLimit(
85
90
  const frame = callerFrame.get();
86
91
  // A job run, or any call nested inside one (an `agent()` run as a job calling its tools as
87
92
  // `'mcp'`): never a visitor's address, and the unattributed bucket rather than `ip:unknown`.
88
- const inJob = options.surface === 'job' || frame?.job === true;
93
+ const inJob = options.surface === 'job' || inJobFrame();
89
94
  const spends = rateLimitSpends(
90
95
  {
91
96
  actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
@@ -101,10 +106,16 @@ export async function spendActionLimit(
101
106
  // one included); counting it here too would charge an HTTP call against the tenant twice.
102
107
  { route: name, tenant: null },
103
108
  );
109
+ // Resolved once per call, BEFORE any bucket is touched, and through `toBucket`'s own screen: a
110
+ // resolver answering `NaN`, `0` or `Infinity` is `X_RATE_LIMIT_INVALID`, never allow-all.
111
+ const bucket = toBucket(name, {
112
+ limit: await resolvedRateLimit(limit, ctx),
113
+ windowMs: limit.windowMs,
114
+ });
104
115
  for (const spend of spends) {
105
116
  const decision = await installedRateLimitStore().take(
106
117
  spend.key,
107
- toBucket(name, limit),
118
+ bucket,
108
119
  1,
109
120
  ctx.now().getTime(),
110
121
  );
@@ -112,3 +123,32 @@ export async function spendActionLimit(
112
123
  if (!decision.allowed) throw rateLimited(spend.key, decision.retryAfterSeconds);
113
124
  }
114
125
  }
126
+
127
+ /**
128
+ * The number a call is held to: the declared one, or what the resolver answers from `ctx` — the
129
+ * tenant's plan, read by the app. A non-number becomes `NaN`, so `toBucket` refuses it by name.
130
+ */
131
+ async function resolvedRateLimit(limit: ActionRateLimit, ctx: Ctx): Promise<number> {
132
+ const resolved: unknown =
133
+ typeof limit.limit === 'function' ? await limit.limit({ ctx }) : limit.limit;
134
+ return typeof resolved === 'number' ? resolved : Number.NaN;
135
+ }
136
+
137
+ /**
138
+ * At mount, never on the first request: a fixed pair is checked whole; a per-call limit has only
139
+ * its window to check until a caller resolves it, so the window is checked against one request.
140
+ */
141
+ export function assertActionRateLimit(name: string, limit: ActionRateLimit): void {
142
+ toBucket(name, {
143
+ limit: typeof limit.limit === 'function' ? 1 : limit.limit,
144
+ windowMs: limit.windowMs,
145
+ });
146
+ }
147
+
148
+ /** The declaration as data: the fixed pair, or `null` for a limit resolved per call. */
149
+ export function fixedRateLimit(
150
+ limit: ActionRateLimit | undefined,
151
+ ): { readonly limit: number; readonly windowMs: number } | null {
152
+ if (limit === undefined || typeof limit.limit === 'function') return null;
153
+ return { limit: limit.limit, windowMs: limit.windowMs };
154
+ }
package/src/registry.ts CHANGED
@@ -5,13 +5,15 @@
5
5
  */
6
6
 
7
7
  import type { ActionPathStyle } from '@ultimat3/core';
8
- import { SchemaUnsupportedError, toWireSchema } from '@ultimat3/schema';
8
+ import { assertNoSensitiveOutput, SchemaUnsupportedError, toWireSchema } from '@ultimat3/schema';
9
9
  import type { ActionDescriptor, AnyAction } from './action';
10
10
  import { isAction, nameAction } from './action';
11
11
  import { resetApiDeclaration } from './api-declaration';
12
12
  import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
13
13
  import {
14
- assertPinnedPath,
14
+ assertPinnedHttp,
15
+ filledRoute,
16
+ methodOfHttp,
15
17
  resetActionPathStyle,
16
18
  resolveActionRoute,
17
19
  routeFor,
@@ -22,11 +24,14 @@ import { defOf } from './invoke';
22
24
  import { jsonSchemaOf } from './json-schema';
23
25
  import type { ActionPath } from './naming';
24
26
  import { seatedActions } from './registry-store';
27
+ import { clearToolGates } from './tool-gate';
25
28
 
26
29
  const registry = seatedActions;
27
30
 
28
31
  /**
29
- * Derived route -> the action name that owns it. A second index because the name is not the
32
+ * `METHOD path` -> the action name that owns it. Keyed on the method too, so REST's two verbs on
33
+ * one URL (`PUT /orders/:id`, `DELETE /orders/:id`) are two routes, as the router seats them. A
34
+ * second index because the name is not the
30
35
  * path: `pluralize` leaves a trailing `s` alone by design, so `archiveOrder` and `archiveOrders`
31
36
  * are two names and one route. Nothing downstream can refuse that — the router seats whichever
32
37
  * came last and the shadowed action stays in the OpenAPI document and the MCP tool list.
@@ -57,28 +62,43 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
57
62
  throw new ActionPolicyMissingError(name);
58
63
  }
59
64
  assertProjectable(name, target);
60
- const pin = defOf(target).http?.path;
61
- if (pin !== undefined) assertPinnedPath(name, pin);
62
- const { path } = routeFor(name, pin);
63
- const owner = paths.get(path);
65
+ // An output is sent back, which `.sensitive()` (`writeOnly`) forbids: refused at boot, once.
66
+ const output = defOf(target).output;
67
+ if (output !== undefined) assertNoSensitiveOutput(`action "${name}" output`, output);
68
+ const http = defOf(target).http;
69
+ if (http !== undefined) assertPinnedHttp(name, http, defOf(target).input);
70
+ const slot = slotOf(name, target);
71
+ const owner = paths.get(slot);
64
72
  if (owner !== undefined && owner !== name) {
65
- throw new ActionPathDuplicateError({ name, existing: owner, path });
73
+ throw new ActionPathDuplicateError({ name, existing: owner, path: slot });
66
74
  }
67
75
  const named = nameAction(target, name);
68
76
  registry.set(name, named);
69
- paths.set(path, name);
77
+ paths.set(slot, name);
70
78
  return named;
71
79
  }
72
80
 
81
+ /**
82
+ * The route table's key, and so the duplicate index's: the path alone for `POST` (every unpinned
83
+ * action's verb, and the spelling `X_ACTION_PATH_DUPLICATE` has always named), `METHOD path` else.
84
+ */
85
+ const slotOf = (name: string, target: AnyAction): string => {
86
+ const http = defOf(target).http;
87
+ const { path } = routeFor(name, http?.path);
88
+ const method = methodOfHttp(http);
89
+ return method === 'POST' ? path : `${method} ${path}`;
90
+ };
91
+
73
92
  /**
74
93
  * Where `target` is served — its pin, else the app's style over its name. A NAME is resolved
75
94
  * through the registry, so a pin declared on the action is honoured for a caller that only has the
76
- * name (a deprecation's `replacedBy`, a page minting an island's endpoint).
95
+ * name (a deprecation's `replacedBy`, a page minting an island's endpoint). A `:param` pin needs
96
+ * `params` (`actionHttpPath('refundOrder', { id })`), each value encoded as one segment.
77
97
  */
78
- export function actionHttpPath(target: AnyAction | string): ActionPath {
98
+ export function actionHttpPath(target: AnyAction | string, params?: unknown): ActionPath {
79
99
  // By NAME is how a page mints a form target, so it answers through the recorded path.
80
- if (typeof target === 'string') return resolveActionRoute(target);
81
- return routeFor(target.name, defOf(target).http?.path);
100
+ if (typeof target === 'string') return resolveActionRoute(target, params);
101
+ return filledRoute(target.name, routeFor(target.name, defOf(target).http?.path), params);
82
102
  }
83
103
 
84
104
  /**
@@ -90,12 +110,12 @@ export function configureActionPathStyle(style: ActionPathStyle): void {
90
110
  setActionPathStyle(style);
91
111
  paths.clear();
92
112
  for (const [name, target] of [...registry.entries()].sort(byName)) {
93
- const { path } = routeFor(name, defOf(target).http?.path);
94
- const owner = paths.get(path);
113
+ const slot = slotOf(name, target);
114
+ const owner = paths.get(slot);
95
115
  if (owner !== undefined && owner !== name) {
96
- throw new ActionPathDuplicateError({ name, existing: owner, path });
116
+ throw new ActionPathDuplicateError({ name, existing: owner, path: slot });
97
117
  }
98
- paths.set(path, name);
118
+ paths.set(slot, name);
99
119
  }
100
120
  }
101
121
 
@@ -103,7 +123,7 @@ export function configureActionPathStyle(style: ActionPathStyle): void {
103
123
  * Both schemas must reach JSON Schema, and this is where that is decided — boot, beside the policy
104
124
  * check, never the first `tools/list`. `jsonSchemaOf` used to swallow the refusal into
105
125
  * `additionalProperties: true`, so an action whose `input:` the provider cannot describe registered
106
- * cleanly and then published "any object accepted" on three surfaces while `validateInput` rejected
126
+ * cleanly and then published "any object accepted" on three surfaces while `parseActionInput` rejected
107
127
  * every payload. The shipped `X_SCHEMA_UNSUPPORTED` is re-raised rather than re-coded — the failure
108
128
  * is the provider's, and only the cause needs to say which action and which field.
109
129
  */
@@ -159,6 +179,7 @@ export function resetActions(): void {
159
179
  paths.clear();
160
180
  resetActionPathStyle();
161
181
  resetApiDeclaration();
182
+ clearToolGates();
162
183
  }
163
184
 
164
185
  function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The gates a named tool passes on its way to `invoke`, keyed by name because a tool is named across
3
+ * both surfaces. Tier 3, the lowest `@ultimat3/mcp` (which registers one) and `@ultimat3/ai` (whose
4
+ * adapter reads one) can both reach, since those two may not import each other.
5
+ */
6
+
7
+ import type { Actor } from '@ultimat3/core';
8
+ import {
9
+ actorOf,
10
+ ctxOf,
11
+ hasContext,
12
+ runWithContext,
13
+ useContext,
14
+ withChildContext,
15
+ } from '@ultimat3/core';
16
+ import type { WireJsonSchema } from '@ultimat3/schema';
17
+ import type { AnyAction } from './action';
18
+ import { guardActionBeforeInput } from './policy-gate';
19
+
20
+ /** One tool call, as a gate reads it: raw arguments, and the schema that tool publishes for them. */
21
+ export interface ToolGateCall {
22
+ readonly tool: string;
23
+ readonly args: unknown;
24
+ readonly inputSchema: WireJsonSchema | undefined;
25
+ readonly actor: Actor;
26
+ }
27
+
28
+ /** Answers by running `run`, or by throwing the refusal the caller reads instead. */
29
+ export type ToolGate = (call: ToolGateCall, run: () => Promise<unknown>) => Promise<unknown>;
30
+
31
+ const gates = new Map<string, ToolGate>();
32
+
33
+ /**
34
+ * First registered keeps the name; a later one for it is a no-op, never an error. Two
35
+ * `mcpConfirmations` on one tool are legal, since each MCP server holds its own, and a duplicate
36
+ * check by identity would trip on the fresh closure a re-evaluated module hands in.
37
+ */
38
+ export function registerToolGate(names: readonly string[], gate: ToolGate): void {
39
+ for (const name of names) if (!gates.has(name)) gates.set(name, gate);
40
+ }
41
+
42
+ /** Read at CALL time: the module declaring a gate may evaluate after the agent listing the tool. */
43
+ export function toolGateFor(name: string): ToolGate | undefined {
44
+ return gates.get(name);
45
+ }
46
+
47
+ /** Sorted, so what two processes registered compares as data (`X_ROLE_LOAD_INCOMPLETE`). */
48
+ export function gatedToolNames(): readonly string[] {
49
+ return [...gates.keys()].sort();
50
+ }
51
+
52
+ const noted = new Set<string>();
53
+
54
+ /**
55
+ * Tools no registry holds: a hand-written MCP tool, an agent's in-process one. Noted by what declares
56
+ * them, so the `manifest` step can tell a gate on one of those from a gate on a typo, which holds
57
+ * nothing and lets the real tool run (`X_MCP_CONFIRMATION_TOOL_UNKNOWN`).
58
+ */
59
+ export function noteToolNames(names: readonly string[]): void {
60
+ for (const name of names) noted.add(name);
61
+ }
62
+
63
+ export function knownToolNames(): readonly string[] {
64
+ return [...noted].sort();
65
+ }
66
+
67
+ /**
68
+ * `resetActions`' half of a reload, and only that. `resetActions` is on the barrel because 48 files
69
+ * outside this package reach it there and no test-only entry exists, so it does empty these tables:
70
+ * a test resetting after each case must re-declare any module-level gate, or later cases run ungated.
71
+ */
72
+ export function clearToolGates(): void {
73
+ gates.clear();
74
+ noted.clear();
75
+ }
76
+
77
+ /**
78
+ * Run `fn` with `actor` as the ambient identity, whether or not a request is in flight.
79
+ * `withChildContext` alone calls `useContext()`, which throws `X_NO_CONTEXT` with nothing in flight:
80
+ * over `x mcp serve` (stdio) every tool call answered that instead of running. With a request, the
81
+ * child keeps the policy subject and what the handler reads off `ctx.actor` one identity.
82
+ */
83
+ export function asCallerContext<T>(actor: Actor, fn: () => T): T {
84
+ return hasContext() ? withChildContext({ actor }, fn) : runWithContext(ctxOf({ actor }), fn);
85
+ }
86
+
87
+ /**
88
+ * The opening gate of `invoke`, asked earlier: throws for a caller the action's policy refuses before
89
+ * reading the input. Both tool surfaces ask it, so such a caller opens no row for a person to look at.
90
+ */
91
+ export function admitActionCaller(target: AnyAction, name: string): (actor: Actor) => void {
92
+ return (actor) =>
93
+ asCallerContext(actor, () => {
94
+ const ctx = useContext();
95
+ guardActionBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
96
+ });
97
+ }
package/src/tx-scope.ts CHANGED
@@ -2,11 +2,23 @@
2
2
  * The open database transaction, as this package needs it — **the only** file here that imports
3
3
  * `@ultimat3/db` (`tx-scope.test.ts` holds that). When is a write durable (`openCommitScope`, the
4
4
  * cache bust), and is a transaction really OPEN, on which client (`liveTransaction`, idempotency).
5
+ * And a scope whose writes are never kept (`inRolledBackScope`, the contract step's exercise).
5
6
  */
6
7
 
7
8
  // Type-only: `idempotency-postgres.ts` imports this module, and a runtime edge would be a cycle.
8
9
  import type { PgExecutor } from '@ultimat3/core';
9
- import { currentTx, liveTxConnection } from '@ultimat3/db';
10
+ import { storeMode } from '@ultimat3/core';
11
+ import {
12
+ completeTransaction,
13
+ currentTx,
14
+ dbClientInstalled,
15
+ liveTxConnection,
16
+ withTransaction,
17
+ } from '@ultimat3/db';
18
+ import { memoryJournal, memoryTransactor } from '@ultimat3/entity';
19
+
20
+ /** The mark a keyed run puts on the open root: its COMMIT carries the settle, abort or no abort. */
21
+ export const carrySettleOnCommit = (): void => completeTransaction();
10
22
 
11
23
  /** The half of a transaction a deferred effect needs. `@ultimat3/db`'s `DbTx` satisfies it. */
12
24
  export interface CommitScope {
@@ -19,10 +31,15 @@ export interface CommitScope {
19
31
  * included: a promise chain the body forgot to await still finds the handle after the scope
20
32
  * closed, and `onCommit` on it runs the effect at once after a COMMIT and drops it after a
21
33
  * ROLLBACK — the answer `@ultimat3/entity`'s row observer gets from the same call, so a late bust
22
- * and a late change report never disagree about one write.
34
+ * and a late change report never disagree about one write. With no SQL transaction, the open
35
+ * MEMORY transaction answers the same way (the row observer asks it too).
23
36
  */
24
37
  export function openCommitScope(): CommitScope | undefined {
25
- return currentTx();
38
+ const sql = currentTx();
39
+ if (sql !== undefined) return sql;
40
+ const memory = memoryJournal();
41
+ const onCommit = memory?.onCommit?.bind(memory);
42
+ return onCommit === undefined ? undefined : { onCommit };
26
43
  }
27
44
 
28
45
  /** A transaction that is still OPEN: statements sent now are inside it. */
@@ -55,3 +72,54 @@ export function liveTransaction(): LiveTransaction | undefined {
55
72
  },
56
73
  };
57
74
  }
75
+
76
+ /**
77
+ * Thrown inside the scope only to end it, and caught right outside: the one way a `withTransaction`
78
+ * body asks for ROLLBACK. Never an `UltimateError`, because nothing but `inRolledBackScope` ever
79
+ * sees it — a code would be a failure mode no caller can meet.
80
+ */
81
+ const ROLLBACK: unique symbol = Symbol('ultimate.action.rollback');
82
+
83
+ /** What `run` settled to inside the scope, carried past the rollback that ends it. */
84
+ export type Settled<T> =
85
+ | { readonly ok: true; readonly value: T }
86
+ | { readonly ok: false; readonly error: unknown };
87
+
88
+ /** Whether a SQL transaction can hold the writes: one is open, or a client is installed. */
89
+ const sqlScopeAvailable = (): boolean => liveTxConnection() !== undefined || dbClientInstalled();
90
+
91
+ /**
92
+ * Whether `inRolledBackScope` undoes the writes: a SQL transaction can hold them, or the store is
93
+ * memory (`storeMode`, `test`), whose writes the memory transaction it always opens holds.
94
+ */
95
+ export const rollbackAvailable = (): boolean =>
96
+ sqlScopeAvailable() || storeMode(Bun.env) === 'memory';
97
+
98
+ /**
99
+ * `run` inside a transaction that ALWAYS rolls back — a memory transaction, always, so a memory
100
+ * store write is undone however the handler wrote it; and in it a SAVEPOINT when a SQL transaction
101
+ * is already open (a test's own scope), a root BEGIN when a test installed a client. The outbox
102
+ * stage and the post-commit bust ride the SQL transaction, so they drop with it. A write made
103
+ * outside both (a second client, `outsideTransaction`) is not undone.
104
+ */
105
+ export async function inRolledBackScope<T>(run: () => Promise<T>): Promise<Settled<T>> {
106
+ let settled: Settled<T> | undefined;
107
+ const settleThenRollBack = async (): Promise<never> => {
108
+ try {
109
+ settled = { ok: true, value: await run() };
110
+ } catch (error) {
111
+ settled = { ok: false, error };
112
+ }
113
+ throw ROLLBACK;
114
+ };
115
+ try {
116
+ // One ROLLBACK ends both: thrown out of the SQL scope, it carries on through the memory one.
117
+ await memoryTransactor().run(() =>
118
+ sqlScopeAvailable() ? withTransaction(settleThenRollBack) : settleThenRollBack(),
119
+ );
120
+ } catch (error) {
121
+ if (error !== ROLLBACK) throw error;
122
+ }
123
+ // `settleThenRollBack` assigns before it throws, so a scope that ended on ROLLBACK assigned it.
124
+ return settled ?? { ok: false, error: ROLLBACK };
125
+ }
package/src/validate.ts CHANGED
@@ -18,7 +18,7 @@ import { InputInvalidError, OutputInvalidError } from './errors';
18
18
  * handed to an HTTP surface that returns it to the caller. Four members travel, and
19
19
  * `describeValue` is what keeps a value out of the fifth.
20
20
  */
21
- export async function validateInput<S extends StandardSchemaV1>(
21
+ export async function parseActionInput<S extends StandardSchemaV1>(
22
22
  schema: S,
23
23
  raw: unknown,
24
24
  actionName: string,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { stringField } from '@ultimat3/core';
9
9
  import type { ValidationIssue } from '@ultimat3/schema';
10
+ import { issueRuleFacts } from '@ultimat3/schema';
10
11
 
11
12
  /**
12
13
  * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
@@ -35,12 +36,14 @@ export function issuesFromWire(value: unknown): readonly ValidationIssue[] | und
35
36
  if (path === undefined || message === undefined || message.length === 0) return undefined;
36
37
  // Built member by member, never spread: a foreign issue object may carry the rejected VALUE
37
38
  // (some libraries put it in `received`), and a whole-object copy would forward it to whoever
38
- // renders the list. Four members travel; everything else stops here.
39
+ // renders the list. Four members travel, plus the `rule` a translator keys on and its scalar
40
+ // `params` (schema's one reader); everything else stops here.
39
41
  issues.push({
40
42
  path,
41
43
  expected: stringField(entry, 'expected') ?? '',
42
44
  received: stringField(entry, 'received') ?? '',
43
45
  message,
46
+ ...issueRuleFacts(entry),
44
47
  });
45
48
  }
46
49
  return issues;