@fuzdev/fuz_app 0.64.0 → 0.66.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 (223) hide show
  1. package/dist/actions/CLAUDE.md +510 -946
  2. package/dist/actions/action_codegen.d.ts +1 -1
  3. package/dist/actions/action_codegen.js +1 -1
  4. package/dist/actions/action_event_data.d.ts +1 -1
  5. package/dist/actions/broadcast_api.d.ts +1 -1
  6. package/dist/actions/broadcast_api.js +1 -1
  7. package/dist/actions/cancel.d.ts +2 -2
  8. package/dist/actions/cancel.js +3 -3
  9. package/dist/actions/connection_closer.d.ts +1 -4
  10. package/dist/actions/connection_closer.d.ts.map +1 -1
  11. package/dist/actions/connection_closer.js +1 -4
  12. package/dist/actions/register_action_ws.d.ts +2 -2
  13. package/dist/actions/register_ws_endpoint.d.ts +1 -1
  14. package/dist/actions/transports_ws_auth_guard.d.ts +1 -2
  15. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  16. package/dist/actions/transports_ws_auth_guard.js +1 -2
  17. package/dist/auth/CLAUDE.md +570 -1871
  18. package/dist/auth/account_schema.d.ts +1 -1
  19. package/dist/auth/account_schema.d.ts.map +1 -1
  20. package/dist/auth/api_token_queries.js +1 -1
  21. package/dist/auth/audit_log_ddl.d.ts +1 -1
  22. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  23. package/dist/auth/audit_log_ddl.js +1 -1
  24. package/dist/auth/audit_log_schema.js +2 -2
  25. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  26. package/dist/auth/bootstrap_account.js +1 -5
  27. package/dist/auth/bootstrap_routes.d.ts +7 -1
  28. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  29. package/dist/auth/bootstrap_routes.js +15 -11
  30. package/dist/auth/daemon_token_middleware.d.ts +15 -5
  31. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  32. package/dist/auth/daemon_token_middleware.js +24 -15
  33. package/dist/auth/invite_queries.d.ts +17 -7
  34. package/dist/auth/invite_queries.d.ts.map +1 -1
  35. package/dist/auth/invite_queries.js +19 -8
  36. package/dist/auth/keyring.d.ts +6 -6
  37. package/dist/auth/keyring.js +8 -8
  38. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  39. package/dist/auth/role_grant_offer_actions.js +4 -2
  40. package/dist/auth/signup_routes.d.ts +47 -1
  41. package/dist/auth/signup_routes.d.ts.map +1 -1
  42. package/dist/auth/signup_routes.js +103 -52
  43. package/dist/db/create_db.d.ts.map +1 -1
  44. package/dist/db/create_db.js +13 -0
  45. package/dist/dev/setup.d.ts +2 -2
  46. package/dist/dev/setup.js +3 -3
  47. package/dist/env/resolve.d.ts +44 -7
  48. package/dist/env/resolve.d.ts.map +1 -1
  49. package/dist/env/resolve.js +94 -27
  50. package/dist/http/CLAUDE.md +243 -522
  51. package/dist/http/error_schemas.d.ts +0 -4
  52. package/dist/http/error_schemas.d.ts.map +1 -1
  53. package/dist/http/error_schemas.js +0 -4
  54. package/dist/http/ip_canonical.d.ts +5 -4
  55. package/dist/http/ip_canonical.d.ts.map +1 -1
  56. package/dist/http/ip_canonical.js +8 -4
  57. package/dist/http/jsonrpc.d.ts +23 -7
  58. package/dist/http/jsonrpc.d.ts.map +1 -1
  59. package/dist/http/jsonrpc.js +19 -3
  60. package/dist/http/origin.d.ts +1 -1
  61. package/dist/http/origin.js +1 -1
  62. package/dist/http/surface.d.ts +9 -2
  63. package/dist/http/surface.d.ts.map +1 -1
  64. package/dist/runtime/mock.d.ts +1 -1
  65. package/dist/runtime/mock.js +2 -2
  66. package/dist/server/app_server.d.ts +41 -10
  67. package/dist/server/app_server.d.ts.map +1 -1
  68. package/dist/server/app_server.js +10 -4
  69. package/dist/server/env.d.ts +7 -7
  70. package/dist/server/env.d.ts.map +1 -1
  71. package/dist/server/env.js +14 -14
  72. package/dist/server/static.d.ts +4 -4
  73. package/dist/server/static.js +7 -7
  74. package/dist/testing/CLAUDE.md +740 -418
  75. package/dist/testing/admin_integration.d.ts +18 -23
  76. package/dist/testing/admin_integration.d.ts.map +1 -1
  77. package/dist/testing/admin_integration.js +230 -216
  78. package/dist/testing/app_server.d.ts +141 -39
  79. package/dist/testing/app_server.d.ts.map +1 -1
  80. package/dist/testing/app_server.js +157 -44
  81. package/dist/testing/audit_completeness.d.ts +25 -22
  82. package/dist/testing/audit_completeness.d.ts.map +1 -1
  83. package/dist/testing/audit_completeness.js +198 -159
  84. package/dist/testing/bootstrap_success.d.ts +28 -0
  85. package/dist/testing/bootstrap_success.d.ts.map +1 -0
  86. package/dist/testing/bootstrap_success.js +144 -0
  87. package/dist/testing/cross_backend/backend_config.d.ts +113 -0
  88. package/dist/testing/cross_backend/backend_config.d.ts.map +1 -0
  89. package/dist/testing/cross_backend/backend_config.js +1 -0
  90. package/dist/testing/cross_backend/bench/bench_report.d.ts +46 -0
  91. package/dist/testing/cross_backend/bench/bench_report.d.ts.map +1 -0
  92. package/dist/testing/cross_backend/bench/bench_report.js +83 -0
  93. package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +44 -0
  94. package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts.map +1 -0
  95. package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +38 -0
  96. package/dist/testing/cross_backend/bench/scenario.d.ts +57 -0
  97. package/dist/testing/cross_backend/bench/scenario.d.ts.map +1 -0
  98. package/dist/testing/cross_backend/bench/scenario.js +28 -0
  99. package/dist/testing/cross_backend/bootstrap_backend.d.ts +41 -0
  100. package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -0
  101. package/dist/testing/cross_backend/bootstrap_backend.js +34 -0
  102. package/dist/testing/cross_backend/build_test_backend_paths.d.ts +24 -0
  103. package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -0
  104. package/dist/testing/cross_backend/build_test_backend_paths.js +33 -0
  105. package/dist/testing/cross_backend/capabilities.d.ts +65 -0
  106. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -0
  107. package/dist/testing/cross_backend/capabilities.js +47 -0
  108. package/dist/testing/cross_backend/default_backend_configs.d.ts +122 -0
  109. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -0
  110. package/dist/testing/cross_backend/default_backend_configs.js +111 -0
  111. package/dist/testing/cross_backend/default_secrets.d.ts +40 -0
  112. package/dist/testing/cross_backend/default_secrets.d.ts.map +1 -0
  113. package/dist/testing/cross_backend/default_secrets.js +39 -0
  114. package/dist/testing/cross_backend/default_spine_surface.d.ts +64 -0
  115. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -0
  116. package/dist/testing/cross_backend/default_spine_surface.js +121 -0
  117. package/dist/testing/cross_backend/setup.d.ts +451 -0
  118. package/dist/testing/cross_backend/setup.d.ts.map +1 -0
  119. package/dist/testing/cross_backend/setup.js +581 -0
  120. package/dist/testing/cross_backend/spawn_backend.d.ts +58 -0
  121. package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -0
  122. package/dist/testing/cross_backend/spawn_backend.js +229 -0
  123. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +66 -0
  124. package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +1 -0
  125. package/dist/testing/cross_backend/spine_stub_backend_config.js +49 -0
  126. package/dist/testing/cross_backend/sse_round_trip.d.ts +37 -0
  127. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -0
  128. package/dist/testing/cross_backend/sse_round_trip.js +137 -0
  129. package/dist/testing/cross_backend/standard.d.ts +96 -0
  130. package/dist/testing/cross_backend/standard.d.ts.map +1 -0
  131. package/dist/testing/cross_backend/standard.js +49 -0
  132. package/dist/testing/cross_backend/testing_reset_actions.d.ts +171 -0
  133. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/testing_reset_actions.js +213 -0
  135. package/dist/testing/cross_backend/testing_server_bun.d.ts +5 -0
  136. package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -0
  137. package/dist/testing/cross_backend/testing_server_bun.js +59 -0
  138. package/dist/testing/cross_backend/testing_server_core.d.ts +140 -0
  139. package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -0
  140. package/dist/testing/cross_backend/testing_server_core.js +68 -0
  141. package/dist/testing/cross_backend/testing_server_deno.d.ts +5 -0
  142. package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -0
  143. package/dist/testing/cross_backend/testing_server_deno.js +37 -0
  144. package/dist/testing/cross_backend/testing_server_node.d.ts +5 -0
  145. package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/testing_server_node.js +50 -0
  147. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +72 -0
  148. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -0
  149. package/dist/testing/cross_backend/ts_spine_backend_config.js +112 -0
  150. package/dist/testing/cross_backend/ws_round_trip.d.ts +35 -0
  151. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -0
  152. package/dist/testing/cross_backend/ws_round_trip.js +113 -0
  153. package/dist/testing/data_exposure.d.ts +11 -14
  154. package/dist/testing/data_exposure.d.ts.map +1 -1
  155. package/dist/testing/data_exposure.js +123 -146
  156. package/dist/testing/db_entities.d.ts +22 -1
  157. package/dist/testing/db_entities.d.ts.map +1 -1
  158. package/dist/testing/db_entities.js +24 -1
  159. package/dist/testing/integration.d.ts +56 -21
  160. package/dist/testing/integration.d.ts.map +1 -1
  161. package/dist/testing/integration.js +294 -319
  162. package/dist/testing/integration_helpers.d.ts +16 -6
  163. package/dist/testing/integration_helpers.d.ts.map +1 -1
  164. package/dist/testing/integration_helpers.js +7 -7
  165. package/dist/testing/mock_fs.d.ts.map +1 -1
  166. package/dist/testing/mock_fs.js +0 -2
  167. package/dist/testing/rate_limiting.d.ts.map +1 -1
  168. package/dist/testing/rate_limiting.js +9 -0
  169. package/dist/testing/role_grant_helpers.d.ts +31 -0
  170. package/dist/testing/role_grant_helpers.d.ts.map +1 -0
  171. package/dist/testing/role_grant_helpers.js +46 -0
  172. package/dist/testing/round_trip.d.ts +20 -16
  173. package/dist/testing/round_trip.d.ts.map +1 -1
  174. package/dist/testing/round_trip.js +61 -86
  175. package/dist/testing/rpc_helpers.d.ts +10 -4
  176. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  177. package/dist/testing/rpc_helpers.js +1 -1
  178. package/dist/testing/rpc_round_trip.d.ts +24 -21
  179. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  180. package/dist/testing/rpc_round_trip.js +87 -104
  181. package/dist/testing/schema_introspect.d.ts +106 -0
  182. package/dist/testing/schema_introspect.d.ts.map +1 -0
  183. package/dist/testing/schema_introspect.js +123 -0
  184. package/dist/testing/schema_parity.d.ts +144 -0
  185. package/dist/testing/schema_parity.d.ts.map +1 -0
  186. package/dist/testing/schema_parity.js +233 -0
  187. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  188. package/dist/testing/sse_round_trip.js +1 -68
  189. package/dist/testing/standard.d.ts +56 -25
  190. package/dist/testing/standard.d.ts.map +1 -1
  191. package/dist/testing/standard.js +62 -5
  192. package/dist/testing/stubs.d.ts +21 -6
  193. package/dist/testing/stubs.d.ts.map +1 -1
  194. package/dist/testing/stubs.js +33 -23
  195. package/dist/testing/testing_rate_limiter.d.ts +59 -0
  196. package/dist/testing/testing_rate_limiter.d.ts.map +1 -0
  197. package/dist/testing/testing_rate_limiter.js +74 -0
  198. package/dist/testing/transports/bootstrap.d.ts +52 -0
  199. package/dist/testing/transports/bootstrap.d.ts.map +1 -0
  200. package/dist/testing/transports/bootstrap.js +70 -0
  201. package/dist/testing/transports/fetch_transport.d.ts +81 -0
  202. package/dist/testing/transports/fetch_transport.d.ts.map +1 -0
  203. package/dist/testing/transports/fetch_transport.js +74 -0
  204. package/dist/testing/transports/sse_frame_reader.d.ts +41 -0
  205. package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -0
  206. package/dist/testing/transports/sse_frame_reader.js +84 -0
  207. package/dist/testing/transports/sse_transport.d.ts +54 -0
  208. package/dist/testing/transports/sse_transport.d.ts.map +1 -0
  209. package/dist/testing/transports/sse_transport.js +51 -0
  210. package/dist/testing/transports/ws_client.d.ts +108 -0
  211. package/dist/testing/transports/ws_client.d.ts.map +1 -0
  212. package/dist/testing/transports/ws_client.js +56 -0
  213. package/dist/testing/transports/ws_transport.d.ts +43 -0
  214. package/dist/testing/transports/ws_transport.d.ts.map +1 -0
  215. package/dist/testing/transports/ws_transport.js +169 -0
  216. package/dist/testing/ws_round_trip.d.ts +21 -103
  217. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  218. package/dist/testing/ws_round_trip.js +42 -40
  219. package/dist/ui/CLAUDE.md +5 -3
  220. package/dist/ui/MenuLink.svelte +16 -16
  221. package/dist/ui/MenuLink.svelte.d.ts +13 -4
  222. package/dist/ui/MenuLink.svelte.d.ts.map +1 -1
  223. package/package.json +10 -4
@@ -1,259 +1,184 @@
1
1
  # actions/ — SAES (Symmetric Action Event System)
2
2
 
3
- One declarative `ActionSpec` shape `{method, kind, initiator, auth, side_effects, input, output, async, description, streams?, error_reasons?}` — binds to three
4
- transport surfaces (REST, JSON-RPC, WebSocket) with uniform DEV-only output
5
- validation and symmetric send/receive. This directory holds the spec types,
6
- registry, codegen helpers, both transport bridges, the single-endpoint RPC
7
- dispatcher, every transport adapter, the event state machine, and the
8
- reactive frontend client.
9
-
10
- For narrative context (consumer wiring examples, client-authoritative vs
11
- server-authoritative dispatch, role-grant-offer UI integration) see
12
- ../../docs/usage.md §Deriving Route/Event Specs, §Single JSON-RPC 2.0 Endpoint,
13
- §WebSocket Endpoint. For DEV-only output validation semantics see
14
- ../../docs/architecture.md §DEV-only Output Validation. For the SAES
15
- binding matrix and middleware ordering see the root ../../CLAUDE.md
16
- §Action Spec System (SAES) and §Middleware Ordering.
17
-
18
- IMPORTANT: Every exported Zod schema is paired with a same-named `z.infer`
19
- type export — the convention callers rely on for type imports. When adding
20
- new schemas, keep the pair invariant (ecosystem-wide rule; see
21
- Skill(fuz-stack) zod-schemas).
22
-
23
- NOTE: `ActionRegistry` keeps a few pre-built getters (auth filters,
24
- initiator-direction filters) that codegen doesn't consume today — kept
25
- low-cost for future filtering. Bridge, RPC endpoint, and per-derivation
26
- codegen helpers are post-SAES-RPC-closeout stable.
27
-
28
- ## Action specs (`action_spec.ts`)
3
+ > One declarative `ActionSpec` binds to three transport surfaces (REST,
4
+ > JSON-RPC over HTTP, WebSocket) with uniform DEV-only output validation and
5
+ > symmetric send/receive.
29
6
 
