@gjermundgaraba/effect-actions 0.2.0 → 0.4.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.
@@ -1,25 +1,41 @@
1
- import { Action_d_exports, Any } from "./Action.mjs";
1
+ import { Action_d_exports, Any, Codec } from "./Action.mjs";
2
2
  import { AnyImplementation, BuildContext, BuildError, HandlerContext, Implementation } from "./internal/implementation.mjs";
3
- import { Layer, Schema } from "effect";
3
+ import { Effect, Layer, Schema } from "effect";
4
4
  import { Tool, Toolkit } from "effect/unstable/ai";
5
5
  //#region src/ActionToolkit.d.ts
6
+ /** What `make` binds around the implementations it projects. */
7
+ export interface Options<Errors extends ReadonlyArray<Codec> = [], R = never> {
8
+ /**
9
+ * Failures the caller answers with instead of a handler: authorization, rate
10
+ * limits. Declared on every tool, so a refusal is returned as a tool failure
11
+ * exactly like an action's own error.
12
+ */
13
+ readonly errors?: Errors;
14
+ /**
15
+ * Runs once per tool call, before the selected handler, with the action contract
16
+ * it is about to run. It fails with this binding's `errors`. Its services join
17
+ * each tool's request requirements, like a handler's.
18
+ */
19
+ readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
20
+ }
6
21
  /** A native tool corresponding to an MCP-enabled action. */
7
- type NativeTool<A extends Any, R> = A extends {
22
+ type NativeTool<A extends Any, E extends Codec, R> = A extends {
8
23
  readonly mcp: {
9
24
  readonly name: infer Name extends string;
10
25
  };
11
26
  } ? Tool.Tool<Name, {
12
27
  readonly parameters: A["input"];
13
28
  readonly success: A["success"];
14
- readonly failure: Schema.Union<A["errors"]>;
29
+ readonly failure: Schema.Union<ReadonlyArray<A["errors"][number] | E>>;
15
30
  readonly failureMode: "return";
16
31
  }, R> : never;
17
32
  type HandlersOf<App extends AnyImplementation> = App extends Implementation<any, infer H, any, any> ? H : never;
18
- type ToolForAction<App extends AnyImplementation, A extends Any> = A extends {
33
+ /** A tool needs what its handler needs, plus what the binding's `before` hook needs. */
34
+ type ToolForAction<App extends AnyImplementation, A extends Any, E extends Codec, RB> = A extends {
19
35
  readonly mcp: object;
20
- } ? NativeTool<A, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>>> : never;
21
- type ToolsFor<App extends AnyImplementation> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number]> : never;
22
- type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>> = { readonly [T in ToolsFor<Apps[number]> as T["name"]]: T; };
36
+ } ? NativeTool<A, E, HandlerContext<HandlersOf<App>, Extract<A["name"], keyof HandlersOf<App>>> | RB> : never;
37
+ type ToolsFor<App extends AnyImplementation, E extends Codec, RB> = App extends AnyImplementation ? ToolForAction<App, App["group"]["actions"][number], E, RB> : never;
38
+ type ToolkitTools<Apps extends ReadonlyArray<AnyImplementation>, E extends Codec, RB> = { readonly [T in ToolsFor<Apps[number], E, RB> as T["name"]]: T; };
23
39
  /** Native tools and the layer that binds their action implementations. */
