@ultimat3/action 1.2.0 → 2.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/CLAUDE.md +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +17 -10
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
package/src/json-schema.ts
CHANGED
|
@@ -4,14 +4,20 @@
|
|
|
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
|
-
*
|
|
14
|
-
* object
|
|
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
23
|
return normalize(() => toJsonSchema(schema));
|
|
@@ -23,13 +29,14 @@ export function mcpSchemaOf(schema: StandardSchemaV1): JsonSchemaObject {
|
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
function normalize(convert: () => unknown): JsonSchemaObject {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const raw: unknown = convert();
|
|
33
|
+
if (isJsonObject(raw)) return raw;
|
|
34
|
+
// A converter that answered with something that is not a JSON object is the same failure by a
|
|
35
|
+
// quieter route, so it gets the same shipped code rather than a permissive node.
|
|
36
|
+
throw new SchemaUnsupportedError({
|
|
37
|
+
cause: `the schema converted to ${raw === null ? 'null' : typeof raw}, not a JSON Schema object`,
|
|
38
|
+
fix: 'declare the schema with `t` from @ultimat3/action, or configure a provider whose toJsonSchema returns an object: configureSchemaProvider({ ... })',
|
|
39
|
+
});
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
/** 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
|
|
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
|
|
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
|
|
3
|
-
*
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
package/src/policy-gate.ts
CHANGED
|
@@ -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
|
+
}
|