30
- Canonical source of truth. Three concrete kinds discriminate on `kind`:
31
-
32
- | Kind | `auth` | `side_effects` | `output` | `async` |
33
- | --------------------- | ---------------------- | -------------- | ----------- | ------- |
34
- | `request_response` | `RouteAuth` (non-null) | arbitrary | arbitrary | `true` |
35
- | `remote_notification` | `null` | `true` | `z.ZodVoid` | `true` |
36
- | `local_call` | `null` | arbitrary | arbitrary | boolean |
37
-
38
- Enums + unions:
39
-
40
- - `ActionKind` — `'request_response' | 'remote_notification' | 'local_call'`
41
- - `ActionInitiator` — `'frontend' | 'backend' | 'both'`
42
- - `RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `http/auth_shape.ts`. Each axis (`account`, `actor`) is `'none' | 'optional' | 'required'`; `roles` and `credential_types` are optional any-of arrays. Cross-axis invariants: roles imply `actor: 'required'`; `account: 'none'` implies `actor: 'none'` (no accountless actors in v1); the unrestricted leaf (`account: 'none', actor: 'none'`) cannot declare roles or credential gates. The biconditional `actor !== 'none' ⟺ input declares acting?: ActingActor` is enforced at registration time via `assert_route_auth_acting_biconditional`.
43
- - `ActionSpecUnion` — discriminated union of the three variants
44
- - `ActionEventPhase` — `'send_request' | 'receive_request' | 'send_response' | 'receive_response' | 'send_error' | 'receive_error' | 'send' | 'receive' | 'execute'`
45
- - `is_action_spec(value)` — structural type guard
46
-
47
- Optional `streams?: string` names a companion `remote_notification` method
48
- emitted as request-scoped progress. Transport-agnostic handshake —
49
- registry-time validation that the named method exists is a consumer concern.
50
-
51
- Optional `error_reasons?: ReadonlyArray<string>` declares the reason codes a
52
- handler may surface via `error.data.reason`. Same precedent as `streams`:
53
- declarative metadata for consumers (codegen, UI form-state matching, docs)
54
- to read off the spec instead of scanning handler code. No runtime
55
- enforcement — drift between declared reasons and what handlers actually
56
- throw is caught per-module by source-scanning unit tests (see
57
- ../../test/auth/role*grant_offer_actions.error_reasons.test.ts). Reuses
58
- the same `as const` string constants the handler throws (e.g.
59
- `ERROR_ROLE_GRANT_OFFER*\*`from`auth/role_grant_offer_action_specs.ts`,
60
- `ERROR_ROLE_GRANT_NOT_FOUND`from`http/error_schemas.ts`) so call
61
- sites can import either side. Standard transport errors (validation,
62
- auth, rate-limit) stay implicit.
63
-
64
- Optional `rate_limit?: 'ip' | 'account' | 'both'` opts the action into
65
- the dispatcher's per-action rate-limit hook. Same hook fires on the HTTP
66
- RPC dispatcher (`create_rpc_endpoint`) and the WebSocket dispatcher
67
- (`register_action_ws`) — one budget per action, not per transport.
68
- `'ip'` keys on the resolved client IP; `'account'` keys on
69
- `request_context.account.id` (post-auth, account-grain — every
70
- authenticated action has an account regardless of whether an actor was
71
- resolved) and is rejected at registration when paired with
72
- `auth.account !== 'required'` (no account to key on); `'both'` runs
73
- both checks. **Throttle-requests semantics** — every invocation records,
74
- regardless of outcome (different from REST login's throttle-failures
75
- that resets on success). The originally motivating threat is admin
76
- mutation oracles (`invite_create` account-existence probe) where the
77
- _successful_ invocation is the threat; the same shape extends to
78
- authed-spam oracles (`role_grant_offer_create` iterating
79
- `to_account_id` to probe `ERROR_ACCOUNT_NOT_FOUND`) and to paginated
80
- cross-account reads (`admin_account_list`, `audit_log_list`,
81
- `audit_log_role_grant_history`) where every successful page is an
82
- enumeration step. Limiters are configured at server-assembly
83
- time via `AppServerOptions.action_ip_rate_limiter` /
84
- `action_account_rate_limiter` and threaded into both dispatchers
85
- automatically; consumers wiring `register_action_ws` directly forward
86
- the same limiters from `AppServerContext`.
87
-
88
- Canonical spec shape: module-scope declaration with `satisfies` +
89
- `{method}_action_spec` naming, preserving the literal `method` type and
90
- dropping per-spec `*_METHOD` constants (readers dereference `.method` at
91
- call sites). See ../../docs/usage.md §Canonical action-spec shape.
92
-
93
- ## Kind → binding constraints
7
+ For consumer wiring (client-authoritative vs server-authoritative dispatch,
8
+ role-grant-offer UI integration), see ../../docs/usage.md §Deriving
9
+ Route/Event Specs, §Single JSON-RPC 2.0 Endpoint, §WebSocket Endpoint. For
10
+ DEV-only output validation semantics see ../../docs/architecture.md
11
+ §DEV-only Output Validation. For the SAES binding matrix and middleware
12
+ ordering see the root ../../CLAUDE.md §Action Spec System (SAES) and
13
+ §Middleware Ordering.
94
14
 
95
- The three action kinds map to bindings with hard constraints:
15
+ **CLAUDE.md is a map; TSDoc is the detail.** Per-symbol semantics
16
+ (parameters, options, lifecycle methods, narrowing rules) live on TSDoc next
17
+ to the code. This file documents the cross-cutting invariants and the
18
+ shapes that span multiple files.
96
19
 
97
- | Kind | REST `RouteSpec` | RPC `RouteSpec` (via dispatcher) | WS dispatch | SSE `EventSpec` |
98
- | --------------------- | ---------------- | -------------------------------- | ----------- | --------------- |
99
- | `request_response` | yes (bridge) | yes (`create_rpc_endpoint`) | yes | no |
100
- | `remote_notification` | no | no | server push | yes (bridge) |
101
- | `local_call` | no | no | no | no |
20
+ Every exported Zod schema is paired with a same-named `z.infer` type export
21
+ ecosystem-wide rule (Skill(fuz-stack) §Zod schemas). New schemas keep
22
+ the pair invariant.
102
23
 
103
- `create_action_route_spec` throws if `spec.auth` is null — enforces that
104
- notifications and local calls cannot become routes. `create_action_event_spec`
105
- throws on any non-`remote_notification` kind.
24
+ ## Action specs (`actions/action_spec.ts`)
106
25
 
107
- ## Registry + codegen (`action_registry.ts`, `action_codegen.ts`)
26
+ Canonical source of truth. Three concrete kinds discriminate on `kind`:
108
27
 
109
- **Symmetric designuniversal calling abstraction.** SAES is one spec
110
- shape that drives dispatch across (a) network boundaries (frontend
111
- backend over HTTP / WS) and (b) within the same runtime (`local_call`
112
- actions). `ActionPeer` is symmetric on both sides (`send` + `receive`).
113
- The two typed surfaces are paired: `FrontendActionsApi` is "what the
114
- frontend can call" (typed Proxy from `create_rpc_client`);
115
- `BackendActionsApi` is "what the backend can call" (typed object from
116
- `create_broadcast_api` today; broader runtime constructors will join).
117
- The remaining asymmetry today is runtime: there is no
118
- `create_backend_rpc_client` and `create_broadcast_api` returns
119
- `Promise<void>` (fire-and-forget broadcast) rather than the
120
- `Promise<Result<{value}, {error}>>` shape `FrontendActionsApi` methods
121
- return. Closing those gaps is on the deferred follow-up set in the
122
- [SAES RPC closeout](https://github.com/ryanatkn/grimoire/blob/main/quests/HISTORY.md#saes-rpc-direction-2026-04)
123
- (grimoire `lore/fuz_app/TODO.md` §Future Directions tracks the symmetric
124
- backend signature, backend RPC client, and local-call symmetry items) —
125
- wait for a second backend runtime case.
126
-
127
- `ActionRegistry(specs)` is a query/filter wrapper over `ActionSpecUnion[]`.
128
- Codegen-used getter groups:
129
-
130
- - Identity: `spec_by_method`, `methods`.
131
- - Kind-narrow specs + matching `_methods`: `request_response_specs`,
132
- `remote_notification_specs`, `local_call_specs`.
133
- - Narrow handler-side (request_response only, `initiator` excludes own
134
- side, drives the typed `BackendActionHandlers` map):
135
- `frontend_handled_specs`, `frontend_handled_methods`,
136
- `backend_handled_specs`, `backend_handled_methods`.
137
- - Loose "relevant to this side" (everything the side might encounter,
138
- drives the typed-Proxy method enums `FrontendActionMethod` and
139
- `BackendActionMethod`): `specs_relevant_to_frontend`,
140
- `methods_relevant_to_frontend`, `specs_relevant_to_backend`,
141
- `methods_relevant_to_backend`.
142
- - Broadcast (kind-narrow `remote_notification`, `initiator !== 'frontend'`,
143
- excludes `streams` targets): `broadcast_specs`, `broadcast_methods`.
144
- - Backend-initiated (forward-looking kind-agnostic version of broadcast;
145
- same content today, will widen when local_calls or backend
146
- `request_response` join): `backend_initiated_specs`,
147
- `backend_initiated_methods`.
148
-
149
- Other getters (auth filters, initiator-direction filters) are pre-built
150
- API surface unused by codegen today.
151
-
152
- `action_codegen.ts` provides gen helpers (used by consumer `*.gen.ts` files,
153
- not the runtime):
154
-
155
- ### Primitives
156
-
157
- - `ImportBuilder` — tracks value / type / namespace imports; emits `import type` when every entry on a module is a type (tree-shaking). Namespace (`* as specs`) entries are emitted verbatim. Public surface: `add`, `add_type`, `add_many`, `add_types`, `build`, `preview`, `has_imports`, `import_count`, `clear`.
158
- - `get_executor_phases(spec, executor)` — phases a given executor (`'frontend' | 'backend'`) participates in for the spec. Branch-aware: the backend `can_receive` branch only pushes `send_error` when `!can_send`, so `initiator: 'both'` doesn't double-count and no `Set` dedup is needed.
159
- - `get_handler_return_type(spec, phase, imports, collections_path?)` — the TS type a phase handler must return; triggers the `ActionOutputs` import (sourced from `collections_path`, default `'./action_collections.js'`) as a side effect.
160
- - `generate_phase_handlers(spec, executor, imports, {action_event_type?, collections_path?})` — emits the typed handler-map fragment for one action; consumers compose these into `ActionHandlers` types. Returns `''` when the spec contributes no phases on the given executor (e.g. a backend-only `local_call` asked for `'frontend'`) so wrappers' `.filter(Boolean)` drops the row entirely instead of emitting a useless `${method}?: never` for a method that doesn't belong on this side.
161
- - `generate_actions_api_method_signature(spec, imports, {sync_returns_value?, collections_path?})` — single source of truth for the typed `FrontendActionsApi` method shape. Threads `options?: RpcClientCallOptions` (`{signal?, transport_name?, queue?}`) onto every async method — `request_response`, `remote_notification`, and async `local_call` — and wraps the return in `Promise<Result<...>>`. Registers exactly the imports the emitted line references on `imports` — `ActionInputs` only when the spec has input, `RpcClientCallOptions` only when async, `Result` / `JsonrpcErrorObject` only when the return wraps in `Result`. Mirrors the leaf-level pattern `get_handler_return_type` already follows so wrappers no longer pre-register imports a per-spec emit might not actually use.
162
- - `create_banner(origin_path)` — gen banner comment.
163
- - `to_action_spec_identifier(method)` / `to_action_spec_input_identifier` / `to_action_spec_output_identifier` — naming convention helpers (emit `foo_action_spec` / `foo_action_spec.input` / `foo_action_spec.output`).
164
- - `PROTOCOL_ACTION_METHODS` (+ `ProtocolActionMethod` type) — readonly tuple `['heartbeat', 'cancel']`. Pairs with `protocol_actions` / `protocol_action_specs` in `actions/protocol.ts` (the runtime bundles). Consumers spread when filtering backend `request_response` methods so dispatcher-owned protocol actions don't leak into `BackendRequestResponseMethod` / handler maps.
165
- - `is_protocol_action_method(method)` — type predicate paired with `PROTOCOL_ACTION_METHODS`; use this in `method_filter` callbacks instead of `PROTOCOL_ACTION_METHODS.includes(s.method as never)`.
166
- - `DEFAULT_COLLECTIONS_PATH = './action_collections.js'` — shared default for every helper that takes a `collections_path?`.
167
- - `DEFAULT_SPECS_MODULE = './action_specs.js'` — shared default for helpers that emit `specs.{method}_action_spec` and need a `* as specs` namespace import.
168
- - `DEFAULT_METATYPES_PATH = './action_metatypes.js'` — shared default for the sibling module carrying the generated `ActionMethod` enum.
169
- - `resolve_spec_qualifier(imports, {specs_module?, qualify_spec?})` — the standard default-vs-callback resolver every multi-source-aware helper in this module uses. With `qualify_spec` set, returns the callback verbatim (consumer owns its namespace setup); otherwise registers `* as specs from specs_module` (default `DEFAULT_SPECS_MODULE`) on `imports` and returns `(s) => 'specs.' + to_action_spec_identifier(s.method)`. Reuse from custom codegen helpers instead of reimplementing the defaulting + import-registration dance.
170
-
171
- ### High-level helpers
172
-
173
- Each accepts `(specs, imports, options?)` and returns one block of declarations.
174
- Composed by consumer `*.gen.ts` producers; outputs do not include the banner or
175
- surrounding `imports.build()`. Use `compose_gen_file` to assemble the block
176
- list + banner + imports into the final file body in one call.
177
-
178
- **Protocol actions are filtered by default.** Every spec-iterating helper
179
- accepts `{include_protocol_actions?: boolean}` (default `false`) and drops
180
- `heartbeat` / `cancel` from the emitted output. Protocol actions ship from
181
- fuz_app and are spread into each consumer's `actions` array at
182
- registration time (via `protocol_actions` from `actions/protocol.ts`) —
183
- they should not appear in consumer-owned typed surfaces (`ActionMethod`,
184
- `FrontendActionsApi`, `ActionInputs`, `FrontendActionHandlers`, etc.).
185
- Pass `include_protocol_actions: true` only if a consumer genuinely owns
186
- protocol actions in their typed API.
187
-
188
- **Consumer tiers and namespace handling.** Single-source consumers (zzz,
189
- undying — every spec lives in one local `action_specs.ts`) drop straight
190
- into the helpers and accept the default `* as specs from specs_module`
191
- namespace import. Multi-source consumers (zap, visiones — which stitch
192
- local specs together with `all_admin_action_specs` /
193
- `all_role_grant_offer_action_specs` / `all_account_action_specs` /
194
- `all_self_service_role_action_specs` from fuz_app) call
195
- `create_namespace_qualifier(sources, imports)` once, then pass the
196
- returned `qualify_spec` callback to the multi-source helpers
197
- (`generate_action_specs_record`, `generate_action_inputs_outputs`,
198
- `generate_backend_actions_api`). When `qualify_spec` is set, the helper
199
- emits the callback's return value (e.g.
200
- `admin_specs.account_list_action_spec`) and skips the default `* as specs`
201
- import — the consumer (or the namespace-qualifier helper) owns the
202
- multi-namespace imports. The helper appends `.input` / `.output` to the
203
- qualified identifier in `generate_action_inputs_outputs` automatically;
204
- the callback returns the bare spec identifier.
205
-
206
- Tier 1 (HTTP-only, e.g. zap/visiones) emits a smaller surface — typically just
207
- `ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`
208
- interfaces — and never calls `generate_typed_action_event_alias` or
209
- `generate_frontend_action_handlers`. Tier 2 (`TypedActionEvent`-aware, e.g.
210
- zzz) emits the full set including `ActionEventDatas`, `TypedActionEvent`,
211
- and `FrontendActionHandlers`.
28
+ - `request_response``auth: RouteAuth` (non-null), `side_effects` arbitrary, `output` arbitrary, `async: true`.
29
+ - `remote_notification` `auth: null`, `side_effects: true`, `output: z.ZodVoid`, `async: true`.
30
+ - `local_call` `auth: null`, `side_effects` arbitrary, `output` arbitrary, `async` boolean.
31
+
32
+ `RouteAuth` is the flat record `{account, actor, roles?, credential_types?}`
33
+ from `http/auth_shape.ts` same shape governs `RouteSpec.auth` so the four
34
+ axes drive one auth surface across REST and SAES. Cross-axis invariants:
35
+ roles imply `actor: 'required'`; `account: 'none'` implies `actor: 'none'`
36
+ (no accountless actors in v1); the unrestricted leaf
37
+ (`account: 'none', actor: 'none'`) cannot declare roles or credential
38
+ gates. The biconditional `actor !== 'none' input declares acting?: ActingActor`
39
+ is enforced at registration time via `assert_route_auth_acting_biconditional`.
40
+
41
+ Optional fields:
42
+
43
+ - `streams?: string` names a companion `remote_notification` method
44
+ emitted as request-scoped progress.
45
+ - `error_reasons?: ReadonlyArray<string>` — reason codes the handler may
46
+ surface via `error.data.reason`. Declarative metadata for consumers
47
+ (codegen, UI form-state matching, docs); no runtime enforcement, drift
48
+ caught per-module by source-scanning unit tests (e.g.
49
+ ../../test/auth/role_grant_offer_actions.error_reasons.test.ts).
50
+ - `rate_limit?: 'ip' | 'account' | 'both'` — opts the action into the
51
+ dispatcher's per-action rate-limit hook. **Throttle-requests semantics**
52
+ every invocation records regardless of outcome (different from REST
53
+ login's throttle-failures). `'account'` rejected at registration when
54
+ paired with `auth.account !== 'required'`. Limiters configured via
55
+ `AppServerOptions.action_ip_rate_limiter` / `action_account_rate_limiter`
56
+ and threaded into both dispatchers automatically.
57
+
58
+ Canonical spec shape: module-scope `satisfies` declaration with
59
+ `{method}_action_spec` naming, preserving the literal `method` type and
60
+ dropping per-spec `*_METHOD` constants (readers dereference `.method`). See
61
+ ../../docs/usage.md §Canonical action-spec shape.
212
62
 
213
- - `generate_action_method_enums(specs, imports, {emit?, include_protocol_actions?})` — up to nine `z.enum` + `z.infer` pairs (`ActionMethod`, `RequestResponseActionMethod`, `RemoteNotificationActionMethod`, `LocalCallActionMethod`, `FrontendActionMethod`, `BackendActionMethod`, `FrontendRequestResponseMethod`, `BackendRequestResponseMethod`, `BroadcastActionMethod`). `emit: ReadonlySet<ActionMethodEnumKind>` restricts to a subset (Tier 1 HTTP-only consumers don't need all nine). Skips kinds whose method list is empty (`z.enum([])` is invalid) and skips the `zod` import when no blocks are emitted. Adds `import {z} from 'zod'` only when at least one block is produced. The `frontend_handled` / `backend_handled` / `broadcast` kinds use the registry's narrow handler-side / streams-aware getters; the loose `frontend` / `backend` kinds preserve the everything-relevant-to-this-side semantic for the typed-Proxy method enum.
214
- - `generate_action_method_enum_block(specs, imports, {name, jsdoc, predicate, include_protocol_actions?})` — lower-level escape hatch for genuinely cross-product enums the discriminator doesn't cover. Caller owns the predicate, name, and jsdoc.
215
- - `generate_typed_action_event_alias(imports, {collections_path?, metatypes_path?})` — fixed-shape `TypedActionEvent<TMethod, TPhase, TStep>` alias narrowing `ActionEvent.data` against `ActionEventDatas`. Adds the three fuz_app type imports + `ActionEventDatas` (from `collections_path`) + `ActionMethod` (from `metatypes_path`).
216
- - `generate_action_specs_record(specs, imports, {specs_module?, qualify_spec?, include_protocol_actions?})` — `ActionSpecs` runtime const + interface + `action_specs: Array<ActionSpecUnion>` value. Adds `* as specs` from `specs_module` unless `qualify_spec` is set (then `specs_module` is ignored and the consumer owns namespace imports).
217
- - `generate_action_inputs_outputs(specs, imports, {specs_module?, qualify_spec?, include_protocol_actions?})` — `ActionInputs` and `ActionOutputs` runtime consts + interfaces. Same `qualify_spec` semantics as `generate_action_specs_record`; the helper appends `.input` / `.output` to the qualified identifier.
218
- - `generate_action_event_datas(specs, imports, {same_file?, collections_path?, include_protocol_actions?})` — `ActionEventDatas` interface; per-spec variant by kind (`ActionEventRequestResponseData` / `ActionEventRemoteNotificationData` / `ActionEventLocalCallData`). `same_file` (default `true`) is the file-layout switch: when `true`, assumes `ActionInputs` / `ActionOutputs` are in the same module and adds no import (the zzz pattern); when `false`, adds the type imports from `collections_path` (default `'./action_collections.js'`). `collections_path` alone is a no-op — the surprising omit-vs-default behavior of earlier versions has been replaced.
219
- - `generate_frontend_actions_api(specs, imports, {interface_name?, method_filter?, collections_path?, sync_returns_value?, include_protocol_actions?})` — emits the typed `FrontendActionsApi` interface (configurable via `interface_name`, default `'FrontendActionsApi'`). One method signature per spec via `generate_actions_api_method_signature`. Protocol actions filtered by default; `method_filter: (spec) => boolean` runs after the protocol-action filter. Renamed from `generate_actions_api` in API review III to make the side-of-the-wire intent visible at every consumer site.
220
- - `generate_frontend_action_handlers(specs, imports, {collections_path?, include_protocol_actions?})` — `FrontendActionHandlers` interface (Tier 2 only — wraps `generate_phase_handlers` with `action_event_type: 'TypedActionEvent'`). Pair with `generate_typed_action_event_alias`.
221
- - `generate_backend_actions_api(specs, imports, {interface_name?, spec_array_name?, specs_module?, collections_path?, qualify_spec?, include_protocol_actions?})` — `BackendActionsApi` interface AND `broadcast_action_specs: ReadonlyArray<ActionSpecUnion>` array (both names configurable). Filter: `kind === 'remote_notification' && initiator !== 'frontend'`, with `streams`-target methods (request-scoped progress notifications invoked via `ctx.notify`) excluded — the discriminator is `ActionSpec.streams`, not a manual list. Adds `ActionInputs` (from `collections_path`) + `ActionSpecUnion`, plus `* as specs` from `specs_module` unless `qualify_spec` is set. Method shape today is `(input) => Promise<void>` (matches `create_broadcast_api`'s fire-and-forget runtime); generalizing to per-kind shapes via `generate_actions_api_method_signature` is deferred until a second backend runtime constructor lands (tracked in grimoire `lore/fuz_app/TODO.md` §Future Directions, _Symmetric backend signature shape_).
222
- - `generate_backend_action_handlers_map(imports, options?)` — emits the `BackendActionHandlers` mapped type (`{[K in BackendRequestResponseMethod]: (input: ActionInputs[K], ctx: BackendHandlerContext) => ActionOutputs[K] | Promise<ActionOutputs[K]>}`). Replaces the hand-maintained `Exclude<>` + parallel mapped-type pattern (zzz had this at `zzz/src/lib/server/zzz_action_handlers.ts:42-66`). Configurable type name, method enum name, and context type name; configurable `collections_path` / `metatypes_path` for the type imports.
63
+ ## Kind binding matrix
223
64
 
224
- ### Wrapper + multi-source helper
65
+ - `request_response` REST `RouteSpec` via bridge; RPC `RouteSpec` via `create_rpc_endpoint`; WS dispatch yes; no SSE.
66
+ - `remote_notification` — no REST/RPC routes; WS server push; SSE `EventSpec` via bridge.
67
+ - `local_call` — none (no REST, no RPC, no WS, no SSE).
225
68
 
226
- - `compose_gen_file({origin_path, imports, blocks})` encapsulates the per-`*.gen.ts` boilerplate (banner + `imports.build()` + blocks join + template literal). Returns the full file body. Each consumer producer collapses to one `compose_gen_file` call wrapping the helper invocations.
227
- - `create_namespace_qualifier(sources, imports)` multi-source consumer helper. Takes `ReadonlyArray<{ns, module, specs}>`, registers `import * as ns from module` for each on `imports`, builds the `method_to_ns` lookup with duplicate-method detection, returns `{qualify_spec, all_specs}` ready to thread through the high-level helpers. Closes the per-file boilerplate gap that kept zap + visiones on hand-rolled template strings even after the `qualify_spec?` callback landed (the per-call callback wasn't enough — the import dance + dup-check was the real boilerplate).
69
+ `create_action_route_spec` throws if `spec.auth` is null (notifications and
70
+ local calls cannot become routes). `create_action_event_spec` throws on any
71
+ non-`remote_notification` kind.
228
72
 
229
- ## Registry compile (`compile_action_registry.ts`)
73
+ ## Registry compile (`actions/compile_action_registry.ts`)
230
74
 
231
- Shared registration loop called by both `create_rpc_endpoint` and
232
- `register_action_ws`. Validates four invariants and returns the
233
- `Map<method, RpcAction>` the dispatchers use:
75
+ `compile_action_registry` is the shared registration loop called by both
76
+ `create_rpc_endpoint` and `register_action_ws`. Validates four
77
+ registry-time invariants and returns the `Map<method, RpcAction>` the
78
+ dispatchers use:
234
79
 
235
- 1. Auth-shape biconditional (`assert_route_auth_acting_biconditional`
236
- `auth.actor !== 'none' input declares acting?: ActingActor`).
237
- 2. Rate-limit / account axis — `rate_limit: 'account' | 'both'` requires
238
- `auth.account === 'required'`.
239
- 3. JSON-RPC §4.2 wire validity — `request_response` specs with a handler
240
- may not use `z.null()` for input (use `z.void()` for nullary).
241
- 4. Unique `method` across the array.
80
+ 1. **Auth-shape biconditional** — `actor !== 'none' ⟺ input declares acting?: ActingActor` (via `assert_route_auth_acting_biconditional`).
81
+ 2. **Rate-limit account axis** — `rate_limit: 'account' | 'both'` requires `auth.account === 'required'`.
82
+ 3. **JSON-RPC §4.2 wire validity** — `request_response` specs with a handler may not use `z.null()` for input (use `z.void()` for nullary).
83
+ 4. **Unique method names** across the array.
242
84
 
243
85
  Only `request_response` specs with a handler reach the dispatch map;
244
86
  `remote_notification` / handler-less specs (e.g. WS `cancel`) stay
245
87
  registry-only.
246
88
 
247
- ## HTTP bridge (`action_bridge.ts`)
89
+ ## Registry + codegen (`actions/action_registry.ts`, `actions/action_codegen.ts`)
90
+
91
+ **Symmetric design — universal calling abstraction.** SAES is one spec
92
+ shape driving dispatch across (a) network boundaries (frontend ⇄ backend
93
+ over HTTP / WS) and (b) within the same runtime (`local_call` actions).
94
+ `ActionPeer` is symmetric on both sides (`send` + `receive`). Typed
95
+ surfaces are paired: `FrontendActionsApi` is "what the frontend can call"
96
+ (typed Proxy from `create_rpc_client`); `BackendActionsApi` is "what the
97
+ backend can call" (typed object from `create_broadcast_api` today;
98
+ broader runtime constructors will join). Remaining asymmetry:
99
+ `create_broadcast_api` returns `Promise<void>` while `FrontendActionsApi`
100
+ methods return `Promise<Result<...>>`. Closing those gaps is on the
101
+ deferred follow-up set from the SAES RPC closeout work
102
+ — wait for a second backend runtime case.
103
+
104
+ ### `ActionRegistry`
105
+
106
+ Query/filter wrapper over `ActionSpecUnion[]`. Codegen-relevant getter
107
+ groups (each pairs `_specs` with matching `_methods`):
108
+
109
+ - Kind-narrow — filter by `kind`; drives `*ActionMethod` enums.
110
+ - `*_handled` — `request_response` + handler-side initiator; drives `BackendActionHandlers` map.
111
+ - `specs_relevant_to_*` — everything the side might encounter; drives typed-Proxy method enums.
112
+ - `broadcast` — `remote_notification`, `initiator !== 'frontend'`, excludes streams; drives `BackendActionsApi` interface.
113
+ - `backend_initiated` — forward-looking kind-agnostic broadcast; same content today.
114
+
115
+ Other getters (auth filters, initiator-direction filters) are pre-built API
116
+ surface unused by codegen today.
117
+
118
+ ### Codegen helpers (`actions/action_codegen.ts`)
119
+
120
+ Used by consumer `*.gen.ts` producers, not the runtime. Detailed signatures
121
+ and options on each function's TSDoc.
122
+
123
+ - `ImportBuilder` — class managing value/type/namespace imports; auto-tree-shakes type-only.
124
+ - `get_executor_phases(spec, executor)` — phases an executor participates in for the spec.
125
+ - `get_handler_return_type` — TS type a phase handler must return; side-effect imports `ActionOutputs`.
126
+ - `generate_phase_handlers` — per-action typed handler-map fragment.
127
+ - `generate_actions_api_method_signature` — single source of truth for the typed `FrontendActionsApi` method shape.
128
+ - `generate_action_method_enums` — up to nine `z.enum` + `z.infer` pairs.
129
+ - `generate_action_method_enum_block` — lower-level escape hatch for cross-product enums.
130
+ - `generate_typed_action_event_alias` — fixed-shape `TypedActionEvent<TMethod, TPhase, TStep>` alias.
131
+ - `generate_action_specs_record` — `ActionSpecs` runtime const + interface + `action_specs` array.
132
+ - `generate_action_inputs_outputs` — `ActionInputs` + `ActionOutputs` runtime consts + interfaces.
133
+ - `generate_action_event_datas` — `ActionEventDatas` interface; per-spec variants.
134
+ - `generate_frontend_actions_api` — typed `FrontendActionsApi` interface.
135
+ - `generate_frontend_action_handlers` — `FrontendActionHandlers` interface (Tier 2 only).
136
+ - `generate_backend_actions_api` — `BackendActionsApi` interface + `broadcast_action_specs` array.
137
+ - `generate_backend_action_handlers_map` — `BackendActionHandlers` mapped type.
138
+ - `compose_gen_file` — wrapper: banner + `imports.build()` + blocks join.
139
+ - `create_namespace_qualifier(sources, imports)` — multi-source consumer helper; registers `import * as ns` per source.
140
+
141
+ Shared defaults: `DEFAULT_COLLECTIONS_PATH = './action_collections.js'`,
142
+ `DEFAULT_SPECS_MODULE = './action_specs.js'`,
143
+ `DEFAULT_METATYPES_PATH = './action_metatypes.js'`,
144
+ `resolve_spec_qualifier` (the default-vs-callback resolver every
145
+ multi-source-aware helper uses).
146
+
147
+ ### Codegen invariants
148
+
149
+ **Protocol actions filtered by default.** Every spec-iterating helper
150
+ accepts `{include_protocol_actions?: boolean}` (default `false`) and drops
151
+ `heartbeat` / `cancel`. Protocol actions ship from fuz_app and spread into
152
+ each consumer's `actions` array at registration time (via
153
+ `protocol_actions` from `actions/protocol.ts`); they should not appear in
154
+ consumer-owned typed surfaces. Pass `include_protocol_actions: true` only
155
+ if a consumer genuinely owns protocol actions in their typed API.
156
+
157
+ **Consumer tiers.** Single-source consumers (zzz) drop into the helpers
158
+ and accept the default `* as specs` namespace import. Multi-source
159
+ consumers (zap, visiones — stitching local specs with
160
+ `all_admin_action_specs` / `all_role_grant_offer_action_specs` /
161
+ `all_account_action_specs` / `all_self_service_role_action_specs` from
162
+ fuz_app) call `create_namespace_qualifier` once, then pass the returned
163
+ `qualify_spec` callback to multi-source helpers.
164
+
165
+ **Tier 1** (HTTP-only, zap/visiones) emits a smaller surface — typically
166
+ `ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`.
167
+ Never calls `generate_typed_action_event_alias` or
168
+ `generate_frontend_action_handlers`. **Tier 2** (`TypedActionEvent`-aware,
169
+ zzz) emits the full set including `ActionEventDatas`, `TypedActionEvent`,
170
+ and `FrontendActionHandlers`.
171
+
172
+ ## HTTP bridge (`actions/action_bridge.ts`)
248
173
 
249
174
  Derives transport-specific specs from action specs. HTTP-specific concerns
250
175
  (path, handler, errors) come from options, not the action spec.
251
176
 
252
- - `create_action_route_spec(spec, options)` — one action → one `RouteSpec`. HTTP method defaults by `side_effects` (`true` → POST, `false` → GET; override via `options.http_method`). `route.auth` is `spec.auth` verbatim (the same `RouteAuth` shape governs both surfaces). `options.errors: RouteErrorSchemas` attaches transport-specific (HTTP status–keyed) error shapes. `transaction: spec.side_effects`. Throws if `spec.auth` is null.
177
+ - `create_action_route_spec(spec, options)` — one action → one `RouteSpec`. HTTP method defaults by `side_effects` (`true` → POST, `false` → GET; override via `options.http_method`). `route.auth` is `spec.auth` verbatim. `transaction: spec.side_effects`. Throws if `spec.auth` is null.
253
178
  - `create_action_event_spec(spec, {channel?})` — one notification action → one `EventSpec` for SSE surface + `create_validated_broadcaster`. Throws on non-`remote_notification` kind.
254
- - `derive_http_method(side_effects)` — exported for consumers that build custom bridges.
179
+ - `derive_http_method(side_effects)` — exported for custom bridges.
255
180
 
256
- ## Single JSON-RPC 2.0 endpoint (`action_rpc.ts`)
181
+ ## Single JSON-RPC 2.0 endpoint (`actions/action_rpc.ts`)
257
182
 
258
183
  `create_rpc_endpoint({path, actions, log}): RouteSpec[]` produces **two**
259
184
  route specs on the same path (GET + POST) that share one internal
@@ -261,68 +186,47 @@ dispatcher. Per-action auth lives inside the dispatcher; the outer routes
261
186
  use `auth: {account: 'none', actor: 'none'}` and `transaction: false`.
262
187
 
263
188
  The HTTP RPC dispatcher is a thin shim around `perform_action`
264
- (`actions/perform_action.ts`). The shim owns the wire-shape concerns
265
- (envelope parsing, GET vs POST split, `c.json` binding); the
266
- auth/validation/dispatch pipeline is shared with the WebSocket
267
- dispatcher.
268
-
269
- Phase order: **401 → 400 → 403 → handler** — validate first, authorize
270
- after. The trade-off is that an unauthorized caller sees the validation
271
- step; the alternative ordering (403-before-400) was rejected because
272
- defense-in-depth via attack-surface obscurity is illusory when the
273
- surface is published in `library.json` codegen anyway.
274
-
275
- Shim responsibilities:
276
-
277
- 1. **Parse envelope** — POST body as `JsonrpcRequest` (parse errors → JSON-RPC `parse_error` 400). GET reads `method`, `id`, `params` from query string; missing `method`/`id` → 400 `invalid_request`. Integer `id` normalization: `?id=42` matches `{id: 42}`.
278
- 2. **Lookup method** — `Map<method, RpcAction>` built via `compile_action_registry` (which runs the registry-time invariants — see §Registry compile above). Unknown method → `method_not_found`.
279
- 3. **GET read restriction** — GET is rejected for `side_effects: true` actions (`invalid_request` with "must use POST"). HTTP-only.
280
- 4. **Build PerformActionInput** — read `account_id` / `credential_type` from `c.var`, resolve `client_ip` via `get_client_ip`, pass `c.req.raw.signal` as `signal`, build a DEV-warn-and-drop `notify`. Test-preset escape hatch reads `TEST_CONTEXT_PRESET_KEY` + `REQUEST_CONTEXT_KEY` and forwards as `preset.request_context`.
281
- 5. **Call `perform_action`** — runs steps 1–6 of the shared pipeline (see §Shared dispatch core below).
282
- 6. **Bind result** — `perform_action_result_to_envelope(id, result)` builds the JSON-RPC wire envelope; `c.json(envelope, result.status)` returns it.
283
-
284
- The shared core inside `perform_action` runs:
285
-
286
- - Pre-validation auth (401), input validation (400), authorization phase (with `apply_authorization_phase` resolving the actor from `validated_input.acting`), post-authorization auth (403 — credential gate first, role gate second), rate limit (429), transactional dispatch + DEV output validation, error normalization.
287
-
288
- Resolution failures from the authorization phase come back as
289
- `AuthorizationResult.ok === false` carrying `{status, body}` —
290
- `perform_action` folds this into a JSON-RPC envelope where `error.code`
291
- maps from `http_status_to_jsonrpc_error_code(result.status)`,
292
- `error.message` is the reason string, and `error.data: {reason, ...rest}`
293
- flattens any diagnostic fields (e.g. `available[]` for `actor_required`).
294
- The two 500 reasons stay distinct: `no_actors_on_account` (signup
295
- invariant violation — the actor enumeration came back empty);
296
- `account_vanished` (torn read after resolve). REST emits the same `body`
297
- directly via `c.json(body, status)` for surface consistency.
298
-
299
- Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error
300
- && typeof err.code === 'number'`) preserves code + data verbatim. Duck-
301
- typing avoids cross-copy `instanceof` misses when consumers throw their
302
- own `ThrownJsonrpcError` (e.g. zzz). Generic thrown errors become
303
- `internal_error` 500; message is the raw error under `DEV`, "internal
304
- server error" otherwise. The HTTP shim's outer `c.json` then binds the
305
- status.
306
-
307
- Per-request handler shape (uniform across HTTP RPC + WS):
189
+ (`actions/perform_action.ts`). The shim owns wire-shape concerns (envelope
190
+ parsing, GET vs POST split, `c.json` binding); the
191
+ auth/validation/dispatch pipeline is shared with the WebSocket dispatcher.
308
192
 
