@gjermundgaraba/effect-actions 0.3.0 → 0.4.1

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.
@@ -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 };
@@ -9,40 +9,31 @@ type ErasedHandler<R> = {
9
9
  }["handle"];
10
10
  /** An adapter's erased view of a record of handlers with request requirements `R`. */
11
11
  export type Handlers<R> = Readonly<Record<string, ErasedHandler<R>>>;
12
- /**
13
- * An adapter's erased view of the pre-handler hook. It sees the selected action
14
- * contract, so a group-wide policy reads `access` rather than the action name.
15
- */
16
- export type Before<R> = (action: Any) => Effect.Effect<void, ErasedValue, R>;
17
12
  /** Per-request requirements of the handler selected by an action name. */
18
13
  export type HandlerContext<H, Name extends keyof H> = H[Name] extends ((input: never) => Effect.Effect<infer _A, infer _E, infer R>) ? R : never;
19
14
  /** The union of per-request requirements declared by a handler record. */
20
15
  export type HandlersContext<H> = { readonly [K in keyof H]: HandlerContext<H, K>; }[keyof H];
21
16
  /**
22
- * An acquired handler record for a group, with the hook that runs before each of them.
17
+ * An acquired handler record for a group.
23
18
  *
24
19
  * The private field makes this class nominal: a structurally similar object,
25
20
  * including one made by spreading an implementation, is not an implementation.
26
21
  */
27
- export declare class Implementation<G extends Actions, H, EX, RX, RB> {
22
+ export declare class Implementation<G extends Actions, H, EX, RX> {
28
23
  #private;
29
24
  /** The group whose exact handler record was built. */
30
25
  readonly group: G;
31
26
  private constructor();
32
27
  /** Acquire the exact handler record in the adapter layer's scope. */
33
28
  get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
34
- /** Runs once per invocation, before the selected handler, on every surface. */
35
- get before(): Before<RB> | undefined;
36
- static make<G extends Actions, H, EX, RX, RB>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>, before: Before<RB> | undefined): Implementation<G, H, EX, RX, RB>;
29
+ static make<G extends Actions, H, EX, RX>(group: G, build: Effect.Effect<H, EX, RX | Scope.Scope>): Implementation<G, H, EX, RX>;
37
30
  }
38
31
  /** Any nominal implementation of `G`, with its record and channels erased. */
39
- export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any, any>;
40
- /** Pre-handler requirements of one implementation, or of a union of them. */
41
- export type BeforeContext<App> = App extends Implementation<any, any, any, any, infer RB> ? RB : never;
32
+ export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
42
33
  /** Per-request requirements of one implementation, or of a union of them. */
43
- export type RequestContext<App> = App extends Implementation<any, infer H, any, any, infer RB> ? HandlersContext<H> | RB : never;
34
+ export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
44
35
  /** Handler-acquisition failures of one implementation, or of a union of them. */
45
- export type BuildError<App> = App extends Implementation<any, any, infer EX, any, any> ? EX : never;
36
+ export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
46
37
  /** Handler-acquisition requirements of one implementation, or of a union of them. */
47
- export type BuildContext<App> = App extends Implementation<any, any, any, infer RX, any> ? RX : never;
38
+ export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
48
39
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  //#region src/internal/implementation.ts
3
3
  /**
4
- * An acquired handler record for a group, with the hook that runs before each of them.
4
+ * An acquired handler record for a group.
5
5
  *
6
6
  * The private field makes this class nominal: a structurally similar object,
7
7
  * including one made by spreading an implementation, is not an implementation.
@@ -9,28 +9,22 @@ import { Effect } from "effect";
9
9
  var Implementation = class Implementation {
10
10
  group;
11
11
  #build;
12
- #before;
13
- constructor(group, build, before) {
12
+ constructor(group, build) {
14
13
  this.group = group;
15
14
  this.#build = build;
16
- this.#before = before;
17
15
  }
18
16
  /** Acquire the exact handler record in the adapter layer's scope. */
19
17
  get build() {
20
18
  return this.#build;
21
19
  }
22
- /** Runs once per invocation, before the selected handler, on every surface. */
23
- get before() {
24
- return this.#before;
25
- }
26
- static make(group, build, before) {
27
- return new Implementation(group, build, before);
20
+ static make(group, build) {
21
+ return new Implementation(group, build);
28
22
  }
29
23
  };
30
24
  /**
31
25
  * Select and invoke a handler after an adapter has selected a concrete action.
32
- * The pre-handler hook runs first, inside the action's span, so every surface
33
- * applies it exactly once and its failures are declared errors of the 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.
34
28
  * `R` remains in the returned effect so transport layers cannot erase required
35
29
  * per-request services while assembling routes.
36
30
  */
