@ultimat3/action 1.2.0 → 3.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/index.ts CHANGED
@@ -7,6 +7,12 @@
7
7
  * framework died; there is exactly one here, structurally.
8
8
  */
9
9
 
10
+ /**
11
+ * `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
12
+ * `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
13
+ * here, not re-implemented, so an action file still reaches it through one import.
14
+ */
15
+ export { toBucket } from '@ultimat3/http';
10
16
  /** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
11
17
  export type { Infer } from '@ultimat3/schema';
12
18
  export { t } from '@ultimat3/schema';
@@ -25,6 +31,19 @@ export type {
25
31
  McpDescriptorMeta,
26
32
  } from './action';
27
33
  export { action, describeAction, isAction } from './action';
34
+ /**
35
+ * The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
36
+ * and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
37
+ * absence is what keeps it one.
38
+ */
39
+ export type {
40
+ AuditFailure,
41
+ AuditOutcome,
42
+ AuditRecord,
43
+ AuditSink,
44
+ MemoryAuditSink,
45
+ } from './audit';
46
+ export { getAuditSink, memoryAuditSink, resetAuditSink, setAuditSink } from './audit';
28
47
  export type {
29
48
  ActionLike,
30
49
  ActionMap,
@@ -39,17 +58,33 @@ export type { ContractTest, ContractTestOptions } from './contract-test';
39
58
  export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
40
59
  export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
41
60
  export { defineApi } from './define-api';
42
- export type { IdempotencyConflictReason } from './errors';
61
+ /**
62
+ * The compat window a retirement gets. Versioning itself is NOT here and never will be: two
63
+ * versions of one action side by side is two deployments behind one ingress (axiom 7), not a
64
+ * router feature. `renderDeprecation` is exported so a plain `route` can announce the same pair
65
+ * of headers the action projection does.
66
+ */
67
+ export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
68
+ export { recordDeprecatedCall, renderDeprecation } from './deprecation';
69
+ export type { IdempotencyConflictReason, IdempotencyKeyProblem, RemoteFailure } from './errors';
43
70
  export {
44
71
  ActionDeniedError,
72
+ ActionDeprecationInvalidError,
45
73
  ActionDuplicateError,
46
74
  ActionForeignError,
75
+ ActionPathDuplicateError,
47
76
  ActionPolicyMissingError,
48
77
  ActionUnregisteredError,
78
+ AuditSinkFailedError,
79
+ AuditSinkMissingError,
49
80
  ContractDriftError,
50
81
  IdempotencyConflictError,
82
+ IdempotencyKeyInvalidError,
83
+ IdempotencyNotSharedError,
84
+ IdempotencyReplayedFailureError,
51
85
  InputInvalidError,
52
86
  OutputInvalidError,
87
+ RemoteActionError,
53
88
  RpcFailedError,
54
89
  } from './errors';
55
90
  export type { OpenApiOperation } from './http';
@@ -60,19 +95,59 @@ export {
60
95
  toOpenApiOperation,
61
96
  toRoute,
62
97
  } from './http';
98
+ /**
99
+ * The idempotency seam. `withIdempotency` and `IDEMPOTENCY_HEADER` are both public, so a plain
100
+ * mutating `route` can reserve-and-replay exactly as an action does — `idempotencyKeyFor` is the
101
+ * namespacing it must apply, or two routes sharing a caller's key would share one record, and so
102
+ * would two callers sending one key value.
103
+ */
63
104
  export type {
105
+ IdempotencyConfig,
106
+ IdempotencyFailure,
64
107
  IdempotencyRecord,
65
108
  IdempotencyReservation,
109
+ IdempotencyScope,
110
+ IdempotencyStatus,
66
111
  IdempotencyStore,
67
112
  IdempotentOutcome,
68
113
  } from './idempotency';
69
114
  export {
115
+ assertIdempotencyScope,
116
+ configureIdempotency,
117
+ DEFAULT_IDEMPOTENCY_CONFIG,
70
118
  getIdempotencyStore,
71
- idempotencyKeyFor,
72
- MemoryIdempotencyStore,
119
+ idempotencyConfig,
120
+ resetIdempotency,
73
121
  setIdempotencyStore,
74
122
  withIdempotency,
75
123
  } from './idempotency';
124
+ export { idempotencyKeyFor, MAX_IDEMPOTENCY_KEY_LENGTH } from './idempotency-key';
125
+ export type { MemoryIdempotencyStoreOptions } from './idempotency-memory';
126
+ export {
127
+ DEFAULT_IDEMPOTENCY_WINDOW_MS,
128
+ DEFAULT_MAX_IDEMPOTENCY_KEYS,
129
+ MemoryIdempotencyStore,
130
+ } from './idempotency-memory';
131
+ /**
132
+ * The SHARED store, and the only one an app running more than one replica may install. The
133
+ * statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
134
+ * by `x db up` in development and by the release-phase `ROLE=migrate` in production.
135
+ */
136
+ export type {
137
+ PgExecutor,
138
+ PostgresIdempotencyStore,
139
+ PostgresIdempotencyStoreOptions,
140
+ } from './idempotency-postgres';
141
+ export {
142
+ postgresIdempotencyStore,
143
+ SQL_IDEMPOTENCY_FAIL,
144
+ SQL_IDEMPOTENCY_GET,
145
+ SQL_IDEMPOTENCY_PURGE,
146
+ SQL_IDEMPOTENCY_RELEASE,
147
+ SQL_IDEMPOTENCY_RESERVE,
148
+ SQL_IDEMPOTENCY_SETTLE,
149
+ SQL_IDEMPOTENCY_TABLE,
150
+ } from './idempotency-postgres';
76
151
  /** The one execution path. `defOf` stays unexported — that is the enforcement. */
77
152
  export { actionName, invoke } from './invoke';
78
153
  export type { ActionJobHandle } from './job-handle';
@@ -95,11 +170,12 @@ export type {
95
170
  } from './mutator';
96
171
  export { custom, isMutator, mutator, resolveConflict, strategyOf } from './mutator';
97
172
  export type { ActionPath } from './naming';
98
- export { derivePath, inputSchemaName, outputSchemaName, pluralize, toToolName } from './naming';
173
+ export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming';
99
174
  export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
100
175
  export { buildOpenApi, serializeOpenApi } from './openapi';
101
176
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
102
- export { actorOf, guard, policyCapability } from './policy-gate';
177
+ /** `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on. */
178
+ export { actorOf, guard, policyCapability, policyPermissions } from './policy-gate';
103
179
  export {
104
180
  describeActions,
105
181
  getAction,
package/src/invoke.ts CHANGED
@@ -3,13 +3,18 @@
3
3
  * output. The declaration lives in this module's private store, so `handle` is
4
4
  * unreachable from anywhere else — HTTP, MCP, jobs and `.as()` hand `invoke` a
5
5
  * payload, and none of them can become a second execution path.
6
+ *
7
+ * `audit: true` wraps that path, it never forks it: `execute` is the same body either way, and
8
+ * the audited branch only observes it. Wrapping rather than hooking is what lets a DENIED attempt
9
+ * be recorded at all — `guard` throws before `handle`, so nothing an app writes around its own
10
+ * handler could ever see one.
6
11
  */
7
12
 
8
- import { invalidateTags } from '@ultimat3/cache';
9
13
  import type { Ctx } from '@ultimat3/core';
10
14
  import {
11
15
  anonymousActor,
12
16
  createContext,
17
+ isUltimateError,
13
18
  runWithContext,
14
19
  tryUseContext,
15
20
  useContext,
@@ -17,8 +22,18 @@ import {
17
22
  withSpan,
18
23
  } from '@ultimat3/core';
19
24
  import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
25
+ import type { AuditRecord } from './audit';
26
+ import {
27
+ auditFailureFor,
28
+ auditOutcomeFor,
29
+ auditSettled,
30
+ auditSinkFor,
31
+ auditThrew,
32
+ } from './audit-gate';
33
+ import { bustAfterCommit } from './cache-gate';
20
34
  import { ActionForeignError, ActionUnregisteredError } from './errors';
21
- import { getIdempotencyStore, idempotencyKeyFor, withIdempotency } from './idempotency';
35
+ import { getIdempotencyStore, withIdempotency } from './idempotency';
36
+ import { idempotencyKeyFor } from './idempotency-key';
22
37
  import { actorOf, guard } from './policy-gate';
23
38
  import { validateInput, validateOutput } from './validate';
24
39
 
@@ -62,7 +77,17 @@ export function invoke(
62
77
  raw: unknown,
63
78
  options: InvokeOptions = {},
64
79
  ): Promise<unknown> {
65
- if (options.actor === undefined) return core(target, raw, options.ctx ?? useContext(), options);
80
+ if (options.actor === undefined) {
81
+ // INSTALLED, never only handed over. An explicit `ctx` used to be passed to `core` and to
82
+ // nothing else, so everything downstream that reads the ambient context — most importantly
83
+ // `@ultimat3/entity`'s tenant guard, which derives from `tryUseContext()` and not from the ctx
84
+ // it is given — saw either a different identity or none at all: policy decided about this
85
+ // actor while a row's tenancy was decided about nobody. When `ctx` is absent this is
86
+ // `runWithContext(useContext(), …)`, which is the context already installed — a no-op on every
87
+ // path that worked before.
88
+ const ctx = options.ctx ?? useContext();
89
+ return runWithContext(ctx, () => core(target, raw, ctx, options));
90
+ }
66
91
 
67
92
  // Impersonation keeps the surrounding context whole — services, clock, locale,
68
93
  // trace — and swaps only the actor. Policy models "nobody" as null; core models
@@ -75,6 +100,17 @@ export function invoke(
75
100
  : runWithContext(base, () => withChildContext(patch, run));
76
101
  }
77
102
 
103
+ /**
104
+ * What `execute` learns on the way through, for the audit record. Mutable and module-private:
105
+ * the three facts a record needs that only exist partway down the one path, and reading them back
106
+ * out is what keeps the audit branch from becoming a second one.
107
+ */
108
+ interface InvokeTrace {
109
+ input: unknown;
110
+ idempotencyKey: string | null;
111
+ replayed: boolean;
112
+ }
113
+
78
114
  async function core(
79
115
  target: AnyAction,
80
116
  raw: unknown,
@@ -83,7 +119,105 @@ async function core(
83
119
  ): Promise<unknown> {
84
120
  const def = defOf(target);
85
121
  const name = actionName(target);
122
+ const trace: InvokeTrace = { input: undefined, idempotencyKey: null, replayed: false };
123
+ if (def.audit !== true) return execute(def, name, raw, ctx, options, trace);
124
+
125
+ // Resolved before the input parse: an audited action nothing can record must refuse while it
126
+ // has still made no change. Everything after this point has a committed write behind it.
127
+ const sink = auditSinkFor(name);
128
+ const draft = {
129
+ // When the attempt began, from the context's clock — never `new Date()`.
130
+ at: ctx.now(),
131
+ action: name,
132
+ // The brand `mutator()` stamps, read structurally — the same read `describeAction` makes,
133
+ // and for the same reason: importing `isMutator` would point this module at the one that
134
+ // imports it, for a check that needs the brand and not the predicate.
135
+ mutator: (target as { readonly isMutator?: unknown }).isMutator === true,
136
+ surface: options.surface ?? 'server',
137
+ ctx,
138
+ } as const;
139
+
140
+ let value: unknown;
141
+ try {
142
+ value = await execute(def, name, raw, ctx, options, trace);
143
+ } catch (error) {
144
+ // A failed mutation is the record an auditor wants most, so the throw is recorded before it
145
+ // is re-thrown — and `auditThrew` never replaces it, which is why this rethrow is
146
+ // unconditional rather than inside an `else`.
147
+ const record: AuditRecord = {
148
+ ...draft,
149
+ ...trace,
150
+ outcome: auditOutcomeFor(error),
151
+ failure: auditFailureFor(error),
152
+ };
153
+ await auditThrew(sink, record);
154
+ throw error;
155
+ }
156
+ // Outside the `catch` above on purpose: an `X_AUDIT_SINK_FAILED` from here describes the
157
+ // RECORD, not the attempt. Letting it fall into that branch wrote a second row claiming the
158
+ // action failed, for a handler that had committed.
159
+ await auditSettled(sink, { ...draft, ...trace, outcome: 'allowed', failure: null });
160
+ return value;
161
+ }
162
+
163
+ /**
164
+ * The span covers the WHOLE invocation, not just `handle`. Wrapping the handler alone reported
165
+ * 40ms for an action whose p99 was 2s, because `def.row()` — the loader a row-level policy needs
166
+ * — ran outside it, along with the input parse and `guard()`. The 1.96s was inside no span at
167
+ * all, so the only reading available was "framework overhead" and the only fix was hand
168
+ * instrumentation. One span, one extent, and the attributes that make it answerable.
169
+ *
170
+ * Attributes are chosen for bounded cardinality — surface, actor KIND, outcome, booleans — with
171
+ * one exception: the namespaced idempotency key, which is the single fact that joins a retry to
172
+ * the call it is retrying and is what a trace is for. It is never a metric label.
173
+ */
174
+ async function execute(
175
+ def: AnyActionDef,
176
+ name: string,
177
+ raw: unknown,
178
+ ctx: Ctx,
179
+ options: InvokeOptions,
180
+ trace: InvokeTrace,
181
+ ): Promise<unknown> {
182
+ return withSpan(`action.${name}`, async (span) => {
183
+ span.setAttributes({
184
+ 'ultimate.primitive': 'action',
185
+ 'ultimate.action': name,
186
+ 'ultimate.surface': options.surface ?? 'server',
187
+ 'ultimate.actor.kind': ctx.actor.kind,
188
+ 'ultimate.idempotent': def.idempotent === true,
189
+ });
190
+ try {
191
+ const value = await perform(def, name, raw, ctx, options, trace);
192
+ span.setAttributes({
193
+ 'ultimate.outcome': 'allowed',
194
+ 'ultimate.idempotency.replayed': trace.replayed,
195
+ });
196
+ if (trace.idempotencyKey !== null) {
197
+ span.setAttribute('ultimate.idempotency.key', trace.idempotencyKey);
198
+ }
199
+ return value;
200
+ } catch (error) {
201
+ // The same two words the audit record uses, from the same function: a denial and a failure
202
+ // are different questions, and a trace that called both "error" cannot separate them.
203
+ span.setAttribute('ultimate.outcome', auditOutcomeFor(error));
204
+ if (isUltimateError(error)) span.setAttribute('ultimate.error.code', error.code);
205
+ throw error;
206
+ }
207
+ });
208
+ }
209
+
210
+ /** The invocation itself, unwrapped: parse, load the row, guard, run, parse, bust. */
211
+ async function perform(
212
+ def: AnyActionDef,
213
+ name: string,
214
+ raw: unknown,
215
+ ctx: Ctx,
216
+ options: InvokeOptions,
217
+ trace: InvokeTrace,
218
+ ): Promise<unknown> {
86
219
  const input = await validateInput(def.input, raw, name);
220
+ trace.input = input;
87
221
  // The one place a row-level rule gets its row. Once per invocation, never per row:
88
222
  // that asymmetry is what lets the predicate stay synchronous, so a live query can
89
223
  // re-evaluate the same policy per subscriber without a query per change event. An
@@ -96,25 +230,36 @@ async function core(
96
230
  options.surface ?? 'server',
97
231
  );
98
232
 
99
- // Output parsing sits inside `run` so a replayed idempotent response is the
100
- // parsed value too — one shape on the wire, first call and every retry.
233
+ // Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
234
+ // one shape on the wire, first call and every retry. No span of its own: `execute` above holds
235
+ // the one that covers this invocation, and a second here would only re-time its tail.
101
236
  const run = async (): Promise<unknown> => {
102
- const produced = await withSpan(`action.${name}`, () =>
103
- Promise.resolve(def.handle({ input, ctx })),
104
- );
237
+ const produced = await Promise.resolve(def.handle({ input, ctx }));
105
238
  return validateOutput(def.output, produced, name);
106
239
  };
107
240
 
108
241
  const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
109
242
  let value: unknown;
243
+ let wrote = true;
110
244
  if (key === null) {
111
245
  value = await run();
112
246
  } else {
113
247
  const store = options.store ?? getIdempotencyStore();
114
- const outcome = await withIdempotency(store, idempotencyKeyFor(name, key), input, run);
248
+ // The namespaced key, not the caller's: the same key under two actions — or from two callers
249
+ // — is two keys, and an audit row keyed on the raw header would collide across both. The
250
+ // ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
251
+ trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
252
+ const outcome = await withIdempotency(store, trace.idempotencyKey, input, run);
115
253
  if (outcome.replayed) options.onReplay?.();
254
+ trace.replayed = outcome.replayed;
255
+ wrote = !outcome.replayed;
116
256
  value = outcome.value;
117
257
  }
118
- if (def.cache !== undefined) await invalidateTags(def.cache.invalidates);
258
+ // Only for a run that actually happened, and only through the gate. A replay ran no handler
259
+ // and changed nothing the first call had not already busted — re-busting per retry re-purges
260
+ // the CDN and re-queues ISR for a write nobody made. And the bust is post-commit either way,
261
+ // so `bustAfterCommit` swallowing its own failure is what keeps a dead cache from turning a
262
+ // durable write into a failed action.
263
+ if (wrote && def.cache !== undefined) await bustAfterCommit(name, def.cache.invalidates);
119
264
  return value;
120
265
  }
package/src/job-handle.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Projection 5: an action as durable work. `@ultimat3/jobs` consumes this shape,
3
- * so enqueueing an existing action costs zero rewriting and the queued run
4
- * goes through the same validation and policy evaluation as the HTTP call.
2
+ * Projection 5: an action as durable work its input schema, a payload-derived
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. Nothing in the framework consumes it — an app bridges it into `job()`.
5
6
  */
6
7
  import type { Ctx } from '@ultimat3/core';
7
8
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
@@ -9,6 +10,24 @@ import type { Action } from './action';
9
10
  import { actionName, invoke } from './invoke';
10
11
  import { fingerprint } from './stable';
11
12
 
13
+ /**
14
+ * **`@ultimat3/jobs` does not consume this, and cannot as written** (`As of 2026-08`; the header
15
+ * claimed it did). `isJobHandle` needs `kind === 'job'` AND membership of a module-private
16
+ * `WeakMap` only `job()` writes, so no externally-built object reaches the registry, the queue or
17
+ * the worker — and `kind: 'action-job'` is deliberately a different literal, not a near-miss.
18
+ *
19
+ * What it IS: the three fields plus the body a `JobDefinition` needs — `name`, `input`,
20
+ * `idempotencyKey`, and `invoke` as its `run`. An app bridges it in one call:
21
+ * `job({ name: h.name, input: h.input, idempotencyKey: h.idempotencyKey, tenant, retry,
22
+ * run: ({ input, ctx }) => h.invoke(input, ctx) })`, which yields a real handle `job()` seated.
23
+ *
24
+ * `tenant` and `retry` are what the bridge cannot fill: both are REQUIRED on `JobDefinition` with
25
+ * no default, on purpose — jobs states that every candidate default for `tenant` is a
26
+ * cross-tenant read waiting to happen. So "enqueueing an action costs zero rewriting" was never
27
+ * reachable; two facts an action does not declare have to come from somewhere. Whoever closes
28
+ * this writes the adapter in the app or at tier 4+: `action` and `jobs` are both tier 3, so
29
+ * neither may import the other.
30
+ */
12
31
  export interface ActionJobHandle<
13
32
  TInput extends StandardSchemaV1 = StandardSchemaV1,
14
33
  TOutput extends StandardSchemaV1 = StandardSchemaV1,
@@ -4,32 +4,50 @@
4
4
  */
5
5
 
6
6
  import type { StandardSchemaV1 } from '@ultimat3/schema';
7
- import { toJsonSchema, toMcpInputSchema } from '@ultimat3/schema';
7
+ import { SchemaUnsupportedError, toJsonSchema, toMcpInputSchema } from '@ultimat3/schema';
8
8
  import { isJsonObject, stableStringify } from './stable';
9
9
 
10
10
  export type JsonSchemaObject = Record<string, unknown>;
11
11
 
12
12
  /**
13
- * Never throws: a schema that cannot be converted degrades to a permissive
14
- * object node, because a missing OpenAPI detail must not break a deploy.
13
+ * REFUSES rather than degrades. This used to swallow a conversion failure into
14
+ * `{ type: 'object', additionalProperties: true }` so that "a missing OpenAPI detail must not
15
+ * break a deploy" — which inverts axiom 3: the deploy succeeded and every caller was lied to.
16
+ * `toJsonSchema` throws exactly when the spec must not claim anything, and the same schema
17
+ * still fails `validateInput` on every payload, so the OpenAPI component and the MCP
18
+ * `inputSchema` were advertising "any object accepted" for an endpoint that accepts none.
19
+ * `registerAction` calls this at boot (`assertProjectable`), so a registered action can never
20
+ * reach a projection that throws.
15
21
  */
16
22
  export function jsonSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
17
- return normalize(() => toJsonSchema(schema));
23
+ return normalizeJsonSchema(() => toJsonSchema(schema));
18
24
  }
19
25
 
20
26
  /** Draft-07, no `$schema` — the exact shape an MCP `tools/list` entry needs. */
21
27
  export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
22
- return normalize(() => toMcpInputSchema(schema));
28
+ return normalizeJsonSchema(() => toMcpInputSchema(schema));
23
29
  }
24
30
 
25
- function normalize(convert: () => unknown): JsonSchemaObject {
26
- try {
27
- const raw: unknown = convert();
28
- if (isJsonObject(raw)) return raw;
29
- } catch {
30
- // fall through to the permissive node
31
- }
32
- return { type: 'object', additionalProperties: true };
31
+ /**
32
+ * The narrowing both projections share, and the refusal it earns: a converter that answered with
33
+ * something that is not a JSON object is the same failure by a quieter route, so it gets the same
34
+ * shipped code rather than a permissive node. Exported for its own test and nothing else — it is
35
+ * absent from `src/index.ts`, exactly as `sortSchema` is.
36
+ *
37
+ * **The `fix:` names `introspect`, never a `toJsonSchema` member.** `SchemaProvider` declares no
38
+ * such member (`packages/schema/src/provider.ts`) and `toJsonSchema()` calls `introspect()`
39
+ * unconditionally, so the old line — "configure a provider whose toJsonSchema returns an object" —
40
+ * instructed a reader to implement an API that does not exist, which is axiom 4 inverted. It is
41
+ * the same false clause `@ultimat3/schema` deleted from its own docs; this was the user-visible
42
+ * half, one package over.
43
+ */
44
+ export function normalizeJsonSchema(convert: () => unknown): JsonSchemaObject {
45
+ const raw: unknown = convert();
46
+ if (isJsonObject(raw)) return raw;
47
+ throw new SchemaUnsupportedError({
48
+ cause: `the schema converted to ${raw === null ? 'null' : typeof raw}, not a JSON Schema object`,
49
+ fix: 'declare the schema with `t` from @ultimat3/action, or add an introspect() returning a SchemaNode to the object passed to configureSchemaProvider()',
50
+ });
33
51
  }
34
52
 
35
53
  /** Key-sorted copy — deterministic ordering for the committed contract file. */
package/src/mcp-tool.ts CHANGED
@@ -4,17 +4,25 @@
4
4
  * endpoint and cannot acquire a second authz path. One authz system, never two.
5
5
  */
6
6
  import type { Ctx } from '@ultimat3/core';
7
+ import { isMcpExposed } from '@ultimat3/core';
7
8
  import type { AnyAction } from './action';
8
9
  import { actionName, defOf, invoke } from './invoke';
9
10
  import { type JsonSchemaObject, mcpSchemaOf, sortSchema } from './json-schema';
10
- import { toToolName } from './naming';
11
11
  import type { ActionPolicy } from './policy-gate';
12
12
  import { listActions } from './registry';
13
13
 
14
14
  export interface McpToolDescriptor {
15
+ /**
16
+ * The export name VERBATIM — `@ultimat3/mcp` serves that name and nothing else, so any
17
+ * transformation here would be a label no `tools/call` could spell. See `toMcpTool`.
18
+ */
15
19
  readonly name: string;
16
20
  /** The action's `mcp.description`, or its name when the author gave none. */
17
21
  readonly description: string;
22
+ /**
23
+ * The action this tool projects. Equal to `name` by construction since 2026-08 — kept because
24
+ * a reader asking "which action is behind this tool?" should not have to know that.
25
+ */
18
26
  readonly action: string;
19
27
  /**
20
28
  * The action's own policy object, not a copy — `tool().policy === action.policy`
@@ -31,11 +39,16 @@ export interface McpInvokeOptions {
31
39
  readonly idempotencyKey?: string | null;
32
40
  }
33
41
 
42
+ /**
43
+ * The tool name is the export name VERBATIM — it was `snake_case`d until 2026-08, and
44
+ * `@ultimat3/mcp` has only ever served the verbatim one, so `.tool().name` was a label no
45
+ * `tools/call` accepted. One name per action, on every surface.
46
+ */
34
47
  export function toMcpTool(target: AnyAction): McpToolDescriptor {
35
48
  const name = actionName(target);
36
49
  const def = defOf(target);
37
50
  return {
38
- name: toToolName(name),
51
+ name,
39
52
  description: def.mcp?.description ?? name,
40
53
  action: name,
41
54
  policy: def.policy,
@@ -56,10 +69,11 @@ export function toMcpTool(target: AnyAction): McpToolDescriptor {
56
69
  * It read `!== false` until 2026-08, which made writing an action silently hand every agent a
57
70
  * new write capability — and disagreed with `@ultimat3/mcp`'s `exposedPrimitives`, the projection
58
71
  * that actually builds a catalog. Two functions answering "is this a tool?" differently is the
59
- * ambiguity axiom 1 rejects, so the fail-closed one wins.
72
+ * ambiguity axiom 1 rejects, so the fail-closed one wins — and `isMcpExposed` from
73
+ * `@ultimat3/core` is now the single answer every reader in the framework asks.
60
74
  */
61
75
  export function isExposed(target: AnyAction): boolean {
62
- return target.mcp?.expose === true;
76
+ return isMcpExposed(target.mcp);
63
77
  }
64
78
 
65
79
  /** Deterministic order — the tool list is part of the agent-visible contract. */
package/src/mutator.ts CHANGED
@@ -70,6 +70,13 @@ export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends Sta
70
70
  readonly cache?: ActionCache;
71
71
  readonly mcp?: ActionMcp;
72
72
  readonly idempotent?: boolean;
73
+ /**
74
+ * Record every attempt through the installed `AuditSink`. Same key, same meaning as an
75
+ * action's — a mutator IS an action, so it inherits the seam rather than getting a second one.
76
+ * `.local()` is the one half nothing records: it never leaves the client, so there is no
77
+ * server-authoritative attempt to attest to.
78
+ */
79
+ readonly audit?: boolean;
73
80
  /** Optimistic twin: runs against the local store, synchronously, no I/O. */
74
81
  local(tx: LocalTx, input: InferOutput<TInput>): void;
75
82
  /** Authoritative write. Identical to an action `handle`, ctx-first for symmetry. */
@@ -123,6 +130,7 @@ export function mutator<TInput extends StandardSchemaV1, TOutput extends Standar
123
130
  ...(def.cache === undefined ? {} : { cache: def.cache }),
124
131
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
125
132
  ...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
133
+ ...(def.audit === undefined ? {} : { audit: def.audit }),
126
134
  handle: ({ input, ctx }) => def.server(ctx, input),
127
135
  };
128
136
  return wrap(def, action(actionDef));
package/src/naming.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The one naming rule: an action's export name derives its HTTP path and MCP
3
- * tool name. Pure string math so the browser client can derive the same path
4
- * without importing a byte of server code.
2
+ * The one naming rule: an action's export name derives its HTTP path and its
3
+ * OpenAPI component names. Pure string math so the browser client can derive
4
+ * the same path without importing a byte of server code. The MCP tool name is
5
+ * derived by nothing — it is the export name verbatim.
5
6
  */
6
7
 
7
8
  /** Irregular plurals we actually hit in domain models. Extend deliberately, not eagerly. */
@@ -66,10 +67,9 @@ export function derivePath(name: string): ActionPath {
66
67
  return { verb: head, resource, path: `/api/${resource}/${head}` };
67
68
  }
68
69
 
69
- /** MCP tool names are `snake_case`: `publishPost` -> `publish_post`. */
70
- export function toToolName(name: string): string {
71
- return splitWords(name).join('_');
72
- }
70
+ // There is deliberately no `toToolName`. An MCP tool name is the export name verbatim — the one
71
+ // `@ultimat3/mcp` serves and the one a `tools/call` spells — so a second derivation would be a
72
+ // second name for one action, which is what shipped `publish_post` in two committed contracts.
73
73
 
74
74
  /** OpenAPI `operationId` is the action name verbatim — it is already unique. */
75
75
  export function toOperationId(name: string): string {
@@ -7,7 +7,7 @@
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
8
  import { assertNever, isAnonymous } from '@ultimat3/core';
9
9
  import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import { enforce } from '@ultimat3/policy';
10
+ import { enforce, policyPermissions as flattenedPermissions } from '@ultimat3/policy';
11
11
  import { ActionDeniedError } from './errors';
12
12
 
13
13
  /**
@@ -75,7 +75,19 @@ export function actorOf(ctx: Ctx): Actor | null {
75
75
  return isAnonymous(ctx.actor) ? null : ctx.actor;
76
76
  }
77
77
 
78
- /** The capability an action requires, for manifests and OpenAPI metadata. */
78
+ /** The capability an action requires, for manifests and OpenAPI metadata. A DISPLAY label. */
79
79
  export function policyCapability(policy: ActionPolicy): string {
80
80
  return policy.label;
81
81
  }
82
+
83
+ /**
84
+ * Every permission the policy tree references, flattened and deduped — and the only field a
85
+ * compliance report may match a grant against. `label` renders a composite as
86
+ * `and(post:publish, org:administer)`, which is a sentence and never equals a permission string,
87
+ * so matching on it reported every action guarded by a composite as enforcing nothing: `x policy
88
+ * list` showed real grants as dead. The two are kept side by side rather than one replacing the
89
+ * other — the label is what a human reads, this is what a machine compares.
90
+ */
91
+ export function policyPermissions(policy: ActionPolicy): readonly string[] {
92
+ return flattenedPermissions(policy);
93
+ }