309
- ```ts
310
- type ActionHandler<TInput, TOutput> = (
311
- input: TInput,
312
- ctx: ActionContext,
313
- ) => TOutput | Promise<TOutput>;
193
+ **Phase order: 401 → 400 → 403 → handler.** Validate first, authorize
194
+ after. Trade-off: an unauthorized caller sees the validation step. The
195
+ alternative ordering (403-before-400) was rejected because
196
+ defense-in-depth via attack-surface obscurity is illusory when the surface
197
+ is published in `library.json` codegen anyway.
198
+
199
+ Shim responsibilities (per-request):
200
+
201
+ 1. Parse envelope (POST body / GET query string); parse errors → `parse_error` 400.
202
+ 2. Lookup method in the `compile_action_registry`-built map; unknown → `method_not_found`.
203
+ 3. GET read restriction — GET rejected for `side_effects: true` actions.
204
+ 4. Build `PerformActionInput` from `c.var` + `get_client_ip` + `c.req.raw.signal`. Test-preset escape hatch reads `TEST_CONTEXT_PRESET_KEY` + `REQUEST_CONTEXT_KEY`.
205
+ 5. Call `perform_action` (shared core).
206
+ 6. Bind result via `perform_action_result_to_envelope(id, result)`; `c.json(envelope, result.status)`.
207
+
208
+ Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error &&
209
+ typeof err.code === 'number'` to handle cross-copy `instanceof` misses,
210
+ e.g. when consumers like zzz throw their own `ThrownJsonrpcError`)
211
+ preserves code + data verbatim. Generic throws become `internal_error` 500;
212
+ message is the raw error under `DEV`, "internal server error" otherwise.
314
213
 
