@gjermundgaraba/effect-actions 0.10.0 → 0.12.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.
Files changed (214) hide show
  1. package/README.md +103 -43
  2. package/dist/Action.d.mts +31 -118
  3. package/dist/Action.mjs +15 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +25 -31
  9. package/dist/ActionMcp.mjs +140 -74
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +32 -25
  17. package/dist/Testing.mjs +45 -30
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +17 -0
  38. package/dist/mcp/protocol.mjs +149 -0
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +206 -424
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -292
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -281
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +229 -193
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -117
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -23
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -95
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +88 -87
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +317 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +75 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +30 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +108 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +89 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +134 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +32 -14
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -11
  211. package/dist/internal/mcp.mjs +0 -52
  212. package/dist/internal/refusal.mjs +0 -67
  213. package/dist/internal/schema-errors.mjs +0 -20
  214. package/dist/internal/tools.mjs +0 -44
package/docs/Action.md CHANGED
@@ -1,449 +1,231 @@
1
1
  # Action
2
2
 
3
- One contract: a name, schemas for input, success and declared errors, whether it is read-only
4
- (`readOnly`), who may call it (`caller`), and how its tool presents itself over MCP (`mcp`). A
5
- contract holds no behavior;
6
- `implement` binds handlers to contracts, with the authorization every surface runs before a
7
- protected action's handler: whether this caller may call it. Every surface that runs handlers
8
- takes an implementation or a list of them, and so does `client`, which calls them in process
9
- with the methods `ActionHttp.client` has. The module also exports the built-in errors every
10
- surface declares and any handler may fail with.
3
+ `Action.make` defines an action, and `Action.implement` binds handlers and an authorizer to
4
+ contracts: every surface takes the implementation it returns, or a list of them.
5
+
6
+ Read this page alone, in one tool call: pages read together are cut. When something fails,
7
+ search [Action-failures.md](Action-failures.md). Reading order: [README.md](README.md).
11
8
 
12
9
  ## API
13
10
 
14
11
  Import `@gjermundgaraba/effect-actions/Action`.
15
12
 