@@ -38,8 +32,8 @@ const dispatch = (group, action, handlers, before) => {
38
32
  const handle = handlers[action.name];
39
33
  if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
40
34
  return (input) => {
41
- const run = () => before === void 0 ? handle(input) : Effect.flatMap(before(action), () => handle(input));
42
- return Effect.withSpan(Effect.suspend(run), `${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);
43
37
  };
44
38
  };
45
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, app.before)(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,20 +11,20 @@ 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
19
  /** What the action does to its resource. Authorization metadata, not an MCP hint. */
20
20
  type Access = "read" | "write";
21
21
 
22
- interface Options<Input, Output, Errors, Http, Mcp> {
22
+ interface Options<Input, Output, Errors, Acc, Http, Mcp> {
23
23
  readonly description: string;
24
24
  readonly input?: Input; // omit for an action without arguments
25
25
  readonly success: Output;
26
26
  readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
27
- readonly access?: Access; // defaults to "write"
27
+ readonly access: Acc; // required, no default
28
28
  readonly http?: Http; // false hides the action from HTTP routes and clients
29
29
  readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
30
30
  }
@@ -37,13 +37,13 @@ interface McpOptions {
37
37
  readonly openWorld?: boolean; // openWorldHint, default true
38
38
  }
39
39
 
40
- interface Action<Name, Input, Output, Errors, Http, Mcp> {
40
+ interface Action<Name, Input, Output, Errors, Acc, Http, Mcp> {
41
41
  readonly name: Name;
42
42
  readonly description: string;
43
43
  readonly input: Input;
44
44
  readonly success: Output;
45
45
  readonly errors: Errors;
46
- readonly access: Access; // resolved
46
+ readonly access: Acc; // the literal it was declared with
47
47
  readonly http: Http;
48
48
  readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
49
49
  }
@@ -68,7 +68,7 @@ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
68
68
  { httpApiStatus: 404 },
69
69
  ) {}
70
70
 
71
- // `access: "read"` states the fact once: it is what the group's `before` hook
71
+ // `access: "read"` states the fact once: it is what a surface's `before` hook
72
72
  // authorizes on, and it is where `mcp.readOnly` comes from.
73
73
  export const GetUser = Action.make("getUser", {
74
74
  description: "Look up a user in your tenant.",
@@ -79,8 +79,7 @@ export const GetUser = Action.make("getUser", {
79
79
  mcp: { name: "get_user" },
80
80
  });
81
81
 
82
- // A write. `access` defaults to "write", but say it: the default is a fallback,
83
- // not a statement, and an unclassified action is the one a reviewer must check.
82
+ // A write. Every action says which it is; there is no default to fall back on.
84
83
  export const RenameUser = Action.make("renameUser", {
85
84
  description: "Rename a user in your tenant.",
86
85
  input: Schema.Struct({ id: Schema.String, name: Schema.String }),
@@ -121,7 +120,7 @@ export const Double = Action.make("double", {
121
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: {} }`.
122
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)).
123
122
  - A handler may fail only with the declared errors. Anything else is a defect.
124
- - `access` is `"read"` or `"write"` and defaults to `"write"`, so an action nobody classified is treated as the dangerous one. It is authorization metadata for a group's `before` hook (see [ActionGroup.md](ActionGroup.md)); the library itself authorizes nothing and no adapter reads it.
123
+ - `access` is `"read"` or `"write"` and is required. `make` also checks it at runtime, so a caller the compiler never sees cannot define an action no rule classifies. 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.
125
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.
126
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.
127
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.
@@ -133,9 +132,9 @@ export const Double = Action.make("double", {
133
132
 
134
133
  ## Failure modes
135
134
 
136
- - Throws at `make`: invalid name, name `then`, `mcp.name` longer than 128 characters or not matching the pattern.
135
+ - Throws at `make`: invalid name, name `then`, an `access` that is neither `"read"` nor `"write"`, `mcp.name` longer than 128 characters or not matching the pattern.
137
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.
138
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`.
139
138
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
140
- - A read action is refused by an authorization hook that reads `access`: `access` was omitted, so it defaulted to `"write"`. Declare it.
139
+ - `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
141
140
  - A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
@@ -20,7 +20,6 @@ interface Entry {
20
20
  readonly group: string;
21
21
  readonly name: string;
22
22
  readonly description: string;
23
- readonly access: "read" | "write"; // resolved; "write" unless the contract says otherwise
24
23
  readonly http: boolean;
25
24
  readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
26
25
  readonly input: JsonSchema.JsonSchema;
@@ -47,8 +46,8 @@ process.stdout.write(JSON.stringify(catalog, null, 2));
47
46
  - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
48
47
  - Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
49
48
  - Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
50
- - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`.
51
- - `access` is the action's resolved authorization metadata, not a permission. A reader may use it to tell reads from writes; it grants nothing and names no scope.
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.
52
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.
53
52
  - The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
54
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, RB, Parameters = never>(
13
- app: Implementation<G, H, EX, RX, RB>,
12
+ const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, EB, RB, Parameters = never>(
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 | RB | 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, RB>(
20
- app: Implementation<G, H, EX, RX, RB>,
21
- options?: GroupOptions,
22
- ) => Command.Command<string, {}, {}, EX | SchemaError | DeclaredErrors, ...>;
19
+ const group: <G, H, EX, RX, EB, RB>(
20
+ app: Implementation<G, H, EX, RX>,
21
+ options?: GroupOptions<EB, RB>,
22
+ ) => Command.Command<string, {}, {}, EX | EB | SchemaError | DeclaredErrors, ...>;
23
+
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
+ }
23
28
 
24
- interface GroupOptions {
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,24 +54,26 @@ 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";
52
- import { actors, CurrentActor } from "./auth.js";
57
+ import { actors, authorize, CurrentActor } from "./auth.js";
53
58
  import { UserApp } from "./handlers.js";
54
59
  import { Users } from "./users.js";
55
60
 
56
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.
57
63
  const command = ActionCli.command(UserApp, "double", {
58
64
  parameters: { value: Flag.String("value") },
59
65
  input: ({ value }) => ({ value }),
66
+ before: authorize,
60
67
  });
61
68
 
62
69
  // Default syntax for a whole group: `users double --input '{"value":"21"}'`.
63
- const all = ActionCli.group(UserApp);
70
+ const all = ActionCli.group(UserApp, { before: authorize });
64
71
 
65
72
  Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
66
73
  Effect.tapCause((cause) => Console.error(cause)),
67
74
  Effect.provideService(Logger.LogToStderr, true),
68
- // The group's `before` hook runs here too, so a local caller supplies the
69
- // identity it reads exactly as HTTP middleware does for a request.
75
+ // The hook runs here too, so a local caller supplies the identity it reads
76
+ // exactly as HTTP middleware does for a request.
70
77
  Effect.provideService(CurrentActor, actors.alice),
71
78
  Effect.provide(Users.layerMemory),
72
79
  Effect.provide(NodeServices.layer),
@@ -84,7 +91,8 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
84
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.
85
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.
86
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.
87
- - The group's pre-handler hook runs here too, before every command's handler. 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.
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.
88
96
  - Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
89
97
 
90
98
  ## Failure modes
@@ -93,5 +101,5 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
93
101
  - Command rejects `--input`: the command was built with `parameters`. Use its flags.
94
102
  - Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
95
103
  - Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
96
- - A command requires a request-identity tag no handler yields: the group's `before` hook yields it. Provide it, or bind a separate implementation without the hook for local use.
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.
97
105
  - `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
@@ -1,8 +1,8 @@
1
1
  # ActionGroup
2
2
 
3
3
  A named set of actions. The group is what adapters serve, what clients are grouped by, and
4
- what `implement` binds handlers to. Shared errors, the pre-handler hook, and the HTTP
5
- schema-error policy live here.
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)).
6
6
 
7
7
  ## API
8
8
 
@@ -12,7 +12,7 @@ import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
12
12
  function make<Name, Actions, Errors = [], PolicyErrors = []>(
13
13
  options: Options<Name, Errors, PolicyErrors>,
14
14
  ...actions: Actions
15
- ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors, Errors>;
15
+ ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
16
16
 
17
17
  interface Options<Name, Errors, PolicyErrors> {
18
18
  readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
@@ -25,34 +25,28 @@ interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
25
25
  readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
26
26
  }
27
27
 
28
- interface ImplementOptions<Errors, RB> {
29
- /** Runs before every handler, on every surface. May fail only with the group's `errors`. */
30
- readonly before: (action: Action.Any) => Effect.Effect<void, Errors[number]["Type"], RB>;
31
- }
32
-
33
- interface Group<Name, Actions, PolicyErrors, Errors> {
28
+ interface Group<Name, Actions, PolicyErrors> {
34
29
  readonly name: Name;
35
30
  readonly actions: Actions;
36
31
  readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
37
32
  /** Bind every handler at once; build-time services resolve once per adapter layer. */
38
- readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never, RB = never>(
33
+ readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
39
34
  build: H | Effect.Effect<H, EX, RX>,
40
- options?: ImplementOptions<Errors, RB>,
41
- ) => Implementation<
42
- Group<Name, Actions, PolicyErrors, Errors>,
43
- H,
44
- EX,
45
- Exclude<RX, Scope.Scope>,
46
- RB
47
- >;
35
+ ) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
48
36
  }
49
37
 
50
38
  /** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
51
- class Implementation<G, H, EX, RX, RB> {
39
+ class Implementation<G, H, EX, RX> {
52
40
  readonly group: G;
53
41
  get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
54
- get before(): ((action: Action.Any) => Effect.Effect<void, unknown, RB>) | undefined;
55
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": ... }
56
50
  ```
57
51
 
58
52
  `HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
@@ -64,8 +58,7 @@ all required.
64
58
  import { Effect, Schema } from "effect";
65
59
  import type { HttpApiError } from "effect/unstable/httpapi";
66
60
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
67
- import type * as Action from "@gjermundgaraba/effect-actions/Action";
68
- import { Forbidden, CurrentActor } from "./auth.js";
61
+ import { CurrentActor } from "./auth.js";
69
62
  import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
70
63
  import { Users } from "./users.js";
71
64
 
@@ -89,25 +82,16 @@ const schemaError = {
89
82
  : new InvalidRequest({ message: "The request does not match the action's input." }),
90
83
  };
91
84
 
92
- // `Forbidden` is declared once and joins every action's errors.
93
85
  export const UserActions = ActionGroup.make(
94
- { name: "users", errors: [Forbidden], schemaError },
86
+ { name: "users", schemaError },
95
87
  GetUser,
96
88
  RenameUser,
97
89
  WhoAmI,
98
90
  );
99
91
 
100
- // One rule for the whole group, read from each contract's own `access`. It runs
101
- // on HTTP, MCP, Toolkit and CLI alike, so no handler contains authorization code.
102
- const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
103
- const permission = action.access === "read" ? "users:read" : "users:write";
104
- const actor = yield* CurrentActor;
105
-
106
- if (!actor.permissions.includes(permission)) return yield* new Forbidden({ permission });
107
- });
108
-
109
92
  // Build-time services (Users) are yielded in the builder, once per adapter layer.
110
- // Request-time services (CurrentActor) are yielded inside handlers and in `before`.
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.
111
95
  export const UserApp = UserActions.implement(
112
96
  Effect.gen(function* () {
113
97
  const users = yield* Users;
@@ -119,31 +103,26 @@ export const UserApp = UserActions.implement(
119
103
  whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
120
104
  };
121
105
  }),
122
- { before: authorize },
123
106
  );
124
107
 
125
108
  // No build-time services: pass the record directly.
126
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;
127
114
  ```
128
115
 
129
116
  ## Rules
130
117
 
131
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`.
132
- - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them.
133
- - `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.
134
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.
135
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)).
136
123
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
137
- - `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.
138
-
139
- ### Pre-handler hook
140
-
141
- - `implement(handlers, { before })` binds one hook to the whole implementation. It runs once per invocation, immediately before the selected handler, on **every** surface: `ActionHttp`, `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, `ActionToolkit`, and `ActionCli`. There is no surface that skips it and no per-action opt-out.
142
- - It receives the selected action contract (`Action.Any`), so a policy reads `action.access`, `action.name` or `action.mcp` instead of a hand-maintained list. Write authorization against `access`.
143
- - It may fail only with the group's own `errors`, because those are the failures every action of the group declares. A hook that fails with anything else is a compile error; a group with no `errors` has no failure a hook could use. Its failure is encoded exactly like a handler's: the error's `httpApiStatus` on HTTP, an `isError` tool result on MCP.
144
- - Services it yields are request-time requirements, like a handler's, and they join the adapter's request context. A local caller (`ActionCli`, `ActionToolkit`, stdio) supplies them itself.
145
- - It runs inside the `<group>.<action>` span, before the handler. When it fails, the handler never runs.
146
- - It is not authentication. Establish identity in middleware, then let the hook authorize what that identity may do.
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.
147
126
 
148
127
  ### Schema-error policy
149
128
 
@@ -155,6 +134,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
155
134
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
156
135
  - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
157
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.
158
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.
159
139
 
160
140
  ## Failure modes
@@ -162,8 +142,7 @@ export const MathApp = MathActions.implement({ double: ({ value }) => Effect.suc
162
142
  - Throws at `make`: duplicate action name, duplicate MCP tool name, invalid group name.
163
143
  - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
164
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.
165
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`).
166
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.
167
- - Type error on `before`'s error channel: the hook fails with an error the group does not declare. Add it to the group's `errors` so every action declares it.
168
- - `Type 'X' is not assignable to type 'never'` on `before` in a group without `errors`: the group declares no shared failure. Declare one.
169
- - The hook does not run for an action: it cannot happen through an adapter. A handler invoked directly through `build` is not dispatched and is not hooked; use an adapter, or `ActionCli`, in tests that must exercise the policy.
148
+ - `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.