214
+ ### Per-request handler shape
215
+
216
+ Unified across HTTP RPC + WS via `ActionContext`:
217
+
218
+ ```ts
315
219
  interface ActionContext {
316
220
  auth: RequestContext | null; // null for public actions
317
221
  request_id: JsonrpcRequestId;
318
222
  connection_id?: Uuid; // populated on WS, undefined on HTTP
319
223
  db: Db; // transaction for mutations, pool for reads
320
- pending_effects: Array<Promise<void>>; // eager pool writes already in flight — see http/CLAUDE.md §Pending Effects
224
+ pending_effects: Array<Promise<void>>; // eager — see http/CLAUDE.md §Pending Effects
321
225
  post_commit_effects: Array<() => void | Promise<void>>; // deferred — push via `emit_after_commit`
322
226
  client_ip: string;
323
- credential_type: CredentialType | null; // session | api_token | daemon_token (or null for anonymous) — same value the credential_types gate consumed
227
+ credential_type: CredentialType | null; // same value the credential_types gate consumed
324
228
  log: Logger;
325
- notify: (method, params) => void; // HTTP: DEV-mode warn + drop (no streaming channel); WS: socket-scoped
229
+ notify: (method, params) => void; // HTTP: DEV-mode warn + drop; WS: socket-scoped
326
230
  signal: AbortSignal; // HTTP: client-disconnect; WS: AbortSignal.any([socket_close, request_cancel])
327
231
  }
328
232
 
@@ -332,198 +236,173 @@ interface RpcAction {
332
236
  }
333
237
  ```
334
238
 
335
- ### `rpc_action(spec, handler)` — typed binder with conditional `ctx.auth` narrowing
239
+ ### `rpc_action(spec, handler)` — typed binder
336
240
 
337
- `rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)`
338
- returns a `RpcAction` with the handler's input / output types pinned
339
- to `z.infer<TSpec['input']>` / `z.infer<TSpec['output']>` and the
340
- handler's `ctx.auth` slot tightened to the narrowest shape the
341
- dispatcher's runtime guarantee allows. The conditional `HandlerForSpec<TSpec>`
342
- discriminates on the spec literal:
241
+ `rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)` pins
242
+ the handler's input / output types to `z.infer<TSpec['input']>` /
243
+ `z.infer<TSpec['output']>` and tightens `ctx.auth` per the conditional
244
+ `HandlerForSpec<TSpec>`:
343
245
 
344
- | Spec auth axes | Selected handler type | `ctx.auth` |
345
- | ------------------------------------------------------ | --------------------- | ------------------------ |
346
- | `auth.actor === 'required'` | `ActorActionHandler` | `RequestActorContext` |
347
- | `auth.account === 'required' && auth.actor === 'none'` | `AuthActionHandler` | `RequestContext` |
348
- | else (public, optional axes) | `ActionHandler` | `RequestContext \| null` |
246
+ - `auth.actor === 'required'` `ctx.auth: RequestActorContext`.
247
+ - `auth.account === 'required' && auth.actor === 'none'` → `ctx.auth: RequestContext`.
248
+ - else (public, optional axes) → `ctx.auth: RequestContext | null`.
349
249
 
350
- Use this at every spec → handler binding site so handler-type errors
351
- surface at the factory call instead of at runtime:
352
-
353
- ```ts
354
- // actor-implying spec ctx.auth: RequestActorContext (actor non-null)
355
- rpc_action(role_grant_revoke_action_spec, async (input, ctx) => {
356
- const revoker_id = ctx.auth.actor.id;
357
- // …
358
- });
359
-
360
- // account-grain spec → ctx.auth: RequestContext (actor: null)
361
- rpc_action(account_verify_action_spec, (_input, ctx) => {
362
- return to_session_account(ctx.auth.account);
363
- });
364
- ```
365
-
366
- The bracketed form `[T] extends ['required']` defeats distributive
367
- conditionals so a degraded `AuthAxisState` union (when the spec was
368
- typed without preserving its literal) falls through to the loosest
369
- tier instead of collapsing to the narrowest. Specs declared with
370
- `satisfies RequestResponseActionSpec` (canonical) preserve the
371
- literals — typing a spec directly as `RequestResponseActionSpec`
372
- widens the axes and silently drops the ergonomic narrowing (the
373
- binder still compiles; consumers just lose the auto-narrow on
374
- `ctx.auth`).
250
+ Use at every spec → handler binding site so handler-type errors surface
251
+ at the factory call instead of at runtime. The bracketed form
252
+ `[T] extends ['required']` defeats distributive conditionals so a degraded
253
+ `AuthAxisState` union (when the spec was typed without preserving its
254
+ literal) falls through to the loosest tier instead of the narrowest.
375
255
 
376
256
  zzz uses a codegen-driven `Record<Method, Handler>` map for the same
377
257
  narrowing — ideal when handlers are stateless free functions. fuz_app's
378
258
  handlers close over factory-captured deps (`log`, `audit`,
379
259
  `options.app_settings`, `options.max_tokens`), so per-pair typing via
380
- `rpc_action()` is the right shape here: the binding happens at
381
- construction time and the handler keeps its closure. The conditional
382
- auto-selects the right tier per spec; consumers don't pick a binder.
260
+ `rpc_action()` is the right shape here.
383
261
 
384
- ## Transports (`transports.ts`, `transports_http.ts`, `transports_ws.ts`, `transports_ws_backend.ts`)
262
+ ## Shared dispatch core (`actions/perform_action.ts`)
385
263
 
386
- `Transport` is the unifying interface: overloaded `send(message, options?)`
387
- returning `Promise<JsonrpcResponseOrError>` for requests and
388
- `Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
389
- and optional `dispose()`. All transports share `TransportSendOptions`:
264
+ The transport-agnostic post-parse pipeline. Each transport assembles a
265
+ `PerformActionInput` from its wire envelope + connection identity, calls
266
+ `perform_action(input, deps)`, and binds the discriminated
267
+ `PerformActionResult` to its wire shape.
268
+
269
+ Pipeline (401 → 400 → 403 → handler):
390
270
 
391
- - `signal?: AbortSignal` — per-call cancel. Bottoms out at `FrontendWebsocketClient.request({signal})` on WS (sends `cancel` notification on abort) and at `fetch({signal})` on HTTP.
392
- - `queue?: boolean`per-call durable-queue opt-in. Honored only by `FrontendWebsocketTransport` on the `request_response` path (default `false`). HTTP, backend, and WS notifications all ignore it.
271
+ 1. Pre-validation auth (401)
272
+ 2. Validate params (400) `spec.input.safeParse` with `z.void()` / `?? {}` rules
273
+ 3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch via `preset.request_context`
274
+ 4. Post-authorization auth (403) — credential-type gate first, role gate second
275
+ 5. Rate limit (429) — throttle-requests semantics
276
+ 6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction vs pool. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`
393
277
 
394
- `Transports` registry holds multiple transports with a `current` selection
395
- and `allow_fallback: boolean` (default `true`). `get_transport(name?)`
396
- returns first-ready: specified current → any. No fallback when
397
- `allow_fallback: false`. Explicit `transport_for_method` (on `rpc_client`)
398
- or `default_send_options.transport_name` (on `ActionPeer`) takes precedence.
278
+ `PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
279
+ `signal`, `notify`, optional `connection_id`, optional `preset`.
280
+ `PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
281
+ the two rate limiters. Audit writes are out-of-band: factories close over
282
+ `AppDeps.audit` independently.
399
283
 
400
- WS close codes live here:
284
+ Authorization-phase resolution failures from the auth domain come back as
285
+ `AuthorizationResult.ok === false` carrying `{status, body}` — folded into
286
+ a JSON-RPC envelope where `error.code` maps from
287
+ `http_status_to_jsonrpc_error_code(result.status)`, `error.message` is the
288
+ reason string, and `error.data: {reason, ...rest}` flattens diagnostic
289
+ fields. REST emits the same `body` directly via `c.json(body, status)` for
290
+ surface consistency.
401
291
 
402
- - `WS_CLOSE_SESSION_REVOKED = 4001` server revoked auth; client enters permanent `revoked` state, no reconnect.
403
- - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
404
- - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
292
+ ## DEV-only output validationuniform across surfaces
405
293
 
406
- ### `FrontendHttpTransport` (`transports_http.ts`)
294
+ Critical invariant: every action-handler surface applies DEV-only output
295
+ validation and produces the **same failure mode** — log an error, return
296
+ the response unchanged, do not throw, do not mutate status.
407
297
 
408
- Thin `fetch` adapter. Name `'frontend_http_rpc'`. POST by default; GET with
409
- `?method=&id=&params=` when the caller supplies `has_side_effects(method)`
410
- returning `false` (matches `create_rpc_endpoint`'s GET convention). Forwards
411
- `signal` to `fetch`. On non-OK HTTP response, synthesizes a JSON-RPC error
412
- envelope via `http_status_to_jsonrpc_error_code`. DEV-mode checks that
413
- JSON-RPC error codes match the declared HTTP status and warns on drift.
414
- `is_ready(): true` always.
298
+ - REST bridge — `http/route_spec.ts` `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`). Production hot path short-circuits (no parse).
299
+ - HTTP RPC + WebSocket dispatch — `actions/perform_action.ts` `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core. Production hot path short-circuits (no parse).
415
300
 
416
- ### `FrontendWebsocketTransport` (`transports_ws.ts`)
301
+ Caller-facing `input` schemas are validated **always** (DEV + production)
302
+ — they're the contract with external callers. Server-authored `output`
303
+ schemas are internal data. See ../../docs/architecture.md §DEV-only Output
304
+ Validation for full rationale.
417
305
 
418
- Name `'frontend_websocket_rpc'`. A **thin adapter** over `WebsocketRpcConnection`
419
- (the canonical implementation is `FrontendWebsocketClient`). No parallel
420
- pending-request map — delegates request/response correlation, durable queue,
421
- heartbeat, and abort-signal cancel to the underlying connection. Routes
422
- inbound server-pushed messages (requests + notifications) into a `receive`
423
- callback; responses are owned by `connection.request()` so the transport
424
- ignores them.
306
+ ## Transports
425
307
 
426
- Two connection interfaces it consumes:
308
+ `Transport` is the unifying interface — overloaded `send(message, options?)`
309
+ returning `Promise<JsonrpcResponseOrError>` for requests and
310
+ `Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
311
+ and optional `dispose()`. All transports share `TransportSendOptions`:
427
312
 
