@fuzdev/fuz_app 0.29.0 → 0.31.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 (210) hide show
  1. package/dist/actions/CLAUDE.md +630 -0
  2. package/dist/actions/action_rpc.d.ts +29 -0
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +42 -6
  5. package/dist/actions/action_types.d.ts +2 -2
  6. package/dist/actions/cancel.d.ts +12 -13
  7. package/dist/actions/cancel.d.ts.map +1 -1
  8. package/dist/actions/cancel.js +10 -13
  9. package/dist/actions/heartbeat.d.ts +8 -13
  10. package/dist/actions/heartbeat.d.ts.map +1 -1
  11. package/dist/actions/heartbeat.js +5 -8
  12. package/dist/actions/register_action_ws.d.ts +3 -3
  13. package/dist/actions/register_action_ws.js +2 -2
  14. package/dist/actions/register_ws_endpoint.d.ts +4 -4
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +3 -3
  17. package/dist/actions/socket.svelte.d.ts +16 -16
  18. package/dist/actions/socket.svelte.d.ts.map +1 -1
  19. package/dist/actions/socket.svelte.js +15 -15
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_backend.d.ts +15 -0
  22. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  23. package/dist/actions/transports_ws_backend.js +17 -0
  24. package/dist/auth/CLAUDE.md +923 -0
  25. package/dist/auth/account_action_specs.d.ts +216 -0
  26. package/dist/auth/account_action_specs.d.ts.map +1 -0
  27. package/dist/auth/account_action_specs.js +159 -0
  28. package/dist/auth/account_actions.d.ts +51 -0
  29. package/dist/auth/account_actions.d.ts.map +1 -0
  30. package/dist/auth/account_actions.js +119 -0
  31. package/dist/auth/account_queries.d.ts +6 -2
  32. package/dist/auth/account_queries.d.ts.map +1 -1
  33. package/dist/auth/account_queries.js +40 -4
  34. package/dist/auth/account_routes.d.ts +94 -16
  35. package/dist/auth/account_routes.d.ts.map +1 -1
  36. package/dist/auth/account_routes.js +108 -180
  37. package/dist/auth/account_schema.d.ts +85 -30
  38. package/dist/auth/account_schema.d.ts.map +1 -1
  39. package/dist/auth/account_schema.js +40 -8
  40. package/dist/auth/admin_action_specs.d.ts +674 -0
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -0
  42. package/dist/auth/admin_action_specs.js +287 -0
  43. package/dist/auth/admin_actions.d.ts +69 -0
  44. package/dist/auth/admin_actions.d.ts.map +1 -0
  45. package/dist/auth/admin_actions.js +256 -0
  46. package/dist/auth/api_token.d.ts +10 -0
  47. package/dist/auth/api_token.d.ts.map +1 -1
  48. package/dist/auth/api_token.js +9 -0
  49. package/dist/auth/api_token_queries.d.ts +3 -3
  50. package/dist/auth/api_token_queries.js +3 -3
  51. package/dist/auth/app_settings_schema.d.ts +4 -3
  52. package/dist/auth/app_settings_schema.d.ts.map +1 -1
  53. package/dist/auth/app_settings_schema.js +2 -1
  54. package/dist/auth/audit_log_routes.d.ts +14 -6
  55. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  56. package/dist/auth/audit_log_routes.js +22 -79
  57. package/dist/auth/audit_log_schema.d.ts +100 -29
  58. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  59. package/dist/auth/audit_log_schema.js +83 -11
  60. package/dist/auth/bootstrap_routes.d.ts +14 -0
  61. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  62. package/dist/auth/bootstrap_routes.js +10 -3
  63. package/dist/auth/cleanup.d.ts +63 -0
  64. package/dist/auth/cleanup.d.ts.map +1 -0
  65. package/dist/auth/cleanup.js +80 -0
  66. package/dist/auth/invite_schema.d.ts +11 -10
  67. package/dist/auth/invite_schema.d.ts.map +1 -1
  68. package/dist/auth/invite_schema.js +4 -3
  69. package/dist/auth/migrations.d.ts +6 -0
  70. package/dist/auth/migrations.d.ts.map +1 -1
  71. package/dist/auth/migrations.js +28 -0
  72. package/dist/auth/permit_offer_action_specs.d.ts +364 -0
  73. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
  74. package/dist/auth/permit_offer_action_specs.js +216 -0
  75. package/dist/auth/permit_offer_actions.d.ts +96 -0
  76. package/dist/auth/permit_offer_actions.d.ts.map +1 -0
  77. package/dist/auth/permit_offer_actions.js +428 -0
  78. package/dist/auth/permit_offer_notifications.d.ts +361 -0
  79. package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
  80. package/dist/auth/permit_offer_notifications.js +179 -0
  81. package/dist/auth/permit_offer_queries.d.ts +165 -0
  82. package/dist/auth/permit_offer_queries.d.ts.map +1 -0
  83. package/dist/auth/permit_offer_queries.js +390 -0
  84. package/dist/auth/permit_offer_schema.d.ts +103 -0
  85. package/dist/auth/permit_offer_schema.d.ts.map +1 -0
  86. package/dist/auth/permit_offer_schema.js +142 -0
  87. package/dist/auth/permit_queries.d.ts +77 -14
  88. package/dist/auth/permit_queries.d.ts.map +1 -1
  89. package/dist/auth/permit_queries.js +119 -24
  90. package/dist/auth/session_queries.d.ts +4 -2
  91. package/dist/auth/session_queries.d.ts.map +1 -1
  92. package/dist/auth/session_queries.js +4 -2
  93. package/dist/auth/signup_routes.d.ts +13 -0
  94. package/dist/auth/signup_routes.d.ts.map +1 -1
  95. package/dist/auth/signup_routes.js +14 -7
  96. package/dist/http/CLAUDE.md +584 -0
  97. package/dist/http/pending_effects.d.ts +29 -0
  98. package/dist/http/pending_effects.d.ts.map +1 -0
  99. package/dist/http/pending_effects.js +31 -0
  100. package/dist/http/route_spec.d.ts.map +1 -1
  101. package/dist/http/route_spec.js +4 -3
  102. package/dist/rate_limiter.d.ts +30 -0
  103. package/dist/rate_limiter.d.ts.map +1 -1
  104. package/dist/rate_limiter.js +25 -2
  105. package/dist/realtime/sse_auth_guard.d.ts +2 -0
  106. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  107. package/dist/realtime/sse_auth_guard.js +5 -3
  108. package/dist/testing/CLAUDE.md +668 -1
  109. package/dist/testing/admin_integration.d.ts +10 -7
  110. package/dist/testing/admin_integration.d.ts.map +1 -1
  111. package/dist/testing/admin_integration.js +382 -482
  112. package/dist/testing/app_server.d.ts +7 -6
  113. package/dist/testing/app_server.d.ts.map +1 -1
  114. package/dist/testing/attack_surface.d.ts +9 -3
  115. package/dist/testing/attack_surface.d.ts.map +1 -1
  116. package/dist/testing/attack_surface.js +4 -4
  117. package/dist/testing/audit_completeness.d.ts +6 -0
  118. package/dist/testing/audit_completeness.d.ts.map +1 -1
  119. package/dist/testing/audit_completeness.js +158 -134
  120. package/dist/testing/auth_apps.d.ts.map +1 -1
  121. package/dist/testing/auth_apps.js +4 -33
  122. package/dist/testing/db.d.ts +1 -1
  123. package/dist/testing/db.d.ts.map +1 -1
  124. package/dist/testing/db.js +2 -0
  125. package/dist/testing/entities.d.ts +35 -13
  126. package/dist/testing/entities.d.ts.map +1 -1
  127. package/dist/testing/entities.js +17 -0
  128. package/dist/testing/integration.d.ts +10 -0
  129. package/dist/testing/integration.d.ts.map +1 -1
  130. package/dist/testing/integration.js +352 -340
  131. package/dist/testing/integration_helpers.d.ts +16 -5
  132. package/dist/testing/integration_helpers.d.ts.map +1 -1
  133. package/dist/testing/integration_helpers.js +24 -4
  134. package/dist/testing/rate_limiting.d.ts +7 -0
  135. package/dist/testing/rate_limiting.d.ts.map +1 -1
  136. package/dist/testing/rate_limiting.js +41 -10
  137. package/dist/testing/rpc_helpers.d.ts +153 -1
  138. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  139. package/dist/testing/rpc_helpers.js +184 -8
  140. package/dist/testing/sse_round_trip.d.ts +8 -0
  141. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  142. package/dist/testing/sse_round_trip.js +10 -3
  143. package/dist/testing/standard.d.ts +9 -1
  144. package/dist/testing/standard.d.ts.map +1 -1
  145. package/dist/testing/standard.js +6 -2
  146. package/dist/testing/surface_invariants.d.ts +7 -3
  147. package/dist/testing/surface_invariants.d.ts.map +1 -1
  148. package/dist/testing/surface_invariants.js +5 -4
  149. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/ws_round_trip.js +9 -38
  151. package/dist/ui/AccountSessions.svelte +8 -4
  152. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  153. package/dist/ui/AdminAccounts.svelte +61 -33
  154. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  155. package/dist/ui/AdminAuditLog.svelte +3 -2
  156. package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
  157. package/dist/ui/AdminInvites.svelte +3 -2
  158. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  159. package/dist/ui/AdminOverview.svelte +14 -9
  160. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  161. package/dist/ui/AdminPermitHistory.svelte +3 -2
  162. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
  163. package/dist/ui/AdminSessions.svelte +29 -25
  164. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  165. package/dist/ui/CLAUDE.md +351 -0
  166. package/dist/ui/OpenSignupToggle.svelte +6 -3
  167. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  168. package/dist/ui/PermitOfferForm.svelte +141 -0
  169. package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
  170. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
  171. package/dist/ui/PermitOfferHistory.svelte +109 -0
  172. package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
  173. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
  174. package/dist/ui/PermitOfferInbox.svelte +121 -0
  175. package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
  176. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
  177. package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
  178. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  179. package/dist/ui/account_sessions_state.svelte.js +39 -16
  180. package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
  181. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  182. package/dist/ui/admin_accounts_state.svelte.js +99 -23
  183. package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
  184. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  185. package/dist/ui/admin_invites_state.svelte.js +38 -26
  186. package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
  187. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  188. package/dist/ui/admin_sessions_state.svelte.js +35 -21
  189. package/dist/ui/app_settings_state.svelte.d.ts +39 -0
  190. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  191. package/dist/ui/app_settings_state.svelte.js +34 -18
  192. package/dist/ui/audit_log_state.svelte.d.ts +40 -3
  193. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  194. package/dist/ui/audit_log_state.svelte.js +36 -42
  195. package/dist/ui/auth_state.svelte.d.ts +4 -3
  196. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  197. package/dist/ui/auth_state.svelte.js +4 -1
  198. package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
  199. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
  200. package/dist/ui/permit_offers_state.svelte.js +197 -0
  201. package/package.json +3 -3
  202. package/dist/auth/admin_routes.d.ts +0 -29
  203. package/dist/auth/admin_routes.d.ts.map +0 -1
  204. package/dist/auth/admin_routes.js +0 -226
  205. package/dist/auth/app_settings_routes.d.ts +0 -27
  206. package/dist/auth/app_settings_routes.d.ts.map +0 -1
  207. package/dist/auth/app_settings_routes.js +0 -66
  208. package/dist/auth/invite_routes.d.ts +0 -18
  209. package/dist/auth/invite_routes.d.ts.map +0 -1
  210. package/dist/auth/invite_routes.js +0 -129
