@ultimat3/action 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @ultimat3/action ⚡
2
+
3
+ One declaration → six artifacts.
4
+
5
+ | # | Artifact | Reach it with | Guarantees |
6
+ |---|---|---|---|
7
+ | 1 | HTTP route `POST /api/<resource>/<verb>` | `toRoute(publishPost)` — the server mounts it | policy + validation + idempotency + invalidation, non-optional |
8
+ | 2 | OpenAPI 3.1 operation + document | `publishPost.openapi()` / `buildOpenApi()` | byte-stable output, diffed by `x verify` |
9
+ | 3 | Typed RPC client | `publishPost.client({ baseUrl })` / `rpc<Api['actions']>()` | server typo = compile error in Solid |
10
+ | 4 | MCP tool | `publishPost.tool()` | *identical* policy evaluation to the route |
11
+ | 5 | Job handle | `publishPost.job()` | enqueue durable work, no rewrite |
12
+ | 6 | Contract tests | `publishPost.contract()` | garbage rejected, anonymous denied, spec present |
13
+
14
+ ## The fluent surface
15
+
16
+ An action carries its own projections, so app code never reaches through `.def` and
17
+ never imports a projection function:
18
+
19
+ ```ts
20
+ publishPost.input // the declared input schema
21
+ publishPost.output // the declared output schema
22
+ publishPost.policy // the one policy object
23
+ publishPost.mcp // { expose, description }, as declared
24
+ await publishPost.as(actor, { postId }) // run as someone, one execution path
25
+ publishPost.tool() // MCP descriptor
26
+ publishPost.openapi() // OpenAPI operation
27
+ publishPost.client({ baseUrl }) // typed RPC method
28
+ publishPost.job() // durable-work handle
29
+ publishPost.contract() // the three generated assertions
30
+ ```
31
+
32
+ `publishPost.tool().policy === publishPost.policy` — the same object, so an MCP call
33
+ cannot reach a different authz path. `.as()` keeps the surrounding context whole and
34
+ swaps only the actor: impersonation, not a second context.
35
+
36
+ ## Declare
37
+
38
+ `t` is re-exported here — the same object `@ultimat3/schema` exports, so an action file
39
+ imports one package for the primitive and its schemas, never two.
40
+
41
+ ```ts
42
+ import { action, t } from '@ultimat3/action';
43
+
44
+ export const publishPost = action({
45
+ input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
46
+ output: PostView,
47
+ policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
48
+ cache: { invalidates: [tag.post, tag.feed] },
49
+ mcp: { expose: true, description: 'Publish a draft post' },
50
+ idempotent: true,
51
+ async handle({ input, ctx }) {
52
+ const post = await ctx.posts.publish(input.postId);
53
+ if (input.notify) await notifySubscribers.enqueue({ postId: post.id });
54
+ return post;
55
+ },
56
+ });
57
+ ```
58
+
59
+ ## Register — one call, at boot
60
+
61
+ `apps/web/api/index.ts` is the whole API surface. Importing it IS the boot.
62
+
63
+ ```ts
64
+ import { defineApi } from '@ultimat3/action';
65
+ import * as postActions from '../app/posts/actions';
66
+ import * as postMutators from '../app/posts/mutator';
67
+ import * as postQueries from '../app/posts/live';
68
+
69
+ export const api = defineApi({
70
+ actions: [postActions],
71
+ mutators: [postMutators],
72
+ queries: [postQueries],
73
+ });
74
+
75
+ export type Api = typeof api;
76
+ ```
77
+
78
+ | Key | Goes to | Why |
79
+ |---|---|---|
80
+ | `actions` | the action registry | the primitive |
81
+ | `mutators` | the action registry | a mutator IS an action, on the same authz path |
82
+ | `llm` | the action registry | `llm()` returns an action, not a ninth primitive |
83
+ | `queries` | `@ultimat3/query`'s registry, via core's registrar table | `query` is on this tier, so importing it here would be a build error |
84
+
85
+ Names come from **export names** — that is what makes the path, the tool name and the
86
+ OpenAPI `operationId` derivable everywhere without a second declaration. Registration
87
+ stamps the name onto the action the module exported, so the binding you imported is the
88
+ one that projects; a projection attempted before boot is `X_ACTION_UNREGISTERED`. Two
89
+ features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging.
90
+
91
+ `registerActions` / `registerQueries` are what `defineApi` composes. An app calling them
92
+ directly is a second path.
93
+
94
+ ## Call it — `rpc`
95
+
96
+ ```ts
97
+ import { rpc } from '@ultimat3/action';
98
+ import type { Api } from '../api';
99
+
100
+ export const client = rpc<Api['actions']>({ baseUrl: '/' });
101
+ ```
102
+
103
+ `Api['actions']` is the merged module type, so `client.publishPost` is typed from the
104
+ declaration with no codegen step. `Api` is imported as a **type only**, which is what keeps
105
+ a page's module graph free of any edge to a feature's implementation.
106
+
107
+ ## Path derivation
108
+
109
+ First camelCase word is the verb; the rest is the resource, last word pluralized,
110
+ kebab-cased. The **MCP tool name is not derived** — it is the export name verbatim, because
111
+ that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
112
+
113
+ | Action | Route | MCP tool |
114
+ |---|---|---|
115
+ | `publishPost` | `POST /api/posts/publish` | `publishPost` |
116
+ | `updateUserProfile` | `POST /api/user-profiles/update` | `updateUserProfile` |
117
+ | `likePost` | `POST /api/posts/like` | `likePost` |
118
+ | `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
119
+
120
+ ## One invocation core
121
+
122
+ `invoke()` is the only execution path: **parse input → evaluate policy → handle →
123
+ parse output**. HTTP, MCP, jobs and direct server calls differ **only** in the
124
+ `surface` they hand to `enforce()` from `@ultimat3/policy`, which selects how a
125
+ denial renders (problem+json / tool error / failed job) — never whether authz runs.
126
+
127
+ Enforced structurally, not by convention: the declaration is held in a private
128
+ store inside `invoke.ts`, so `handle` is reachable from nowhere else. An action has
129
+ no `.def`. A second authz path cannot be written without deleting that store.
130
+
131
+ | Stage | Failure |
132
+ |---|---|
133
+ | parse input | `X_INPUT_INVALID` |
134
+ | evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
135
+ | handle | whatever the handler throws |
136
+ | parse output | `X_OUTPUT_INVALID` — and fields the schema never declared are dropped |
137
+
138
+ Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
139
+ no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
140
+
141
+ ## mutator = action + local twin
142
+
143
+ `mutator()` is built **on top of** `action()`. A mutator IS an action, so it gets all
144
+ six projections; it adds `local(tx, input)` for the optimistic write and a `conflict`
145
+ strategy for the rebase.
146
+
147
+ ```ts
148
+ export const likePost = mutator({
149
+ input: t.object({ postId: t.uuid }),
150
+ output: PostLikes,
151
+ policy: can('post:like'),
152
+ // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
153
+ // equal applying it once — `likedByMe` is what makes the second application a no-op.
154
+ local(tx, { postId }) {
155
+ tx.posts.update(postId, (p) =>
156
+ p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
157
+ },
158
+ async server(ctx, { postId }) { return ctx.posts.like(postId); },
159
+ conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
160
+ });
161
+ ```
162
+
163
+ The projected surface carries the same three names the declaration used, on top of
164
+ every action member above:
165
+
166
+ ```ts
167
+ likePost.local(tx, { postId }) // the optimistic write, replayed on rebase
168
+ await likePost.server(ctx, { postId }) // the authoritative write
169
+ likePost.conflict // the declared strategy
170
+ ```
171
+
172
+ `.server()` is not a shortcut past `invoke` — it calls the action's own callable, so
173
+ the input parse, the policy and the output parse all still run: an actor the policy
174
+ denies is denied there exactly as over HTTP. `.local()` is the only half that skips
175
+ the core, because it never leaves the client; keep it a pure function of `(tx, input)`
176
+ — no I/O, no clock, no randomness — since every rebase replays it.
177
+
178
+ `LocalTx` is the client write surface (`@ultimat3/realtime` implements it over OPFS
179
+ SQLite). Type your tables once: `declare module '@ultimat3/action' { interface
180
+ LocalTables { posts: PostRow } }`.
181
+
182
+ ## Determinism + idempotency
183
+
184
+ `serializeOpenApi(buildOpenApi())` sorts keys at every depth, iterates the registry
185
+ name-sorted, and reads no clock, env or random source — same registry ⇒ same bytes ⇒
186
+ `x verify` can diff the spec and fail on `X_CONTRACT_DRIFT`.
187
+
188
+ `idempotent: true` + an `Idempotency-Key` header replays the first response
189
+ (`x-ultimate-replayed: 1`); a duplicate still in flight, or a reused key with a new
190
+ payload, is `X_IDEMPOTENCY_CONFLICT`. Store is swappable via `setIdempotencyStore()`.
191
+
192
+ ## Errors
193
+
194
+ | Code | When | Fix |
195
+ |---|---|---|
196
+ | `X_ACTION_DUPLICATE` | two actions registered under one name | rename one export |
197
+ | `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
198
+ | `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
199
+ | `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
200
+ | `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
201
+ | `X_RPC_FAILED` | non-`problem+json` failure reached the client | check the gateway |
202
+ | `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
203
+
204
+ Denials re-throw the policy layer's own codes (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) —
205
+ this package never invents an authz code.
206
+
207
+ ## Boundaries
208
+
209
+ Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
210
+ `query`, `jobs`, `realtime` (same tier) or anything above it — those import *this*.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@ultimat3/action",
3
+ "version": "1.0.0",
4
+ "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/action"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "bun": ">=1.3.0"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "test": "bun test"
31
+ },
32
+ "dependencies": {
33
+ "@ultimat3/cache": "1.0.0",
34
+ "@ultimat3/core": "1.0.0",
35
+ "@ultimat3/http": "1.0.0",
36
+ "@ultimat3/policy": "1.0.0",
37
+ "@ultimat3/schema": "1.0.0"
38
+ }
39
+ }
package/src/action.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The `action` primitive: one server-authoritative mutation, declared once.
3
+ * Every projection in this package (route, OpenAPI, client, MCP tool, job
4
+ * handle, contract tests) reads this declaration — none of them re-declare it.
5
+ */
6
+
7
+ import type { CacheTag } from '@ultimat3/cache';
8
+ import type { Actor, Ctx } from '@ultimat3/core';
9
+ import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
+ import type { ClientMethod, ClientOptions } from './client';
11
+ import type { ContractTest, ContractTestOptions } from './contract-test';
12
+ import { facadeFor } from './facade';
13
+ import type { OpenApiOperation } from './http';
14
+ import type { IdempotencyStore } from './idempotency';
15
+ import { actionName, defOf, hasDef, invoke, stashDef } from './invoke';
16
+ import type { ActionJobHandle } from './job-handle';
17
+ import type { JsonSchemaObject } from './json-schema';
18
+ import { jsonSchemaOf } from './json-schema';
19
+ import type { McpToolDescriptor } from './mcp-tool';
20
+ import { derivePath, toToolName } from './naming';
21
+ import { type ActionPolicy, policyCapability, type Surface } from './policy-gate';
22
+ import { tagKeys } from './tags';
23
+
24
+ export interface ActionCache {
25
+ /** Tags dropped from every cache tier after the handler settles. */
26
+ readonly invalidates: readonly CacheTag[];
27
+ }
28
+
29
+ export interface ActionMcp {
30
+ /** Opt-in: only a literal `true` makes the action a tool. Silence exposes nothing. */
31
+ readonly expose: boolean;
32
+ /**
33
+ * Contract text, NOT UI text — deliberately outside `t()`. It becomes the OpenAPI
34
+ * operation `summary` (`toOpenApiOperation`), and `buildOpenApi`'s bytes are what
35
+ * `x verify` diffs for contract drift. Resolving it through the ambient, request-scoped
36
+ * translator would make `openapi.json` depend on whichever locale happened to be active
37
+ * when it was generated, which is exactly the determinism that file's header forbids.
38
+ * Localised agent-facing text needs a separate, locale-resolved projection; there is no
39
+ * second field for it here until that exists, because two ways to describe one tool is
40
+ * the drift axiom 1 rejects.
41
+ */
42
+ readonly description?: string;
43
+ /**
44
+ * Roles that may SEE the projected tool. A CATALOG audience, never an authz rule — the
45
+ * `policy` above still decides every call, and this list decides nothing about one. Omitted
46
+ * means every caller may enumerate it.
47
+ *
48
+ * Fail-closed where it lands (`@ultimat3/mcp`): a caller whose role is not named — including
49
+ * one carrying no role at all — gets the answer an ABSENT tool gets, never `Forbidden`.
50
+ * Forbidden would confirm the tool exists, which turns the catalog into something an agent
51
+ * can enumerate by probing names.
52
+ *
53
+ * A plain role list, never a predicate: a declared fact has to stay static and serialisable.
54
+ * A surface deriving visibility from something richer (`@ultimat3/admin` derives it from the
55
+ * actor's admin permissions) hands `@ultimat3/mcp` a predicate instead.
56
+ */
57
+ readonly visibleTo?: readonly string[];
58
+ }
59
+
60
+ export interface ActionRateLimit {
61
+ readonly limit: number;
62
+ readonly windowMs: number;
63
+ }
64
+
65
+ export interface ActionHandlerArgs<TInput extends StandardSchemaV1> {
66
+ readonly input: InferOutput<TInput>;
67
+ readonly ctx: Ctx;
68
+ }
69
+
70
+ /** What a row loader gets: the parsed input and the context, never the request. */
71
+ export interface ActionRowArgs<TInput extends StandardSchemaV1> {
72
+ readonly input: InferOutput<TInput>;
73
+ readonly ctx: Ctx;
74
+ }
75
+
76
+ export interface ActionDef<
77
+ TInput extends StandardSchemaV1,
78
+ TOutput extends StandardSchemaV1,
79
+ TRow = unknown,
80
+ > {
81
+ readonly input: TInput;
82
+ readonly output: TOutput;
83
+ readonly policy: ActionPolicy<TRow>;
84
+ readonly cache?: ActionCache;
85
+ readonly mcp?: ActionMcp;
86
+ readonly rateLimit?: ActionRateLimit;
87
+ /** Marks the action safe to retry with an `Idempotency-Key`. */
88
+ readonly idempotent?: boolean;
89
+ /**
90
+ * Loads the row a row-level `policy` decides about, once per invocation, after the
91
+ * input parse and before the guard. This is the async half authz is not allowed to
92
+ * have: a predicate stays synchronous — a live query re-evaluates one per subscriber
93
+ * on every change, so an `await` inside it would be a database round trip per row per
94
+ * connected client. The caller loads what the rule needs and passes it in; here, the
95
+ * caller is the framework.
96
+ *
97
+ * Omitted means the rule decides on input alone and `row` reaches it as `null`. A rule
98
+ * that reads `row` must therefore fail closed on `null`, because "no loader declared"
99
+ * and "row not found" are the same value and neither is evidence of permission.
100
+ */
101
+ row?(args: ActionRowArgs<TInput>): TRow | null | Promise<TRow | null>;
102
+ handle(args: ActionHandlerArgs<TInput>): Promise<InferOutput<TOutput>> | InferOutput<TOutput>;
103
+ }
104
+
105
+ export interface InvokeOptions {
106
+ /** Explicit context. Omitted means "take the ambient request context". */
107
+ readonly ctx?: Ctx;
108
+ /**
109
+ * Run as someone else. Omitted keeps the context's own actor; `null` is the
110
+ * signed-out caller. The rest of the context is untouched, so impersonation
111
+ * stays on the one execution path instead of forking a second one.
112
+ */
113
+ readonly actor?: Actor | null;
114
+ readonly surface?: Surface;
115
+ readonly idempotencyKey?: string | null;
116
+ readonly store?: IdempotencyStore;
117
+ readonly onReplay?: () => void;
118
+ }
119
+
120
+ export interface McpDescriptorMeta {
121
+ readonly expose: boolean;
122
+ readonly tool: string;
123
+ readonly description: string | null;
124
+ }
125
+
126
+ export interface ActionDescriptor {
127
+ readonly kind: 'action';
128
+ /**
129
+ * Built by `mutator()`. `kind` cannot carry this: `describeActions()` hands back
130
+ * `ActionDescriptor`, whose `kind` is the literal `'action'` for a mutator too — so every
131
+ * reader downstream (`x.manifest.json`'s mutator count included) sees zero without it.
132
+ */
133
+ readonly mutator: boolean;
134
+ readonly name: string;
135
+ readonly verb: string;
136
+ readonly resource: string;
137
+ readonly method: 'POST';
138
+ readonly path: string;
139
+ readonly capability: string;
140
+ readonly input: JsonSchemaObject;
141
+ readonly output: JsonSchemaObject;
142
+ readonly invalidates: readonly string[];
143
+ readonly idempotent: boolean;
144
+ readonly mcp: McpDescriptorMeta;
145
+ readonly rateLimit: ActionRateLimit | null;
146
+ }
147
+
148
+ /**
149
+ * Schema-erased view of a definition, held only by `invoke.ts`'s private store —
150
+ * never reachable from an action. Members stay method-syntax on purpose:
151
+ * bivariant parameters are what make the erasure assignable.
152
+ */
153
+ export interface AnyActionDef {
154
+ readonly input: StandardSchemaV1;
155
+ readonly output: StandardSchemaV1;
156
+ readonly policy: ActionPolicy;
157
+ readonly cache?: ActionCache;
158
+ readonly mcp?: ActionMcp;
159
+ readonly rateLimit?: ActionRateLimit;
160
+ readonly idempotent?: boolean;
161
+ row?(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
162
+ handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
163
+ }
164
+
165
+ export interface AnyAction {
166
+ readonly kind: 'action';
167
+ readonly name: string;
168
+ /** The declaration, minus `handle`: readable, and never a way to run it. */
169
+ readonly input: StandardSchemaV1;
170
+ readonly output: StandardSchemaV1;
171
+ readonly policy: ActionPolicy;
172
+ readonly mcp?: ActionMcp;
173
+ describe(): ActionDescriptor;
174
+ /** A twin under another name. Registration uses `nameAction`, which names in place. */
175
+ named(name: string): AnyAction;
176
+ /** Run as this actor. Same `invoke` core, only the context's actor changes. */
177
+ as(actor: Actor | null, input: unknown, options?: InvokeOptions): Promise<unknown>;
178
+ tool(): McpToolDescriptor;
179
+ openapi(): OpenApiOperation;
180
+ contract(options?: ContractTestOptions): readonly ContractTest[];
181
+ }
182
+
183
+ export interface Action<
184
+ TInput extends StandardSchemaV1 = StandardSchemaV1,
185
+ TOutput extends StandardSchemaV1 = StandardSchemaV1,
186
+ > extends AnyAction {
187
+ /** Callable server-side with the same types the client and MCP tool see. */
188
+ (input: InferInput<TInput>, opts?: InvokeOptions): Promise<InferOutput<TOutput>>;
189
+ readonly input: TInput;
190
+ readonly output: TOutput;
191
+ named(name: string): Action<TInput, TOutput>;
192
+ as(
193
+ actor: Actor | null,
194
+ input: InferInput<TInput>,
195
+ options?: InvokeOptions,
196
+ ): Promise<InferOutput<TOutput>>;
197
+ /**
198
+ * Typed against this action's schemas, which is the whole point of both — so they
199
+ * live here and not on the schema-erased `AnyAction` view.
200
+ */
201
+ client(options: ClientOptions): ClientMethod<TInput, TOutput>;
202
+ job(): ActionJobHandle<TInput, TOutput>;
203
+ }
204
+
205
+ /** The fluent half of an action: lifted declaration plus one method per projection. */
206
+ export type ActionFacade<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> = Pick<
207
+ Action<TInput, TOutput>,
208
+ 'input' | 'output' | 'policy' | 'mcp' | 'as' | 'tool' | 'openapi' | 'client' | 'job' | 'contract'
209
+ >;
210
+
211
+ export function action<
212
+ TInput extends StandardSchemaV1,
213
+ TOutput extends StandardSchemaV1,
214
+ TRow = unknown,
215
+ >(def: ActionDef<TInput, TOutput, TRow>): Action<TInput, TOutput> {
216
+ return build(def, '');
217
+ }
218
+
219
+ /**
220
+ * Structural, not nominal: an object only counts as an action if `action()` built
221
+ * it, because only then does a declaration exist for `invoke` to run. A look-alike
222
+ * with `kind: 'action'` never reaches the registry or a projection.
223
+ */
224
+ export function isAction(value: unknown): value is AnyAction {
225
+ return (
226
+ typeof value === 'function' && (value as { kind?: unknown }).kind === 'action' && hasDef(value)
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Stamp the export name onto the action the app declared, rather than handing back a
232
+ * differently-named copy of it. `import { publishPost } from './actions'` is then the
233
+ * action that projects — `publishPost.tool()` after boot, with nothing to remember.
234
+ * Naming twice is the one case that still needs a twin: one object, one name, forever.
235
+ */
236
+ export function nameAction<A extends AnyAction>(target: A, name: string): A {
237
+ if (target.name === name) return target;
238
+ if (target.name.length > 0) return target.named(name) as A;
239
+ Object.defineProperty(target, 'name', { value: name, configurable: true });
240
+ return target;
241
+ }
242
+
243
+ function build<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1, TRow>(
244
+ def: ActionDef<TInput, TOutput, TRow>,
245
+ name: string,
246
+ ): Action<TInput, TOutput> {
247
+ const callable = (
248
+ input: InferInput<TInput>,
249
+ opts: InvokeOptions = {},
250
+ ): Promise<InferOutput<TOutput>> =>
251
+ // `invoke` is schema-erased; the output type is this action's by construction.
252
+ invoke(self, input, opts) as Promise<InferOutput<TOutput>>;
253
+
254
+ const self: Action<TInput, TOutput> = Object.assign(callable, {
255
+ kind: 'action' as const,
256
+ describe: (): ActionDescriptor => describeAction(self),
257
+ named: (next: string): Action<TInput, TOutput> => build(def, next),
258
+ ...facadeFor(def, () => self),
259
+ });
260
+ // `name` on a function is non-writable, so Object.assign cannot set it.
261
+ Object.defineProperty(self, 'name', { value: name, configurable: true });
262
+ // The declaration goes to `invoke.ts` and stays there: `handle` has no other reader.
263
+ stashDef(self, def);
264
+ return self;
265
+ }
266
+
267
+ export function describeAction(target: AnyAction): ActionDescriptor {
268
+ const name = actionName(target);
269
+ const def = defOf(target);
270
+ const path = derivePath(name);
271
+ const mcp = def.mcp;
272
+ return {
273
+ kind: 'action',
274
+ // The brand `mutator()`'s `wrap` stamps on the action it lifted, read structurally.
275
+ // Importing `isMutator` would point this module at the one that already imports it, for a
276
+ // check that needs the brand and not the predicate — `defOf` above already proved the rest.
277
+ mutator: (target as { readonly isMutator?: unknown }).isMutator === true,
278
+ name,
279
+ verb: path.verb,
280
+ resource: path.resource,
281
+ method: 'POST',
282
+ path: path.path,
283
+ capability: policyCapability(def.policy),
284
+ input: jsonSchemaOf(def.input),
285
+ output: jsonSchemaOf(def.output),
286
+ invalidates: tagKeys(def.cache?.invalidates ?? []),
287
+ idempotent: def.idempotent === true,
288
+ mcp: {
289
+ expose: mcp?.expose ?? true,
290
+ tool: toToolName(name),
291
+ description: mcp?.description ?? null,
292
+ },
293
+ rateLimit: def.rateLimit ?? null,
294
+ };
295
+ }