24
40
  export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R = never> {
25
41
  readonly toolkit: Toolkit.Toolkit<Tools>;
@@ -33,5 +49,5 @@ export interface Binding<Tools extends Record<string, Tool.Any>, E = never, R =
33
49
  * Build services are needed to construct `layer`; request services are needed
34
50
  * when the resulting toolkit handles a call.
35
51
  */
36
- export declare function make<const Apps extends ReadonlyArray<AnyImplementation>>(...apps: Apps): Binding<ToolkitTools<Apps>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
52
+ export declare function make<const Apps extends ReadonlyArray<AnyImplementation>, const Errors extends ReadonlyArray<Codec> = [], RB = never>(options: Options<Errors, RB>, ...apps: Apps): Binding<ToolkitTools<Apps, Errors[number], RB>, BuildError<Apps[number]>, BuildContext<Apps[number]>>;
37
53
  //#endregion
@@ -3,8 +3,13 @@ import { bindTools } from "./internal/tools.mjs";
3
3
  import "effect";
4
4
  import "effect/unstable/ai";
5
5
  //#region src/ActionToolkit.ts
6
- function make(...apps) {
7
- return bindTools(apps, "native");
6
+ function make(options, ...apps) {
7
+ const before = options.before;
8
+ const toolOptions = {
9
+ errors: options.errors,
10
+ before
11
+ };
12
+ return bindTools(apps, "native", toolOptions);
8
13
  }
9
14
  //#endregion
10
15
  export { make };
package/dist/Testing.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect, Layer, Predicate } from "effect";
2
- import { HttpApiClient } from "effect/unstable/httpapi";
3
2
  import { FetchHttpClient } from "effect/unstable/http";
3
+ import { HttpApiClient } from "effect/unstable/httpapi";
4
4
  //#region src/Testing.ts
5
5
  /**
6
6
  * The native grouped HTTP client, calling `handler` in memory instead of the network.
@@ -1,4 +1,10 @@
1
1
  //#region src/internal/actions.ts
2
+ /**
3
+ * An action's own failures plus the ones its surface answers with, which is what
4
+ * a projection of that action declares. A schema the action already declares is
5
+ * not repeated.
6
+ */
7
+ const projectedErrors = (action, surface) => [...action.errors, ...(surface ?? []).filter((error) => !action.errors.includes(error))];
2
8
  const validName = /^[A-Za-z0-9_-]+$/;
3
9
  /** Names become path segments, OpenAPI identifiers and client method keys. */
4
10
  const assertName = (what, name) => {
@@ -13,4 +19,4 @@ const assertDistinct = (what, names) => {
13
19
  }
14
20
  };
15
21
  //#endregion
16
- export { assertDistinct, assertName };
22
+ export { assertDistinct, assertName, projectedErrors };
@@ -23,14 +23,17 @@ var Implementation = class Implementation {
23
23
  };
24
24
  /**
25
25
  * Select and invoke a handler after an adapter has selected a concrete action.
26
+ * The surface's pre-handler hook runs first, outside the action's span, so a
27
+ * refusal is attributed to the surface rather than to a handler that never ran.
26
28
  * `R` remains in the returned effect so transport layers cannot erase required
27
29
  * per-request services while assembling routes.
28
30
  */
29
- const dispatch = (group, action, handlers) => {
31
+ const dispatch = (group, action, handlers, before) => {
30
32
  const handle = handlers[action.name];
31
33
  if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
32
34
  return (input) => {
33
- return Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
35
+ const handled = Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
36
+ return before === void 0 ? handled : Effect.flatMap(before(action), () => handled);
34
37
  };
35
38
  };
36
39
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { projectedErrors } from "./actions.mjs";
1
2
  import { dispatch } from "./implementation.mjs";
2
3
  import { Effect, Schema } from "effect";
3
4
  import { Tool, Toolkit } from "effect/unstable/ai";
@@ -30,22 +31,22 @@ const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annot
30
31
  * A native Effect AI tool. Its schemas retain action transforms and its result
31
32
  * is the action result itself, rather than an MCP response envelope.
32
33
  */
33
- const nativeTool = (action) => annotate(Tool.make(action.mcp.name, {
34
+ const nativeTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
34
35
  description: action.description,
35
36
  parameters: action.input,
36
37
  success: action.success,
37
- failure: Schema.Union(action.errors),
38
+ failure: Schema.Union(errors),
38
39
  failureMode: "return"
39
40
  }), action.mcp);
40
41
  /**
41
42
  * MCP has a JSON-only wire contract. Success uses its documented `{ value }`
42
43
  * structured-content envelope; declared failures are returned as JSON text.
43
44
  */
44
- const mcpTool = (action) => annotate(Tool.make(action.mcp.name, {
45
+ const mcpTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
45
46
  description: action.description,
46
47
  parameters: Schema.toCodecJson(action.input),
47
48
  success: Schema.toCodecJson(Schema.Struct({ value: action.success })),
48
- failure: Schema.toCodecJson(Schema.Union(action.errors)),
49
+ failure: Schema.toCodecJson(Schema.Union(errors)),
49
50
  failureMode: "return"
50
51
  }), action.mcp);
51
52
  /** MCP's input schema must describe a JSON object, including no-argument tools. */
@@ -53,14 +54,15 @@ const assertMcpObjectInput = (action, tool) => {
53
54
  const root = Tool.getJsonSchemaFromSchema(tool.parametersSchema);
54
55
  if (root.$ref === void 0 && root.type !== "object") throw new Error(`${action.name}: MCP input must have an object root; omit input for no arguments`);
55
56
  };
56
- const project = (projection, entry) => {
57
- const tool = projection === "native" ? nativeTool(entry.action) : mcpTool(entry.action);
57
+ const project = (projection, entry, options) => {
58
+ const errors = projectedErrors(entry.action, options.errors);
59
+ const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
58
60
  if (projection === "native") return tool;
59
61
  assertMcpObjectInput(entry.action, tool);
60
62
  return tool.annotate(Tool.Strict, true);
61
63
  };
62
- const handler = (projection, app, action, handlers) => {
63
- const run = (input) => dispatch(app.group, action, handlers)(input);
64
+ const handler = (projection, app, action, handlers, before) => {
65
+ const run = (input) => dispatch(app.group, action, handlers, before)(input);
64
66
  return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
65
67
  };
66
68
  /**
@@ -68,16 +70,16 @@ const handler = (projection, app, action, handlers) => {
68
70
  * Native and MCP differ only in tool codecs and the MCP success envelope; selection,
69
71
  * scoped acquisition, dispatch and native Toolkit binding stay identical.
70
72
  */
71
- const bindTools = (apps, projection) => {
73
+ const bindTools = (apps, projection, options) => {
72
74
  const served = toolApps(apps);
73
75
  const selected = entries(served);
74
76
  assertDistinctToolNames(selected);
75
- const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry)));
77
+ const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry, options)));
76
78
  return {
77
79
  toolkit,
78
80
  layer: toolkit.toLayer(Effect.map(Effect.forEach(served, ({ app, actions }) => Effect.map(app.build, (handlers) => {
79
81
  const record = handlers;
80
- return actions.map((action) => [action.mcp.name, handler(projection, app, action, record)]);
82
+ return actions.map((action) => [action.mcp.name, handler(projection, app, action, record, options.before)]);
81
83
  })), (built) => Object.fromEntries(built.flat())))
82
84
  };