428
- - `WebsocketConnection` — minimal fire-and-forget (`send(data)`, `connected`, `add_message_handler`, `add_error_handler`).
429
- - `WebsocketRpcConnection extends WebsocketConnection` — adds `request(method, params, {signal?, queue?, id?})` that throws `ThrownJsonrpcError` with the right code (`service_unavailable`, `queue_overflow`, `request_cancelled`, wire code from peer).
313
+ - `signal?: AbortSignal` — per-call cancel. Bottoms out at
314
+ `FrontendWebsocketClient.request({signal})` on WS (sends `cancel`
315
+ notification on abort) and at `fetch({signal})` on HTTP.
316
+ - `queue?: boolean` — per-call durable-queue opt-in. Honored only by
317
+ `FrontendWebsocketTransport` on the `request_response` path (default
318
+ `false`). HTTP, backend, and WS notifications all ignore it.
430
319
 
431
- Notification sends fail-fast when disconnected regardless of `queue`
432
- `connection.send()` has no queue semantic, so buffering would masquerade
433
- as success at the rpc_client layer. Requests are routed via `queue`.
320
+ `Transports` registry holds multiple transports with a `current` selection
321
+ and `allow_fallback: boolean` (default `true`). Explicit
322
+ `transport_for_method` (on `rpc_client`) or
323
+ `default_send_options.transport_name` (on `ActionPeer`) takes precedence.
434
324
 
435
- ### `BackendWebsocketTransport` (`transports_ws_backend.ts`)
325
+ ### WS close codes (`actions/transports.ts`)
436
326
 
437
- Name `'backend_websocket_rpc'`. Server-side WS transport with session
438
- tracking. Implements `FilterableBroadcastTransport` (the structural
439
- capability for per-connection ACL'd fan-out; feature-detected via
440
- `is_filterable_broadcast_transport`).
327
+ - `WS_CLOSE_SESSION_REVOKED = 4001` server revoked auth; client enters permanent `revoked` state, no reconnect.
328
+ - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
329
+ - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
330
+
331
+ ### Transport modules
332
+
333
+ - `actions/transports_http.ts` — `frontend_http_rpc`; thin `fetch` adapter, POST default, GET on `has_side_effects(method) === false`.
334
+ - `actions/transports_ws.ts` — `frontend_websocket_rpc`; thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`).
335
+ - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking; satisfies `FilterableBroadcastTransport`.
441
336
 
442
- State is three aligned maps keyed by `connection_id` (branded `Uuid`):
337
+ `FrontendHttpTransport` synthesizes a JSON-RPC error envelope via
338
+ `http_status_to_jsonrpc_error_code` on non-OK HTTP; DEV warns on drift
339
+ between JSON-RPC error code and declared HTTP status.
443
340
 
444
- - `#connections: Map<Uuid, WSContext>` id → socket.
445
- - `#connection_ids: WeakMap<WSContext, Uuid>` socket id (reverse).
446
- - `#connection_identities: Map<Uuid, ConnectionIdentity>` id auth identity. `ConnectionIdentity` is `{token_hash: string | null, account_id: Uuid, api_token_id: string | null}` — session connections set `token_hash`, bearer set `api_token_id`, daemon-token sets both null.
341
+ `FrontendWebsocketTransport` notification sends fail-fast when disconnected
342
+ regardless of `queue``connection.send()` has no queue semantic, so
343
+ buffering would masquerade as success at the rpc_client layer. Requests
344
+ route via `queue`.
447
345
 
448
- Lifecycle:
346
+ ### `BackendWebsocketTransport` — server-side WS state
449
347
 
450
- - `add_connection(ws, token_hash, account_id, api_token_id) → Uuid` assigns a fresh `connection_id`.
451
- - `remove_connection(ws)` — idempotent; safe after revocation.
348
+ Three aligned maps keyed by `connection_id` (branded `Uuid`):
452
349
 
453
- Targeted closure (all return `number` of sockets closed; use `WS_CLOSE_SESSION_REVOKED`):
350
+ - `#connections: Map<Uuid, WSContext>` id socket
351
+ - `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse)
352
+ - `#connection_identities: Map<Uuid, ConnectionIdentity>` — id → `{token_hash, account_id, api_token_id}` (session sets `token_hash`, bearer sets `api_token_id`, daemon-token sets both null)
454
353
 
455
- - `close_sockets_for_session(token_hash)` single session revocation.
456
- - `close_sockets_for_token(api_token_id)` — one bearer token, leaves account's other sockets intact.
457
- - `close_sockets_for_account(account_id)` — coarse; covers session + bearer + daemon-token.
354
+ Targeted closure (all return socket count closed, use
355
+ `WS_CLOSE_SESSION_REVOKED`):
458
356
 
459
- Fan-out:
357
+ - `close_sockets_for_session(token_hash)`
358
+ - `close_sockets_for_token(api_token_id)`
359
+ - `close_sockets_for_account(account_id)` — coarse, covers session + bearer + daemon-token
460
360
 
461
- - `send(notification)` broadcasts to every connection (current `send(request)` returns an internal_error "not yet implemented" — backend cannot initiate request-response).
462
- - `broadcast_filtered(message, predicate)` per-connection predicate over `ConnectionIdentity`; skips non-matching. Returns count.
463
- - `send_to_account(account_id, message)` — targeted wrapper over `broadcast_filtered`. Mirrors `close_sockets_for_account` on the send side (every connection for the account). Structurally satisfies the `NotificationSender` interface from `auth/role_grant_offer_notifications.ts` (see `auth/CLAUDE.md` §WS notifications).
464
- - `get_connection_count()` telemetry counter over the connection map.
361
+ Fan-out: `send(notification)` broadcasts to every connection;
362
+ `broadcast_filtered(message, predicate)` runs per-connection ACL predicate
363
+ over `ConnectionIdentity`; `send_to_account` wraps `broadcast_filtered` and
364
+ structurally satisfies `NotificationSender` (see `auth/CLAUDE.md` §WS
365
+ notifications).
465
366
 
466
367
  Return values are bookkeeping, not delivery receipts — `0` means no live
467
368
  sockets, non-zero means `ws.send` did not throw. Durable delivery requires
468
369
  persistence + rehydration by the consumer.
469
370
 
470
- ## WS auth guard (`transports_ws_auth_guard.ts`)
371
+ ## WS auth guard (`actions/transports_ws_auth_guard.ts`)
471
372
 
472
373
  Closes WS sockets on audit revoke events — per-message dispatch doesn't
473
- re-check session/token validity, so this guard is the revocation seam
474
- for open connections. Module TSDoc carries the full rationale.
374
+ re-check session/token validity, so this guard is the revocation seam for
375
+ open connections.
475
376
 
476
377
  `create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback.
477
378
  For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
478
- `create_app_server` composes this guard onto
479
- `backend.deps.audit.on_event_chain` automatically (per
480
- `WsEndpointSpec.auth_guard`). For custom wiring, append it inside the
481
- consumer's `audit_factory` body (or via `audit.on_event_chain.push(...)`
482
- post-assembly). Mirrors the SSE guard in `realtime/sse_auth_guard.ts`
483
- but targets the WS transport.
484
-
485
- `ws_disconnect_event_types` (ReadonlySet): `session_revoke`,
486
- `token_revoke`, `session_revoke_all`, `token_revoke_all`, `password_change`.
487
- `role_grant_revoke` is intentionally **omitted** — the WS transport does not
379
+ `create_app_server` composes this guard onto `backend.deps.audit.on_event_chain`
380
+ automatically (per `WsEndpointSpec.auth_guard`). For custom wiring, append
381
+ inside the consumer's `audit_factory` body.
382
+
383
+ `ws_disconnect_event_types` (ReadonlySet): `session_revoke`, `token_revoke`,
384
+ `session_revoke_all`, `token_revoke_all`, `password_change`.
385
+ `role_grant_revoke` is intentionally **omitted** — the WS transport doesn't
488
386
  track per-connection role requirements, so role-scoped disconnection would
489
387
  require either closing all sockets (too aggressive) or new per-connection
490
388
  role tracking (out of scope). Consumers that need it compose their own
491
389
  callback.
492
390
 
493
- Event dispatch:
494
-
495
- - `session_revoke` `close_sockets_for_session(metadata.session_id)`
496
- - `token_revoke` → `close_sockets_for_token(metadata.token_id)`
497
- - `session_revoke_all` / `token_revoke_all` / `password_change` → `close_sockets_for_account(target_account_id ?? account_id)` (admin actions set `target_account_id`; self-service only `account_id`).
498
-
499
- `outcome === 'failure'` events are ignored — they carry
500
- attacker-controlled identifiers. Reacting to them would let an authenticated
501
- caller close another user's socket by guessing a session hash or token id.
391
+ `outcome === 'failure'` events are ignored — they carry attacker-controlled
392
+ identifiers. Reacting to them would let an authenticated caller close
393
+ another user's socket by guessing a session hash or token id.
502
394
 
503
395
  `create_ws_logout_closer(transport, log)` is the sibling helper for
504
396
  user-initiated `logout` events — kept separate because
505
397
  `ws_disconnect_event_types` deliberately omits `logout` (admin-initiated
506
398
  revocations use `session_revoke`, while `logout` is the user-initiated
507
- case). Compose the two on `on_audit_event`:
399
+ case). Closes via `close_sockets_for_account(event.account_id)`.
508
400
 
509
- ```ts
510
- const ws_guard = create_ws_auth_guard(transport, log);
511
- const ws_logout_closer = create_ws_logout_closer(transport, log);
512
- const on_audit_event = (event: AuditLogEvent): void => {
513
- ws_guard(event);
514
- ws_logout_closer(event);
515
- };
516
- ```
517
-
518
- Same `outcome === 'failure'` guard as `create_ws_auth_guard`. Closes via
519
- `close_sockets_for_account(event.account_id)` — `logout` is always
520
- self-service, so there is no `target_account_id` to fall back on.
401
+ ## Connection closer (`actions/connection_closer.ts`)
521
402
 
522
- ## Connection closer (`connection_closer.ts`)
523
-
524
- Narrow structural capability for handler-side eager WS socket closure
525
- on revocation — the belt+suspenders layer that complements the audit-
526
- listener guards above.
403
+ Narrow structural capability for handler-side eager WS socket closure on
404
+ revocation — belt+suspenders layer that complements the audit-listener
405
+ guards above.
527
406
 
528
407
  ```ts
