@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/job-handle.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Projection 5: an action as durable work — its input schema, a payload-derived
3
3
  * idempotency key, and an `invoke` that runs the action's one execution path under
4
- * `surface: 'job'`, so a queued run gets the same validation and policy evaluation
5
- * as the HTTP call. `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any
6
- * `Action` and composes `job()` around this shape — and an app may write the same three lines.
4
+ * `surface: 'job'`, so a queued run takes the same validation and policy path as the HTTP call —
5
+ * under the worker's system authority: the enqueuer is `enqueuedBy`, attribution only, and never
6
+ * authorises the run. An action that acts for a user takes that user's id and re-authorises it.
7
+ * `@ultimat3/ai`'s `agentJob()` is the framework's one consumer — it takes any `Action` and
8
+ * composes `job()` around this shape — and an app may write the same three lines.
7
9
  */
8
10
  import type { Ctx } from '@ultimat3/core';
9
11
  import { fingerprint } from '@ultimat3/core';
@@ -3,9 +3,10 @@
3
3
  * MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
4
4
  */
5
5
 
6
+ import { isJsonObject } from '@ultimat3/core';
6
7
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
8
  import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
8
- import { isJsonObject, stableStringify } from './stable';
9
+ import { stableStringify } from './stable';
9
10
 
10
11
  export type JsonSchemaObject = Record<string, unknown>;
11
12
 
package/src/mutator.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * contract tests for free, and its authz is the same single evaluation.
6
6
  */
7
7
 
8
- import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
8
+ import type { ConflictPolicy, Ctx, Deprecation, Row } from '@ultimat3/core';
9
9
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
10
  import type {
11
11
  Action,
@@ -17,7 +17,6 @@ import type {
17
17
  ActionRowArgs,
18
18
  } from './action';
19
19
  import { action, isAction } from './action';
20
- import type { Deprecation } from './deprecation';
21
20
  import { MutatorNotIdempotentError } from './errors';
22
21
  import type { ActionHttp } from './http-path';
23
22
  import { assertConflictClock } from './mutator-clock';
package/src/naming.ts CHANGED
@@ -11,10 +11,9 @@ import { resolveActionRoute } from './http-path';
11
11
 
12
12
  /**
13
13
  * The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
14
- * spec derive one URL from one function. Re-exported by name so `@ultimat3/action`'s public
15
- * `derivePath` / `pluralize` / `splitWords` / `ActionPath` keep resolving; never re-declared here.
14
+ * spec derive one URL from one function; `pluralize` and `splitWords` are imported from core by
15
+ * whoever needs them, never re-exported here (`X_HELPER_COPY`).
16
16
  */
17
- export { pluralize, splitWords } from '@ultimat3/core';
18
17
 
19
18
  export type ActionPath = ActionRoute;
20
19
 
@@ -79,10 +79,25 @@ interface Refusals {
79
79
  readonly unauthorizedHeaders?: Record<string, unknown>;
80
80
  }
81
81
 
82
+ /**
83
+ * An ACTION's limit is spent inside `invoke`, so its route carries no bucket to read; the operation
84
+ * itself publishes the declaration (`x-ultimate.rateLimit`, `null` when none). A query's route
85
+ * still does, hence both reads above.
86
+ */
87
+ function declaresRateLimit(operation: Operation): boolean {
88
+ const extensions = operation['x-ultimate'];
89
+ if (typeof extensions !== 'object' || extensions === null) return false;
90
+ const declared = (extensions as Record<string, unknown>)['rateLimit'];
91
+ return declared !== undefined && declared !== null;
92
+ }
93
+
82
94
  /** One operation, with its route's refusals and security added. Never mutates `operation`. */
83
95
  function completeOperation(operation: Operation, route: Route | undefined, refusals: Refusals) {
84
96
  const authenticated = route?.meta.auth === 'required';
85
- const limited = refusals.everyOpLimited || route?.meta.rateLimitBucket !== undefined;
97
+ const limited =
98
+ refusals.everyOpLimited ||
99
+ route?.meta.rateLimitBucket !== undefined ||
100
+ declaresRateLimit(operation);
86
101
  if (!authenticated && !limited) return operation;
87
102
  const responses = { ...(operation['responses'] as Record<string, unknown>) };
88
103
  if (limited) {
@@ -1,18 +1,13 @@
1
1
  /**
2
2
  * The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP,
3
- * job, direct server call) reaches authz through `guard()` — there is no second
3
+ * job, direct server call) reaches authz through `guardAction()` — there is no second
4
4
  * code path, which is what makes "one authz system" true rather than aspirational.
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
- import { assertNever, isAnonymous } from '@ultimat3/core';
8
+ import { assertNever } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import {
11
- enforce,
12
- enforceBeforeInput,
13
- policyPermissions as flattenedPermissions,
14
- admitsAnonymous as policyAdmitsAnonymous,
15
- } from '@ultimat3/policy';
10
+ import { enforce, enforceBeforeInput } from '@ultimat3/policy';
16
11
  import { ActionDeniedError } from './errors';
17
12
 
18
13
  /**
@@ -45,7 +40,7 @@ export interface PolicySubject {
45
40
  * object for every surface, so an actor denied over HTTP is denied over MCP with
46
41
  * the same reason and the same code.
47
42
  */
48
- export function guard(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
43
+ export function guardAction(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
49
44
  const denial = enforce(policySurface(surface), policy, {
50
45
  input: subject.input,
51
46
  actor: subject.actor,
@@ -58,13 +53,13 @@ export function guard(policy: ActionPolicy, subject: PolicySubject, surface: Sur
58
53
  }
59
54
 
60
55
  /**
61
- * The actor-only half of `guard`, run BEFORE the input is parsed: a caller the policy refuses
56
+ * The actor-only half of `guardAction`, run BEFORE the input is parsed: a caller the policy refuses
62
57
  * whatever they send gets the 403 (or 401) here, and never the 400 whose issue list describes
63
58
  * the input schema of an operation they may not call. Undecided — a predicate that reads the input
64
- * or the row — passes, and `guard` decides after the parse exactly as before. Same denial class,
65
- * same code, same surface rendering as `guard`'s.
59
+ * or the row — passes, and `guardAction` decides after the parse exactly as before. Same denial
60
+ * class, same code, same surface rendering as `guardAction`'s.
66
61
  */
67
- export function guardBeforeInput(
62
+ export function guardActionBeforeInput(
68
63
  policy: ActionPolicy,
69
64
  subject: Omit<PolicySubject, 'input' | 'row'>,
70
65
  surface: Surface,
@@ -90,41 +85,3 @@ function policySurface(surface: Surface): PolicySurface {
90
85
  return assertNever(surface);
91
86
  }
92
87
  }
93
-
94
- /**
95
- * Core models "nobody" as an anonymous actor; policy models it as `null`, which is
96
- * what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
97
- */
98
- export function actorOf(ctx: Ctx): Actor | null {
99
- return isAnonymous(ctx.actor) ? null : ctx.actor;
100
- }
101
-
102
- /** The capability an action requires, for manifests and OpenAPI metadata. A DISPLAY label. */
103
- export function policyCapability(policy: ActionPolicy): string {
104
- return policy.label;
105
- }
106
-
107
- /**
108
- * Every permission the policy tree references, flattened and deduped — and the only field a
109
- * compliance report may match a grant against. `label` renders a composite as
110
- * `and(post:publish, org:administer)`, which is a sentence and never equals a permission string,
111
- * so matching on it reported every action guarded by a composite as enforcing nothing: `x policy
112
- * list` showed real grants as dead. The two are kept side by side rather than one replacing the
113
- * other — the label is what a human reads, this is what a machine compares.
114
- */
115
- export function policyPermissions(policy: ActionPolicy): readonly string[] {
116
- return flattenedPermissions(policy);
117
- }
118
-
119
- /**
120
- * Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
121
- * `http.ts` reads it through this file like every other authz question. `toRoute` derives
122
- * `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
123
- * combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `invoke` ran
124
- * while the MCP tool and the job handle allowed it. `true` is not "unguarded" — `invoke` still
125
- * evaluates the policy for every call. Declared once in `policy.ts`, exactly as
126
- * `policyPermissions` is: the answer is a property of the combinators it walks.
127
- */
128
- export function admitsAnonymous(policy: ActionPolicy): boolean {
129
- return policyAdmitsAnonymous(policy);
130
- }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Where a declared `rateLimit:` is spent: once, inside `invoke`, so HTTP, MCP, the in-app agent
3
+ * tool and `.job()` draw on ONE bucket. The store is `@ultimat3/http`'s installed one — the slot
4
+ * `@ultimat3/query` spends from too, filled at boot with the instance `httpServer` is handed.
5
+ */
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import { asyncContext, isAnonymous } from '@ultimat3/core';
9
+ import type { RateLimitDecision } from '@ultimat3/http';
10
+ // The bucket maths, the subject precedence and the refusal are all `@ultimat3/http`'s (tier 2):
11
+ // a copy here would be a second answer to "what does this limit mean" and "whose bucket is it".
12
+ import { installedRateLimitStore, rateLimited, rateLimitSpends, toBucket } from '@ultimat3/http';
13
+ import type { ActionRateLimit } from './action';
14
+ import type { Surface } from './policy-gate';
15
+
16
+ /**
17
+ * Who a call chain started from: the address of the request, or the fact that it is a job run.
18
+ * An `agent()` is an action whose handler calls its tools through `invoke` with no address of its
19
+ * own, so without this every anonymous visitor's tool calls shared one `ip:unknown` bucket and one
20
+ * visitor could exhaust it for all. Opened by `invoke`; read by every nested call that has none.
21
+ */
22
+ interface CallerFrame {
23
+ readonly address: string | null;
24
+ /** Inside a job run: nested calls are the job's, never a visitor's. */
25
+ readonly job: boolean;
26
+ }
27
+
28
+ const callerFrame = asyncContext<CallerFrame>('the caller frame');
29
+
30
+ /** Run `fn` with `address` as the inherited caller address; an absent one changes nothing. */
31
+ export function withCallerAddress<T>(address: string | null | undefined, fn: () => T): T {
32
+ return typeof address === 'string' && address !== ''
33
+ ? callerFrame.run({ address, job: false }, fn)
34
+ : fn();
35
+ }
36
+
37
+ /**
38
+ * Run `fn` as a job: no inherited address, and every nested call unattributed unless it has an
39
+ * actor or an org of its own. A FRESH frame, not a read of the current one — a worker loop re-armed
40
+ * by `setTimeout` inside a request carries that request's frame, and a tenantless job charged to
41
+ * whichever visitor happened to start the loop is the leak this closes.
42
+ */
43
+ export function withJobFrame<T>(fn: () => T): T {
44
+ return callerFrame.run({ address: null, job: true }, fn);
45
+ }
46
+
47
+ /**
48
+ * The subject of a job run nobody attributed — no actor, no tenant. Its own population: sharing
49
+ * `ip:unknown` let a flood of unaddressed anonymous requests park every tenantless job, and the
50
+ * reverse. Still ONE bucket for all such runs, deliberately: unlimited is the abuse this exists for.
51
+ */
52
+ const UNATTRIBUTED_JOB = 'job:unattributed';
53
+
54
+ /**
55
+ * Namespaced, never the bare action name: the HTTP pipeline still spends its `default` bucket
56
+ * under `<route name>|<subject>`, and one key taken against two bucket shapes in one store would
57
+ * corrupt both counters.
58
+ */
59
+ const ACTION_SCOPE = 'action';
60
+
61
+ export interface RateLimitSpendOptions {
62
+ readonly surface: Surface;
63
+ /** The connection address — the only subject an anonymous caller has. */
64
+ readonly clientAddress?: string | null | undefined;
65
+ /** The decision, before a refusal is thrown: how HTTP puts `RateLimit-*` on the answer. */
66
+ readonly onRateLimit?: ((decision: RateLimitDecision) => void) | undefined;
67
+ }
68
+
69
+ /**
70
+ * Spend one token of `limit` for this caller, or throw `X_RATE_LIMITED` — the code, the cause and
71
+ * the `Retry-After` seconds every limit in the framework answers with.
72
+ *
73
+ * `surface: 'server'` spends nothing: that is app code calling its own action in-process (a
74
+ * service composing two, a seed, a test), not a caller arriving from outside. A refused attempt
75
+ * spends nothing either — the token bucket only decrements on an admission — so a job retried
76
+ * into a refusal is not charged for it.
77
+ */
78
+ export async function spendActionLimit(
79
+ name: string,
80
+ limit: ActionRateLimit | undefined,
81
+ ctx: Ctx,
82
+ options: RateLimitSpendOptions,
83
+ ): Promise<void> {
84
+ if (limit === undefined || options.surface === 'server') return;
85
+ const frame = callerFrame.get();
86
+ // A job run, or any call nested inside one (an `agent()` run as a job calling its tools as
87
+ // `'mcp'`): never a visitor's address, and the unattributed bucket rather than `ip:unknown`.
88
+ const inJob = options.surface === 'job' || frame?.job === true;
89
+ const spends = rateLimitSpends(
90
+ {
91
+ actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
92
+ // Read off an anonymous actor too: a job run IS the worker's anonymous identity carrying the
93
+ // job's org (`jobRunActor`), and that org is who the run is — charging every tenant's runs
94
+ // to one `ip:unknown` key let one tenant's backlog refuse everyone's.
95
+ orgId: ctx.actor.orgId ?? null,
96
+ ip: options.clientAddress ?? (inJob ? null : (frame?.address ?? null)),
97
+ routeName: `${ACTION_SCOPE}:${name}`,
98
+ ...(inJob ? { unattributed: UNATTRIBUTED_JOB } : {}),
99
+ },
100
+ // The tenant allowance is the HTTP pipeline's (it spends it for every route, a handler-limited
101
+ // one included); counting it here too would charge an HTTP call against the tenant twice.
102
+ { route: name, tenant: null },
103
+ );
104
+ for (const spend of spends) {
105
+ const decision = await installedRateLimitStore().take(
106
+ spend.key,
107
+ toBucket(name, limit),
108
+ 1,
109
+ ctx.now().getTime(),
110
+ );
111
+ options.onRateLimit?.(decision);
112
+ if (!decision.allowed) throw rateLimited(spend.key, decision.retryAfterSeconds);
113
+ }
114
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
8
8
  import { hasEntityRows, rowsOf } from '@ultimat3/entity';
9
- import { json } from '@ultimat3/http';
9
+ import { jsonResponse } from '@ultimat3/http';
10
10
  import type { StandardSchemaV1 } from '@ultimat3/schema';
11
11
 
12
12
  /** The header value that says "this body is an envelope". Presence alone is not enough. */
@@ -26,7 +26,7 @@ export function carriesRecords(output: StandardSchemaV1): boolean {
26
26
 
27
27
  /** The 200 for an action that carries records: the envelope, and the header that names it. */
28
28
  export function recordResponse(output: StandardSchemaV1, result: unknown): Response {
29
- const response = json(encodeRecordEnvelope(result, rowsOf(output, result)));
29
+ const response = jsonResponse(encodeRecordEnvelope(result, rowsOf(output, result)));
30
30
  response.headers.set(RECORDS_HEADER, ENVELOPED);
31
31
  return response;
32
32
  }
package/src/registry.ts CHANGED
@@ -111,7 +111,7 @@ function assertProjectable(name: string, target: AnyAction): void {
111
111
  for (const field of ['input', 'output'] as const) {
112
112
  try {
113
113
  jsonSchemaOf(target[field]);
114
- // The schema `.tool()` and `tools/list` publish — the one an MCP client reads.
114
+ // The schema `tools/list` publishes — the one an MCP client reads.
115
115
  toWireSchema(target[field]);
116
116
  } catch {
117
117
  // The thrown value is deliberately not rendered into the cause: it is the provider's, of
@@ -154,7 +154,7 @@ export function describeActions(): readonly ActionDescriptor[] {
154
154
  }
155
155
 
156
156
  /** Test-only. Production registers once at boot and never unregisters. */
157
- export function resetRegistry(): void {
157
+ export function resetActions(): void {
158
158
  registry.clear();
159
159
  paths.clear();
160
160
  resetActionPathStyle();
@@ -99,6 +99,25 @@ function sampleObject(node: SchemaNode): Record<string, unknown> {
99
99
  return sample;
100
100
  }
101
101
 
102
+ /**
103
+ * The most items a sample will hold. A contract sample is a handful of values; `minItems` past
104
+ * this is an input the framework does not invent — `2 ** 32` made `Array.from` throw, and anything
105
+ * below it allocated that many samples first. Such an array is a GAP (`sampleGaps`), reported as
106
+ * `X_CONTRACT_DRIFT` with the instruction to pass the input, never silently under-sampled.
107
+ */
108
+ export const SAMPLE_ITEMS_MAX = 1000;
109
+
110
+ /**
111
+ * The `minItems` an array sample owes, or `undefined` when none can be built: past the ceiling,
112
+ * or not a count at all. The builtin `t.array` refuses a bad bound at declaration
113
+ * (`X_SCHEMA_BOUNDS_INVALID`), but this IR may come from a swapped provider.
114
+ */
115
+ function owedItems(node: SchemaNode): number | undefined {
116
+ const min = node.minItems;
117
+ if (min === undefined) return 0;
118
+ return Number.isSafeInteger(min) && min >= 0 && min <= SAMPLE_ITEMS_MAX ? min : undefined;
119
+ }
120
+
102
121
  function sampleFor(node: SchemaNode): unknown {
103
122
  // A nullable field accepts `null`, and `null` is the smallest thing it accepts.
104
123
  if (node.nullable === true) return null;
@@ -115,8 +134,13 @@ function sampleFor(node: SchemaNode): unknown {
115
134
  return node.values?.[0] ?? SAMPLE_STRING;
116
135
  case 'literal':
117
136
  return node.literal ?? null;
137
+ // The fewest items the array accepts: `[]` under a `minItems` is a sample its own schema
138
+ // refuses. Items repeat the one item sample; a uniqueness rule would be a refinement.
118
139
  case 'array':
119
- return [];
140
+ // An unbuildable count samples `[]`, which the schema refuses — `sampleGaps` names why.
141
+ return Array.from({ length: owedItems(node) ?? 0 }, () =>
142
+ node.items === undefined ? null : sampleFor(node.items),
143
+ );
120
144
  case 'union': {
121
145
  const first = node.anyOf?.[0];
122
146
  return first === undefined ? null : sampleFor(first);
@@ -174,24 +198,29 @@ function gapsIn(node: SchemaNode, path: string): string[] {
174
198
  }
175
199
  return out;
176
200
  }
177
- if (satisfiesPattern(node, sampleFor(node))) return [];
201
+ const unbuildable = node.kind === 'array' && owedItems(node) === undefined;
202
+ if (!unbuildable && satisfiesPattern(node, sampleFor(node))) return [];
178
203
  return [path === '' ? ROOT_PATH : path];
179
204
  }
180
205
 
181
206
  /** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
182
207
  export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
183
- const pattern = patternAt(tryIntrospect(schema), path);
208
+ const node = nodeAt(tryIntrospect(schema), path);
209
+ if (node?.kind === 'array' && node.minItems !== undefined && owedItems(node) === undefined) {
210
+ return `${path} (needs at least ${node.minItems} items)`;
211
+ }
212
+ const pattern = node?.pattern;
184
213
  return pattern === undefined ? path : `${path} (must match ${pattern})`;
185
214
  }
186
215
 
187
- function patternAt(node: SchemaNode | undefined, path: string): string | undefined {
216
+ function nodeAt(node: SchemaNode | undefined, path: string): SchemaNode | undefined {
188
217
  if (node === undefined) return undefined;
189
- if (path === '' || path === ROOT_PATH) return node.pattern;
218
+ if (path === '' || path === ROOT_PATH) return node;
190
219
  const [head, ...rest] = path.split('.');
191
220
  // `path` is the caller's — `describeSampleGap` is exported — so the same own-property read the
192
221
  // format table takes: `properties['constructor']` is otherwise the `Object` function.
193
222
  const properties = node.properties ?? {};
194
223
  const child =
195
224
  head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
196
- return child === undefined ? undefined : patternAt(child, rest.join('.'));
225
+ return child === undefined ? undefined : nodeAt(child, rest.join('.'));
197
226
  }
package/src/stable.ts CHANGED
@@ -9,13 +9,6 @@
9
9
  * published. Ordinary payloads are byte-identical between the two; `stable.test.ts` pins that.
10
10
  */
11
11
 
12
- /**
13
- * `@ultimat3/core`'s, re-exported so `./stable` stays this package's one import path for the JSON
14
- * helpers. It was declared here AND identically in `@ultimat3/query`'s own `stable.ts`, which is
15
- * the duplication `client-wire.ts` moving to tier 0 made unnecessary.
16
- */
17
- export { isJsonObject } from '@ultimat3/core';
18
-
19
12
  export type JsonObject = Record<string, unknown>;
20
13
 
21
14
  /** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
package/src/transition.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  StringSchema,
19
19
  } from '@ultimat3/schema';
20
20
  import { nodeOf, t } from '@ultimat3/schema';
21
+ import type { ActionRowArgs } from './action';
21
22
  import { type Mutator, mutator } from './mutator';
22
23
  import type { ActionPolicy } from './policy-gate';
23
24
 
@@ -57,6 +58,7 @@ export interface TransitionDef<
57
58
  Row extends InferOutput<TOutput> & object,
58
59
  K extends keyof Row & string,
59
60
  S extends Row[K] & string,
61
+ TRow = unknown,
60
62
  > {
61
63
  /** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */
62
64
  readonly table: (ctx: Ctx) => TransitionTarget<Row, S>;
@@ -84,7 +86,21 @@ export interface TransitionDef<
84
86
  * column's own shape.
85
87
  */
86
88
  readonly id?: StringSchema;
87
- readonly policy: ActionPolicy;
89
+ readonly policy: ActionPolicy<TRow>;
90
+ /**
91
+ * The row a row-level `policy` decides about — an action's own `row` loader, handed through
92
+ * unchanged: loaded once, after the input parse, before the guard and before the statement. The
93
+ * factory reads no row itself; which columns a rule needs (an `authorId` the `output` view may not
94
+ * carry) is the app's. Omitted, the rule sees `row: null` — and must fail closed on it.
95
+ *
96
+ * CHECK-THEN-ACT, as every row-policy action's: the read is unlocked and outside any transaction,
97
+ * and the compare-and-set predicate is `id` + `from` only. A concurrent write to a column the
98
+ * rule read (an `authorId` reassigned between the read and the move) is not seen, so the move
99
+ * lands on the decision about the older row. Where that matters, do not use `transition()`: an
100
+ * `action` whose handler runs `withTransaction`, reads the row `for update`, decides, and calls
101
+ * `Table.transition` in that transaction closes the window.
102
+ */
103
+ row?(args: ActionRowArgs<TransitionInput<S>>): TRow | null | Promise<TRow | null>;
88
104
  /**
89
105
  * OFF unless the app says otherwise, and deliberately not `?? true`.
90
106
  *
@@ -110,7 +126,8 @@ export function transition<
110
126
  Row extends InferOutput<TOutput> & object,
111
127
  K extends keyof Row & string,
112
128
  const S extends Row[K] & string,
113
- >(def: TransitionDef<TOutput, Row, K, S>): Mutator<TransitionInput<S>, TOutput> {
129
+ TRow = unknown,
130
+ >(def: TransitionDef<TOutput, Row, K, S, TRow>): Mutator<TransitionInput<S>, TOutput> {
114
131
  const state = t.enum(def.states);
115
132
  // ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is
116
133
  // a mapped type over its shape, and a mapped type does not reduce while a type parameter is still
@@ -119,13 +136,16 @@ export function transition<
119
136
  // reason. What arrives here has already been parsed by the schema two lines up, and nothing else
120
137
  // can reach these two callbacks.
121
138
  const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
122
- return mutator({
139
+ const row = def.row;
140
+ return mutator<TransitionInput<S>, TOutput, TRow>({
123
141
  input: t.object({ id: def.id ?? keySchemaOf(def.output), from: state, to: state }),
124
142
  output: def.output,
125
143
  policy: def.policy,
126
144
  // A compare-and-set move replayed under its key answers the first outcome, never a second
127
145
  // `X_STATE_CONFLICT` for a move that already happened.
128
146
  idempotent: true,
147
+ // `.call(def, …)`, as `mutator()` hands its own on: a loader written as a method keeps `this`.
148
+ ...(row === undefined ? {} : { row: (args) => row.call(def, args) }),
129
149
  ...(def.audit === undefined ? {} : { audit: def.audit }),
130
150
  // Never overridable: the server is the half that REFUSED the move, and a local twin that won
131
151
  // the rebase would leave the client showing a state the database rejected.
package/src/tx-scope.ts CHANGED
@@ -40,7 +40,7 @@ export interface LiveTransaction extends CommitScope {
40
40
  * settlement bound to a transaction that already ended is bound to nothing: it would run as its
41
41
  * own commit while the record claimed otherwise, and that record is the one a retry may reclaim.
42
42
  *
43
- * The fragment is assembled by hand, exactly as `@ultimat3/cli`'s `pgExecutorFor` does: the caller
43
+ * The fragment is assembled by hand, exactly as `@ultimat3/db`'s `dbExecutor` does: the caller
44
44
  * wrote the `$1..$n` text itself and hands over already-bound values.
45
45
  */
46
46
  export function liveTransaction(): LiveTransaction | undefined {
package/src/type-pins.ts CHANGED
@@ -3,18 +3,32 @@
3
3
  // type-level claim written in one can never fail. This module emits nothing and exports nothing
4
4
  // anybody imports — a regression here is a build error, the only enforcement that counts.
5
5
 
6
- import type { Row } from '@ultimat3/core';
6
+ import type { AuditRecord, McpExposureDeclaration, Row } from '@ultimat3/core';
7
7
  import type { StandardSchemaV1 } from '@ultimat3/schema';
8
- import type { Action, AnyAction } from './action';
8
+ import type { Action, ActionDef, AnyAction, AnyActionDef } from './action';
9
9
  import type { ClientMethod } from './client';
10
+ import type {
11
+ IdempotencyRecord,
12
+ IdempotencyReservation,
13
+ IdempotencyScope,
14
+ IdempotencyStore,
15
+ } from './idempotency';
10
16
  import type { ActionJobHandle } from './job-handle';
11
- import type { LocalTable } from './mutator';
17
+ import type { LocalTable, MutatorDef } from './mutator';
12
18
 
13
19
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
14
20
  type Assert<T extends true> = T;
15
21
 
16
22
  type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
17
23
 
24
+ /**
25
+ * IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
26
+ * to one with it and back, so `Equals` cannot see an optional field added on one side only — and
27
+ * every field of the `mcp` block is optional but `expose`.
28
+ */
29
+ type Identical<A, B> =
30
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
31
+
18
32
  type PublishInput = StandardSchemaV1<{ readonly postId: string }>;
19
33
  type PublishOutput = StandardSchemaV1<{ readonly published: boolean }>;
20
34
  type PublishPost = Action<PublishInput, PublishOutput>;
@@ -59,3 +73,89 @@ export type _LocalTableIsTheStoreTxShape = Assert<
59
73
  export type _LocalInsertTakesTheKey = Assert<
60
74
  Equals<Parameters<LocalTable['insert']>, [key: string, row: Row]>
61
75
  >;
76
+
77
+ /**
78
+ * #591: a store written before answers were redacted at rest drops `settle`'s `redacted` flag and
79
+ * would replay `[redacted]` as the answer. A required 4th PARAMETER cannot refuse it — TypeScript
80
+ * accepts an implementation with fewer parameters — so the store DECLARES it keeps the flag
81
+ * (`keepsRedaction: true`), and a pre-25 store without the declaration is a build error here.
82
+ */
83
+ interface PreRedactionStore {
84
+ readonly scope?: IdempotencyScope | undefined;
85
+ reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
86
+ settle(key: string, value: unknown, reservationId: string): Promise<void>;
87
+ release(key: string): Promise<void>;
88
+ get(key: string): Promise<IdempotencyRecord | undefined>;
89
+ }
90
+
91
+ export type _APreRedactionStoreIsNotAStore = Assert<
92
+ Equals<PreRedactionStore extends IdempotencyStore ? true : false, false>
93
+ >;
94
+
95
+ /** A release names the reservation it drops — the fence `settle` and `fail` already carry. */
96
+ export type _ReleaseNamesTheReservation = Assert<
97
+ Equals<Parameters<IdempotencyStore['release']>['length'], 2>
98
+ >;
99
+
100
+ /** …and `settle`'s flag is required of every CALLER, so a wrapping store cannot drop it either. */
101
+ export type _SettleRequiresTheRedactedFlag = Assert<
102
+ Equals<Parameters<IdempotencyStore['settle']>['length'], 4>
103
+ >;
104
+
105
+ /**
106
+ * 25.0.0 (plan 101, M9): `name` and `primitive` are REQUIRED on an audit record and the `action`
107
+ * alias is gone. Pinned as keys rather than with a literal record, because a literal would need a
108
+ * `Ctx` this module has no business building: a field that turned optional again, or an alias
109
+ * that came back, flips one of these to `false`.
110
+ */
111
+ type RequiredKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? never : K }[keyof T];
112
+
113
+ export type _AuditRecordRequiresNameAndPrimitive = Assert<
114
+ Equals<Extract<RequiredKeys<AuditRecord>, 'name' | 'primitive'>, 'name' | 'primitive'>
115
+ >;
116
+
117
+ export type _AuditRecordHasNoActionAlias = Assert<
118
+ Equals<Extract<keyof AuditRecord, 'action'>, never>
119
+ >;
120
+
121
+ /**
122
+ * 25.0.0 (plan 101, M5): the deprecation TYPES are `@ultimat3/core`'s alone, as the helpers are.
123
+ * A runtime test cannot see a type re-export, so each is an expected error: a re-export coming
124
+ * back makes the directive unused, which `tsc` refuses.
125
+ */
126
+ // @ts-expect-error — `Deprecation` is imported from `@ultimat3/core`, never from this barrel.
127
+ export type _NoDeprecationReexport = import('./index').Deprecation;
128
+ // @ts-expect-error — `DeprecationField` is imported from `@ultimat3/core`.
129
+ export type _NoDeprecationFieldReexport = import('./index').DeprecationField;
130
+ // @ts-expect-error — `DeprecationRender` is imported from `@ultimat3/core`.
131
+ export type _NoDeprecationRenderReexport = import('./index').DeprecationRender;
132
+
133
+ /**
134
+ * O-tool, 25.0.0: an action projects no MCP tool of its own — `@ultimat3/mcp`'s `toolFrom`
135
+ * is the one projection, and a tier-3 `.tool()` could only ever be a second one. A `tool` member
136
+ * coming back on either view flips these to `false`.
137
+ */
138
+ export type _AnActionHasNoToolTwin = Assert<Equals<Extract<keyof AnyAction, 'tool'>, never>>;
139
+ export type _ATypedActionHasNoToolTwin = Assert<Equals<Extract<keyof PublishPost, 'tool'>, never>>;
140
+
141
+ /**
142
+ * 25.0.0: an action's `mcp` block is core's ONE `McpExposureDeclaration`, minus the list
143
+ * whitelist an action has nothing to compose over — on the declaration, the erased declaration,
144
+ * the erased action and a mutator alike. `ActionMcp` restated the block field by field (and
145
+ * `McpAnnotationHints` its hints); a field drifting on either side flips these to `false`.
146
+ */
147
+ type ActionBlock = Omit<McpExposureDeclaration, 'listParams'>;
148
+ export type _ActionDeclaresCoresBlock = Assert<
149
+ Identical<NonNullable<ActionDef<PublishInput, PublishOutput>['mcp']>, ActionBlock>
150
+ >;
151
+ export type _ErasedActionDefDeclaresCoresBlock = Assert<
152
+ Identical<NonNullable<AnyActionDef['mcp']>, ActionBlock>
153
+ >;
154
+ export type _AnyActionCarriesCoresBlock = Assert<
155
+ Identical<NonNullable<AnyAction['mcp']>, ActionBlock>
156
+ >;
157
+ export type _MutatorDeclaresCoresBlock = Assert<
158
+ Identical<NonNullable<MutatorDef<PublishInput, PublishOutput>['mcp']>, ActionBlock>
159
+ >;
160
+ // @ts-expect-error — `McpAnnotationHints` is `@ultimat3/core`'s, never this barrel's.
161
+ export type _NoAnnotationHintsTwin = import('./index').McpAnnotationHints;