16
- | Export | Purpose |
17
- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
18
- | `make(name, options)` | Define a pure contract; literal names, `readOnly` and `caller` stay typed. |
19
- | `byName(actions)` | A list of actions keyed by name, each its exact contract: `contracts.getUser`. |
20
- | `implement(action, handler, { authorize })` | Bind one handler, and a protected action's authorizer; returns one `Implementation`. |
21
- | `implement([actions], handlers, { authorize })` | Bind a record of handlers keyed by action name; returns one `Implementation` of all. |
22
- | `implement(actions, builder, options?)` | Either form, with an Effect that builds the handler or record once per layer graph. |
23
- | `allowAll` | The authorizer without an action-level rule: every authenticated caller may call. |
24
- | `Anyone` | The caller of a public action, `caller: Action.Anyone`: anyone, signed in or not. |
25
- | `layer(implementations)` | Their builders as one layer: provided above every surface, each runs once for all. |
26
- | `client(implementations, { actions? })` | An Effect of a caller running them in process: `client.<action>(input)`. |
27
- | `InvalidInput`, `Unauthenticated`, `Forbidden` | Built-in errors: 400, 401, 403, body `{ _tag, message }`, `InvalidInput`'s with `issues`; `message` defaults. |
28
- | `InvalidInput.fromSchemaError(error)` | The `InvalidInput` every surface answers a `Schema.SchemaError` with, `issues` given. |
29
- | `Issue` | One of `InvalidInput`'s `issues`: a `path` into the input and a `message`. |
30
- | `Refusal` | `Unauthenticated \| Forbidden`, schema and type: what authentication or an authorizer refuses with. |
31
- | `BuiltIn` | `InvalidInput \| Refusal`, schema and type: what any handler may fail with. |
32
- | `Errors` | A list of error schemas, as `error` holds it: `<E extends Action.Errors>`. |
33
- | `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
34
- | `Client<Apps>` | What `client` gives for the implementations `Apps`, one or a list. |
35
- | `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased: a generic helper's constraint. |
36
- | `BuildServices`, `BuildError` | What their builders read and fail with at startup, which a surface serving them owes. |
37
- | `implementation.actions` | Its exact contract values, as `Testing.mcpClient(users.actions)` takes them. |
38
- | `Handler`, `Authorize` | Typed handlers, and authorizers `(action) => Effect<void, Refusal, R>`. |
39
- | `Handlers` | A list's handlers keyed by name, for a builder written apart from `implement`. |
40
- | `Options`, `ClientOptions`, `Mcp` | What `make` and `client` take, and `make`'s `mcp` options. |
41
-
42
- | Option | Meaning |
43
- | ------------- | ---------------------------------------------------------------------------------------------- |
44
- | `description` | Required description. |
45
- | `readOnly` | Required: whether the action leaves its resource unchanged. |
46
- | `caller` | Required: `Action.Anyone`, or the identity service a caller must have, such as `CurrentActor`. |
47
- | `input` | Optional schema or fields; omitted or `{}`, an empty object. |
48
- | `success` | Optional schema or fields; omission means `Schema.Void`. |
49
- | `error` | Declared error codecs: one schema or a list; defaults to none. |
50
- | `mcp` | The tool's MCP hints, in MCP's names, its `title` and `_meta`. |
51
-
52
- `authorize` is required where the target holds a protected action, and refused where it holds
53
- none. It receives the selected action and fails only with a `Refusal`; every surface runs it
54
- for protected actions, after authentication and input decoding
55
- ([guarantees.md](guarantees.md#authorization)). It may instead be an Effect that builds it, as a
56
- builder builds handlers. Authentication is the surface's: a remote one verifies the identity a
57
- protected contract declares through the descriptor its binding or endpoint names
58
- ([Authentication.md](Authentication.md)); on a local one the host provides it.
59
-
60
- `input` and `success` take a schema or plain fields: `{ id: Schema.String }` is
61
- `Schema.Struct({ id: Schema.String })`. A tool is read-only exactly when its action is
62
- (`readOnlyHint`). `mcp` takes MCP's own hints, `destructiveHint`, `idempotentHint` and
63
- `openWorldHint`, each MCP's default when left out, but `destructiveHint`, which is `false` for a
64
- read-only action. The tool is named after the action. Handlers receive decoded input and return
65
- decoded success, failing only with declared errors and the built-in ones.
66
-
67
- ## Canonical
68
-
69
- Contracts are pure values, safe to import in a browser. A protected contract names its identity,
70
- a `Context.Service` declared without a verifier.
71
-
72
- ```ts example=contracts.ts
73
- import { Schema } from "effect";
74
- import * as Action from "@gjermundgaraba/effect-actions/Action";
75
- import { CurrentActor } from "./authorization.js";
76
-
77
- export const User = Schema.Struct({
78
- id: Schema.String,
79
- name: Schema.String,
80
- });
81
-
82
- export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
83
- "UserNotFound",
84
- { id: Schema.String },
85
- { httpApiStatus: 404 },
86
- ) {}
87
-
88
- // Reachable without credentials: it must work before anyone has signed in.
89
- export const Status = Action.make("status", {
90
- description: "Report whether the service is up.",
91
- success: { service: Schema.String, users: Schema.Finite },
92
- readOnly: true,
93
- caller: Action.Anyone,
94
- });
95
-
96
- export const GetUser = Action.make("getUser", {
97
- description: "Look up a user in your tenant.",
13
+ | Export | Purpose |
14
+ | ---------------------------------------------- | ------------------------------------------------------------ |
15
+ | `make(name, options)` | Define a contract. |
16
+ | `byName(actions)` | A list of actions keyed by name, each its exact contract. |
17
+ | `implement(actions, handlers, { authorize })` | Bind a handler, a record of them, or a builder of either. |
18
+ | `allowAll` | The authorizer without a rule: any authenticated caller. |
19
+ | `Anyone` | The `caller` of a public action: anyone, signed in or not. |
20
+ | `client`, `layer`, `Client`, `ClientOptions` | In [Action-in-process.md](Action-in-process.md#api). |
21
+ | `Image` | A media field's schema: `data` bytes and a `mimeType`. |
22
+ | `InvalidInput`, `Unauthenticated`, `Forbidden` | Built-in errors: 400 with `issues`, 401, 403 with `scopes`. |
23
+ | `InvalidInput.fromSchemaError(error)` | What every surface answers a `Schema.SchemaError` with. |
24
+ | `Issue` | One of `InvalidInput`'s `issues`: a `path` and a `message`. |
25
+ | `Refusal` | `Unauthenticated \| Forbidden`: an authorizer's failures. |
26
+ | `BuiltIn` | `InvalidInput \| Refusal`: what any handler may fail with. |
27
+ | `Errors` | A list of error schemas, as `error` holds it. |
28
+ | `Action`, `Any`, `Implementation` | Concrete and erased contracts, and bound implementations. |
29
+ | `AnyImplementation`, `AnyImplementation<A>` | Any implementation, or any of actions `A`, erased. |
30
+ | `BuildServices`, `BuildError` | What builders read and fail with at startup. |
31
+ | `implementation.actions` | Its exact contracts, as `Testing.mcpClient` takes them. |
32
+ | `Handler`, `Handlers`, `Authorize` | A typed handler, a list's record of them, and an authorizer. |
33
+ | `Options`, `Mcp` | What `make` takes, and its `mcp` options. |
34
+
35
+ | Option | Meaning |
36
+ | ------------- | -------------------------------------------------------------- |
37
+ | `description` | Required. A model reads it beside the tool; a CLI prints it. |
38
+ | `readOnly` | Required: whether the action leaves its resource unchanged. |
39
+ | `caller` | Required: `Action.Anyone`, or the caller's identity service. |
40
+ | `input` | A schema or struct fields; omitted, no arguments. |
41
+ | `success` | A schema or struct fields; omitted, nothing is returned. |
42
+ | `error` | The declared errors: one schema or a list; defaults to none. |
43
+ | `mcp` | The tool's MCP hints, in MCP's names, its `title` and `_meta`. |
44
+
45
+ ## Define a contract
46
+
47
+ Write one `Action.make` per action in the domain's contract file, and export the domain's
48
+ actions as one `as const` list: `implement` and every surface take lists.
49
+
50
+ ```ts
51
+ export const GetNote = Action.make("getNote", {
52
+ description: "Read one of your notes.",
98
53
  input: { id: Schema.String },
99
- success: User,
100
- error: UserNotFound,
101
- readOnly: true,
102
- caller: CurrentActor,
103
- });
104
-
105
- export const RenameUser = Action.make("renameUser", {
106
- description: "Rename a user in your tenant.",
107
- input: {
108
- id: Schema.String,
109
- name: Schema.String.check(Schema.isMinLength(1)),
110
- },
111
- success: User,
112
- error: UserNotFound,
113
- readOnly: false,
114
- caller: CurrentActor,
115
- mcp: { destructiveHint: false },
116
- });
117
-
118
- // On either transport, input is { value: "21" }. The handler receives numeric 21.
119
- export const Double = Action.make("double", {
120
- description: "Double a finite number supplied as a string.",
121
- input: { value: Schema.FiniteFromString },
122
- success: Schema.Finite,
123
- readOnly: true,
124
- caller: CurrentActor,
125
- });
126
-
127
- // Identity comes from the host's authenticated request context, not action input.
128
- export const WhoAmI = Action.make("whoAmI", {
129
- description: "Inspect the authenticated actor.",
130
- success: { id: Schema.String, tenantId: Schema.String },
131
- readOnly: true,
132
- caller: CurrentActor,
133
- });
134
-
135
- export const Change = Schema.Struct({
136
- actorId: Schema.String,
137
- userId: Schema.String,
138
- name: Schema.String,
139
- });
140
-
141
- export const ListChanges = Action.make("listChanges", {
142
- description: "List the renames made in your tenant, oldest first.",
143
- success: { changes: Schema.Array(Change) },
54
+ success: Note,
55
+ error: NoteNotFound,
144
56
  readOnly: true,
145
- caller: CurrentActor,
146
- });
147
- ```
148
-
149
- ### Identity and authorization
150
-
151
- The module the contracts, the implementations, the authentication and every local caller import:
152
- the identity, `CurrentActor`, which the protected contracts declare and authentication provides
153
- per request, and `authorize`, which reads each action's `readOnly`. Its demo actors stand in for
154
- the identities a token verifier returns.
155
-
156
- ```ts example=authorization.ts
157
- import { Context, Effect } from "effect";
158
- import * as Action from "@gjermundgaraba/effect-actions/Action";
159
-
160
- export type Permission = "users:read" | "users:write";
161
-
162
- export interface Actor {
163
- readonly id: string;
164
- readonly tenantId: string;
165
- readonly permissions: ReadonlyArray<Permission>;
166
- }
167
-
168
- /** DEMO ONLY: fixed credentials, not OAuth or a production token verifier. */
169
- export const actors = {
170
- alice: { id: "alice", tenantId: "acme", permissions: ["users:read", "users:write"] },
171
- reader: { id: "reader", tenantId: "acme", permissions: ["users:read"] },
172
- bob: { id: "bob", tenantId: "other", permissions: ["users:read", "users:write"] },
173
- } as const satisfies Readonly<Record<string, Actor>>;
174
-
175
- /**
176
- * The identity a protected contract declares, `caller: CurrentActor`: provided per request by
177
- * authentication, and by the host on a local surface.
178
- */
179
- export class CurrentActor extends Context.Service<CurrentActor, Actor>()("example/CurrentActor") {}
180
-
181
- /**
182
- * One authorization rule for every surface, derived from each contract's own `readOnly`. An
183
- * implementation of protected actions states it, `{ authorize }`, so every surface serving
184
- * them runs it before each handler, once the caller is authenticated, and no handler contains
185
- * authorization code. Its `Forbidden` is built in: every endpoint and tool declares it, and
186
- * every client decodes it. Naming the missing scope makes it the `insufficient_scope`
187
- * challenge an OAuth client steps up on.
188
- */
189
- export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
190
- const permission: Permission = action.readOnly ? "users:read" : "users:write";
191
- const actor = yield* CurrentActor;
192
-
193
- if (!actor.permissions.includes(permission)) {
194
- return yield* new Action.Forbidden({
195
- message: `Requires ${permission}.`,
196
- scopes: [permission],
197
- });
198
- }
57
+ caller: CurrentUser,
199
58
  });