529
408
  interface ConnectionCloser {
@@ -533,543 +412,277 @@ interface ConnectionCloser {
533
412
  }
534
413
  ```
535
414
 
536
- `BackendWebsocketTransport` satisfies this structurally — consumers
537
- pass the transport instance directly (same shape as
538
- `NotificationSender`). Wired into `AccountRouteOptions.connection_closer`
539
- (logout / password), `AccountActionOptions.connection_closer`
540
- (`account_session_revoke` / `_revoke_all` / `account_token_revoke`),
541
- and `AdminActionOptions.connection_closer`
542
- (`admin_session_revoke_all` / `admin_token_revoke_all`). Each handler
543
- calls the appropriate `close_sockets_for_*` method synchronously
544
- BEFORE the audit emit so revocation lands even on audit INSERT
545
- failure. Listener-based close
546
- (`create_ws_auth_guard` / `create_ws_logout_closer`) stays as a
547
- fail-safe for out-of-band emit sites; `close_sockets_for_*` is
548
- idempotent. Failure outcomes (`revoked: false`, 404 not-found) skip
549
- the eager close — matches the listener's `outcome === 'failure'`
550
- guard so attacker-guessable ids cannot be used to target arbitrary
551
- sockets. Mirrors `zzz_server`'s handler-side `close_sockets_for_*`
552
- calls (landed 2026-05-16; see
553
- `~/dev/grimoire/lore/fuz_app/TODO_AUTH.md` § Audit-driven WS
554
- revocation: handler-side belt+suspenders).
555
-
556
- ## WebSocket dispatch
557
-
558
- Three layered entry points, in decreasing abstraction:
559
-
560
- ### `create_app_server.ws_endpoints` (`server/app_server.ts`) canonical
561
-
562
- The top-level mount surface mirror of `rpc_endpoints` for WebSocket
563
- endpoints. Accepts either an array of `WsEndpointSpec` or a factory
564
- `(ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>`; the factory
565
- form runs after the server context is assembled so action lists can
566
- depend on `ctx.deps` / `ctx.action_*_rate_limiter`. Each entry is
567
- auto-mounted via `register_ws_endpoint` against the assembled Hono app,
568
- so consumers no longer call `register_ws_endpoint` themselves in their
569
- server-assembly module.
570
-
571
- `upgradeWebSocket` (the Hono adapter helper) is supplied once at the
572
- top level — `create_app_server` throws when `ws_endpoints` resolves
573
- non-empty but `upgradeWebSocket` is missing. A factory returning `[]`
574
- does NOT trip the check, so feature-flag gated WS surfaces stay safe.
575
-
576
- Per-endpoint `WsEndpointSpec` fields:
577
-
578
- - `path` — Hono mount path
579
- - `allowed_origins` — origin allowlist regexes (parsed via `parse_allowed_origins`)
580
- - `actions` — the `ReadonlyArray<Action>` to dispatch (spread `protocol_actions` first)
581
- - `required_roles?: ReadonlyArray<RoleName>` — any-of upgrade-time role gate; omit or `[]` to skip
582
- - `transport?: BackendWebsocketTransport` — supplied or auto-created; returned on `AppServer.ws_endpoints[path]` either way
583
- - `heartbeat?`, `artificial_delay?`, `on_socket_open?`, `on_socket_close?` — passed through to `register_ws_endpoint`
584
- - `auth_guard?: boolean` — default `true`; auto-composes `create_ws_auth_guard` + `create_ws_logout_closer` against the endpoint's transport and appends them to `deps.audit.on_event_chain`. The wiring is deduped by **reference identity** (`WeakSet<BackendWebsocketTransport>`), so two `WsEndpointSpec`s sharing one `BackendWebsocketTransport` instance still get a single pair of listeners. Wrapped / DI-proxied transports dedupe as separate entries — set `auth_guard: false` on duplicates and compose `create_ws_auth_guard` / `create_ws_logout_closer` against the underlying transport once
585
- - `extra_audit_handlers?: ReadonlyArray<AuditEventHandler>` — appended after the standard guards; consumer-owned, never deduped
415
+ `BackendWebsocketTransport` satisfies this structurally — consumers pass
416
+ the transport instance directly (same shape as `NotificationSender`). Wired
417
+ into `AccountRouteOptions.connection_closer` (logout / password),
418
+ `AccountActionOptions.connection_closer` (session/token revoke), and
419
+ `AdminActionOptions.connection_closer` (admin revoke-all). Each handler
420
+ calls the appropriate `close_sockets_for_*` synchronously **before** the
421
+ audit emit so revocation lands even on audit INSERT failure. Failure
422
+ outcomes (`revoked: false`, 404 not-found) skip the eager close mirrors
423
+ the listener's `outcome === 'failure'` guard so attacker-guessable ids can
424
+ never target arbitrary sockets.
425
+
426
+ ## WebSocket dispatch three layered entry points
427
+
428
+ In decreasing abstraction.
429
+
430
+ ### `create_app_server.ws_endpoints` canonical mount surface
431
+
432
+ Mirror of `rpc_endpoints` for WebSocket endpoints. Accepts either an array
433
+ of `WsEndpointSpec` or a factory
434
+ `(ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>`; factory form
435
+ runs after server context is assembled so action lists can depend on
436
+ `ctx.deps` / `ctx.action_*_rate_limiter`. Each entry is auto-mounted via
437
+ `register_ws_endpoint` against the assembled Hono app.
438
+
439
+ `upgradeWebSocket` (the Hono adapter helper) is supplied once at the top
440
+ level — `create_app_server` throws when `ws_endpoints` resolves non-empty
441
+ but `upgradeWebSocket` is missing. A factory returning `[]` does NOT trip
442
+ the check, so feature-flag gated WS surfaces stay safe.
443
+
444
+ `WsEndpointSpec` fields: `path`, `allowed_origins`, `actions`,
445
+ `required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`,
446
+ `on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
447
+ deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
448
+ `extra_audit_handlers?`.
449
+
450
+ Mounted transport reachable at `app_server.ws_endpoints[path]`
451
+ (`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
452
+ across `WsEndpointSpec`s throw at mount time. Cross-surface collisions
453
+ (same `GET <path>` on both `RouteSpec` and `WsEndpointSpec`) throw with
454
+ exact-string match. Pattern overlap (e.g. `GET /api/:resource` vs
455
+ `/api/ws`) is not detected — Hono's specific-before-wildcard routing keeps
456
+ those working but avoid the overlap.
586
457
 
587
458
  `auth_guard: true` does NOT close sockets on `role_grant_revoke`
588
- (deliberate — per-connection role tracking is out of scope). Consumers
589
- that need role-revoke disconnection wire it via `extra_audit_handlers`.
590
-
591
- The mounted transport is reachable at `app_server.ws_endpoints[path]` —
592
- a `Readonly<Record<string, BackendWebsocketTransport>>` keyed by mount
593
- path. Use this handle for fan-out (`send_to_account`) and broadcast.
594
-
595
- Duplicate paths across `WsEndpointSpec`s throw at mount time
596
- (`'create_app_server: duplicate ws_endpoints path: <path>'`), closing
597
- the route-shadow hole Hono's silent `app.get` overwriting would leave.
598
- Cross-surface collisions are also detected — registering `GET <path>`
599
- as both a `RouteSpec` and a `WsEndpointSpec` throws
600
- `'create_app_server: ws_endpoints path collides with a GET RouteSpec: <path>'`
601
- at mount time. Exact-string match only; pattern overlap (e.g. a
602
- `RouteSpec` at `GET /api/:resource` vs `WsEndpointSpec` at `/api/ws`)
603
- is not detected — Hono's specific-before-wildcard routing keeps those
604
- working, but if you need certainty avoid the overlap.
605
-
606
- `auth_guard` semantics when multiple specs share a transport: **any**
607
- spec with `auth_guard !== false` wires the guard for that transport
608
- (OR-semantics, order-independent). To opt out for a shared transport,
609
- every sibling spec must pass `auth_guard: false`. Documented on the
610
- `WsEndpointSpec.auth_guard` TSDoc.
611
-
612
- `AppSurfaceWsEndpoint.methods` surfaces `request_response` + `remote_notification`
613
- specs only — `local_call` specs are filtered out because they don't
614
- dispatch over WS (`compile_action_registry` routes only
615
- `request_response` with a handler into `action_map`; `local_call` is
616
- frontend-side registry metadata).
617
-
618
- ### `register_ws_endpoint` (`register_ws_endpoint.ts`) — middle tier
459
+ (deliberate — per-connection role tracking out of scope). Compose via
460
+ `extra_audit_handlers` when needed. When multiple specs share a transport,
461
+ **any** spec with `auth_guard !== false` wires the guard for that
462
+ transport (OR-semantics).
463
+
464
+ `AppSurfaceWsEndpoint.methods` surfaces `request_response` +
465
+ `remote_notification` specs only — `local_call` specs are filtered out
466
+ because they don't dispatch over WS.
467
+
468
+ ### `register_ws_endpoint` middle tier
619
469
 
620
470
  Composes the standard upgrade stack:
621
471
 
622
472
  1. `verify_request_source(allowed_origins)`
623
473
  2. `require_auth`
624
- 3. upgrade-time authorization phase — resolves the acting actor and seeds `REQUEST_CONTEXT_KEY` for the inner `register_action_ws`'s upgrade-time identity capture
625
- 4. optional `require_role(required_roles)` — any-of disjunction
626
- 5. delegates to `register_action_ws`
627
-
628
- Extends `RegisterActionWsOptions` with `allowed_origins: ReadonlyArray<RegExp>`
629
- and optional `required_roles: ReadonlyArray<RoleName>`. Returns
630
- `{transport}`. Note: `required_roles` is a **coarse upgrade-time gate**
631
- — per-action `auth` in each spec still applies at dispatch time via
632
- `perform_action`. Pass `[]` (or omit) to skip the role gate.
633
- (`verify_request_source` and `require_auth` / `require_role` are from
634
- `auth/`; see `auth/CLAUDE.md` §Middleware for their semantics.)
474
+ 3. Upgrade-time authorization phase — resolves the acting actor, seeds `REQUEST_CONTEXT_KEY` for the inner `register_action_ws`
475
+ 4. Optional `require_role(required_roles)` — any-of disjunction (coarse upgrade-time gate; per-action `auth` in each spec still applies at dispatch time)
476
+ 5. Delegates to `register_action_ws`
635
477
 
636
- Most consumers reach for the higher-level `ws_endpoints` option above —
637
- this is the entry test harnesses use when they need the upgrade stack
638
- without `create_app_server`'s full assembly.
478
+ Extends `RegisterActionWsOptions` with `allowed_origins` and optional
479
+ `required_roles`. Returns `{transport}`. Most consumers reach for
480
+ `ws_endpoints` above; this is the entry test harnesses use when they need
481
+ the upgrade stack without `create_app_server`'s full assembly.
639
482
 
640
- ### `register_action_ws` (`register_action_ws.ts`) — lower-level
483
+ ### `register_action_ws` — lower-level dispatcher
641
484
 
642
485
  Exposed for tests (`create_ws_test_harness`) that need to drive the
643
486
  dispatcher without the origin/auth front-stack.
644
487
 
645
- Actions are passed as `ReadonlyArray<Action>` — the composable
646
- `{spec, handler?}` tuple shared with `create_rpc_client`. The dispatcher
647
- builds its `action_map: Map<string, RpcAction>` via `compile_action_registry`
648
- (see §Registry compile above)only `request_response` specs with a
649
- handler land in the map. Specs without a handler (client-only /
650
- dispatcher-handled like `cancel`) surface as `method_not_found` if the
651
- wire targets them.
652
-
653
- Required deps: `db: Db` (pool-level, used by `perform_action` for both the
654
- per-message authorization phase and the transactional dispatch wrap when
655
- `spec.side_effects: true`). Audit fan-out and other rollback-resilient
656
- fire-and-forget writes run through `AppDeps.audit` from each action
657
- factory's closure — the dispatcher never holds a separate pool reference.
658
-
659
- Per-message dispatch delegates to `perform_action` (`actions/perform_action.ts`)
660
- — the shared core that HTTP RPC also calls. `register_action_ws` only owns
661
- WS-specific concerns:
662
-
663
- - **Wire envelope parsing** — JSON.parse → batch rejection → notification interception (cancel, silent drop) → per-message dispatch.
664
- - **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher.
665
- - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`.
666
- - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded into `perform_action` as `signal`.
667
- - **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat.
668
-
669
- Per-message authorization phase: `perform_action` calls
488
+ Per-message dispatch delegates to `perform_action` — the shared core that
489
+ HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
490
+
491
+ - **Wire envelope parsing**JSON.parse batch rejection → notification interception (cancel, silent drop) → per-message dispatch
492
+ - **Cancel-notification interception** `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
493
+ - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
494
+ - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
495
+ - **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
496
+
497
+ **Per-message authorization phase.** `perform_action` calls
670
498
  `apply_authorization_phase` per-message (HTTP and WS uniformly). Role grant
671
499
  changes during a connection lifetime are picked up on the next message —
672
500
  no in-place refresh, no socket-close on `role_grant_revoke`. Authentication
673
501
  invalidation (`session_revoke`, `password_change`, `token_revoke_all`)
674
502
  still closes the socket via `create_ws_auth_guard`.
675
503
 
676
- Per-message wire behavior (every step delegated to `perform_action`
677
- except the WS-specific framing):
678
-
679
- - **Batch JSON-RPC rejected** arrays get `invalid_request`.
680
- - **Notifications** method + no id. Intercepted: `cancel` aborts the matching per-request controller; other notifications are silenced per JSON-RPC spec (no consumer notification handlers yet).
681
- - **Per-action auth + validation + dispatch** — uniform with HTTP RPC via `perform_action`: pre-validation auth (401) input validation (400) authorization phase post-authorization auth (403) → rate limit (429) → handler under transaction (when `side_effects: true`) → DEV output validation.
682
- - **Error handling** — handler throws normalize via `perform_action`'s thrown-error path. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`. The WS shim sends the resulting envelope over the socket.
683
-
684
- Two abort signals, composed via `AbortSignal.any`:
685
-
686
- - `socket_abort_controller` per-socket, fires on close. Drives every handler's `ctx.signal` on that socket.
687
- - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
688
-
689
- Per-message side-effect queues: `pending_effects: Array<Promise<void>>`
690
- (eager) drains via `flush_pending_effects`; `post_commit_effects: Array<() => void | Promise<void>>`
691
- (deferred pushed by handlers via `emit_after_commit`) drains via
692
- `flush_post_commit_effects`. Both flush in the same `try/finally` that
693
- releases the request controller, so fire-and-forget audit / notification
694
- effects pushed by the handler complete (or reject visibly) before the
695
- next message dispatches. See `http/CLAUDE.md` §Pending Effects.
696
-
697
- Lifecycle hooks on `RegisterActionWsOptions`:
698
-
699
- - `on_socket_open({ws, connection_id, identity, notify, signal})` — fires after `transport.add_connection` but before the first message. Awaited. Throws log + close with `1011 'socket bootstrap failed'` + send an `internal_error` frame.
700
- - `on_socket_close({ws, connection_id, identity})` — fires before `transport.remove_connection`, so `identity` is still readable even when the audit guard already tore the transport record down. Errors are logged and swallowed.
701
-
702
- Server-side heartbeat (`heartbeat?: boolean | ServerHeartbeatOptions`):
504
+ Per-message side-effect queues: `pending_effects` (eager) drains via
505
+ `flush_pending_effects`; `post_commit_effects` (deferred — pushed by
506
+ handlers via `emit_after_commit`) drains via `flush_post_commit_effects`.
507
+ Both flush in the same `try/finally` that releases the request controller,
508
+ so fire-and-forget audit / notification effects pushed by the handler
509
+ complete (or reject visibly) before the next message dispatches. See
510
+ `http/CLAUDE.md` §Pending Effects.
511
+
512
+ **Lifecycle hooks.** `on_socket_open({ws, connection_id, identity, notify, signal})`
513
+ fires after `transport.add_connection` but before the first message;
514
+ awaited; throws log + close with `1011 'socket bootstrap failed'`.
515
+ `on_socket_close({ws, connection_id, identity})` fires before
516
+ `transport.remove_connection` so `identity` is still readable. Errors
517
+ logged and swallowed.
518
+
519
+ **Server-side heartbeat** (`heartbeat?: boolean | ServerHeartbeatOptions`):
703
520
  default-on, 60s silence timeout. Any inbound message resets
704
521
  `last_receive_time` — chatty clients never trip it. First timeout window
705
- after open is exempt (cold-start grace). Tick interval is
706
- `timeout / 2`, so event-loop blockage pauses the timer itself.
707
-
708
- ## Event state machine
709
-
710
- Five modules make up a discriminated-union-based state machine used by the
711
- reactive client (`rpc_client.ts` + consumer ActionEvent-aware UIs) to track
712
- an action through its lifecycle.
713
-
714
- ### `action_event_types.ts`
715
-
716
- - `ActionExecutor` — `'frontend' | 'backend'`
717
- - `ActionEventStep` — `'initial' | 'parsed' | 'handling' | 'handled' | 'failed'`
718
- - `action_event_step_transitions` — valid next-steps: `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal.
719
- - `action_event_phase_by_kind` — valid phases per kind (`request_response` has 6, `remote_notification` has 2, `local_call` has 1).
720
- - `action_event_phase_transitions` — chained phases: `send_request → receive_response`; `receive_request → send_response`; everything else terminal.
721
- - `ActionEventEnvironment` — `{executor, lookup_action_handler, lookup_action_spec, log?}`. The ambient registry + handler resolver for an `ActionEvent`.
722
-
723
- ### `action_event_data.ts`
724
-
725
- `ActionEventData` is the base Zod schema — a strict object with all 10
726
- possible fields always present (nullable where not applicable for the
727
- current phase/step). The exported union `ActionEventDataUnion<TMethod,
728
- TInput, TOutput>` is a **39-variant discriminated union** across `kind` +
729
- `phase` + `step`: 28 variants for `request_response`, 6 for
730
- `remote_notification`, 5 for `local_call`. Narrows the shape of
731
- `input` / `output` / `error` / `request` / `response` / `notification` /
732
- `progress` at each point in the lifecycle.
522
+ after open is exempt (cold-start grace). Tick interval is `timeout / 2`,
523
+ so event-loop blockage pauses the timer itself.
733
524
 
734
- ### `action_event_helpers.ts`
525
+ Two abort signals composed via `AbortSignal.any`:
735
526
 
736
- Type guards (discriminate on `kind` + `phase` + `step`):
737
-
738
- - By kind: `is_request_response`, `is_remote_notification`, `is_local_call`
739
- - By phase: `is_send_request`, `is_receive_request`, `is_send_response`, `is_receive_response`, `is_notification_send`, `is_notification_receive`, `is_execute`
740
- - By step: `is_initial`, `is_parsed`, `is_handling`, `is_handled`, `is_failed`
741
- - Combined: `is_send_request_with_parsed_input`, `is_notification_send_with_parsed_input`
742
-
743
- Validators:
744
-
745
- - `validate_step_transition(from, to)` — throws on illegal step moves.
746
- - `validate_phase_for_kind(kind, phase)` — throws if the phase isn't valid for the kind.
747
- - `validate_phase_transition(from, to)` — throws on illegal phase chain.
748
- - `get_initial_phase(kind, initiator, executor)` — the phase an executor starts an action from, or `null` if this executor can't initiate.
749
- - `should_validate_output(kind, phase)` — true for `receive_request`/`receive_response` on `request_response` and `execute` on `local_call`.
750
- - `is_action_complete(data)` — `failed`, or `handled` at a terminal phase.
751
-
752
- Constructors / extractors:
753
-
754
- - `create_initial_data(kind, phase, method, executor, input)` — produces a well-formed initial-step `ActionEventData` with every nullable field null.
755
- - `extract_action_result(event): Result<{value}, {error}>` — pulls the terminal outcome. Throws on non-terminal events (programming error).
756
-
757
- ### `action_event.ts`
758
-
759
- `ActionEvent<TMethod, TPhase, TStep>` — the mutable state-machine class.
760
- Holds `#data` (current `ActionEventDataUnion`), notifies observers on
761
- every transition via `observe(listener): () => unsubscribe`. Keeps the
762
- spec + environment references.
763
-
764
- Lifecycle methods:
765
-
766
- - `parse()` — transitions `initial → parsed` by running `spec.input.safeParse(data.input)`. Input validation failures **fail immediately** without routing through an error phase — they're client-side programming errors, not runtime conditions with handlers. Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with an error response, transitions to `receive_error` instead of failing.
767
- - `handle_async()` / `handle_sync()` — `parsed → handling → handled`. Looks up the registered handler via `environment.lookup_action_handler(method, phase)`. Missing handler skips to `handled` (terminal with no output). Throws routed via `#get_error_phase_for_current_phase`: `send_request`/`receive_request` → `send_error`; `receive_response` → `receive_error`; other phases → `failed`. `ThrownJsonrpcError` preserves code + message + data; other throws become `internal_error`.
768
- - `transition(phase)` — `handled` at a chainable phase → next phase's `initial`. Uses `#create_phase_data` to carry forward `request` / `response` / `error` / `output` as appropriate.
769
- - `is_complete()`, `update_progress(progress)`, `set_request(request)`, `set_response(response)`, `set_notification(notification)`.
770
-
771
- Constructors:
527
+ - `socket_abort_controller` per-socket, fires on close. Drives every handler's `ctx.signal`.
528
+ - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
772
529
 
773
- - `create_action_event(environment, spec, input, initial_phase?)` — default phase via `get_initial_phase`; throws if the executor can't initiate.
774
- - `create_action_event_from_json(json, environment)` — rehydrate after wire transfer.
775
- - `parse_action_event(raw_json, environment)` — `ActionEventData.parse` + `create_action_event_from_json`.
530
+ ## Protocol actions (`actions/protocol.ts`)
776
531
 
777
- Protocol message creation is automatic: when transitioning `parsed handling`
778
- on a `send_request` phase, `ActionEvent` materializes the outgoing
779
- `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification)
780
- it materializes the `JsonrpcNotification`.
532
+ Two shared `{spec, handler}` tuples that every consumer spreads into both
533
+ sides' `actions` arrays disconnect detection and per-request cancel
534
+ work identically across every repo without per-consumer ping plumbing.
781
535
 
782
- ## Action peer (`action_peer.ts`)
536
+ The category is wire-protocol concerns shipped by fuz_app, not consumer
537
+ domain logic. Protocol vs domain: a future clock-skew probe or
538
+ reconnect-resume token belongs here; a `payment_charge` action does not.
783
539
 
784
- `ActionPeer` symmetric JSON-RPC send + receive over a `Transports`
785
- registry and `ActionEventEnvironment`. Construct with
786
- `{environment, transports?, default_send_options?}`.
540
+ Two const arrays:
787
541
 
788
- `default_send_options` excludes `signal` signals are inherently per-call
789
- (a shared signal would abort every subsequent call after the first trip).
790
- `transport_name` and `queue` can be defaulted here once to flip the peer
791
- into client-authoritative mode: `new ActionPeer({..., default_send_options:
792
- {queue: true}})` durably queues every request_response call by default.
542
+ - `protocol_actions: ReadonlyArray<Action>` — for the server's `register_action_ws` `actions`. Spread before consumer-owned actions.
543
+ - `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via `.map(a => a.spec)` so the two arrays cannot drift. For the frontend `ActionRegistry`.
793
544
 
794
- Per-call options:
545
+ Asymmetry intentional — server runs handlers (heartbeat echo + cancel
546
+ stub), frontend registry only stores specs. Both bundles plus the codegen
547
+ `include_protocol_actions: false` default form a three-leg contract.
795
548
 
796
- ```ts
797
- interface ActionPeerSendOptions extends TransportSendOptions {
798
- transport_name?: TransportName;
799
- }
800
- ```
549
+ **Not auto-spread by `create_frontend_rpc_client` or `register_ws_endpoint`** —
550
+ bundled helpers stay pure factories so the dispatch surface stays
551
+ grep-traceable at every registration site and consumers can override
552
+ individual protocol actions without an opt-out flag.
801
553
 
802
- `send(message, options?)`:
554
+ ### Individual actions
803
555
 
804
- - Resolves the transport via `transports.get_transport(options?.transport_name ?? default.transport_name)`.
805
- - No transport `service_unavailable` JSON-RPC error (does not throw).
806
- - Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
807
- - Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
556
+ - **`heartbeat_action`** `request_response`, `initiator: 'frontend'`, `auth: 'authenticated'`, `side_effects: false`, nullary input/output (`z.strictObject({})`). Handler is a stateless no-op echo. Client's activity-aware heartbeat timer fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; server's `register_action_ws` heartbeat tracker counts the incoming message as activity.
557
+ - **`cancel_action`** `remote_notification`, `initiator: 'frontend'`, `auth: null`, `side_effects: true`. Params: `CancelNotificationParams = z.strictObject({request_id: JsonrpcRequestId})`. **Handler is an empty stub** — cancel semantics are dispatcher-owned (`register_action_ws` has the `{request_id → AbortController}` map). Wire format is snake_case `cancel` + `{request_id}`, not MCP's `$/cancelRequest` + `{requestId}` — MCP adoption would happen at an MCP adapter's translation layer, not in the base transport.
808
558
 
809
- `receive(message)` dispatch for inbound messages:
559
+ ## Event state machine
810
560
 
811
- - **Requests** — look up spec via `environment.lookup_action_spec`; unknown → `method_not_found`. Otherwise `create_action_event(environment, spec, params, 'receive_request')`, wire the request via `set_request`, run `parse().handle_async()`. On `handled`, transition to `send_response` + re-run `parse().handle_async()`. On `failed` or `send_error` phase, returns a `JsonrpcErrorResponse`.
812
- - **Notifications** — same flow for `'receive'` phase; returns `null` (no response).
813
- - **Anything else** `invalid_request` JSON-RPC error.
561
+ Five modules (`action_event_types.ts`, `action_event_data.ts`,
562
+ `action_event_helpers.ts`, `action_event.ts`, `action_peer.ts`) define a
563
+ discriminated-union-based state machine used by the reactive client to
564
+ track an action through its lifecycle. Per-symbol semantics on TSDoc;
565
+ high-level shapes that span modules:
814
566
 
815
- Currently partial: `#receive_request`'s `send_response` transition step has
816
- a known sharp edge ("shouldn't need the guard" TODO).
567
+ - **39-variant discriminated union** — `ActionEventDataUnion<TMethod, TInput, TOutput>` across `kind` + `phase` + `step` (28 for `request_response`, 6 for `remote_notification`, 5 for `local_call`). Narrows `input` / `output` / `error` / `request` / `response` / `notification` / `progress` at each lifecycle point.
568
+ - **Step transitions** `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal. `validate_step_transition(from, to)` throws on illegal moves.
569
+ - **Phase transitions** — chained: `send_request → receive_response`, `receive_request → send_response`; everything else terminal. `validate_phase_for_kind` + `validate_phase_transition` enforce.
570
+ - **`ActionEvent.parse()`** — `initial → parsed` via `spec.input.safeParse`. Input validation failures **fail immediately** without routing through an error phase (client-side programming errors, not runtime conditions with handlers). Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with error response, transitions to `receive_error` instead of failing.
571
+ - **Protocol message creation is automatic** — transitioning `parsed → handling` on `send_request` materializes the outgoing `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification) it materializes the `JsonrpcNotification`.
817
572
 
818
- ## Protocol actions (`heartbeat.ts`, `cancel.ts`, `protocol.ts`)
573
+ `ActionPeer` is symmetric send + receive over a `Transports` registry and
574
+ `ActionEventEnvironment`. `default_send_options` excludes `signal`
575
+ deliberately — a shared signal would abort every subsequent call after the
576
+ first trip. `transport_name` and `queue` can be defaulted here once to
577
+ flip the peer into client-authoritative mode.
819
578
 
820
- Two shared `{spec, handler}` tuples that every consumer spreads into both
821
- sides' `actions` arrays — disconnect detection and per-request cancel work
822
- identically across every repo without per-consumer ping plumbing.
579
+ ## Reactive frontend client
823
580
 
824
- The category is wire-protocol concerns shipped by fuz_app, not consumer
825
- domain logic. The contrast that matters is protocol vs domain: a future
826
- clock-skew probe or reconnect-resume token belongs in this bundle; a
827
- `payment_charge` action does not. Avoid the framing "composable vs
828
- non-composable" — every `Action` is composable by the same mechanism
829
- (spread into the `actions` array), so the distinction would not carve
830
- nature at the joints.
831
-
832
- ### Canonical bundles (`protocol.ts`)
833
-
834
- Two const arrays declare the canonical protocol-action set so consumers
835
- spread one symbol per side instead of importing each primitive
836
- individually:
837
-
838
- - `protocol_actions: ReadonlyArray<Action>` — for the server's
839
- `register_action_ws` `actions` array. Spread before consumer-owned
840
- actions: `actions: [...protocol_actions, ...consumer_actions]`.
841
- - `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via
842
- `.map(a => a.spec)` so the two arrays cannot drift. For the frontend
843
- `ActionRegistry`. Spread before consumer-owned specs:
844
- `new ActionRegistry([...protocol_action_specs, ...action_specs])`.
845
-
846
- The asymmetry is intentional — the server runs handlers (heartbeat echo +
847
- cancel stub), the frontend registry only stores specs. Both bundles plus
848
- the codegen `include_protocol_actions: false` default form a three-leg
849
- contract: codegen excludes protocol actions from generated typed surfaces
850
- because consumers spread these bundles in at registration time.
851
-
852
- The bundles are **not** auto-spread by `create_frontend_rpc_client` or
853
- `register_ws_endpoint` — bundled helpers stay pure factories so the
854
- dispatch surface stays grep-traceable at every consumer registration site
855
- and consumers can override individual protocol actions (custom heartbeat,
856
- etc.) without an opt-out flag.
857
-
858
- ### `heartbeat_action`
859
-
860
- Method `'heartbeat'`, `request_response`, `initiator: 'frontend'`, `auth:
861
- 'authenticated'`, `side_effects: false`, nullary input/output
862
- (`z.strictObject({})`). Handler is a stateless no-op echo. The client's
863
- activity-aware heartbeat timer (`FrontendWebsocketClient.#heartbeat_tick`)
864
- fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; the server's
865
- `register_action_ws` heartbeat tracker counts the incoming message as
866
- activity and resets `last_receive_time`.
867
-
868
- ### `cancel_action`
869
-
870
- Method `'cancel'`, `remote_notification`, `initiator: 'frontend'`, `auth:
871
- null`, `side_effects: true`. Params: `CancelNotificationParams =
872
- z.strictObject({request_id: JsonrpcRequestId})`. The **handler is an empty
873
- stub** — cancel semantics are dispatcher-owned
874
- (`register_action_ws` has the `{request_id → AbortController}` map, not the
875
- handler). The tuple exists for symmetry + so `spec_by_method` knows about
876
- it (enables input validation on incoming cancels) + so `create_rpc_client`
877
- sees the method.
878
-
879
- Wire format is snake_case `cancel` + `{request_id}`, not MCP's
880
- `$/cancelRequest` + `{requestId}`. MCP adoption would happen at an MCP
881
- adapter's translation layer, not in the base transport.
882
-
883
- ## Reactive frontend client (`socket.svelte.ts`, `request_tracker.svelte.ts`)
884
-
885
- ### `FrontendWebsocketClient`
581
+ ### `FrontendWebsocketClient` (`actions/socket.svelte.ts`)
886
582
 
887
583
  Portable, Svelte-reactive (`$state.raw` for `ws`, `status`, `reconnect_count`,
888
- `current_reconnect_delay`, `last_connect_time`, `last_close_time`,
889
- `last_close_code`, `last_close_reason`, `last_send_error`). Plain class — no
890
- Cell inheritance, no app coupling. Implements `WebsocketConnection` +
891
- `WebsocketRpcConnection`, and is `Disposable`.
584
+ etc.). Plain class — no Cell inheritance, no app coupling. Implements
585
+ `WebsocketConnection` + `WebsocketRpcConnection`, and is `Disposable`.
892
586
 
893
587
  Ships three correctness primitives default-on:
894
588
 
895
- 1. **Promise-based `request`** — auto-assigned monotonic id (override via `options.id` for transport-minted UUIDs). Pending map keyed by id, resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes `unauthenticated` (revoked), `request_cancelled` (abort), `queue_overflow`, `service_unavailable`, `internal_error`, or the server's wire code verbatim. The transport catch block preserves `.code` exactly so `FrontendWebsocketTransport` never collapses to `internal_error`.
589
+ 1. **Promise-based `request`** — auto-assigned monotonic id; pending map keyed by id; resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes (`unauthenticated`, `request_cancelled`, `queue_overflow`, `service_unavailable`, `internal_error`, or the server's wire code verbatim). The transport catch block preserves `.code` exactly so `FrontendWebsocketTransport` never collapses to `internal_error`.
896
590
  2. **Durable queue** — `request()` calls while disconnected buffer up to `DEFAULT_QUEUE_MAX_SIZE = 100` and flush on reopen. Overflow rejects `queue_overflow`. Pass `{queue: false}` to reject immediately (used internally by the heartbeat — it must not fight the queue for the disconnect-detection slot). Raw `send(data)` is **drop-on-disconnect** by design (fire-and-forget notifications want that).
897
- 3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself — dead-because-blocked and dead-because-unresponsive close arguably the same way.
591
+ 3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself.
898
592
 
899
- Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`, capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is **terminal** — sets `#revoked = true`, no reconnect loop on 401.
593
+ Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
594
+ capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is
595
+ **terminal** — sets `#revoked = true`, no reconnect loop on 401.
900
596
 
901
- Live policy swaps (behave like constructor — whole policy atomic, missing fields fall back to defaults, not "keep current"):
597
+ Live policy swaps (behave like constructor — whole policy atomic, missing
598
+ fields fall back to defaults, not "keep current"): `set_reconnect`,
599
+ `set_heartbeat`, `cancel_reconnect`.
902
600
 
903
- - `set_reconnect(reconnect?)` monotonically **shortens** pending reconnects (never extends). Turning off while a reconnect is pending cancels it + transitions to `closed`.
904
- - `set_heartbeat(heartbeat?)` restarts the live timer when connected.
905
- - `cancel_reconnect()` — `reconnecting → closed` + resets backoff without disabling future reconnects. Queue stays intact; next `connect()` flushes.
601
+ `SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
602
+ `socket_status_to_async_status(status, revoked)` collapses to fuz_util's
603
+ 4-way `AsyncStatus`.
906
604
 
907
- `SocketStatus` is `'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`. Terminal only when `revoked: true` or auto-reconnect is disabled.
605
+ ### `RequestTracker` (`actions/request_tracker.svelte.ts`)
908
606
 
909
- `socket_status_to_async_status(status, revoked): AsyncStatus` collapses
910
- the 5-way `SocketStatus` to fuz_util's 4-way `AsyncStatus` for UI
911
- indicators: `reconnecting 'failure'`, `closed` splits by `revoked`
912
- (`failure` if revoked, else `initial` the "not connected, not trying"
913
- state).
607
+ Public utilityreactive pending-request state with timeouts.
608
+ `SvelteMap` keyed by `JsonrpcRequestId`, default `request_timeout_ms = 120_000`.
609
+ Used by transports that don't delegate pending correlation to a
610
+ `WebsocketRpcConnection` (`FrontendWebsocketTransport` delegates to
611
+ `FrontendWebsocketClient`'s own `#pending` map).
914
612
 
915
- ### `RequestTracker` (`request_tracker.svelte.ts`)
613
+ ## RPC client (`actions/rpc_client.ts`)
916
614
 
917
- Public utility reactive pending-request state with timeouts. `SvelteMap`
918
- keyed by `JsonrpcRequestId`, each entry a `RequestTrackerItem` with `id`,
919
- `deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
920
- `timeout`. Default `request_timeout_ms = 120_000`.
615
+ `create_rpc_client({peer, environment, actions?, transport_for_method?})`
616
+ returns a Proxy-based typed API. Per-kind dispatch:
921
617
 
922
- Methods: `track_request(id): Deferred`, `resolve_request(id, response)`,
923
- `reject_request(id, error_message)`, `handle_message(message)` (id-keyed
924
- dispatch of JSON-RPC responses, ignores notifications / id-less frames),
925
- `cancel_request(id)` (cleanup only, does not reject), `cancel_all_requests(reason?)`
926
- (rejects all with `internal_error`).
618
+ - **`local_call` sync** — `parse().handle_sync()`, return value directly. Throws on error (sync can't return `Result`). Ignores `signal`.
619
+ - **`local_call` async** `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits.
620
+ - **`request_response`** builds `ActionEvent`, runs `parse().handle_async()` to produce the request, calls `peer.send(request, {transport_name, signal, queue})`, transitions to `receive_response`, wires the response, parses (may transition to `receive_error`), runs handler, extracts `Result`.
621
+ - **`remote_notification`** — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
927
622
 
928
- Used by transports that don't delegate pending correlation to a
929
- `WebsocketRpcConnection`. `FrontendWebsocketTransport` does not use it
930
- (delegates to `FrontendWebsocketClient`'s own `#pending` map).
623
+ `RpcClientCallOptions extends ActionPeerSendOptions` `{signal?, queue?, transport_name?}`.
624
+ `transport_for_method: (method) => TransportName | undefined` for per-method
625
+ selection. `on_action_event(event)` fires once per dispatched action with
626
+ the live `ActionEvent` (zzz wires reactive history here).
931
627
 
932
- ## RPC client (`rpc_client.ts`)
628
+ ### Throwing variants
933
629
 
934
- `create_rpc_client({peer, environment, actions?, transport_for_method?})`
935
- returns a Proxy-based typed API. Method name action method via
936
- `environment.lookup_action_spec`. Dispatches based on the spec's `kind`:
937
-
938
- - `local_call` (sync) — execute `parse().handle_sync()`, return value directly. Throws on error (sync methods can't return `Result`). Ignores `signal` (no cooperative interrupt mid-handler).
939
- - `local_call` (async) — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits with `internal_error`.
940
- - `request_response` — builds `ActionEvent`, runs `parse().handle_async()` to produce the outgoing `request`, calls `peer.send(request, {transport_name, signal, queue})`, transitions to `receive_response`, wires the response via `set_response`, parses (may transition to `receive_error`), runs handler, extracts `Result`.
941
- - `remote_notification` — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
942
-
943
- Per-call options: `RpcClientCallOptions extends ActionPeerSendOptions` —
944
- `{signal?, queue?, transport_name?}`. `transport_name` overrides
945
- per-method `transport_for_method` selector for this call.
946
-
947
- `transport_for_method: (method) => TransportName | undefined` — optional
948
- per-method transport selector. Useful when methods are registered on
949
- different backend dispatchers (e.g. streaming action on WS, rest on HTTP).
950
- Returning `undefined` falls through to the peer's default selection.
951
-
952
- `on_action_event: (event: ActionEvent<keyof TApi & string>) => void` —
953
- optional callback fired once per dispatched action with the live
954
- `ActionEvent`. Consumers wire reactive state inside the callback — e.g.
955
- zzz's `Actions` cell calls its own `add_from_json` +
956
- `listen_to_action_event` here so the history plumbing stays inside zzz
957
- instead of leaking onto the rpc_client surface. `event.spec.method` and
958
- `event.data.method` narrow to `keyof TApi & string` so consumers passing
959
- a generated `FrontendActionsApi` get the literal method-name union without
960
- an `as ActionMethod` cast at the call site.
961
-
962
- Cast the return to a generated `FrontendActionsApi` interface for full
963
- typing: codegen via `generate_actions_api_method_signature` keeps the
964
- shape consistent. See ../../docs/usage.md §Typed Client Codegen.
965
-
966
- ### Throwing variants — `create_throwing_rpc_call` + `create_throwing_api`
967
-
968
- Two helpers wrap a typed `create_rpc_client` Proxy so `{ok: false}` results
969
- throw an `Error` with `{code, message, data?}` (catch blocks read
970
- `err.data?.reason` — optional chaining required because JSON-RPC `data`
971
- is spec-level optional). Same hardening on both: only `{code, data}` cross
972
- onto the Error, leaving `name` / `stack` as the native Error's own so
973
- attacker-shaped `result.error` payloads cannot overwrite them.
974
-
975
- | Helper | Shape | Use at |
976
- | -------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
977
- | `create_throwing_rpc_call` | `(method, input?) => Promise<T>` | adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map |
978
- | `create_throwing_api` | typed Proxy over `FrontendActionsApi` | direct call sites — `await api.foo(input)` keeps full inference |
979
-
980
- **Layered design.** Result is the protocol primitive — `create_rpc_client`
981
- returns `Result<{value}, {error}>` per call with no Error allocation. The
982
- throwing wrappers sit _above_ it as ergonomic adapters; both shapes share
983
- the same underlying transport and call sites pick per-site. `Result` is
984
- preferable when the call site inspects `error.data.reason` (no Error
985
- allocation, no try/catch nesting) or when overhead matters (reconnect
986
- storms, hot paths). Throwing is preferable when the call site doesn't
987
- inspect — `await api.foo()` reads cleaner than the `if (!r.ok) throw …`
988
- ritual.
989
-
990
- `create_frontend_rpc_client` ships both shapes by default — see
991
- [Frontend factory](#frontend-factory-frontend_rpc_clientts) below. Direct
992
- consumers of `create_rpc_client` pass their typed `FrontendActionsApi`
993
- as the generic to get the typed Result-shaped Proxy without casts, then
994
- build the throwing form on top:
630
+ - `create_throwing_rpc_call` — shape `(method, input?) => Promise<T>`. Use at adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map.
631
+ - `create_throwing_api` typed Proxy over `FrontendActionsApi`. Use at direct call sites — `await api.foo(input)` keeps full inference.
995
632
 
996
- ```ts
997
- const api_result = create_rpc_client<FrontendActionsApi>({peer, environment});
998
- const api = create_throwing_api(api_result);
999
- // hot path: await api.foo(input)
1000
- // rare branch: const r = await api_result.foo(input); if (!r.ok) { … }
1001
- ```
633
+ **Layered design.** `Result` is the protocol primitive —
634
+ `create_rpc_client` returns `Result<{value}, {error}>` with no Error
635
+ allocation. The throwing wrappers sit _above_ it as ergonomic adapters;
636
+ both shapes share the same underlying transport and call sites pick
637
+ per-site. `Result` is preferable when the call site inspects
638
+ `error.data.reason` (no allocation, no try/catch) or when overhead matters
639
+ (reconnect storms, hot paths). Throwing is preferable when the call site
640
+ doesn't inspect — `await api.foo()` reads cleaner than `if (!r.ok) throw …`.
1002
641
 
1003
- `create_throwing_rpc_call` is **not** a peer choice for direct call sites
1004
- it's a niche primitive for method-name-mapping adapter factories
1005
- (`ui/admin_rpc_adapters.ts`) where the method string comes from a domain
1006
- mapping rather than a typed call site. Use it only at adapter boundaries.
642
+ Hardening on both: only `{code, data}` cross onto the Error, leaving
643
+ `name` / `stack` as the native Error's own so attacker-shaped
644
+ `result.error` payloads cannot overwrite them.
1007
645
 
1008
646
  `ThrowingApi<TApi>` (the mapped type returned by `create_throwing_api`)
1009
647
  strips `Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>` to
1010
- `Promise<T>` on every method that matches the `request_response` /
1011
- async `local_call` return shape; `remote_notification` (`=> void`) and
1012
- sync `local_call` methods pass through. The Proxy inspects each call's
1013
- result shape at runtime and only unwraps when it sees a Result, so
1014
- non-Result returns flow through unchanged.
648
+ `Promise<T>` on every method matching the `request_response` / async
649
+ `local_call` return shape; `remote_notification` and sync `local_call`
650
+ methods pass through. The Proxy inspects each call's result shape at
651
+ runtime and only unwraps when it sees a Result.
1015
652
 
1016
653
  Both helpers throw `"rpc method not found: <name>"` on invocation of an
1017
- unknown method. For `create_throwing_api` the thrower is returned from
1018
- the Proxy get trap so `api.missing()` errors with the same clear
1019
- message rather than the JS default `"api.missing is not a function"`.
1020
- Symbol props and `then` stay `undefined` so the Proxy doesn't get
1021
- probed as a thenable by `await`.
654
+ unknown method. Symbol props and `then` stay `undefined` so the Proxy
655
+ doesn't get probed as a thenable by `await`.
1022
656
 
1023
- ### Frontend factory (`frontend_rpc_client.ts`)
657
+ ### Frontend factory (`actions/frontend_rpc_client.ts`)
1024
658
 
1025
659
  `create_frontend_rpc_client<TApi>({specs, path?, transports?, transport_for_method?, on_action_event?})`
1026
- bundles the `ActionRegistry + ActionEventEnvironment + Transports +
1027
- ActionPeer + create_rpc_client + create_throwing_api` boilerplate every
1028
- consumer repeats — plus the `lookup_action_handler: () => undefined`
1029
- stub (frontend never registers `request_response` handlers; every
1030
- method dispatches over the wire). The `as unknown as TApi` cast happens
1031
- inside the helper, so call sites get a typed return without the cast
1032
- hostility.
660
+ bundles `ActionRegistry + ActionEventEnvironment + Transports + ActionPeer +
661
+ create_rpc_client + create_throwing_api` boilerplate every consumer
662
+ repeats — plus the `lookup_action_handler: () => undefined` stub (frontend
663
+ never registers `request_response` handlers; every method dispatches over
664
+ the wire).
1033
665
 
1034
666
  Returns both Proxy shapes from one factory call:
1035
667
 
1036
668
  - `api: ThrowingApi<TApi>` — typed throwing Proxy. Default for hot-path call sites.
1037
669
  - `api_result: TApi` — typed Result-shaped Proxy. For sites that inspect `error.data.reason` without try/catch.
1038
- - `peer`, `environment` — exposed for advanced consumers that want to register more transports or share the environment with a separate dispatcher.
1039
-
1040
- ```ts
1041
- const {api, api_result} = create_frontend_rpc_client<FrontendActionsApi>({
1042
- specs: all_standard_action_specs,
1043
- });
1044
- // hot path: await api.account_verify()
1045
- // rare branch: const r = await api_result.account_verify(); if (!r.ok) { … }
1046
- ```
670
+ - `peer`, `environment` — exposed for advanced consumers.
1047
671
 
1048
672
  Default transport is `FrontendHttpTransport(path ?? '/api/rpc')`. Pass
1049
- `transports` for WS-first or mixed setups when supplied, the default
1050
- HTTP transport is **not** registered. `local_call` specs in `specs`
1051
- silently no-op because `lookup_action_handler` always returns
1052
- `undefined`; this factory targets wire-dispatched actions.
1053
-
1054
- `all_standard_action_specs` is transport-agnostic — when a consumer
1055
- spreads `create_standard_rpc_actions(ctx.deps, opts)` into both
1056
- `rpc_endpoints` AND `ws_endpoints` on `create_app_server`, every method
1057
- is reachable on both transports and `transport_for_method` can route
1058
- per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
1059
- `admin_*` methods to bind them to the live WS connection).
1060
-
1061
- `transport_for_method` and `on_action_event` are pure pass-throughs to
1062
- `create_rpc_client` — exposed so consumers needing per-method routing
1063
- (zap-style WS-for-actions / HTTP-for-rest split) or per-dispatch event
1064
- wiring (zzz-style reactive Cells observing `ActionEvent` lifecycle)
1065
- don't have to drop down to manual `create_rpc_client` construction
1066
- (which forfeits the bundled `api` / `api_result` pair).
673
+ `transports` for WS-first or mixed setups (the default HTTP transport is
674
+ **not** registered when `transports` is supplied). `local_call` specs in
675
+ `specs` silently no-op because `lookup_action_handler` always returns
676
+ `undefined`.
1067
677
 
1068
678
  `all_standard_action_specs` (in `auth/standard_action_specs.ts`) is
1069
- the matching aggregate spec list mirroring `create_standard_rpc_actions`
1070
- on the backend see `auth/CLAUDE.md` §`standard_rpc_actions.ts`.
679
+ transport-agnostic when a consumer spreads `create_standard_rpc_actions`
680
+ into both `rpc_endpoints` AND `ws_endpoints`, `transport_for_method` can
681
+ route per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
682
+ `admin_*` methods to bind them to the live WS connection). See
683
+ `auth/CLAUDE.md` §Standard RPC bundle.
1071
684
 
1072
- ## Broadcast API (`broadcast_api.ts`)
685
+ ## Broadcast API (`actions/broadcast_api.ts`)
1073
686
 
1074
687
  `create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
1075
688
  typed `{method: (input) => Promise<void>}` object from a list of action
@@ -1078,77 +691,28 @@ request-scoped dispatch, this handles backend-initiated broadcast.
1078
691
  Request-scoped streaming stays on `ctx.notify` inside a handler.
1079
692
 
1080
693
  Per-method call: validates input against `spec.input` (logs + returns on
1081
- failure), wraps in a `JsonrpcNotification`, sends via the peer's resolved
694
+ failure), wraps in `JsonrpcNotification`, sends via the peer's resolved
1082
695
  transport. `transport_name` on `peer.default_send_options` pins the target
1083
696
  deterministically — no fallback, because broadcast is 1→N over a specific
1084
697
  primary transport and "any ready transport" could reach an unexpected
1085
- audience. Silently skips when no ready transport.
698
+ audience. Silently skips when none ready.
1086
699
 
1087
700
  `should_deliver: (identity, method, input) => boolean` — optional
1088
701
  per-connection ACL predicate. When set, fans out via
1089
702
  `transport.broadcast_filtered` (feature-detected via
1090
- `is_filterable_broadcast_transport`). Errors during send are logged but
1091
- never thrown — broadcasts are fire-and-forget.
703
+ `is_filterable_broadcast_transport`). Errors logged but never thrown
704
+ broadcasts are fire-and-forget.
1092
705
 
1093
706
  Typed surface: consumers declare an explicit `interface BackendActionsApi`
1094
- and pin it via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
1095
- cast, so the interface and `specs` array must stay in sync (codegen is a
1096
- natural fit when consumers already generate per-method type maps).
1097
-
1098
- ## Shared type surface (`action_types.ts`)
1099
-
1100
- Sits above `action_spec.ts` (pure Zod) and below the dispatchers
1101
- (`register_action_ws.ts`, `action_rpc.ts`, `perform_action.ts`).
1102
- Extracted so composable primitives (e.g. `heartbeat_action`) can name the
1103
- types without pulling in server-only modules.
1104
-
1105
- This is the polymorphic `Action` shape only. The unified `ActionContext`
1106
- from `action_rpc.ts` is the single handler context across every
1107
- transport; `ActionHandler` is the single handler signature.
707
+ and pin via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
708
+ cast, so interface and `specs` array must stay in sync (codegen is a
709
+ natural fit).
1108
710
 
1109
- - `Action<TSpec>` `{spec: TSpec, handler?: ActionHandler}`. The composable unit passed to both sides' `actions` arrays. Polymorphic on `kind`: `request_response` specs require a handler for dispatch; `remote_notification` specs may declare a stub for symmetry but are dispatcher-handled (e.g. `cancel`); `local_call` specs never reach a network dispatcher. The WS dispatcher only invokes handlers on `request_response` actions; everything else is registry-only.
711
+ ## Shared type surface (`actions/action_types.ts`)
1110
712
 
1111
- `RpcAction = Action<RequestResponseActionSpec> & {handler: ActionHandler}`
1112
- is the narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`)
1113
- and the `rpc_action` binder produces (the actor-axis narrowing lives
1114
- in `HandlerForSpec<TSpec>`).
713
+ Sits above `action_spec.ts` (pure Zod) and below the dispatchers. Extracted
714
+ so composable primitives (e.g. `heartbeat_action`) can name the types
715
+ without pulling in server-only modules.
1115
716
 
1116
- ## Shared dispatch core (`perform_action.ts`)
1117
-
1118
- The transport-agnostic post-parse pipeline shared by HTTP RPC and
1119
- WebSocket. Each transport assembles a `PerformActionInput` from its wire
1120
- envelope + connection identity, calls `perform_action(input, deps)`,
1121
- and binds the discriminated `PerformActionResult` to its wire shape.
1122
-
1123
- Pipeline (401 → 400 → 403 → handler):
1124
-
1125
- 1. Pre-validation auth (401) — short-circuits unauthenticated callers on `'required'` axes before input validation.
1126
- 2. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules.
1127
- 3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch lives in the caller — pass `preset.request_context` to skip the live phase.
1128
- 4. Post-authorization auth (403) — credential-type gate first, role gate second.
1129
- 5. Rate limit (429) — per-action IP / account throttling, throttle-requests semantics (every invocation records).
1130
- 6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction (`deps.db.transaction`) vs pool. Handler throws roll back the transaction; `ThrownJsonrpcError` preserves code + data, generic throws become `internal_error`.
1131
-
1132
- `PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
1133
- `signal`, `notify`, optional `connection_id`, optional `preset`.
1134
- `PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
1135
- the two rate limiters. Audit writes are out-of-band: factories close over
1136
- `AppDeps.audit` independently. `PerformActionResult` is `{kind: 'ok',
1137
- result} | {kind: 'error', error, status}`; `perform_action_result_to_envelope(id, result)`
1138
- builds the JSON-RPC wire shape both transports send.
1139
-
1140
- ## DEV-only output validation — uniform across surfaces
1141
-
1142
- The critical invariant: every action-handler surface applies DEV-only
1143
- output validation and produces the **same failure mode** — log an error,
1144
- return the response unchanged, do not throw, do not mutate status.
1145
-
1146
- | Surface | Code location | Hot path under production |
1147
- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
1148
- | REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
1149
- | HTTP RPC + WebSocket dispatch | `actions/perform_action.ts` — `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core | short-circuit (no parse) |
1150
-
1151
- Caller-facing `input` schemas are validated **always** (DEV + production) —
1152
- they're the contract with external callers. Server-authored `output`
1153
- schemas are internal data. See ../../docs/architecture.md §DEV-only Output
1154
- Validation for the full rationale.
717
+ - `Action<TSpec>` — `{spec: TSpec, handler?: ActionHandler}`. Polymorphic on `kind`: `request_response` specs require a handler for dispatch; `remote_notification` specs may declare a stub for symmetry but are dispatcher-handled (e.g. `cancel`); `local_call` specs never reach a network dispatcher.
718
+ - `RpcAction = Action<RequestResponseActionSpec> & {handler: ActionHandler}` — narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`) and the `rpc_action` binder produces.