83
85
  };
package/docs/Action.md CHANGED
@@ -11,34 +11,39 @@ import * as Action from "@gjermundgaraba/effect-actions/Action";
11
11
  /** Any service-free schema. */
12
12
  type Codec = Schema.Codec<unknown, unknown, never, never>;
13
13
 
14
- function make<Name, Input = NoInput, Output, Errors = [], Http = true, Mcp = undefined>(
14
+ function make<Name, Input = NoInput, Output, Errors = [], Acc extends Access, Http = true, Mcp = undefined>(
15
15
  name: Name,
16
- options: Options<Input, Output, Errors, Http, Mcp>,
17
- ): Action<Name, Input, Output, Errors, Http, Mcp>;
16
+ options: Options<Input, Output, Errors, Acc, Http, Mcp>,
17
+ ): Action<Name, Input, Output, Errors, Acc, Http, Mcp>;
18
18
 
19
- interface Options<Input, Output, Errors, Http, Mcp> {
19
+ /** What the action does to its resource. Authorization metadata, not an MCP hint. */
20
+ type Access = "read" | "write";
21
+
22
+ interface Options<Input, Output, Errors, Acc, Http, Mcp> {
20
23
  readonly description: string;
21
24
  readonly input?: Input; // omit for an action without arguments
22
25
  readonly success: Output;
23
26
  readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
27
+ readonly access: Acc; // required, no default
24
28
  readonly http?: Http; // false hides the action from HTTP routes and clients
25
29
  readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
26
30
  }
27
31
 
28
32
  interface McpOptions {
29
33
  readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
30
- readonly readOnly?: boolean; // readOnlyHint, default false
34
+ readonly readOnly?: boolean; // readOnlyHint, default access === "read"
31
35
  readonly destructive?: boolean; // destructiveHint, default !readOnly
32
36
  readonly idempotent?: boolean; // idempotentHint, default false
33
37
  readonly openWorld?: boolean; // openWorldHint, default true
34
38
  }
35
39
 
36
- interface Action<Name, Input, Output, Errors, Http, Mcp> {
40
+ interface Action<Name, Input, Output, Errors, Acc, Http, Mcp> {
37
41
  readonly name: Name;
38
42
  readonly description: string;
39
43
  readonly input: Input;
40
44
  readonly success: Output;
41
45
  readonly errors: Errors;
46
+ readonly access: Acc; // the literal it was declared with
42
47
  readonly http: Http;
43
48
  readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
44
49
  }
@@ -63,27 +68,41 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
63
68
  { httpApiStatus: 404 },
64
69
  ) {}
65
70
 
71
+ // `access: "read"` states the fact once: it is what a surface's `before` hook
72
+ // authorizes on, and it is where `mcp.readOnly` comes from.
66
73
  export const GetUser = Action.make("getUser", {
67
74
  description: "Look up a user in your tenant.",
68
75
  input: Schema.Struct({ id: Schema.String }),
69
76
  success: Schema.Struct({ id: Schema.String, name: Schema.String }),
70
77
  errors: [UserNotFound],
71
- mcp: { name: "get_user", readOnly: true },
78
+ access: "read",
79
+ mcp: { name: "get_user" },
80
+ });
81
+
82
+ // A write. Every action says which it is; there is no default to fall back on.
83
+ export const RenameUser = Action.make("renameUser", {
84
+ description: "Rename a user in your tenant.",
85
+ input: Schema.Struct({ id: Schema.String, name: Schema.String }),
86
+ success: Schema.Struct({ id: Schema.String, name: Schema.String }),
87
+ errors: [UserNotFound],
88
+ access: "write",
89
+ mcp: { name: "rename_user", destructive: false },
72
90
  });
73
91
 
74
92
  // No arguments: omit `input`. Identity comes from request context, never from input.
75
93
  export const WhoAmI = Action.make("whoAmI", {
76
94
  description: "Inspect the authenticated actor.",
77
95
  success: Schema.Struct({ id: Schema.String, tenantId: Schema.String }),
78
- mcp: { readOnly: true },
96
+ access: "read",
79
97
  });
80
98
 
81
99
  // MCP-only: no HTTP route, no client method.
82
100
  export const ListChanges = Action.make("listChanges", {
83
101
  description: "List renames in your tenant, oldest first.",
84
102
  success: Schema.Struct({ changes: Schema.Array(Schema.String) }),
103
+ access: "read",
85
104
  http: false,
86
- mcp: { name: "list_changes", readOnly: true },
105
+ mcp: { name: "list_changes" },
87
106
  });
88
107
 
89
108
  // Input decoding is the schema's: "21" on the wire, 21 in the handler.
@@ -91,7 +110,7 @@ export const Double = Action.make("double", {
91
110
  description: "Double a finite number supplied as a string.",
92
111
  input: Schema.Struct({ value: Schema.FiniteFromString }),
93
112
  success: Schema.Finite,
94
- mcp: { readOnly: true },
113
+ access: "read",
95
114
  });
96
115
  ```
97
116
 
@@ -101,13 +120,15 @@ export const Double = Action.make("double", {
101
120
  - Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
102
121
  - `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
103
122
  - A handler may fail only with the declared errors. Anything else is a defect.
123
+ - `access` is `"read"` or `"write"` and is required. It stays a literal on the action, so a rule may switch on it at the type level. It is authorization metadata for a surface's `before` hook (see [guarantees.md](guarantees.md)); the library itself authorizes nothing and no adapter reads it.
124
+ - `access` is independent of `mcp`. A local-only action (`mcp: false`) still has one, and an action may set `access: "write"` with `mcp: { readOnly: true }` if the tool hint should say something else. Derive authorization from `access`, never from a tool hint.
104
125
  - Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
105
126
  - MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
106
127
  - `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
107
- - Hint defaults: `readOnly: false`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
128
+ - Hint defaults: `readOnly: access === "read"`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
108
129
  - Schemas must be service-free. Put service access in the handler.
109
130
  - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
110
- - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP cannot be strict natively: `HttpApi.ParseOptions` also governs error encoding, where `onExcessProperty: "error"` rejects an error's own `stack`.
131
+ - HTTP strips undeclared input fields. MCP tools are strict (`Tool.Strict`): undeclared arguments are an invalid-arguments result and input schemas publish `additionalProperties: false`. HTTP is not strict, and cannot be made strict without a hack: Effect merges one `HttpApi.ParseOptions` per endpoint and uses it for payload decoding _and_ error encoding, so `onExcessProperty: "error"` also rejects a `TaggedError` instance's own `message` and `stack`, turning a declared 409 into an empty 500. Making the payload schema itself strict would require wrapping it in an open schema, which erases its OpenAPI shape. Declare the fields you accept and treat extra HTTP fields as ignored.
111
132
 
112
133
  ## Failure modes
113
134
 
@@ -115,3 +136,5 @@ export const Double = Action.make("double", {
115
136
  - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
116
137
  - `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
117
138
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
139
+ - `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
140
+ - A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
@@ -46,7 +46,8 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
46
46
  - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
47
47
  - Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
48
48
  - Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
49
- - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
49
+ - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`. Surface errors are not listed: they belong to a binding, not to the contract.
50
+ - `access` is not in the document. It is authorization metadata for a surface's hook, and the catalog is descriptive; read it from the contract (`group.actions`) when you need it.
50
51
  - Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
51
52
  - The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
52
53
 
package/docs/ActionCli.md CHANGED
@@ -9,25 +9,30 @@ nothing is fetched over HTTP.
9
9
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
10
10
 
11
11
  /** One explicitly selected action. */
12
- const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, Parameters = never>(
12
+ const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, EB, RB, Parameters = never>(
13
13
  app: Implementation<G, H, EX, RX>,
14
14
  name: Name,
15
- options?: Options<SuccessType, Parameters>,
16
- ) => Command.Command<string, never, {}, EX | SchemaError | DeclaredErrors, Exclude<RX | HandlerContext, Scope>>;
15
+ options?: Options<SuccessType, EB, RB, Parameters>,
16
+ ) => Command.Command<string, never, {}, EX | EB | SchemaError | DeclaredErrors, Exclude<RX | RB | HandlerContext, Scope>>;
17
17
 
18
18
  /** Every action of the group, including local-only ones, under the group name. */
19
- const group: <G, H, EX, RX>(
19
+ const group: <G, H, EX, RX, EB, RB>(
20
20
  app: Implementation<G, H, EX, RX>,
21
- options?: GroupOptions,
22
- ) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
21
+ options?: GroupOptions<EB, RB>,
22
+ ) => Command.Command<string, {}, {}, EX | EB | SchemaError | DeclaredErrors, ...>;
23
23
 
24
- interface GroupOptions {
24
+ /** The hook both projections accept. A CLI encodes nothing, so it declares no errors. */
25
+ interface BeforeOptions<EB, RB> {
26
+ readonly before?: (action: Action.Any) => Effect.Effect<void, EB, RB>;
27
+ }
28
+
29
+ interface GroupOptions<EB, RB> extends BeforeOptions<EB, RB> {
25
30
  readonly name?: string; // override the group command name
26
31
  }
27
32
 
28
- type Options<Output, Parameters extends Command.Command.Config = never> =
29
- | JsonOptions<Output>
30
- | ParametersOptions<Output, Parameters>;
33
+ type Options<Output, EB, RB, Parameters extends Command.Command.Config = never> =
34
+ | (JsonOptions<Output> & BeforeOptions<EB, RB>)
35
+ | (ParametersOptions<Output, Parameters> & BeforeOptions<EB, RB>);
31
36
 
32
37
  interface JsonOptions<Output> {
33
38
  readonly name?: string; // override the command name; default is the action name
@@ -49,21 +54,27 @@ import { Console, Effect, Logger } from "effect";
49
54
  import { Command, Flag } from "effect/unstable/cli";
50
55
  import { NodeRuntime, NodeServices } from "@effect/platform-node";
51
56
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
57
+ import { actors, authorize, CurrentActor } from "./auth.js";
52
58
  import { UserApp } from "./handlers.js";
53
59
  import { Users } from "./users.js";
54
60
 
55
61
  // Explicit syntax: `double --value 21`. No `--input` on this command.
62
+ // The CLI binds the same hook as the servers; a local caller is not trusted more.
56
63
  const command = ActionCli.command(UserApp, "double", {
57
64
  parameters: { value: Flag.String("value") },
58
65
  input: ({ value }) => ({ value }),
66
+ before: authorize,
59
67
  });
60
68
 
61
69
  // Default syntax for a whole group: `users double --input '{"value":"21"}'`.
62
- const all = ActionCli.group(UserApp);
70
+ const all = ActionCli.group(UserApp, { before: authorize });
63
71
 
64
72
  Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
65
73
  Effect.tapCause((cause) => Console.error(cause)),
66
74
  Effect.provideService(Logger.LogToStderr, true),
75
+ // The hook runs here too, so a local caller supplies the identity it reads
76
+ // exactly as HTTP middleware does for a request.
77
+ Effect.provideService(CurrentActor, actors.alice),
67
78
  Effect.provide(Users.layerMemory),
68
79
  Effect.provide(NodeServices.layer),
69
80
  NodeRuntime.runMain({ disableErrorReporting: true }),
@@ -80,6 +91,8 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
80
91
  - Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
81
92
  - Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
82
93
  - Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
94
+ - `before` runs before the selected handler, on `command` and on every subcommand of `group`. Its failure joins the command's error channel and its services are the caller's to provide, so a local CLI supplies the identity the hook reads exactly as HTTP middleware does. A CLI is not a trusted bypass.
95
+ - The CLI declares no `errors`: it encodes nothing, so a refusal is simply a typed failure of the command effect. The native parser also decodes `--input` before the command runs, so unlike HTTP the hook runs after input decoding.
83
96
  - Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
84
97
 
85
98
  ## Failure modes
@@ -88,4 +101,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
88
101
  - Command rejects `--input`: the command was built with `parameters`. Use its flags.
89
102
  - Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
90
103
  - Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
104
+ - A command requires a request-identity tag no handler yields: the `before` hook yields it. Provide it, or build the command without a hook for local use.
91
105
  - `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
@@ -2,6 +2,7 @@
2
2
 
3
3
  A named set of actions. The group is what adapters serve, what clients are grouped by, and
4
4
  what `implement` binds handlers to. Shared errors and the HTTP schema-error policy live here.
5
+ The pre-handler hook does not: each surface binds its own (see [guarantees.md](guarantees.md)).
5
6
 
6
7
  ## API
7
8
 
@@ -39,6 +40,13 @@ class Implementation<G, H, EX, RX> {
39
40
  readonly group: G;
40
41
  get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
41
42
  }
43
+
44
+ /** Every action of the supplied groups, keyed `<group>.<action>`. */
45
+ function contracts<const Groups extends ReadonlyArray<Group>>(
46
+ ...groups: Groups
47
+ ): Contracts<Groups>;
48
+
49
+ type Contracts<Groups>; // { "users.getUser": typeof GetUser, "users.renameUser": ... }
42
50
  ```
43
51
 
44
52
  `HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
@@ -50,7 +58,7 @@ all required.
50
58
  import { Effect, Schema } from "effect";
51
59
  import type { HttpApiError } from "effect/unstable/httpapi";
52
60
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
53
- import { Forbidden, authorize, CurrentActor } from "./auth.js";
61
+ import { CurrentActor } from "./auth.js";
54
62
  import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
55
63
  import { Users } from "./users.js";
56
64
 
@@ -74,33 +82,24 @@ const schemaError = {
74
82
  : new InvalidRequest({ message: "The request does not match the action's input." }),
75
83
  };
76
84
 
77
- // `Forbidden` is declared once and joins every action's errors.
78
85
  export const UserActions = ActionGroup.make(
79
- { name: "users", errors: [Forbidden], schemaError },
86
+ { name: "users", schemaError },
80
87
  GetUser,
81
88
  RenameUser,
82
89
  WhoAmI,
83
90
  );
84
91
 
85
92
  // Build-time services (Users) are yielded in the builder, once per adapter layer.
86
- // Request-time services (CurrentActor) are yielded inside handlers.
93
+ // Request-time services (CurrentActor) are yielded inside handlers. Authorization
94
+ // is not here: each adapter binds one `before` hook for every group it serves.
87
95
  export const UserApp = UserActions.implement(
88
96
  Effect.gen(function* () {
89
97
  const users = yield* Users;
90
98
 
91
99
  return {
92
- getUser: ({ id }) =>
93
- Effect.gen(function* () {
94
- const actor = yield* authorize("users:read");
95
-
96
- return yield* users.get(actor.tenantId, id);
97
- }),
100
+ getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
98
101
  renameUser: ({ id, name }) =>
99
- Effect.gen(function* () {
100
- const actor = yield* authorize("users:write");
101
-
102
- return yield* users.rename(actor, id, name);
103
- }),
102
+ Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
104
103
  whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
105
104
  };
106
105
  }),
@@ -108,17 +107,22 @@ export const UserApp = UserActions.implement(
108
107
 
109
108
  // No build-time services: pass the record directly.
110
109
  export const MathApp = MathActions.implement({ double: ({ value }) => Effect.succeed(value * 2) });
110
+
111
+ // One typed map of every contract, keyed "<group>.<action>".
112
+ export const contracts = ActionGroup.contracts(UserActions, MathActions);
113
+ const renames: "renameUser" = contracts["users.renameUser"].name;
111
114
  ```
112
115
 
113
116
  ## Rules
114
117
 
115
118
  - `name` matches `[A-Za-z0-9_-]+` and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
116
- - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them.
117
- - `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error.
119
+ - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
120
+ - `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error. It takes nothing else.
118
121
  - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
119
122
  - Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
120
123
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
121
- - `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and is not a substitute for adapter tests. There is no public handler service or Layer.
124
+ - `build` exists for direct handler tests under `Effect.scoped`. It bypasses transport validation and the hook, and is not a substitute for adapter tests. There is no public handler service or Layer.
125
+ - `contracts(...groups)` returns one record keyed `<group>.<action>`, with each action's exact contract type. Duplicate group names throw. Use it where routes, tools, jobs or commands must name an action: the key set is derived, so an added action cannot be missed.
122
126
 
123
127
  ### Schema-error policy
124
128
 
@@ -130,6 +134,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
130
134
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
131
135
  - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
132
136
  - Groups served by one adapter may have different policies; each action answers with its own group's.
137
+ - A `before` hook refuses before decoding, so a refused request never reaches the policy.
133
138
  - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
134
139
 
135
140
  ## Failure modes
@@ -137,5 +142,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
137
142
  - Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
138
143
  - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
139
144
  - Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
145
+ - `Expected 1 arguments, but got 2` at `implement`: the second options object is gone. Move `before` to the adapter that serves the group.
140
146
  - `map` is not typed from `errors` when declared as a standalone constant: annotate its parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`).
141
147
  - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
148
+ - `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.