200
59
  ```
201
60
 
202
- ### Implementations
203
-
204
- Build-time services (`Users`) are yielded in a builder; request-time services
205
- (`CurrentActor`) inside handlers. The implementations of protected actions state `authorize`,
206
- which every surface runs before each of their handlers; the public `status` states none.
207
- Every surface takes one implementation or a list: `double`, `userActions`,
208
- `[status, userActions, double]`. Implementations group actions by domain, not by surface or by
209
- who may call them: `userActions` holds `listChanges`, which HTTP does not serve because its
210
- binding leaves it out, and one implementation may hold public and protected actions alike.
211
-
212
- ```ts example=handlers.ts
213
- import { Effect } from "effect";
214
- import * as Action from "@gjermundgaraba/effect-actions/Action";
215
- import { authorize, CurrentActor } from "./authorization.js";
216
- import { Double, GetUser, ListChanges, RenameUser, Status, WhoAmI } from "./contracts.js";
217
- import { Users } from "./users.js";
218
-
219
- // A public contract: no authorization runs, and it owes nothing per request, on every
220
- // surface.
221
- export const status = Action.implement(
222
- Status,
223
- Effect.gen(function* () {
224
- const users = yield* Users;
225
-
226
- return () => Effect.map(users.count, (count) => ({ service: "effect-actions", users: count }));
227
- }),
228
- );
229
-
230
- // Capture Users at startup; resolve CurrentActor per request. Every surface authenticates
231
- // the caller, then runs `authorize` before each handler, so it has already refused an actor
232
- // without the permission the action needs. HTTP serves only the actions its binding holds:
233
- // `listChanges`, which it leaves out, is a tool and a command, never a route.
234
- export const userActions = Action.implement(
235
- [GetUser, RenameUser, WhoAmI, ListChanges],
61
+ Start from [quickstart/src/greeting/Greeting.ts](../examples/quickstart/src/greeting/Greeting.ts).
62
+ [notes-service/src/notes/Notes.ts](../examples/notes-service/src/notes/Notes.ts) adds declared
63
+ errors and the list, and [kitchen-sink/src/issues/Issues.ts](../examples/kitchen-sink/src/issues/Issues.ts)
64
+ uses every option, sharing some by spreading a constant (`IssueWriteErrors`, `change`).
65
+
66
+ - **The contract file imports no server code and no transport**, so a browser or a CLI can
67
+ import it ([setup.md](setup.md#bundle-for-a-browser)).
68
+ - **The name** matches `[A-Za-z0-9_-]{1,128}`, is not `then`, and is unique within each binding,
69
+ MCP endpoint, Toolkit, CLI and client holding it.
70
+ - **For no arguments, omit `input`.** A `Schema.Struct({})` accepts any value but `null`, and MCP
71
+ refuses it as a tool's input ([ActionMcp.md](ActionMcp.md#describe-tools-to-a-model)).
72
+ - **A field callers may omit is `Schema.optional`.** On the wire it also takes `null`, read as
73
+ left out, as a tool's schema lists; a typed client refuses `null`. `Schema.optionalKey` takes
74
+ omission alone, and `Schema.NullOr` keeps `null` as a value. One with a decoding default (`limit` in
75
+ `Issues.ts`) is still stated by a typed client given an argument
76
+ ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
77
+ - **An action's `name`, `input`, `success`, `error` and `readOnly` are public**: reuse them, as
78
+ in `Schema.Array(SaveLink.input)`.
79
+ - **Annotate each declared error with `httpApiStatus`**, or HTTP answers it as a 422.
80
+ - **Give each declared error its own `_tag`**: a client tells errors sharing one, or having
81
+ none, apart by their other fields alone, the first that decodes winning.
82
+ - **A contract does not say where it is served**: each surface takes a list of actions. Keep
83
+ those lists in one file beside the contracts
84
+ ([kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts)): an action added
85
+ later is no tool until listed there.
86
+ - **List an action as a tool only where a model may hold what it takes and returns.** A tool
87
+ call passes both through the model's context and records its arguments on the current span,
88
+ a `Schema.Redacted` value included.
89
+ - **Surfaces match an implementation to a contract by object identity, never by name**:
90
+ implement, bind and list the one exported value. A list narrowed at run time, by a filter or a
91
+ conditional element, is still typed as holding every member.
92
+
93
+ ## Say who may call an action
94
+
95
+ `caller: Action.Anyone` makes a public action. An identity service, `caller: CurrentUser`, makes
96
+ a protected one: every surface requires that identity, whether or not the handler reads it. A
97
+ remote surface gets it by authenticating the request
98
+ ([Authentication.md](Authentication.md#declare-how-callers-sign-in)); on a local surface the
99
+ host provides it ([ActionToolkit.md](ActionToolkit.md#name-the-caller),
100
+ [ActionCli.md](ActionCli.md#name-the-caller), [ActionMcp.md](ActionMcp.md#serve-tools-over-stdio)).
101
+
102
+ `CurrentUser` is in [notes-service/src/identity/Identity.ts](../examples/notes-service/src/identity/Identity.ts),
103
+ and a public action in [notes-service/src/status/Status.ts](../examples/notes-service/src/status/Status.ts).
104
+
105
+ - **Declare the identity as a `Context.Service`**, in a contract file, apart from its verifier.
106
+ - **Never provide an identity, or a tenant, at startup**: not to a server's layer or root
107
+ context, not to `Action.layer`, not around the acquisition of a client. A public handler or a
108
+ route of your own reading the tag would serve every caller as that identity, and the types do
109
+ not stop it. A test sends each caller's credential instead
110
+ ([Testing.md](Testing.md#test-as-a-signed-in-caller)).
111
+ - **One identity per action**, and every protected action one remote surface serves declares
112
+ the same one. A tenant is a request-time requirement of the handler, not a second identity.
113
+ A second audience takes its own identity, descriptor and binding (`Operator` in
114
+ [kitchen-sink/src/admin/Admin.ts](../examples/kitchen-sink/src/admin/Admin.ts)).
115
+ - **No surface provides a public action an identity.** An action whose answer depends on the
116
+ caller is protected.
117
+ - **One protected contract serves callers with a token over HTTP and a local host without
118
+ one**, never a public copy of it: the host provides the caller around `runStdio`
119
+ ([notes-service/src/stdio.ts](../examples/notes-service/src/stdio.ts)).
120
+
121
+ ## Implement actions
122
+
123
+ `Action.implement` binds a handler to one action, or a record of handlers keyed by name to a
124
+ list. Either may instead be a builder: an Effect that returns the handler or the record, which
125
+ is how a handler gets a service.
126
+
127
+ ```ts
128
+ export const notes = Action.implement(
129
+ Notes,
236
130
  Effect.gen(function* () {
237
- const users = yield* Users;
131
+ const store = yield* NoteStore; // a startup service: yielded once
238
132
 
239
133
  return {
240
- getUser: ({ id }) => Effect.flatMap(CurrentActor, (actor) => users.get(actor.tenantId, id)),
241
- renameUser: ({ id, name }) =>
242
- Effect.flatMap(CurrentActor, (actor) => users.rename(actor, id, name)),
243
- whoAmI: () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
244
- listChanges: () =>
245
- Effect.gen(function* () {
246
- const actor = yield* CurrentActor;
247
-
248
- return { changes: yield* users.changes(actor.tenantId) };
249
- }),
134
+ // CurrentUser is a request-time requirement: yielded per call
135
+ getNote: ({ id }) => Effect.flatMap(CurrentUser, (user) => store.get(user.id, id)),
136
+ // ...one handler per action of the list
250
137
  };
251
138
  }),
252
139
  { authorize },
253
140
  );
254
-
255
- // Pure: no builder and no services, only the authorization.
256
- export const double = Action.implement(Double, ({ value }) => Effect.succeed(value * 2), {
257
- authorize,
258
- });
259
- ```
260
-
261
- ### Built authorization
262
-
263
- An authorizer that reads a startup service, such as a permission store, is built as handlers
264
- are: an Effect yields the store once and returns the authorizer, which yields the caller on every
265
- call. The host provides `Permissions.layerMemory` at startup, as it provides `Users`;
266
- authentication still provides `CurrentActor` per request. Defined apart from `implement`, its
267
- `action` is annotated; written in `implement`'s options, it would be inferred.
268
-
269
- ```ts example=authorization-built.ts
270
- import { Context, Effect, Layer } from "effect";
271
- import * as Action from "@gjermundgaraba/effect-actions/Action";
272
- import { actors, CurrentActor, type Permission } from "./authorization.js";
273
- import { WhoAmI } from "./contracts.js";
274
-
275
- /** DEMO ONLY: each actor's permissions, kept in a store rather than in the identity. */
276
- export class Permissions extends Context.Service<
277
- Permissions,
278
- { readonly of: (actorId: string) => Effect.Effect<ReadonlyArray<Permission>> }
279
- >()("example/Permissions") {
280
- static readonly layerMemory = Layer.succeed(Permissions, {
281
- of: (actorId) =>
282
- Effect.succeed(Object.values(actors).find(({ id }) => id === actorId)?.permissions ?? []),
283
- });
284
- }
285
-
286
- // The `authorize` rule, built as handlers are: the store is yielded once, a startup service
287
- // provided next to `Users`, and the function it returns yields the actor on every call.
288
- export const authorizeStored = Effect.gen(function* () {
289
- const permissions = yield* Permissions;
290
-
291
- return (action: Action.Any) =>
292
- Effect.gen(function* () {
293
- const permission: Permission = action.readOnly ? "users:read" : "users:write";
294
- const actor = yield* CurrentActor;
295
-
296
- if (!(yield* permissions.of(actor.id)).includes(permission)) {
297
- return yield* new Action.Forbidden({
298
- message: `Requires ${permission}.`,
299
- scopes: [permission],
300
- });
301
- }
302
- });
303
- });
304
-
305
- // Built once per layer graph for this implementation, and per invocation of a local command.
306
- export const whoAmI = Action.implement(
307
- WhoAmI,
308
- () => Effect.map(CurrentActor, ({ id, tenantId }) => ({ id, tenantId })),
309
- { authorize: authorizeStored },
310
- );
311
141
  ```
312
142
 
313
- ### Client
314
-
315
- Implementations called in process, by code: a test, a job, a command of your own. The client is
316
- acquired once, where builders live, and each call is given its caller, as authentication gives
317
- one per request, so one client serves several callers. Every implemented action has a method,
318
- `listChanges` included, which no binding holds; `actions` lists fewer.
319
-
320
- ```ts example=in-process.ts
321
- import { Effect } from "effect";
322
- import * as Action from "@gjermundgaraba/effect-actions/Action";
323
- import { actors, CurrentActor } from "./authorization.js";
324
- import { userActions } from "./handlers.js";
325
- import { Users } from "./users.js";
326
-
327
- // Each call names its caller, as authentication names one per request.
328
- const asAlice = Effect.provideService(CurrentActor, actors.alice);
329
-
330
- const asReader = Effect.provideService(CurrentActor, actors.reader);
331
-
332
- const program = Effect.gen(function* () {
333
- // Acquired once, as a layer is built: the builders run here, not per call.
334
- const users = yield* Action.client(userActions);
335
-
336
- // The methods of `ActionHttp.client(Http)`, with no transport between: each call decodes
337
- // its input, runs `authorize`, then the handler, and checks the success or the failure.
338
- const renamed = yield* users.renameUser({ id: "1", name: "Bea" }).pipe(asAlice);
339
- const refused = yield* Effect.flip(users.renameUser({ id: "1", name: "Cy" }).pipe(asReader));
340
- const { changes } = yield* users.listChanges().pipe(asReader); // not an HTTP route
143
+ A plain handler is [quickstart/src/greeting/handlers.ts](../examples/quickstart/src/greeting/handlers.ts),
144
+ and the whole builder above [notes-service/src/notes/handlers.ts](../examples/notes-service/src/notes/handlers.ts).
145
+
146
+ - **One implementation may hold public and protected actions**, and actions only some
147
+ surfaces serve: group by domain.
148
+ - **What the builder yields is shared by every call; what a handler yields is resolved per
149
+ call**, as the caller must be.
150
+ - **A builder runs once per layer graph**, however many surfaces serve its implementation. Two
151
+ servers are two graphs
152
+ ([Share one build across servers](Action-in-process.md#share-one-build-across-servers)), and
153
+ two `implement` calls are two builders, even of one builder Effect.
154
+ - **Each call has a scope of its own**, so a resource that must outlive a call is the
155
+ builder's.
156
+ - **Pass the builder to `implement` itself.** Through `.pipe(...)` or a wrapper such as
157
+ `Effect.withSpan(builder, name)`, an `Effect.fn` handler it returns takes an `any` input. A
158
+ builder written apart returns its record `satisfies Action.Handlers<typeof actions>`.
159
+ - **Leave a handler's `Effect.fn` unnamed**: each handler already runs in a span named after its
160
+ action, and its log lines carry `action.name` and `action.read_only`.
161
+ - **Calling the function you passed to `implement` skips authorization.** Call the
162
+ implementation through [`Action.client`](Action-in-process.md#call-actions-in-process).
163
+
164
+ ## Authorize calls
165
+
166
+ An implementation holding a protected action states `{ authorize }`: a function of the called
167
+ action that succeeds, or fails with a refusal. Every surface runs it before the handler, and
168
+ none has a policy of its own. An implementation of public actions alone takes none
169
+ ([notes-service/src/status/handlers.ts](../examples/notes-service/src/status/handlers.ts)), and
170
+ the authorizer never runs for a public action.
171
+
172
+ ```ts
173
+ export const authorize = Effect.fn("authorize")(function* (action: Action.Any) {
174
+ const scope = action.readOnly ? scopes.read : scopes.write;
175
+ const user = yield* CurrentUser;
341
176
 
342
- return { renamed, refused, changes };
177
+ if (!user.scopes.includes(scope)) {
178
+ return yield* new Action.Forbidden({ message: `Requires ${scope}.` });
179
+ }
343
180
  });
344
-
345
- // The builders are released with the program's scope, before the services they captured.
346
- console.log(
347
- await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(Users.layerMemory))),
348
- );
349
181
  ```
350
182
 
351
- ## Rules
352
-
353
- ### Contracts
354
-
355
- - Names are 1 to 128 characters of `[A-Za-z0-9_-]`. `then` is rejected: it would make a client thenable. The name is also the HTTP route segment, the client method and the MCP tool name.
356
- - Omit `input`, or give `{}`, for a no-argument action. Its input is an empty object that accepts only `{}`: over HTTP, extra fields and an empty JSON body are a 400. It is MCP's object root. Its client method may be called without an argument.
357
- - Omit `success` for an action that returns nothing. The default is `Schema.Void`: the client methods of `ActionHttp.client` and `Testing.mcpClient` return `void`, HTTP answers with no content, and a CLI command prints nothing by default or with `--json`; a custom `render` may print text. As for a function returning `void`, its handler may still return a value; the encoding drops it. Declare `success` to return data.
358
- - `input` and `success` take a schema or fields. Fields become `Schema.Struct(fields)`, and no fields the empty object above; each field must be service-free, like every schema here. A schema is kept as given: a `Schema.Struct({})` accepts any value but `null`, as in Effect, and MCP refuses it as a tool's input, so write `{}` instead.
359
- - `error` is one schema or a list of them, as `HttpApiEndpoint` takes it, default none: `error: UserNotFound`, `error: [UserNotFound, Conflict]`. The contract holds it as a list, `action.error`, one schema alone a list of one. Each keeps its own `httpApiStatus` annotation, and so does each member of a union without one of its own. An unannotated error is served as HTTP 422: an expected outcome, not Effect's default 500, which reads as a server fault.
360
- - To share errors across actions, spread one constant array into each action's `error`. To share several options, such as a caller and its errors, spread one constant object into each action's options: with `const Owner = { caller: CurrentOwner, error: [NotFound, Conflict] } as const`, `Action.make("rename", { ...Owner, description, readOnly: false, input })` is typed as if written inline, and a handler failing with an error `Owner` does not list still fails to compile.
361
- - `caller` is required: `Action.Anyone`, or the identity service a caller must have. An action whose `caller` is `Action.Anyone` is a public action, any other a protected one. There is no default, so an action nobody classified fails to compile rather than being served to anyone; `make` also throws without it, for a caller the compiler never sees.
362
- - A protected contract imports its identity, so the module declaring it stays browser-safe: a `Context.Service` and its type, without the verifier ([setup.md](setup.md#browser)). Never a `Context.Reference`: its default is always present, so it would stand in for every caller who supplies none; the types refuse one, and `make` throws. A contract never imports a transport, its authentication descriptor included.
363
- - One identity per action, and every protected action one remote surface serves declares the same one, its authentication descriptor's. A tenant or other request context is an ordinary request-time requirement of the handler, never a second identity.
364
- - Every surface enforces `caller`, whatever the handler reads: a protected action is authenticated remotely and owes its identity per call locally, even where its handler and authorizer read none ([guarantees.md](guarantees.md#authorization)). A public action is neither authenticated nor authorized, and no surface provides it an identity: a public handler that reads one leaves its layer owing it ([Failure modes](#failure-modes)). An action whose answer depends on the caller is protected.
365
- - `readOnly` is a boolean and is required: whether the action leaves its resource unchanged. `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 type may select the read-only actions, `Extract<A, { readonly readOnly: true }>`.
366
- - An implementation's `authorize` reads `readOnly` ([guarantees.md](guarantees.md#authorization)); the library itself authorizes nothing with it. Its only built-in uses are the tool's hints and the span/log annotation.
367
- - `readOnly` is also the tool's read-only hint, which no option overrides, so authorization and what MCP clients are told cannot disagree. Derive authorization from `readOnly`, never from an `mcp` hint.
368
- - A contract says nothing about where it is served. Each surface takes the list of actions it serves, from the implementations passed to it: HTTP its binding's, `ActionHttp.make(actions)`, and MCP, a Toolkit and `ActionCli.make` their `actions` option, or every action of the implementations without it. To keep an action off a surface, leave it out of that list, whatever implementation holds it. A listed action keeps its `caller`, its implementation's authorizer and its builder's one run.
369
- - List the actions that are tools in one place, beside the contracts, `export const Tools = [GetUser, RenameUser] as const`, and give every MCP endpoint and Toolkit `actions: Tools`: an action added later is no tool until it is listed. List an action only where the model may hold what it takes and returns: a tool call passes both through the model's context, and records its input on a span ([guarantees.md](guarantees.md#observability)).
370
- - `byName(actions)` keys a list by name: `const contracts = Action.byName(Actions)` gives `contracts.getUser`, its schemas for a test or a mock, `keyof typeof contracts` as the names, and an action's types by name, `(typeof contracts)[K]["input"]["Type"]`, which resolves under a generic `K`, where `Extract` over the list does not. The list is typed by its members ([guarantees.md](guarantees.md#names)). It throws `Duplicate action: <name>` for a name held twice.
371
- - `make` accepts only the keys listed above, and `mcp` only `title`, `destructiveHint`, `idempotentHint`, `openWorldHint` and `_meta`: MCP's own names. An unknown key is a compile error, beside known ones too, and so is `readOnlyHint`. `title` is the tool's display name, MCP's `annotations.title`, and `_meta` its `_meta`, sent as given, such as an MCP App's UI resource: native `Tool.Title` and `Tool.Meta` on a Toolkit's tool. Neither has a default, and neither is sent when left out.
372
- - An `undefined` option takes its default, as an omitted one does. One that may be either is typed as either: with `success: enabled ? Schema.String : undefined`, or the same through a conditional spread, the success is `string | void`. The same holds for `input` and `error`.
373
- - Options annotated with a wide type, such as `Parameters<typeof Action.make>[1]`, are not checked. Their action's schemas are as wide as what may run, so its success is `unknown`. A constant object spread into the options, as above, keeps its exact types and is checked like inline options.
374
- - MCP input must be one object with keys, an identified, recursive or suspended root included. Scalar, array or union input is fine for HTTP and for a native Toolkit, but `ActionMcp` refuses it when `layerHttp` or `runStdio` is called, naming the action ([ActionMcp.md](ActionMcp.md#rules)). Success and error schemas may be any shape.
375
- - The contract keeps `mcp` as given, `action.mcp`, and defaults none of it. A hint left out is not annotated, so the native default, MCP's, stands: `destructiveHint` `true`, `idempotentHint` `false`, `openWorldHint` `true`; but a read-only action's `destructiveHint` is `false` unless it states one. A stated hint is sent as it is, a read-only action's included, which MCP reads only for a tool that is not read-only. `readOnlyHint` is always `readOnly`. Hints are metadata for the model. They do not enforce authorization, approval, or retries; a native Toolkit's approval is `ActionToolkit.make`'s `needsApproval` option.
376
- - The built-in errors are declared everywhere ([guarantees.md](guarantees.md#wire-behavior)), and any handler may fail with them without listing them: `InvalidInput` for input that decodes but cannot be served, naming `issues` of its own as the library's do ([guarantees.md](guarantees.md#wire-behavior)), `new Action.InvalidInput({ message, issues: [{ path: ["lines"], message }] })`, so an application declares no second error for bad input; a refusal for a step-up. `make` refuses an `error` entry that encodes with a built-in tag, the built-in itself included, since every surface declares it already and a client could not tell a look-alike apart. Only those classes are built-in errors: a handler or an authorizer failing with an error of the same `_tag` and fields, such as Effect's `HttpApiError.Forbidden` or a `Forbidden` of your own, does not compile, since a built-in error's codec encodes its own class alone.
377
- - Code that decodes input of its own, such as a header, a route parameter or a file's metadata, answers a failure as the surfaces do with `Action.InvalidInput.fromSchemaError(error)`: the schema's message and its `issues`, `Effect.mapError(Action.InvalidInput.fromSchemaError)` after a `Schema.decodeUnknownEffect`. To place the issues under a field of a larger input, decode the value as that field, `Schema.decodeUnknownEffect(Schema.Struct({ payload: Payload }))({ payload })`, so each issue's `path` starts at it.
378
- - `Action.Refusal` and `Action.BuiltIn` are schemas as well as types, so code outside Effect tells a failure apart without listing the classes: `Schema.is(Action.Refusal)(error)` for a refusal before the handler (signing in again helps only an `Unauthenticated`, or a `Forbidden` naming `scopes` for a caller that can step up), and `Schema.Union([...Action.BuiltIn.members, NotFound])` for an action's declared and built-in errors; a remote client also sees its binding's `error` and its descriptor's. Neither belongs in an `error` option: `make` refuses a built-in tag there.
379
- - Build a refusal with or without a message: `new Action.Forbidden()` sends `"Not allowed."`, `new Action.Forbidden({ message: "Requires users:write." })` sends that. A `Forbidden` may name the OAuth scopes the call lacks, `new Action.Forbidden({ scopes: ["users:write"] })`: a refused OAuth client then re-authorizes with them ([Authentication.md](Authentication.md#rules)).
380
- - Schemas must be service-free. Put service access in the handler.
381
-
382
- ### Implementations
383
-
384
- - `implement` returns one `Implementation` of everything it binds: `implement(action, handler, options)` one action, `implement([a, b], { a: ..., b: ... }, options)` every listed action. Either may take an Effect that builds the handler or the record instead. One implementation may hold public and protected actions. Every surface takes one implementation or a list: `[status, userActions, double]`. Which actions it exposes follows the [surface selection rules](guarantees.md#names).
385
- - An implementation holding a protected action states who of the authenticated callers may call: `{ authorize }` is required, and leaving it out is a compile error. `Action.allowAll` lets every authenticated caller call; authentication still decides who that is. An authorizer that depends on the deployment is `enabled ? authorize : Action.allowAll`; its services are owed either way. Plain JavaScript has no types, so `implement` also checks at runtime that it got one.
386
- - An implementation of public actions alone takes no options: `{ authorize }` there is a compile error, since nothing would run it.
387
- - `authorize` runs only for protected actions, after authentication and input decoding, before the handler, on every surface ([guarantees.md](guarantees.md#authorization)). It fails only with a refusal: a limit, or any other failure a caller should see beside a refusal, is the handler's. Typed `Action.Authorize<Action.Any>`, an authorizer fits every implementation, as `Action.allowAll` does.
388
- - `authorize` may be an Effect that builds it, as a builder builds handlers ([Built authorization](#built-authorization)). Its lifetime, and startup versus request-time services: [guarantees.md](guarantees.md#dependency-lifetimes). Guarding several implementations, it is built once for each: keep state they share in a service the Effect yields, or pass a service whose value is the authorizer itself, `{ authorize: Guard }`, which Effect builds once. `Effect.isEffect` tells the two forms apart, so an authorizer written with `Effect.fn` is a plain one.
389
- - `authorize` sees `action` typed as the implementation's own actions when it is written in `implement`'s options, as a plain function, an `Effect.fn` or a built one. Defined apart, as a rule several implementations share is, nothing types it there: annotate it, `(action: Action.Any)`.
390
- - A limit, such as a rate limit, is the handler's: the action declares its error in `error`, the builder yields the limiter, a startup service shared by every surface of the layer graph, and the handler fails with the error before its work, as with any declared error. Several actions sharing one limit spread one error array into their `error` and wrap their handlers in one function the builder defines. A limit counting every request, decoded or not, is router middleware over HTTP ([guarantees.md](guarantees.md#authorization)).
391
- - A record has exactly one own-property function per action, keyed by its name. Missing and extra keys are compile errors, for a plain record and for a builder's. An inherited method does not count. Handlers are called without a receiver. Duplicate action names in one call throw at `implement`.
392
- - A plain handler or record is checked at `implement`; a builder's record when it is built. A record that slips past the types (plain JavaScript, a cast) throws `Unknown handlers` for a key no action names, and `Missing handlers` for an action without a function. From a builder, the layer build dies with the same message. Nothing is served with a handler missing.
393
- - Builder lifetime and build-time versus request-time services: [guarantees.md](guarantees.md#dependency-lifetimes). Authentication and authorization: [guarantees.md](guarantees.md#authorization). Surfaces keep the two kinds of services separate in their types, per action.
394
- - A handler's input is typed from its action however it is written: an arrow, a function typed `Action.Handler`, or a generator, `Effect.fn(function* (input) { ... })` or `Effect.fnUntraced`, alone or in a record, a builder's included. The builder must itself be `implement`'s argument: through `.pipe(...)`, or a wrapper such as `Effect.withSpan(builder, name)`, an `Effect.fn` it returns takes an `any` input, so annotate the input there.
395
- - Each handler already runs in a span named after its action, and every log line it writes is annotated with `action.name` and `action.read_only` ([guarantees.md](guarantees.md#observability)). Leave `Effect.fn` unnamed: don't wrap a handler in `Effect.fn(name)` or annotate its logs with the action's name yourself.
396
- - A surface serves fewer of an implementation's actions through its own list ([Contracts](#contracts)), and `client` through `actions`: each listed action keeps its implementation's handlers, builder and authorizer. Selection never changes who may call. A caller trusted on one surface, such as an operator on an admin CLI, is an identity the host supplies there, which the same authorizer admits ([ActionCli.md](ActionCli.md#trusted-callers)). Another authorizer for the same handlers takes another `implement`, of the same builder Effect, which then runs once per implementation in each layer graph. A builder written apart from `implement` returns its record `satisfies Action.Handlers<typeof actions>`, so each handler is typed from its contract, as inside `implement`.
397
- - A helper over implementations is generic, `<const Apps extends Action.AnyImplementation | ReadonlyArray<Action.AnyImplementation>>(apps: Apps)`, so each implementation's requirements reach the surface it passes them to, `ActionHttp.layer` included, whose binding decides what it serves. Beside the helper's own implementations, list or spread the parameter, `[app, double]` or `[...apps, double]`. `AnyImplementation` erases every channel to `unknown`: a value or a parameter typed with it owes `unknown`, which no surface can be given. An implementation's own type is inferred from `implement`. A package stating one in an interface of its own writes it out, `Action.Implementation<typeof Whoami, { readonly whoami: Principal }, never, Tenant>`: its actions, each action's per-request services by name, its builder's failures and services, then its authorizer's per-request services and its authorizer builder's failures and services, each `never` when left out. Exported from a package that emits declarations, the helper states its return type as the surface's own, `ReturnType<typeof Action.client<Apps>>`, which TypeScript can name where the inferred one cannot.
398
- - `Implementation` is nominal. Spreading its properties does not produce an implementation. Surfaces match an implementation to an action by object identity, so implement the exact contract value a binding or a CLI selector receives.
399
- - Test what an implementation does, its authorizer and its handlers, in process with `client`, each caller provided around its own calls ([Clients](#clients)); test what a surface adds, authentication, statuses and codecs, under `Testing.layer` ([Testing.md](Testing.md)). A builder's handlers exist only once it runs, so calling the function passed to `implement` works only for a plain handler, and skips authorization. Cover each surface the application exposes.
400
-
401
- ### Clients
402
-
403
- - `client(implementations)` takes one implementation or a list, as a surface does, and gives one method per action, named after it and taking its input directly: the methods `ActionHttp.client` gives, so moving between an in-process and a remote caller changes the line acquiring it. The argument may be left out when `{}` is a valid encoded input, such as an input whose every field has a decoding default, and is then what `{}` decodes to; an input whose encoded form requires a field its decoded one lacks takes the argument. Every implemented action has a method, whether or not a surface serves it. An action name twice among the implementations throws `Duplicate action: <name>` where `client` is called.
404
- - A call runs as a remote one does, through the dispatch every surface shares. Its input passes through its JSON codec, encoded to JSON text then decoded, so the handler receives what a remote handler decodes: an undeclared field dropped, as a client's encoding drops it, a class instance built anew, a decoding transformation such as a trim applied, `0` for `-0`. Authorization runs, then the handler, in its action's span, each call in a scope of its own ([guarantees.md](guarantees.md#dependency-lifetimes)). The success passes through its codec the same way, and a failure through the codec of every error the action declares, the built-in ones included, so the caller gets what a remote caller decodes: no undeclared field, a decoding transformation applied, `undefined` for a `Schema.Void` success. A failure keeps its trace: where the authorizer or the handler failed.
405
- - A call fails with the action's declared errors and the built-in ones, as `ActionHttp.client` decodes them, and with nothing of a transport. Input that does not pass through its codec is `InvalidInput`, with the schema's message and `issues`, and authorization and the handler never run; a class input is passed as an instance, as over HTTP. A protected action's call without its identity fails with `Unauthenticated` before the authorizer runs. The authorizer's refusal is the call's failure. A success or a failure that does not pass, an error the action does not declare included, is a defect, as it is an empty 500 over HTTP: the handler broke its contract. The defect is its `SchemaError`, and for a failure, the failure itself after it.
406
- - Each method owes, per call, what its handler and its implementation's authorizer read, and a protected action's identity whether or not they read it, and the caller provides them around the call, or around a program making several: `users.renameUser(input).pipe(Effect.provideService(CurrentActor, actor))`. A call reads its caller's context, never the one the client was acquired in: provided around the acquisition, a service reaches no call, so neither does a startup identity, and one client serves every caller.
407
- - `actions` lists the actions it calls among the implementations', as a surface's `actions` does: an implementation holding none of them is left out, unbuilt, and a listed action none of them holds throws `Listed in actions, but no implementation holds it: <names>`. Where `actions` may be absent, an optional property or a union with options lacking it, the client offers methods only for the actions it may list, which are present either way, while it owes and builds what every action needs ([guarantees.md](guarantees.md#names)). An explicit type argument listing actions requires the options argument.
408
- - Acquiring a client builds its implementations' builders and every authorizer an Effect builds, as a layer does: the acquisition owes their startup services and a `Scope`, and fails as they fail; no call does. Where it builds, and what it shares: [dependency lifetimes](guarantees.md#dependency-lifetimes). Acquire it where builders live, once: in a builder, in a layer providing it as a service of type `Action.Client<typeof users>`, or in a scoped program, such as a test, a job or a command. Never per request, in a handler.
409
-
410
- ## Failure modes
411
-
412
- - `Invalid action name: <name>` thrown by `make`: the name is empty, longer than 128 characters, `then`, or has another character.
413
- - `Invalid readOnly: <value>` thrown by `make`: `readOnly` is not a boolean, which only a caller the compiler never sees can pass.
414
- - `Property 'caller' is missing` at `make`, or `Missing caller: declare Action.Anyone or an identity service key` thrown by `make`: the contract does not state who may call it. Give `caller: Action.Anyone`, or the identity service a caller must have. There is no default.
415
- - `Type ... is not assignable to type '"An identity is a Context.Service, not a Context.Reference"'` at `make`, or `Invalid caller: an identity is a Context.Service, not a Context.Reference` thrown by `make`: `caller` is a `Context.Reference`, whose default would authenticate every caller. Declare the identity with `Context.Service`.
416
- - `... is not assignable to parameter of type 'ProtectedActionsTakeAuthorize'` at `implement`, ending `Type 'typeof CurrentActor' is not assignable to type 'unique symbol'`: the target holds a protected action, and the implementation states no `authorize`. Pass `{ authorize }`, or `{ authorize: Action.allowAll }` where every authenticated caller may call.
417
- - `No overload matches this call` at `implement`, ending `... is not assignable to type '"A public-only target takes no authorize"'`: an implementation of public actions alone states `authorize`, which nothing would run. Drop it; a public action is never authorized. If the action needs a caller, make its contract protected. Plain JavaScript passing one throws `A public-only target takes no authorize` at `implement`.
418
- - `Protected actions require authorize, or Action.allowAll` thrown at `implement`: plain JavaScript left `authorize` out for a target holding a protected action. `Missing authorize: pass an authorization function, or Action.allowAll` thrown at `implement`, or the layer build dies with it: `authorize` is neither a function nor an Effect building one, or that Effect built something else.
419
- - `Argument of type '... | undefined' is not assignable` at `implement`'s `authorize`: an authorizer that may be `undefined`, such as `enabled ? authorize : undefined`. Write `enabled ? authorize : Action.allowAll`.
420
- - `Parameter 'action' implicitly has an 'any' type`, or an `Effect.fn` authorizer whose `action` is `any`, where it is defined apart from `implement`: nothing types it there. Annotate it, `(action: Action.Any)`.
421
- - Type error `Effect<..., X, ...> is not assignable` at `implement`: the handler fails with an undeclared error `X`. Add it to the action's `error` or handle it.
422
- - `Type 'X' is not assignable to type 'Refusal'` at `implement`, ending in a `_tag` mismatch with `"Unauthenticated"`: the authorizer fails with `X`, which is not a refusal. A limit or another failure callers should see is the handler's: declare `X` in the action's `error` and fail with it there.
423
- - `Type 'CurrentActor' is not assignable to type 'never'`, or `Request<"Requires", CurrentActor>` still owed, for a layer serving only public actions: a public action's handler reads the identity, and no surface provides an identity to a public action. Make the action protected.
424
- - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
425
- - `Missing handlers: <names>` at `implement`, or when a builder's layer builds: the record has no own-property function for those actions. Add them to the record itself, not to a prototype.
426
- - `Unknown handlers: <keys>` at `implement` or when a builder's layer builds, or a type error names a key: the record has a handler for an action not in this `implement` call. Remove it, or add its action to the list.
427
- - `Listed in actions, but no implementation holds it: <names>` thrown by `client`: a listed action is none of the implementations'. Pass the implementation holding it, or check the contract values are the ones it implemented.
428
- - `Duplicate action: <name>` at `implement`: the list names one action twice. Thrown by `client`: two of its implementations implement an action of that name. Separate contracts sharing a name are told apart by `actions`, listing one; two implementations of the same contract are not, since listing it keeps both: pass one implementation.
429
- - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
430
- - `Property 'readOnly' is missing` at `make`: every action declares whether it is read-only. There is no default.
431
- - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder, or in the Effect building the authorizer. Move the `yield*` into the handler or the authorizer. An identity yielded there is a startup requirement of the layer; never provide one at startup ([guarantees.md](guarantees.md#dependency-lifetimes)).
432
- - `HttpRouter.Request<"Requires", X>` still owed, or `Type 'X' is not assignable to type 'never'` at `runMain`, although `X`, a store the authorizer reads, is provided at startup: the authorizer yields it on every call. Yield it in an Effect that builds the authorizer ([Built authorization](#built-authorization)).
433
- - `unknown` among a surface's requirements, and a type error where it is served or run, such as `Argument of type 'Layer<never, unknown, unknown>' is not assignable` at `HttpRouter.toWebHandler` or `Type 'unknown' is not assignable to type 'never'` where it is launched or run: the implementations are typed `Action.AnyImplementation`, as a helper's parameter or a list's annotation. Make the helper generic over them, or drop the annotation.
434
- - `Request<"Requires", unknown>` in a type error where an implementation is served, or `unknown` among the services a call or a run still requires: TypeScript inferred no requirements for a handler of a record. A data-first call whose type takes a parameter from no argument does it, `Effect.catchTag(effect, tag, f)` for one, where `Effect.catch`, `Effect.map` and `Effect.orDie` do not. Write that handler's Effect `effect.pipe(Effect.catchTag(tag, f))`, or as a generator.
435
- - `'readOnlyHint' does not exist in type 'Mcp'` at `make`, or `Type 'true' is not assignable to type 'never'` on `readOnlyHint` beside another hint: a tool's read-only hint is its action's `readOnly`. Set `readOnly` instead.
436
- - `Type '...' is not assignable to type 'never'` on a key at `make`, in `mcp` too: an option or hint it does not take, or a misspelled one, such as `destructive` for `destructiveHint`.
437
- - `Type 'H' is not assignable to type 'H & ...'` at `make`, where `H` is a helper's type parameter, as in `<const H extends Action.Mcp>(mcp: H)`: `make` cannot read options a type parameter stands for, so it refuses them. An action's type carries no `mcp` types, so type the parameter `Action.Mcp`; the helper loses nothing. A constant object spread into the options is no type parameter: `make` reads it as written.
438
- - Type error on `error` at `make`, `... is not assignable to type 'readonly never[]'` or naming the remaining errors: it lists a built-in error. Drop it; every surface declares it, and a handler may fail with it anyway.
439
- - `make` throws `Action "<name>": error _tag "Forbidden" is built in, and declared on every surface`: an error in the action's `error`, or a member of a union there, encodes with the `_tag` of a built-in error, `InvalidInput`, `Unauthenticated` or `Forbidden`. Drop a built-in error from `error`, since a handler may fail with it anyway; rename an error of your own.
440
- - `ReferenceError` thrown by `make`: an entry of `error` is a `Schema.suspend` whose thunk reads a `const` declared further down. `make` reads every error's `_tag` when it is called; declare the error first.
441
- - `Type 'CurrentActor' is not assignable to type 'never'` where a program calling a client runs: a call of a protected action owes the caller, and nothing provides one around it. Provide it around the call, or around the calls it makes; provided around the acquisition, it reaches no call. Never provide it to a layer or a builder acquiring the client.
442
- - A type error that a program still requires `Scope`: acquiring a client builds, as a layer does. Acquire it in a builder or a layer, or run the program in `Effect.scoped`.
443
- - A builder runs on every request, or a route owes `HttpRouter.Request<"Requires", X>` for `X`, a service its builder yields: a handler acquires a client per request. Acquire it in the builder, and call it in the handler.
444
- - A builder runs twice, or a job, an agent or a second server reads other state than the routes, such as an empty in-memory store: it is merged beside `HttpRouter.serve`, whose layer graph built the builder first. Put it inside the layer the server serves, or provide `Action.layer` and the services they share above both ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
445
- - Surfaces given startup services of their own, provided around each or to an `Action.layer` of each, all answer with one surface's: a builder runs once per layer graph, with the services of its first build. Wrap each surface in `Layer.fresh`, or give each an implementation of its own ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
446
- - `'<name>' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer` (TS7022) at `implement`, or, with the types bypassed, an acquisition that never completes: a builder acquires a client of its own implementation, directly or through another builder. Call the services both use instead.
447
- - A call dies with a `SchemaError`: the handler's success, or what the handler fails with, does not pass through its codec, which an HTTP caller sees as an empty 500. A failure follows the `SchemaError` in the cause, as `Cause.pretty` shows. Return what the success schema accepts, and fail with an error the action declares, as its schema accepts it.
448
- - `InvalidInput` with `Expected <Class>` from a call given a plain object: the input is a class, which encodes only its instances. Pass `new Class({ ... })`, or leave out an argument whose fields are all optional.
449
- - `Not an implementation made by this Action.implement: is effect-actions installed twice?` thrown by a surface, `client` or `layer`, or by a local CLI command when it runs: another installed copy of the package made the implementation, and only that copy serves and calls it. Install one copy of the package; contracts and bindings still cross copies.
183
+ [notes-service/src/identity/authorize.ts](../examples/notes-service/src/identity/authorize.ts)
184
+ is that rule with `scopes: [scope]` added, the OAuth variant: it publishes discovery.
185
+ Derive the rule from `action.readOnly`, never from an `mcp` hint: changing an action's
186
+ `readOnly` then changes who may call it.
187
+
188
+ - **A call runs authentication, then over HTTP and RPC the layer's `middleware`, input
189
+ decoding, the authorizer, then the handler**, each refusing before the next. So a caller the
190
+ authorizer would refuse can still receive an `InvalidInput` describing the input's schema. To
191
+ refuse or limit before decoding, use that `middleware`
192
+ ([ActionHttp.md](ActionHttp.md#serve-the-routes), [ActionRpc.md](ActionRpc.md#serve-the-rpcs)).
193
+ - **Name `scopes` in a `Forbidden` only where re-authorizing can grant them.** An OAuth
194
+ challenge answers them, which a client follows only where the server publishes discovery
195
+ ([Authentication.md](Authentication.md#publish-oauth-discovery)); with fixed tokens, such as
196
+ API keys, leave `scopes` out (it holds at least one: `scopes: []` is a type error). Without scopes it is a 403 over HTTP and, over MCP, an `isError` tool
197
+ result in a 200 ([guarantees.md](guarantees.md#wire-behavior)).
198
+ - **What a plain authorizer yields is a request-time requirement of every surface.** To read a
199
+ store, make `authorize` an Effect that yields the store once and returns the function, as a
200
+ builder does
201
+ ([assistant/src/identity/authorize.ts](../examples/assistant/src/identity/authorize.ts)). It
202
+ is built once for each implementation stating it: keep state they share in a service it
203
+ yields.
204
+ - **A limit, such as a rate limit, is the handler's, not the authorizer's.** It fails with an
205
+ error the action declares, and its limiter is a service the builder yields
206
+ ([kitchen-sink/src/issues/write-limit.ts](../examples/kitchen-sink/src/issues/write-limit.ts)):
207
+ a service is shared wherever it is provided
208
+ ([kitchen-sink/src/memory.ts](../examples/kitchen-sink/src/memory.ts)), state kept in a
209
+ builder only within one layer graph.
210
+ - **A check on one record belongs in the handler's data access**
211
+ ([notes-service/src/notes/store.ts](../examples/notes-service/src/notes/store.ts)). So does a
212
+ rule across callers, such as uniqueness.
213
+
214
+ ## Fail with declared and built-in errors
215
+
216
+ A handler fails with the errors its action lists in `error`, and with the three built-in
217
+ errors, which every surface declares and every client decodes: never list those. No other
218
+ failure compiles. What each surface answers is in [guarantees.md](guarantees.md#wire-behavior).
219
+
220
+ - **Fail with `Action.InvalidInput` for input that decodes but cannot be served**, naming
221
+ `issues` of your own, instead of declaring a second error for bad input
222
+ ([kitchen-sink/src/attachments/handlers.ts](../examples/kitchen-sink/src/attachments/handlers.ts)).
223
+ - **For input you decode yourself**, such as a header or a route parameter, answer as the
224
+ surfaces do: `Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(Action.InvalidInput.fromSchemaError))`.
225
+ - **Map another service's refusals before failing with them.** A client that decodes the
226
+ built-in errors, such as `ActionHttp.client`, hands a handler that service's `Unauthenticated`
227
+ or `Forbidden`. Passed on, it is this server's own refusal: a caller whose token is valid is
228
+ told to sign in again. Map it to an error the action declares, or to a defect.
229
+ - **Map that client's `HttpClientError` and `SchemaError` to one declared error a caller can
230
+ act on** ([mcp-tools/src/printers/farm.ts](../examples/mcp-tools/src/printers/farm.ts)). As
231
+ defects, a model reads only a generic text.