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