@@ -0,0 +1,630 @@
1
+ # actions/ — SAES (Symmetric Action Event System)
2
+
3
+ One declarative `ActionSpec` shape — `{method, kind, initiator, auth, side_effects, input, output, async, description}` — 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, permit-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. When adding new schemas, keep the pair invariant — it is the
20
+ convention callers rely on for type imports.
21
+
22
+ NOTE: The system is marked `@action-system-review` — `action_registry`,
23
+ `action_codegen`, and `action_bridge` still hold partial/stub API. Non-obvious
24
+ future-churn sites are flagged in source. Treat the core bridge + RPC endpoint
25
+ as stable; registry getters and codegen shapes may move.
26
+
27
+ ## Action specs (`action_spec.ts`)
28
+
29
+ Canonical source of truth. Three concrete kinds discriminate on `kind`:
30
+
31
+ | Kind | `auth` | `side_effects` | `output` | `async` |
32
+ | --------------------- | ----------------------- | -------------- | ----------- | ------- |
33
+ | `request_response` | `ActionAuth` (non-null) | arbitrary | arbitrary | `true` |
34
+ | `remote_notification` | `null` | `true` | `z.ZodVoid` | `true` |
35
+ | `local_call` | `null` | arbitrary | arbitrary | boolean |
36
+
37
+ Enums + unions:
38
+
39
+ - `ActionKind` — `'request_response' | 'remote_notification' | 'local_call'`
40
+ - `ActionInitiator` — `'frontend' | 'backend' | 'both'`
41
+ - `ActionAuth` — `'public' | 'authenticated' | 'keeper' | {role: string}`
42
+ - `ActionSpecUnion` — discriminated union of the three variants
43
+ - `ActionEventPhase` — `'send_request' | 'receive_request' | 'send_response' | 'receive_response' | 'send_error' | 'receive_error' | 'send' | 'receive' | 'execute'`
44
+ - `is_action_spec(value)` — structural type guard
45
+
46
+ Optional `streams?: string` names a companion `remote_notification` method
47
+ emitted as request-scoped progress. Transport-agnostic handshake —
48
+ registry-time validation that the named method exists is a consumer concern.
49
+
50
+ Canonical spec shape: module-scope declaration with `satisfies` +
51
+ `{method}_action_spec` naming, preserving the literal `method` type and
52
+ dropping per-spec `*_METHOD` constants (readers dereference `.method` at
53
+ call sites). See ../../docs/usage.md §Canonical action-spec shape.
54
+
55
+ ## Kind → binding constraints
56
+
57
+ The three action kinds map to bindings with hard constraints:
58
+
59
+ | Kind | REST `RouteSpec` | RPC `RouteSpec` (via dispatcher) | WS dispatch | SSE `EventSpec` |
60
+ | --------------------- | ---------------- | -------------------------------- | ----------- | --------------- |
61
+ | `request_response` | yes (bridge) | yes (`create_rpc_endpoint`) | yes | no |
62
+ | `remote_notification` | no | no | server push | yes (bridge) |
63
+ | `local_call` | no | no | no | no |
64
+
65
+ `create_action_route_spec` throws if `spec.auth` is null — enforces that
66
+ notifications and local calls cannot become routes. `create_action_event_spec`
67
+ throws on any non-`remote_notification` kind.
68
+
69
+ ## Registry + codegen (`action_registry.ts`, `action_codegen.ts`)
70
+
71
+ `ActionRegistry(specs)` is a query/filter wrapper over `ActionSpecUnion[]`.
72
+ Used getters today are `spec_by_method`, `request_response_specs`,
73
+ `remote_notification_specs`, `local_call_specs`, `frontend_methods`,
74
+ `backend_methods`, `methods`. The rest are pre-built stubs — revisit when
75
+ the system matures.
76
+
77
+ `action_codegen.ts` provides gen helpers (used by consumer `*.gen.ts` files,
78
+ not the runtime):
79
+
80
+ - `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`.
81
+ - `get_executor_phases(spec, executor)` — phases a given executor (`'frontend' | 'backend'`) participates in for the spec. Deduplicates via `Set` (handles `initiator: 'both'` overlap).
82
+ - `get_handler_return_type(spec, phase, imports, path_prefix)` — the TS type a phase handler must return; triggers the `ActionOutputs` import as a side effect.
83
+ - `generate_phase_handlers(spec, executor, imports, {action_event_type?})` — emits the typed handler-map fragment for one action; consumers compose these into `ActionHandlers` types.
84
+ - `generate_actions_api_method_signature(spec, {sync_returns_value?})` — single source of truth for the typed `ActionsApi` method shape. Threads `options?: RpcClientCallOptions` (`{signal?, transport_name?, queue?}`) onto every async method. Consumers must regenerate onto this helper — older inline templates using `get_innermost_type_name` directly drop the options arg.
85
+ - `create_banner(origin_path)` — gen banner comment.
86
+ - `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`).
87
+ - `get_innermost_type(schema)` / `get_innermost_type_name(schema)` — unwrap Zod `ZodOptional` / `ZodNullable` / `ZodDefault` / transforms / pipes / prefaults to reach the base schema.
88
+
89
+ ## HTTP bridge (`action_bridge.ts`)
90
+
91
+ Derives transport-specific specs from action specs. HTTP-specific concerns
92
+ (path, handler, errors) come from options, not the action spec.
93
+
94
+ - `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`). Auth maps via `map_action_auth` (`'public'` → `{type: 'none'}`, `'authenticated'` → `{type: 'authenticated'}`, `'keeper'` → `{type: 'keeper'}`, `{role}` → `{type: 'role', role}`). `options.errors: RouteErrorSchemas` attaches transport-specific (HTTP status–keyed) error shapes. `transaction: spec.side_effects`. Throws if `spec.auth` is null.
95
+ - `create_action_event_spec(spec, {channel?})` — one notification action → one `EventSpec` for SSE surface + `create_validated_broadcaster`. Throws on non-`remote_notification` kind.
96
+ - `map_action_auth(auth)` / `derive_http_method(side_effects)` — exported for consumers that build custom bridges.
97
+
98
+ ## Single JSON-RPC 2.0 endpoint (`action_rpc.ts`)
99
+
100
+ `create_rpc_endpoint({path, actions, log}): RouteSpec[]` produces **two**
101
+ route specs on the same path (GET + POST) that share one internal
102
+ dispatcher. Per-action auth lives inside the dispatcher; the outer routes
103
+ use `auth: {type: 'none'}` and `transaction: false`.
104
+
105
+ Dispatcher phase order (POST; GET differs only at step 1):
106
+
107
+ 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}`.
108
+ 2. **Lookup method** — `Map<method, RpcAction>`. Unknown method → `method_not_found`. Duplicate methods throw at construction.
109
+ 3. **GET read restriction** — GET is rejected for `side_effects: true` actions (`invalid_request` with "must use POST").
110
+ 4. **Auth check** — via `check_action_auth(spec.auth, request_context, credential_type)`. `keeper` requires `credential_type === 'daemon_token'` AND `has_role(request_context, 'keeper')` — the `has_role` alone is insufficient, session/bearer cannot elevate. `{role}` uses `has_role`. Failure → `unauthenticated` / `forbidden`.
111
+ 5. **Validate params** — `spec.input.safeParse(raw_params ?? null-if-null-schema)`. Failure → `invalid_params` with `{issues}`.
112
+ 6. **Dispatch** — `spec.side_effects` picks transaction (`route.db.transaction(tx => execute(tx))`) vs pool (`route.db`). Handler throws roll back the transaction — the catch sits outside the transaction boundary.
113
+ 7. **DEV-only output validation** — `spec.output.safeParse(output)` runs only under `DEV` (from `esm-env`). On mismatch: `log.error(...)`, return response unchanged; never throws, never mutates status.
114
+
115
+ Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error &&
116
+ typeof err.code === 'number'`) preserves code + data verbatim, status via
117
+ `jsonrpc_error_code_to_http_status`. Duck-typing avoids cross-copy
118
+ `instanceof` misses when consumers throw their own `ThrownJsonrpcError`
119
+ (e.g. zzz). Generic thrown errors become `internal_error` 500; message is
120
+ the raw error under `DEV`, "internal server error" otherwise.
121
+
122
+ Per-request handler shape:
123
+
124
+ ```ts
125
+ type ActionHandler<TInput, TOutput> = (
126
+ input: TInput,
127
+ ctx: ActionContext,
128
+ ) => TOutput | Promise<TOutput>;
129
+
130
+ interface ActionContext {
131
+ auth: RequestContext | null; // null for public actions
132
+ request_id: JsonrpcRequestId;
133
+ db: Db; // transaction for mutations, pool for reads
134
+ background_db: Db; // always pool — for fire-and-forget outlive
135
+ pending_effects: Array<Promise<void>>;
136
+ log: Logger;
137
+ notify: (method, params) => void; // HTTP: DEV-mode warn + drop (no streaming channel)
138
+ signal: AbortSignal; // c.req.raw.signal — fires on client disconnect
139
+ }
140
+
141
+ interface RpcAction {
142
+ spec: RequestResponseActionSpec;
143
+ handler: ActionHandler;
144
+ }
145
+ ```
146
+
147
+ ### `rpc_action(spec, handler)` — typed binder
148
+
149
+ `rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)`
150
+ returns a `RpcAction` with the handler's input / output types pinned to
151
+ `z.infer<TSpec['input']>` and `z.infer<TSpec['output']>` via the generic.
152
+ Use this at every spec → handler binding site so handler-type errors
153
+ surface at the factory call instead of at runtime:
154
+
155
+ ```ts
156
+ export const create_admin_actions = (deps, options) => [
157
+ rpc_action(admin_account_list_action_spec, account_list_handler),
158
+ rpc_action(admin_session_revoke_all_action_spec, session_revoke_all_handler),
159
+ // …
160
+ ];
161
+ ```
162
+
163
+ zzz uses a codegen-driven `Record<Method, Handler>` map for the same
164
+ narrowing — ideal when handlers are stateless free functions. fuz_app's
165
+ handlers close over factory-captured deps (`log`, `on_audit_event`,
166
+ `options.app_settings`, `options.max_tokens`), so per-pair typing via
167
+ `rpc_action()` is the right shape here: the binding happens at
168
+ construction time and the handler keeps its closure. Applied across
169
+ `admin_actions.ts` + `permit_offer_actions.ts` + `account_actions.ts`
170
+ — each pairs a spec imported from its `*_action_specs.ts` sibling with
171
+ a closure-bound handler.
172
+
173
+ ## Transports (`transports.ts`, `transports_http.ts`, `transports_ws.ts`, `transports_ws_backend.ts`)
174
+
175
+ `Transport` is the unifying interface: overloaded `send(message, options?)`
176
+ returning `Promise<JsonrpcResponseOrError>` for requests and
177
+ `Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
178
+ and optional `dispose()`. All transports share `TransportSendOptions`:
179
+
180
+ - `signal?: AbortSignal` — per-call cancel. Bottoms out at `FrontendWebsocketClient.request({signal})` on WS (sends `cancel` notification on abort) and at `fetch({signal})` on HTTP.
181
+ - `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.
182
+
183
+ `Transports` registry holds multiple transports with a `current` selection
184
+ and `allow_fallback: boolean` (default `true`). `get_transport(name?)`
185
+ returns first-ready: specified → current → any. No fallback when
186
+ `allow_fallback: false`. Explicit `transport_for_method` (on `rpc_client`)
187
+ or `default_send_options.transport_name` (on `ActionPeer`) takes precedence.
188
+
189
+ WS close codes live here:
190
+
191
+ - `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
192
+ - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
193
+ - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
194
+
195
+ ### `FrontendHttpTransport` (`transports_http.ts`)
196
+
197
+ Thin `fetch` adapter. Name `'frontend_http_rpc'`. POST by default; GET with
198
+ `?method=&id=&params=` when the caller supplies `has_side_effects(method)`
199
+ returning `false` (matches `create_rpc_endpoint`'s GET convention). Forwards
200
+ `signal` to `fetch`. On non-OK HTTP response, synthesizes a JSON-RPC error
201
+ envelope via `http_status_to_jsonrpc_error_code`. DEV-mode checks that
202
+ JSON-RPC error codes match the declared HTTP status and warns on drift.
203
+ `is_ready(): true` always.
204
+
205
+ ### `FrontendWebsocketTransport` (`transports_ws.ts`)
206
+
207
+ Name `'frontend_websocket_rpc'`. A **thin adapter** over `WebsocketRpcConnection`
208
+ (the canonical implementation is `FrontendWebsocketClient`). No parallel
209
+ pending-request map — delegates request/response correlation, durable queue,
210
+ heartbeat, and abort-signal cancel to the underlying connection. Routes
211
+ inbound server-pushed messages (requests + notifications) into a `receive`
212
+ callback; responses are owned by `connection.request()` so the transport
213
+ ignores them.
214
+
215
+ Two connection interfaces it consumes:
216
+
217
+ - `WebsocketConnection` — minimal fire-and-forget (`send(data)`, `connected`, `add_message_handler`, `add_error_handler`).
218
+ - `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).
219
+
220
+ Notification sends fail-fast when disconnected regardless of `queue` —
221
+ `connection.send()` has no queue semantic, so buffering would masquerade
222
+ as success at the rpc_client layer. Requests are routed via `queue`.
223
+
224
+ ### `BackendWebsocketTransport` (`transports_ws_backend.ts`)
225
+
226
+ Name `'backend_websocket_rpc'`. Server-side WS transport with session
227
+ tracking. Implements `FilterableBroadcastTransport` (the structural
228
+ capability for per-connection ACL'd fan-out; feature-detected via
229
+ `is_filterable_broadcast_transport`).
230
+
231
+ State is three aligned maps keyed by `connection_id` (branded `Uuid`):
232
+
233
+ - `#connections: Map<Uuid, WSContext>` — id → socket.
234
+ - `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse).
235
+ - `#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.
236
+
237
+ Lifecycle:
238
+
239
+ - `add_connection(ws, token_hash, account_id, api_token_id) → Uuid` — assigns a fresh `connection_id`.
240
+ - `remove_connection(ws)` — idempotent; safe after revocation.
241
+
242
+ Targeted closure (all return `number` of sockets closed; use `WS_CLOSE_SESSION_REVOKED`):
243
+
244
+ - `close_sockets_for_session(token_hash)` — single session revocation.
245
+ - `close_sockets_for_token(api_token_id)` — one bearer token, leaves account's other sockets intact.
246
+ - `close_sockets_for_account(account_id)` — coarse; covers session + bearer + daemon-token.
247
+
248
+ Fan-out:
249
+
250
+ - `send(notification)` — broadcasts to every connection (current `send(request)` returns an internal_error "not yet implemented" — backend cannot initiate request-response).
251
+ - `broadcast_filtered(message, predicate)` — per-connection predicate over `ConnectionIdentity`; skips non-matching. Returns count.
252
+ - `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/permit_offer_notifications.ts` (see `../auth/CLAUDE.md` §WS notifications).
253
+ - `get_connection_count()` — telemetry counter over the connection map.
254
+
255
+ Return values are bookkeeping, not delivery receipts — `0` means no live
256
+ sockets, non-zero means `ws.send` did not throw. Durable delivery requires
257
+ persistence + rehydration by the consumer.
258
+
259
+ ## WS auth guard (`transports_ws_auth_guard.ts`)
260
+
261
+ `create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback
262
+ wireable via `CreateAppBackendOptions.on_audit_event`. Mirrors the SSE
263
+ guard in `realtime/sse_auth_guard.ts` but targets the WS transport.
264
+
265
+ `WS_DISCONNECT_EVENT_TYPES` (ReadonlySet): `session_revoke`,
266
+ `token_revoke`, `session_revoke_all`, `token_revoke_all`, `password_change`.
267
+ `permit_revoke` is intentionally **omitted** — the WS transport does not
268
+ track per-connection role requirements, so role-scoped disconnection would
269
+ require either closing all sockets (too aggressive) or new per-connection
270
+ role tracking (out of scope). Consumers that need it compose their own
271
+ callback.
272
+
273
+ Event dispatch:
274
+
275
+ - `session_revoke` → `close_sockets_for_session(metadata.session_id)`
276
+ - `token_revoke` → `close_sockets_for_token(metadata.token_id)`
277
+ - `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`).
278
+
279
+ `outcome === 'failure'` events are ignored — they carry
280
+ attacker-controlled identifiers. Reacting to them would let an authenticated
281
+ caller close another user's socket by guessing a session hash or token id.
282
+
283
+ ## WebSocket dispatch
284
+
285
+ Two layered entry points:
286
+
287
+ ### `register_ws_endpoint` (`register_ws_endpoint.ts`) — idiomatic
288
+
289
+ Composes the standard upgrade stack:
290
+
291
+ 1. `verify_request_source(allowed_origins)`
292
+ 2. `require_auth`
293
+ 3. optional `require_role(required_role)`
294
+ 4. delegates to `register_action_ws`
295
+
296
+ Extends `RegisterActionWsOptions<TCtx>` with `allowed_origins: Array<RegExp>`
297
+ and optional `required_role: RoleName`. Returns `{transport}`. Note:
298
+ `required_role` is a **coarse upgrade-time gate** — per-action `auth` in
299
+ each spec still applies at dispatch time. (`verify_request_source` and
300
+ `require_auth` / `require_role` are from `../auth/`; see
301
+ `../auth/CLAUDE.md` §Middleware for their semantics.)
302
+
303
+ ### `register_action_ws` (`register_action_ws.ts`) — lower-level
304
+
305
+ Exposed for tests (`create_ws_test_harness`) that need to drive the
306
+ dispatcher without the origin/auth front-stack.
307
+
308
+ Actions are passed as `ReadonlyArray<Action<TCtx>>` — the composable
309
+ `{spec, handler?}` tuple shared with `create_rpc_client`. The dispatcher
310
+ fans the array into a `spec_by_method` map (drives auth + validation) and
311
+ a `handlers` record (drives invocation). Spec without handler is fine for
312
+ client-only specs (incoming notification specs); spec without handler that
313
+ the dispatcher is asked to invoke returns `method_not_found`.
314
+
315
+ `extend_context(base, c)` builds the per-request context on every message.
316
+ `BaseHandlerContext` (the non-extended minimum, exported from `action_types.ts`):
317
+
318
+ ```ts
319
+ interface BaseHandlerContext {
320
+ request_id: JsonrpcRequestId;
321
+ connection_id: Uuid; // stable across messages on this socket
322
+ notify: (method, params) => void; // socket-scoped, not broadcast
323
+ signal: AbortSignal; // AbortSignal.any([socket_close, per_request_cancel])
324
+ }
325
+ ```
326
+
327
+ `WsActionHandler<TCtx>` is the WS-side handler type (single-context-slot,
328
+ returns `unknown` — disambiguated from `action_rpc.ts`'s `ActionHandler`).
329
+
330
+ Per-message wire behavior:
331
+
332
+ - **Batch JSON-RPC rejected** — arrays get `invalid_request`.
333
+ - **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).
334
+ - **Per-action auth** — `public` / `authenticated` pass through (upgrade already verified); `keeper` requires `credential_type === 'daemon_token'` AND `has_role(ROLE_KEEPER)`; `{role}` requires `has_role(role)`. Same shape as `action_rpc.ts`.
335
+ - **Input validation** — `spec.input.safeParse(params)`; failure → `invalid_params` with `{issues}`.
336
+ - **DEV-only output validation** — `spec.output.safeParse(output)` under `DEV`; logs error on mismatch, never throws, sends result unchanged. Uniform with RPC + REST surfaces.
337
+ - **Error handling** — `ThrownJsonrpcError` preserves code + data; generic throws are wrapped via `create_jsonrpc_error_response_from_thrown`. `ThrownJsonrpcError` is logged at `debug` (expected protocol outcome); generic errors at `error`.
338
+
339
+ Two abort signals, composed via `AbortSignal.any`:
340
+
341
+ - `socket_abort_controller` — per-socket, fires on close. Drives every handler's `ctx.signal` on that socket.
342
+ - `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.
343
+
344
+ Lifecycle hooks on `RegisterActionWsOptions`:
345
+
346
+ - `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.
347
+ - `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.
348
+
349
+ Server-side heartbeat (`heartbeat?: boolean | ServerHeartbeatOptions`):
350
+ default-on, 60s silence timeout. Any inbound message resets
351
+ `last_receive_time` — chatty clients never trip it. First timeout window
352
+ after open is exempt (cold-start grace). Tick interval is
353
+ `timeout / 2`, so event-loop blockage pauses the timer itself.
354
+
355
+ ## Event state machine
356
+
357
+ Five modules make up a discriminated-union-based state machine used by the
358
+ reactive client (`rpc_client.ts` + consumer ActionEvent-aware UIs) to track
359
+ an action through its lifecycle.
360
+
361
+ ### `action_event_types.ts`
362
+
363
+ - `ActionExecutor` — `'frontend' | 'backend'`
364
+ - `ActionEventStep` — `'initial' | 'parsed' | 'handling' | 'handled' | 'failed'`
365
+ - `ACTION_EVENT_STEP_TRANSITIONS` — valid next-steps: `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal.
366
+ - `ACTION_EVENT_PHASE_BY_KIND` — valid phases per kind (`request_response` has 6, `remote_notification` has 2, `local_call` has 1).
367
+ - `ACTION_EVENT_PHASE_TRANSITIONS` — chained phases: `send_request → receive_response`; `receive_request → send_response`; everything else terminal.
368
+ - `ActionEventEnvironment` — `{executor, lookup_action_handler, lookup_action_spec, log?}`. The ambient registry + handler resolver for an `ActionEvent`.
369
+
370
+ ### `action_event_data.ts`
371
+
372
+ `ActionEventData` is the base Zod schema — a strict object with all 10
373
+ possible fields always present (nullable where not applicable for the
374
+ current phase/step). The exported union `ActionEventDataUnion<TMethod,
375
+ TInput, TOutput>` is a **39-variant discriminated union** across `kind` +
376
+ `phase` + `step`: 28 variants for `request_response`, 6 for
377
+ `remote_notification`, 5 for `local_call`. Narrows the shape of
378
+ `input` / `output` / `error` / `request` / `response` / `notification` /
379
+ `progress` at each point in the lifecycle.
380
+
381
+ ### `action_event_helpers.ts`
382
+
383
+ Type guards (discriminate on `kind` + `phase` + `step`):
384
+
385
+ - By kind: `is_request_response`, `is_remote_notification`, `is_local_call`
386
+ - By phase: `is_send_request`, `is_receive_request`, `is_send_response`, `is_receive_response`, `is_notification_send`, `is_notification_receive`, `is_execute`
387
+ - By step: `is_initial`, `is_parsed`, `is_handling`, `is_handled`, `is_failed`
388
+ - Combined: `is_send_request_with_parsed_input`, `is_notification_send_with_parsed_input`
389
+
390
+ Validators:
391
+
392
+ - `validate_step_transition(from, to)` — throws on illegal step moves.
393
+ - `validate_phase_for_kind(kind, phase)` — throws if the phase isn't valid for the kind.
394
+ - `validate_phase_transition(from, to)` — throws on illegal phase chain.
395
+ - `get_initial_phase(kind, initiator, executor)` — the phase an executor starts an action from, or `null` if this executor can't initiate.
396
+ - `should_validate_output(kind, phase)` — true for `receive_request`/`receive_response` on `request_response` and `execute` on `local_call`.
397
+ - `is_action_complete(data)` — `failed`, or `handled` at a terminal phase.
398
+
399
+ Constructors / extractors:
400
+
401
+ - `create_initial_data(kind, phase, method, executor, input)` — produces a well-formed initial-step `ActionEventData` with every nullable field null.
402
+ - `extract_action_result(event): Result<{value}, {error}>` — pulls the terminal outcome. Throws on non-terminal events (programming error).
403
+
404
+ ### `action_event.ts`
405
+
406
+ `ActionEvent<TMethod, TPhase, TStep>` — the mutable state-machine class.
407
+ Holds `#data` (current `ActionEventDataUnion`), notifies observers on
408
+ every transition via `observe(listener): () => unsubscribe`. Keeps the
409
+ spec + environment references.
410
+
411
+ Lifecycle methods:
412
+
413
+ - `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.
414
+ - `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`.
415
+ - `transition(phase)` — `handled` at a chainable phase → next phase's `initial`. Uses `#create_phase_data` to carry forward `request` / `response` / `error` / `output` as appropriate.
416
+ - `is_complete()`, `update_progress(progress)`, `set_request(request)`, `set_response(response)`, `set_notification(notification)`.
417
+
418
+ Constructors:
419
+
420
+ - `create_action_event(environment, spec, input, initial_phase?)` — default phase via `get_initial_phase`; throws if the executor can't initiate.
421
+ - `create_action_event_from_json(json, environment)` — rehydrate after wire transfer.
422
+ - `parse_action_event(raw_json, environment)` — `ActionEventData.parse` + `create_action_event_from_json`.
423
+
424
+ Protocol message creation is automatic: when transitioning `parsed → handling`
425
+ on a `send_request` phase, `ActionEvent` materializes the outgoing
426
+ `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification)
427
+ it materializes the `JsonrpcNotification`.
428
+
429
+ ## Action peer (`action_peer.ts`)
430
+
431
+ `ActionPeer` — symmetric JSON-RPC send + receive over a `Transports`
432
+ registry and `ActionEventEnvironment`. Construct with
433
+ `{environment, transports?, default_send_options?}`.
434
+
435
+ `default_send_options` excludes `signal` — signals are inherently per-call
436
+ (a shared signal would abort every subsequent call after the first trip).
437
+ `transport_name` and `queue` can be defaulted here once to flip the peer
438
+ into client-authoritative mode: `new ActionPeer({..., default_send_options:
439
+ {queue: true}})` durably queues every request_response call by default.
440
+
441
+ Per-call options:
442
+
443
+ ```ts
444
+ interface ActionPeerSendOptions extends TransportSendOptions {
445
+ transport_name?: TransportName;
446
+ }
447
+ ```
448
+
449
+ `send(message, options?)`:
450
+
451
+ - Resolves the transport via `transports.get_transport(options?.transport_name ?? default.transport_name)`.
452
+ - No transport → `service_unavailable` JSON-RPC error (does not throw).
453
+ - Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
454
+ - Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
455
+
456
+ `receive(message)` — dispatch for inbound messages:
457
+
458
+ - **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`.
459
+ - **Notifications** — same flow for `'receive'` phase; returns `null` (no response).
460
+ - **Anything else** — `invalid_request` JSON-RPC error.
461
+
462
+ Currently partial: `#receive_request`'s `send_response` transition step has
463
+ a known sharp edge ("shouldn't need the guard" TODO).
464
+
465
+ ## Composable actions (`heartbeat.ts`, `cancel.ts`)
466
+
467
+ Two shared `{spec, handler}` tuples that every consumer spreads into both
468
+ sides' `actions` arrays — disconnect detection and per-request cancel work
469
+ identically across every repo without per-consumer ping plumbing.
470
+
471
+ ### `heartbeat_action`
472
+
473
+ Method `'heartbeat'`, `request_response`, `initiator: 'frontend'`, `auth:
474
+ 'authenticated'`, `side_effects: false`, nullary input/output
475
+ (`z.strictObject({})`). Handler is a stateless no-op echo. The client's
476
+ activity-aware heartbeat timer (`FrontendWebsocketClient.#heartbeat_tick`)
477
+ fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; the server's
478
+ `register_action_ws` heartbeat tracker counts the incoming message as
479
+ activity and resets `last_receive_time`.
480
+
481
+ ### `cancel_action`
482
+
483
+ Method `'cancel'`, `remote_notification`, `initiator: 'frontend'`, `auth:
484
+ null`, `side_effects: true`. Params: `CancelNotificationParams =
485
+ z.strictObject({request_id: JsonrpcRequestId})`. The **handler is an empty
486
+ stub** — cancel semantics are dispatcher-owned
487
+ (`register_action_ws` has the `{request_id → AbortController}` map, not the
488
+ handler). The tuple exists for symmetry + so `spec_by_method` knows about
489
+ it (enables input validation on incoming cancels) + so `create_rpc_client`
490
+ sees the method.
491
+
492
+ Wire format is snake_case `cancel` + `{request_id}`, not MCP's
493
+ `$/cancelRequest` + `{requestId}`. MCP adoption would happen at an MCP
494
+ adapter's translation layer, not in the base transport.
495
+
496
+ ## Reactive frontend client (`socket.svelte.ts`, `request_tracker.svelte.ts`)
497
+
498
+ ### `FrontendWebsocketClient`
499
+
500
+ Portable, Svelte-reactive (`$state.raw` for `ws`, `status`, `reconnect_count`,
501
+ `current_reconnect_delay`, `last_connect_time`, `last_close_time`,
502
+ `last_close_code`, `last_close_reason`, `last_send_error`). Plain class — no
503
+ Cell inheritance, no app coupling. Implements `WebsocketConnection` +
504
+ `WebsocketRpcConnection`, and is `Disposable`.
505
+
506
+ Ships three correctness primitives default-on:
507
+
508
+ 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`.
509
+ 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).
510
+ 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.
511
+
512
+ 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.
513
+
514
+ Live policy swaps (behave like constructor — whole policy atomic, missing fields fall back to defaults, not "keep current"):
515
+
516
+ - `set_reconnect(reconnect?)` — monotonically **shortens** pending reconnects (never extends). Turning off while a reconnect is pending cancels it + transitions to `closed`.
517
+ - `set_heartbeat(heartbeat?)` — restarts the live timer when connected.
518
+ - `cancel_reconnect()` — `reconnecting → closed` + resets backoff without disabling future reconnects. Queue stays intact; next `connect()` flushes.
519
+
520
+ `SocketStatus` is `'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`. Terminal only when `revoked: true` or auto-reconnect is disabled.
521
+
522
+ `socket_status_to_async_status(status, revoked): AsyncStatus` — collapses
523
+ the 5-way `SocketStatus` to fuz_util's 4-way `AsyncStatus` for UI
524
+ indicators: `reconnecting → 'failure'`, `closed` splits by `revoked`
525
+ (`failure` if revoked, else `initial` — the "not connected, not trying"
526
+ state).
527
+
528
+ ### `RequestTracker` (`request_tracker.svelte.ts`)
529
+
530
+ Public utility — reactive pending-request state with timeouts. `SvelteMap`
531
+ keyed by `JsonrpcRequestId`, each entry a `RequestTrackerItem` with `id`,
532
+ `deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
533
+ `timeout`. Default `request_timeout_ms = 120_000`.
534
+
535
+ Methods: `track_request(id): Deferred`, `resolve_request(id, response)`,
536
+ `reject_request(id, error_message)`, `handle_message(message)` (id-keyed
537
+ dispatch of JSON-RPC responses, ignores notifications / id-less frames),
538
+ `cancel_request(id)` (cleanup only, does not reject), `cancel_all_requests(reason?)`
539
+ (rejects all with `internal_error`).
540
+
541
+ Used by transports that don't delegate pending correlation to a
542
+ `WebsocketRpcConnection`. `FrontendWebsocketTransport` does not use it
543
+ (delegates to `FrontendWebsocketClient`'s own `#pending` map).
544
+
545
+ ## RPC client (`rpc_client.ts`)
546
+
547
+ `create_rpc_client({peer, environment, actions?, transport_for_method?})` —
548
+ returns a Proxy-based typed API. Method name → action method via
549
+ `environment.lookup_action_spec`. Dispatches based on the spec's `kind`:
550
+
551
+ - `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).
552
+ - `local_call` (async) — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits with `internal_error`.
553
+ - `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`.
554
+ - `remote_notification` — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
555
+
556
+ Per-call options: `RpcClientCallOptions extends ActionPeerSendOptions` —
557
+ `{signal?, queue?, transport_name?}`. `transport_name` overrides
558
+ per-method `transport_for_method` selector for this call.
559
+
560
+ `transport_for_method: (method) => TransportName | undefined` — optional
561
+ per-method transport selector. Useful when methods are registered on
562
+ different backend dispatchers (e.g. streaming action on WS, rest on HTTP).
563
+ Returning `undefined` falls through to the peer's default selection.
564
+
565
+ `RpcClientActionHistory` — duck-typed integration point for consumers
566
+ (e.g. zzz's Actions cell) that want to record every dispatched event.
567
+ `add_from_json({method, action_event_data})` returns an object with
568
+ `listen_to_action_event(event)` — the Proxy wires each new event into the
569
+ history if supplied.
570
+
571
+ Cast the return to a generated `ActionsApi` interface for full typing:
572
+ codegen via `generate_actions_api_method_signature` keeps the shape
573
+ consistent. See ../../docs/usage.md §Typed Client Codegen.
574
+
575
+ ## Broadcast API (`broadcast_api.ts`)
576
+
577
+ `create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
578
+ typed `{method: (input) => Promise<void>}` object from a list of action
579
+ specs. Counterpart to `register_action_ws`: that handles frontend-initiated
580
+ request-scoped dispatch, this handles backend-initiated broadcast.
581
+ Request-scoped streaming stays on `ctx.notify` inside a handler.
582
+
583
+ Per-method call: validates input against `spec.input` (logs + returns on
584
+ failure), wraps in a `JsonrpcNotification`, sends via the peer's resolved
585
+ transport. `transport_name` on `peer.default_send_options` pins the target
586
+ deterministically — no fallback, because broadcast is 1→N over a specific
587
+ primary transport and "any ready transport" could reach an unexpected
588
+ audience. Silently skips when no ready transport.
589
+
590
+ `should_deliver: (identity, method, input) => boolean` — optional
591
+ per-connection ACL predicate. When set, fans out via
592
+ `transport.broadcast_filtered` (feature-detected via
593
+ `is_filterable_broadcast_transport`). Errors during send are logged but
594
+ never thrown — broadcasts are fire-and-forget.
595
+
596
+ Typed surface: consumers declare an explicit `interface BackendActionsApi`
597
+ and pin it via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
598
+ cast, so the interface and `specs` array must stay in sync (codegen is a
599
+ natural fit when consumers already generate per-method type maps).
600
+
601
+ ## Shared type surface (`action_types.ts`)
602
+
603
+ Sits above `action_spec.ts` (pure Zod) and below the dispatchers
604
+ (`register_action_ws.ts`, `action_rpc.ts`). Extracted so composable
605
+ primitives (e.g. `heartbeat_action`) can name the types without pulling
606
+ in server-only modules.
607
+
608
+ - `BaseHandlerContext` — `{request_id, connection_id, notify, signal}` (see §WebSocket dispatch for field semantics).
609
+ - `WsActionHandler<TCtx>` — `(input, ctx) => unknown`. Disambiguated from HTTP's `ActionHandler`.
610
+ - `Action<TCtx>` — `{spec: ActionSpecUnion, handler?: WsActionHandler<TCtx>}`. The composable unit passed to both sides' `actions` arrays. Left open for future fields (rate_limit, ACL, middleware hooks) so additions attach to the action itself instead of scattering parallel arrays.
611
+
612
+ Re-exported from `register_action_ws.ts` as `Action`, `BaseHandlerContext`,
613
+ `WsActionHandler` for ergonomics.
614
+
615
+ ## DEV-only output validation — uniform across surfaces
616
+
617
+ The critical invariant: all three action-handler surfaces apply DEV-only
618
+ output validation and produce the **same failure mode** — log an error,
619
+ return the response unchanged, do not throw, do not mutate status.
620
+
621
+ | Surface | Code location | Hot path under production |
622
+ | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
623
+ | REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
624
+ | JSON-RPC endpoint | `action_rpc.ts` — `if (DEV) action.spec.output.safeParse(output)` | short-circuit (no parse) |
625
+ | WebSocket | `register_action_ws.ts` — `if (DEV) spec.output.safeParse(output)` | short-circuit (no parse) |
626
+
627
+ Caller-facing `input` schemas are validated **always** (DEV + production) —
628
+ they're the contract with external callers. Server-authored `output`
629
+ schemas are internal data. See ../../docs/architecture.md §DEV-only Output
630
+ Validation for the full rationale.