@fuzdev/fuz_app 0.64.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 (111) hide show
  1. package/dist/actions/CLAUDE.md +513 -928
  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 +1 -4
  7. package/dist/actions/connection_closer.d.ts.map +1 -1
  8. package/dist/actions/connection_closer.js +1 -4
  9. package/dist/actions/register_action_ws.d.ts +2 -2
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -1
  11. package/dist/actions/transports_ws_auth_guard.d.ts +1 -2
  12. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  13. package/dist/actions/transports_ws_auth_guard.js +1 -2
  14. package/dist/auth/CLAUDE.md +591 -1871
  15. package/dist/auth/account_schema.d.ts +1 -1
  16. package/dist/auth/account_schema.d.ts.map +1 -1
  17. package/dist/auth/api_token_queries.js +1 -1
  18. package/dist/auth/audit_log_ddl.d.ts +1 -1
  19. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  20. package/dist/auth/audit_log_ddl.js +1 -1
  21. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  22. package/dist/auth/bootstrap_account.js +1 -5
  23. package/dist/auth/bootstrap_routes.d.ts +7 -1
  24. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  25. package/dist/auth/bootstrap_routes.js +15 -11
  26. package/dist/auth/keyring.d.ts +6 -6
  27. package/dist/auth/keyring.js +8 -8
  28. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  29. package/dist/auth/role_grant_offer_actions.js +4 -2
  30. package/dist/db/create_db.d.ts.map +1 -1
  31. package/dist/db/create_db.js +13 -0
  32. package/dist/dev/setup.d.ts +2 -2
  33. package/dist/dev/setup.js +3 -3
  34. package/dist/http/CLAUDE.md +224 -498
  35. package/dist/http/error_schemas.d.ts +0 -4
  36. package/dist/http/error_schemas.d.ts.map +1 -1
  37. package/dist/http/error_schemas.js +0 -4
  38. package/dist/http/ip_canonical.d.ts +5 -4
  39. package/dist/http/ip_canonical.d.ts.map +1 -1
  40. package/dist/http/ip_canonical.js +8 -4
  41. package/dist/http/origin.d.ts +1 -1
  42. package/dist/http/origin.js +1 -1
  43. package/dist/runtime/mock.js +1 -1
  44. package/dist/server/app_server.d.ts +41 -10
  45. package/dist/server/app_server.d.ts.map +1 -1
  46. package/dist/server/app_server.js +10 -4
  47. package/dist/server/env.d.ts +7 -7
  48. package/dist/server/env.d.ts.map +1 -1
  49. package/dist/server/env.js +14 -14
  50. package/dist/server/static.d.ts +4 -4
  51. package/dist/server/static.js +7 -7
  52. package/dist/testing/CLAUDE.md +220 -46
  53. package/dist/testing/admin_integration.d.ts +18 -23
  54. package/dist/testing/admin_integration.d.ts.map +1 -1
  55. package/dist/testing/admin_integration.js +159 -201
  56. package/dist/testing/app_server.d.ts +125 -38
  57. package/dist/testing/app_server.d.ts.map +1 -1
  58. package/dist/testing/app_server.js +140 -42
  59. package/dist/testing/audit_completeness.d.ts +23 -22
  60. package/dist/testing/audit_completeness.d.ts.map +1 -1
  61. package/dist/testing/audit_completeness.js +199 -156
  62. package/dist/testing/bootstrap_success.d.ts +28 -0
  63. package/dist/testing/bootstrap_success.d.ts.map +1 -0
  64. package/dist/testing/bootstrap_success.js +144 -0
  65. package/dist/testing/cross_backend/capabilities.d.ts +64 -0
  66. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -0
  67. package/dist/testing/cross_backend/capabilities.js +47 -0
  68. package/dist/testing/cross_backend/setup.d.ts +215 -0
  69. package/dist/testing/cross_backend/setup.d.ts.map +1 -0
  70. package/dist/testing/cross_backend/setup.js +101 -0
  71. package/dist/testing/data_exposure.d.ts +14 -15
  72. package/dist/testing/data_exposure.d.ts.map +1 -1
  73. package/dist/testing/data_exposure.js +127 -146
  74. package/dist/testing/db_entities.d.ts +11 -1
  75. package/dist/testing/db_entities.d.ts.map +1 -1
  76. package/dist/testing/db_entities.js +13 -1
  77. package/dist/testing/integration.d.ts +35 -21
  78. package/dist/testing/integration.d.ts.map +1 -1
  79. package/dist/testing/integration.js +231 -291
  80. package/dist/testing/integration_helpers.d.ts +16 -6
  81. package/dist/testing/integration_helpers.d.ts.map +1 -1
  82. package/dist/testing/integration_helpers.js +7 -7
  83. package/dist/testing/mock_fs.d.ts.map +1 -1
  84. package/dist/testing/mock_fs.js +0 -2
  85. package/dist/testing/rate_limiting.d.ts.map +1 -1
  86. package/dist/testing/rate_limiting.js +9 -0
  87. package/dist/testing/role_grant_helpers.d.ts +31 -0
  88. package/dist/testing/role_grant_helpers.d.ts.map +1 -0
  89. package/dist/testing/role_grant_helpers.js +46 -0
  90. package/dist/testing/round_trip.d.ts +21 -16
  91. package/dist/testing/round_trip.d.ts.map +1 -1
  92. package/dist/testing/round_trip.js +65 -86
  93. package/dist/testing/rpc_round_trip.d.ts +24 -21
  94. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  95. package/dist/testing/rpc_round_trip.js +91 -104
  96. package/dist/testing/schema_introspect.d.ts +106 -0
  97. package/dist/testing/schema_introspect.d.ts.map +1 -0
  98. package/dist/testing/schema_introspect.js +123 -0
  99. package/dist/testing/schema_parity.d.ts +144 -0
  100. package/dist/testing/schema_parity.d.ts.map +1 -0
  101. package/dist/testing/schema_parity.js +233 -0
  102. package/dist/testing/standard.d.ts +57 -25
  103. package/dist/testing/standard.d.ts.map +1 -1
  104. package/dist/testing/standard.js +62 -5
  105. package/dist/testing/stubs.d.ts +11 -3
  106. package/dist/testing/stubs.d.ts.map +1 -1
  107. package/dist/testing/stubs.js +24 -21
  108. package/dist/testing/transports/surface_source.d.ts +51 -0
  109. package/dist/testing/transports/surface_source.d.ts.map +1 -0
  110. package/dist/testing/transports/surface_source.js +19 -0
  111. 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,198 +246,180 @@ 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`)
471
389
 
472
390
  Closes WS sockets on audit revoke events — per-message dispatch doesn't
473
- re-check session/token validity, so this guard is the revocation seam
474
- for open connections. Module TSDoc carries the full rationale.
391
+ re-check session/token validity, so this guard is the revocation seam for
392
+ open connections.
475
393
 
476
394
  `create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback.
477
395
  For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
478
- `create_app_server` composes this guard onto
479
- `backend.deps.audit.on_event_chain` automatically (per
480
- `WsEndpointSpec.auth_guard`). For custom wiring, append it inside the
481
- consumer's `audit_factory` body (or via `audit.on_event_chain.push(...)`
482
- post-assembly). Mirrors the SSE guard in `realtime/sse_auth_guard.ts`
483
- but targets the WS transport.
484
-
485
- `ws_disconnect_event_types` (ReadonlySet): `session_revoke`,
486
- `token_revoke`, `session_revoke_all`, `token_revoke_all`, `password_change`.
487
- `role_grant_revoke` is intentionally **omitted** — the WS transport does not
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.
399
+
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
488
403
  track per-connection role requirements, so role-scoped disconnection would
489
404
  require either closing all sockets (too aggressive) or new per-connection
490
405
  role tracking (out of scope). Consumers that need it compose their own
491
406
  callback.
492
407
 
493
- Event dispatch:
494
-
495
- - `session_revoke` `close_sockets_for_session(metadata.session_id)`
496
- - `token_revoke` → `close_sockets_for_token(metadata.token_id)`
497
- - `session_revoke_all` / `token_revoke_all` / `password_change` → `close_sockets_for_account(target_account_id ?? account_id)` (admin actions set `target_account_id`; self-service only `account_id`).
498
-
499
- `outcome === 'failure'` events are ignored — they carry
500
- attacker-controlled identifiers. Reacting to them would let an authenticated
501
- caller close another user's socket by guessing a session hash or token id.
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.
502
411
 
503
412
  `create_ws_logout_closer(transport, log)` is the sibling helper for
504
413
  user-initiated `logout` events — kept separate because
505
414
  `ws_disconnect_event_types` deliberately omits `logout` (admin-initiated
506
415
  revocations use `session_revoke`, while `logout` is the user-initiated
507
- case). Compose the two on `on_audit_event`:
416
+ case). Closes via `close_sockets_for_account(event.account_id)`.
508
417
 
509
- ```ts
510
- const ws_guard = create_ws_auth_guard(transport, log);
511
- const ws_logout_closer = create_ws_logout_closer(transport, log);
512
- const on_audit_event = (event: AuditLogEvent): void => {
513
- ws_guard(event);
514
- ws_logout_closer(event);
515
- };
516
- ```
517
-
518
- Same `outcome === 'failure'` guard as `create_ws_auth_guard`. Closes via
519
- `close_sockets_for_account(event.account_id)` — `logout` is always
520
- self-service, so there is no `target_account_id` to fall back on.
418
+ ## Connection closer (`actions/connection_closer.ts`)
521
419
 
522
- ## Connection closer (`connection_closer.ts`)
523
-
524
- Narrow structural capability for handler-side eager WS socket closure
525
- on revocation — the belt+suspenders layer that complements the audit-
526
- listener guards above.
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.
527
423
 
528
424
  ```ts
529
425
  interface ConnectionCloser {
@@ -533,543 +429,281 @@ interface ConnectionCloser {
533
429
  }
534
430
  ```
535
431
 
536
- `BackendWebsocketTransport` satisfies this structurally — consumers
537
- pass the transport instance directly (same shape as
538
- `NotificationSender`). Wired into `AccountRouteOptions.connection_closer`
539
- (logout / password), `AccountActionOptions.connection_closer`
540
- (`account_session_revoke` / `_revoke_all` / `account_token_revoke`),
541
- and `AdminActionOptions.connection_closer`
542
- (`admin_session_revoke_all` / `admin_token_revoke_all`). Each handler
543
- calls the appropriate `close_sockets_for_*` method synchronously
544
- BEFORE the audit emit so revocation lands even on audit INSERT
545
- failure. Listener-based close
546
- (`create_ws_auth_guard` / `create_ws_logout_closer`) stays as a
547
- fail-safe for out-of-band emit sites; `close_sockets_for_*` is
548
- idempotent. Failure outcomes (`revoked: false`, 404 not-found) skip
549
- the eager close — matches the listener's `outcome === 'failure'`
550
- guard so attacker-guessable ids cannot be used to target arbitrary
551
- sockets. Mirrors `zzz_server`'s handler-side `close_sockets_for_*`
552
- calls (landed 2026-05-16; see
553
- `~/dev/grimoire/lore/fuz_app/TODO_AUTH.md` § Audit-driven WS
554
- revocation: handler-side belt+suspenders).
555
-
556
- ## WebSocket dispatch
557
-
558
- Three layered entry points, in decreasing abstraction:
559
-
560
- ### `create_app_server.ws_endpoints` (`server/app_server.ts`) canonical
561
-
562
- The top-level mount surface mirror of `rpc_endpoints` for WebSocket
563
- endpoints. Accepts either an array of `WsEndpointSpec` or a factory
564
- `(ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>`; the factory
565
- form runs after the server context is assembled so action lists can
566
- depend on `ctx.deps` / `ctx.action_*_rate_limiter`. Each entry is
567
- auto-mounted via `register_ws_endpoint` against the assembled Hono app,
568
- so consumers no longer call `register_ws_endpoint` themselves in their
569
- server-assembly module.
570
-
571
- `upgradeWebSocket` (the Hono adapter helper) is supplied once at the
572
- top level — `create_app_server` throws when `ws_endpoints` resolves
573
- non-empty but `upgradeWebSocket` is missing. A factory returning `[]`
574
- does NOT trip the check, so feature-flag gated WS surfaces stay safe.
575
-
576
- Per-endpoint `WsEndpointSpec` fields:
577
-
578
- - `path` — Hono mount path
579
- - `allowed_origins` — origin allowlist regexes (parsed via `parse_allowed_origins`)
580
- - `actions` — the `ReadonlyArray<Action>` to dispatch (spread `protocol_actions` first)
581
- - `required_roles?: ReadonlyArray<RoleName>` — any-of upgrade-time role gate; omit or `[]` to skip
582
- - `transport?: BackendWebsocketTransport` — supplied or auto-created; returned on `AppServer.ws_endpoints[path]` either way
583
- - `heartbeat?`, `artificial_delay?`, `on_socket_open?`, `on_socket_close?` — passed through to `register_ws_endpoint`
584
- - `auth_guard?: boolean` — default `true`; auto-composes `create_ws_auth_guard` + `create_ws_logout_closer` against the endpoint's transport and appends them to `deps.audit.on_event_chain`. The wiring is deduped by **reference identity** (`WeakSet<BackendWebsocketTransport>`), so two `WsEndpointSpec`s sharing one `BackendWebsocketTransport` instance still get a single pair of listeners. Wrapped / DI-proxied transports dedupe as separate entries — set `auth_guard: false` on duplicates and compose `create_ws_auth_guard` / `create_ws_logout_closer` against the underlying transport once
585
- - `extra_audit_handlers?: ReadonlyArray<AuditEventHandler>` — appended after the standard guards; consumer-owned, never deduped
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.
586
474
 
587
475
  `auth_guard: true` does NOT close sockets on `role_grant_revoke`
588
- (deliberate — per-connection role tracking is out of scope). Consumers
589
- that need role-revoke disconnection wire it via `extra_audit_handlers`.
590
-
591
- The mounted transport is reachable at `app_server.ws_endpoints[path]` —
592
- a `Readonly<Record<string, BackendWebsocketTransport>>` keyed by mount
593
- path. Use this handle for fan-out (`send_to_account`) and broadcast.
594
-
595
- Duplicate paths across `WsEndpointSpec`s throw at mount time
596
- (`'create_app_server: duplicate ws_endpoints path: <path>'`), closing
597
- the route-shadow hole Hono's silent `app.get` overwriting would leave.
598
- Cross-surface collisions are also detected — registering `GET <path>`
599
- as both a `RouteSpec` and a `WsEndpointSpec` throws
600
- `'create_app_server: ws_endpoints path collides with a GET RouteSpec: <path>'`
601
- at mount time. Exact-string match only; pattern overlap (e.g. a
602
- `RouteSpec` at `GET /api/:resource` vs `WsEndpointSpec` at `/api/ws`)
603
- is not detected — Hono's specific-before-wildcard routing keeps those
604
- working, but if you need certainty avoid the overlap.
605
-
606
- `auth_guard` semantics when multiple specs share a transport: **any**
607
- spec with `auth_guard !== false` wires the guard for that transport
608
- (OR-semantics, order-independent). To opt out for a shared transport,
609
- every sibling spec must pass `auth_guard: false`. Documented on the
610
- `WsEndpointSpec.auth_guard` TSDoc.
611
-
612
- `AppSurfaceWsEndpoint.methods` surfaces `request_response` + `remote_notification`
613
- specs only — `local_call` specs are filtered out because they don't
614
- dispatch over WS (`compile_action_registry` routes only
615
- `request_response` with a handler into `action_map`; `local_call` is
616
- frontend-side registry metadata).
617
-
618
- ### `register_ws_endpoint` (`register_ws_endpoint.ts`) — middle tier
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
619
486
 
620
487
  Composes the standard upgrade stack:
621
488
 
622
489
  1. `verify_request_source(allowed_origins)`
623
490
  2. `require_auth`
624
- 3. upgrade-time authorization phase — resolves the acting actor and seeds `REQUEST_CONTEXT_KEY` for the inner `register_action_ws`'s upgrade-time identity capture
625
- 4. optional `require_role(required_roles)` — any-of disjunction
626
- 5. delegates to `register_action_ws`
627
-
628
- Extends `RegisterActionWsOptions` with `allowed_origins: ReadonlyArray<RegExp>`
629
- and optional `required_roles: ReadonlyArray<RoleName>`. Returns
630
- `{transport}`. Note: `required_roles` is a **coarse upgrade-time gate**
631
- — per-action `auth` in each spec still applies at dispatch time via
632
- `perform_action`. Pass `[]` (or omit) to skip the role gate.
633
- (`verify_request_source` and `require_auth` / `require_role` are from
634
- `auth/`; see `auth/CLAUDE.md` §Middleware for their semantics.)
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`
635
494
 
636
- Most consumers reach for the higher-level `ws_endpoints` option above —
637
- this is the entry test harnesses use when they need the upgrade stack
638
- without `create_app_server`'s full assembly.
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.
639
499
 
640
- ### `register_action_ws` (`register_action_ws.ts`) — lower-level
500
+ ### `register_action_ws` — lower-level dispatcher
641
501
 
642
502
  Exposed for tests (`create_ws_test_harness`) that need to drive the
643
503
  dispatcher without the origin/auth front-stack.
644
504
 
645
- Actions are passed as `ReadonlyArray<Action>` — the composable
646
- `{spec, handler?}` tuple shared with `create_rpc_client`. The dispatcher
647
- builds its `action_map: Map<string, RpcAction>` via `compile_action_registry`
648
- (see §Registry compile above)only `request_response` specs with a
649
- handler land in the map. Specs without a handler (client-only /
650
- dispatcher-handled like `cancel`) surface as `method_not_found` if the
651
- wire targets them.
652
-
653
- Required deps: `db: Db` (pool-level, used by `perform_action` for both the
654
- per-message authorization phase and the transactional dispatch wrap when
655
- `spec.side_effects: true`). Audit fan-out and other rollback-resilient
656
- fire-and-forget writes run through `AppDeps.audit` from each action
657
- factory's closure — the dispatcher never holds a separate pool reference.
658
-
659
- Per-message dispatch delegates to `perform_action` (`actions/perform_action.ts`)
660
- — the shared core that HTTP RPC also calls. `register_action_ws` only owns
661
- WS-specific concerns:
662
-
663
- - **Wire envelope parsing** — JSON.parse → batch rejection → notification interception (cancel, silent drop) → per-message dispatch.
664
- - **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher.
665
- - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`.
666
- - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded into `perform_action` as `signal`.
667
- - **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat.
668
-
669
- Per-message authorization phase: `perform_action` calls
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
670
515
  `apply_authorization_phase` per-message (HTTP and WS uniformly). Role grant
671
516
  changes during a connection lifetime are picked up on the next message —
672
517
  no in-place refresh, no socket-close on `role_grant_revoke`. Authentication
673
518
  invalidation (`session_revoke`, `password_change`, `token_revoke_all`)
674
519
  still closes the socket via `create_ws_auth_guard`.
675
520
 
676
- Per-message wire behavior (every step delegated to `perform_action`
677
- except the WS-specific framing):
678
-
679
- - **Batch JSON-RPC rejected** arrays get `invalid_request`.
680
- - **Notifications** method + no id. Intercepted: `cancel` aborts the matching per-request controller; other notifications are silenced per JSON-RPC spec (no consumer notification handlers yet).
681
- - **Per-action auth + validation + dispatch** — uniform with HTTP RPC via `perform_action`: pre-validation auth (401) input validation (400) authorization phase post-authorization auth (403) → rate limit (429) → handler under transaction (when `side_effects: true`) → DEV output validation.
682
- - **Error handling** — handler throws normalize via `perform_action`'s thrown-error path. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`. The WS shim sends the resulting envelope over the socket.
683
-
684
- Two abort signals, composed via `AbortSignal.any`:
685
-
686
- - `socket_abort_controller` per-socket, fires on close. Drives every handler's `ctx.signal` on that socket.
687
- - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
688
-
689
- Per-message side-effect queues: `pending_effects: Array<Promise<void>>`
690
- (eager) drains via `flush_pending_effects`; `post_commit_effects: Array<() => void | Promise<void>>`
691
- (deferred pushed by handlers via `emit_after_commit`) drains via
692
- `flush_post_commit_effects`. Both flush in the same `try/finally` that
693
- releases the request controller, so fire-and-forget audit / notification
694
- effects pushed by the handler complete (or reject visibly) before the
695
- next message dispatches. See `http/CLAUDE.md` §Pending Effects.
696
-
697
- Lifecycle hooks on `RegisterActionWsOptions`:
698
-
699
- - `on_socket_open({ws, connection_id, identity, notify, signal})` — fires after `transport.add_connection` but before the first message. Awaited. Throws log + close with `1011 'socket bootstrap failed'` + send an `internal_error` frame.
700
- - `on_socket_close({ws, connection_id, identity})` — fires before `transport.remove_connection`, so `identity` is still readable even when the audit guard already tore the transport record down. Errors are logged and swallowed.
701
-
702
- Server-side heartbeat (`heartbeat?: boolean | ServerHeartbeatOptions`):
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`):
703
537
  default-on, 60s silence timeout. Any inbound message resets
704
538
  `last_receive_time` — chatty clients never trip it. First timeout window
705
- after open is exempt (cold-start grace). Tick interval is
706
- `timeout / 2`, so event-loop blockage pauses the timer itself.
707
-
708
- ## Event state machine
709
-
710
- Five modules make up a discriminated-union-based state machine used by the
711
- reactive client (`rpc_client.ts` + consumer ActionEvent-aware UIs) to track
712
- an action through its lifecycle.
713
-
714
- ### `action_event_types.ts`
715
-
716
- - `ActionExecutor` — `'frontend' | 'backend'`
717
- - `ActionEventStep` — `'initial' | 'parsed' | 'handling' | 'handled' | 'failed'`
718
- - `action_event_step_transitions` — valid next-steps: `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal.
719
- - `action_event_phase_by_kind` — valid phases per kind (`request_response` has 6, `remote_notification` has 2, `local_call` has 1).
720
- - `action_event_phase_transitions` — chained phases: `send_request → receive_response`; `receive_request → send_response`; everything else terminal.
721
- - `ActionEventEnvironment` — `{executor, lookup_action_handler, lookup_action_spec, log?}`. The ambient registry + handler resolver for an `ActionEvent`.
722
-
723
- ### `action_event_data.ts`
724
-
725
- `ActionEventData` is the base Zod schema — a strict object with all 10
726
- possible fields always present (nullable where not applicable for the
727
- current phase/step). The exported union `ActionEventDataUnion<TMethod,
728
- TInput, TOutput>` is a **39-variant discriminated union** across `kind` +
729
- `phase` + `step`: 28 variants for `request_response`, 6 for
730
- `remote_notification`, 5 for `local_call`. Narrows the shape of
731
- `input` / `output` / `error` / `request` / `response` / `notification` /
732
- `progress` at each point in the lifecycle.
733
-
734
- ### `action_event_helpers.ts`
735
-
736
- Type guards (discriminate on `kind` + `phase` + `step`):
737
-
738
- - By kind: `is_request_response`, `is_remote_notification`, `is_local_call`
739
- - By phase: `is_send_request`, `is_receive_request`, `is_send_response`, `is_receive_response`, `is_notification_send`, `is_notification_receive`, `is_execute`
740
- - By step: `is_initial`, `is_parsed`, `is_handling`, `is_handled`, `is_failed`
741
- - Combined: `is_send_request_with_parsed_input`, `is_notification_send_with_parsed_input`
539
+ after open is exempt (cold-start grace). Tick interval is `timeout / 2`,
540
+ so event-loop blockage pauses the timer itself.
742
541
 
743
- Validators:
542
+ Two abort signals composed via `AbortSignal.any`:
744
543
 
745
- - `validate_step_transition(from, to)` — throws on illegal step moves.
746
- - `validate_phase_for_kind(kind, phase)`throws if the phase isn't valid for the kind.
747
- - `validate_phase_transition(from, to)` — throws on illegal phase chain.
748
- - `get_initial_phase(kind, initiator, executor)` — the phase an executor starts an action from, or `null` if this executor can't initiate.
749
- - `should_validate_output(kind, phase)` — true for `receive_request`/`receive_response` on `request_response` and `execute` on `local_call`.
750
- - `is_action_complete(data)` — `failed`, or `handled` at a terminal phase.
751
-
752
- Constructors / extractors:
753
-
754
- - `create_initial_data(kind, phase, method, executor, input)` — produces a well-formed initial-step `ActionEventData` with every nullable field null.
755
- - `extract_action_result(event): Result<{value}, {error}>` — pulls the terminal outcome. Throws on non-terminal events (programming error).
756
-
757
- ### `action_event.ts`
758
-
759
- `ActionEvent<TMethod, TPhase, TStep>` — the mutable state-machine class.
760
- Holds `#data` (current `ActionEventDataUnion`), notifies observers on
761
- every transition via `observe(listener): () => unsubscribe`. Keeps the
762
- spec + environment references.
763
-
764
- Lifecycle methods:
765
-
766
- - `parse()` — transitions `initial → parsed` by running `spec.input.safeParse(data.input)`. Input validation failures **fail immediately** without routing through an error phase — they're client-side programming errors, not runtime conditions with handlers. Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with an error response, transitions to `receive_error` instead of failing.
767
- - `handle_async()` / `handle_sync()` — `parsed → handling → handled`. Looks up the registered handler via `environment.lookup_action_handler(method, phase)`. Missing handler skips to `handled` (terminal with no output). Throws routed via `#get_error_phase_for_current_phase`: `send_request`/`receive_request` → `send_error`; `receive_response` → `receive_error`; other phases → `failed`. `ThrownJsonrpcError` preserves code + message + data; other throws become `internal_error`.
768
- - `transition(phase)` — `handled` at a chainable phase → next phase's `initial`. Uses `#create_phase_data` to carry forward `request` / `response` / `error` / `output` as appropriate.
769
- - `is_complete()`, `update_progress(progress)`, `set_request(request)`, `set_response(response)`, `set_notification(notification)`.
770
-
771
- Constructors:
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.
772
546
 
773
- - `create_action_event(environment, spec, input, initial_phase?)` — default phase via `get_initial_phase`; throws if the executor can't initiate.
774
- - `create_action_event_from_json(json, environment)` — rehydrate after wire transfer.
775
- - `parse_action_event(raw_json, environment)` — `ActionEventData.parse` + `create_action_event_from_json`.
547
+ ## Protocol actions (`actions/protocol.ts`)
776
548
 
777
- Protocol message creation is automatic: when transitioning `parsed handling`
778
- on a `send_request` phase, `ActionEvent` materializes the outgoing
779
- `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification)
780
- it materializes the `JsonrpcNotification`.
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.
781
552
 
782
- ## Action peer (`action_peer.ts`)
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.
783
557
 
784
- `ActionPeer` symmetric JSON-RPC send + receive over a `Transports`
785
- registry and `ActionEventEnvironment`. Construct with
786
- `{environment, transports?, default_send_options?}`.
558
+ Two const arrays:
787
559
 
788
- `default_send_options` excludes `signal` signals are inherently per-call
789
- (a shared signal would abort every subsequent call after the first trip).
790
- `transport_name` and `queue` can be defaulted here once to flip the peer
791
- into client-authoritative mode: `new ActionPeer({..., default_send_options:
792
- {queue: true}})` durably queues every request_response call by default.
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`.
793
562
 
794
- Per-call options:
563
+ Asymmetry intentional — server 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.
795
566
 
796
- ```ts
797
- interface ActionPeerSendOptions extends TransportSendOptions {
798
- transport_name?: TransportName;
799
- }
800
- ```
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.
801
571
 
802
- `send(message, options?)`:
572
+ ### Individual actions
803
573
 
804
- - Resolves the transport via `transports.get_transport(options?.transport_name ?? default.transport_name)`.
805
- - No transport `service_unavailable` JSON-RPC error (does not throw).
806
- - Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
807
- - Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
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.
808
576
 
809
- `receive(message)` dispatch for inbound messages:
577
+ ## Event state machine
810
578
 
811
- - **Requests** — look up spec via `environment.lookup_action_spec`; unknown → `method_not_found`. Otherwise `create_action_event(environment, spec, params, 'receive_request')`, wire the request via `set_request`, run `parse().handle_async()`. On `handled`, transition to `send_response` + re-run `parse().handle_async()`. On `failed` or `send_error` phase, returns a `JsonrpcErrorResponse`.
812
- - **Notifications** — same flow for `'receive'` phase; returns `null` (no response).
813
- - **Anything else** `invalid_request` JSON-RPC error.
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:
814
584
 
815
- Currently partial: `#receive_request`'s `send_response` transition step has
816
- a known sharp edge ("shouldn't need the guard" TODO).
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`.
817
590
 
818
- ## Protocol actions (`heartbeat.ts`, `cancel.ts`, `protocol.ts`)
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.
819
596
 
820
- Two shared `{spec, handler}` tuples that every consumer spreads into both
821
- sides' `actions` arrays — disconnect detection and per-request cancel work
822
- identically across every repo without per-consumer ping plumbing.
597
+ ## Reactive frontend client
823
598
 
824
- The category is wire-protocol concerns shipped by fuz_app, not consumer
825
- domain logic. The contrast that matters is protocol vs domain: a future
826
- clock-skew probe or reconnect-resume token belongs in this bundle; a
827
- `payment_charge` action does not. Avoid the framing "composable vs
828
- non-composable" — every `Action` is composable by the same mechanism
829
- (spread into the `actions` array), so the distinction would not carve
830
- nature at the joints.
831
-
832
- ### Canonical bundles (`protocol.ts`)
833
-
834
- Two const arrays declare the canonical protocol-action set so consumers
835
- spread one symbol per side instead of importing each primitive
836
- individually:
837
-
838
- - `protocol_actions: ReadonlyArray<Action>` — for the server's
839
- `register_action_ws` `actions` array. Spread before consumer-owned
840
- actions: `actions: [...protocol_actions, ...consumer_actions]`.
841
- - `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via
842
- `.map(a => a.spec)` so the two arrays cannot drift. For the frontend
843
- `ActionRegistry`. Spread before consumer-owned specs:
844
- `new ActionRegistry([...protocol_action_specs, ...action_specs])`.
845
-
846
- The asymmetry is intentional — the server runs handlers (heartbeat echo +
847
- cancel stub), the frontend registry only stores specs. Both bundles plus
848
- the codegen `include_protocol_actions: false` default form a three-leg
849
- contract: codegen excludes protocol actions from generated typed surfaces
850
- because consumers spread these bundles in at registration time.
851
-
852
- The bundles are **not** auto-spread by `create_frontend_rpc_client` or
853
- `register_ws_endpoint` — bundled helpers stay pure factories so the
854
- dispatch surface stays grep-traceable at every consumer registration site
855
- and consumers can override individual protocol actions (custom heartbeat,
856
- etc.) without an opt-out flag.
857
-
858
- ### `heartbeat_action`
859
-
860
- Method `'heartbeat'`, `request_response`, `initiator: 'frontend'`, `auth:
861
- 'authenticated'`, `side_effects: false`, nullary input/output
862
- (`z.strictObject({})`). Handler is a stateless no-op echo. The client's
863
- activity-aware heartbeat timer (`FrontendWebsocketClient.#heartbeat_tick`)
864
- fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; the server's
865
- `register_action_ws` heartbeat tracker counts the incoming message as
866
- activity and resets `last_receive_time`.
867
-
868
- ### `cancel_action`
869
-
870
- Method `'cancel'`, `remote_notification`, `initiator: 'frontend'`, `auth:
871
- null`, `side_effects: true`. Params: `CancelNotificationParams =
872
- z.strictObject({request_id: JsonrpcRequestId})`. The **handler is an empty
873
- stub** — cancel semantics are dispatcher-owned
874
- (`register_action_ws` has the `{request_id → AbortController}` map, not the
875
- handler). The tuple exists for symmetry + so `spec_by_method` knows about
876
- it (enables input validation on incoming cancels) + so `create_rpc_client`
877
- sees the method.
878
-
879
- Wire format is snake_case `cancel` + `{request_id}`, not MCP's
880
- `$/cancelRequest` + `{requestId}`. MCP adoption would happen at an MCP
881
- adapter's translation layer, not in the base transport.
882
-
883
- ## Reactive frontend client (`socket.svelte.ts`, `request_tracker.svelte.ts`)
884
-
885
- ### `FrontendWebsocketClient`
599
+ ### `FrontendWebsocketClient` (`actions/socket.svelte.ts`)
886
600
 
887
601
  Portable, Svelte-reactive (`$state.raw` for `ws`, `status`, `reconnect_count`,
888
- `current_reconnect_delay`, `last_connect_time`, `last_close_time`,
889
- `last_close_code`, `last_close_reason`, `last_send_error`). Plain class — no
890
- Cell inheritance, no app coupling. Implements `WebsocketConnection` +
891
- `WebsocketRpcConnection`, and is `Disposable`.
602
+ etc.). Plain class — no Cell inheritance, no app coupling. Implements
603
+ `WebsocketConnection` + `WebsocketRpcConnection`, and is `Disposable`.
892
604
 
893
605
  Ships three correctness primitives default-on:
894
606
 
895
- 1. **Promise-based `request`** — auto-assigned monotonic id (override via `options.id` for transport-minted UUIDs). Pending map keyed by id, resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes `unauthenticated` (revoked), `request_cancelled` (abort), `queue_overflow`, `service_unavailable`, `internal_error`, or the server's wire code verbatim. The transport catch block preserves `.code` exactly so `FrontendWebsocketTransport` never collapses to `internal_error`.
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`.
896
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).
897
- 3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself — dead-because-blocked and dead-because-unresponsive close arguably the same way.
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.
898
610
 
899
- Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`, capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is **terminal** — sets `#revoked = true`, no reconnect loop on 401.
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.
900
614
 
901
- 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`.
902
618
 
903
- - `set_reconnect(reconnect?)` monotonically **shortens** pending reconnects (never extends). Turning off while a reconnect is pending cancels it + transitions to `closed`.
904
- - `set_heartbeat(heartbeat?)` restarts the live timer when connected.
905
- - `cancel_reconnect()` — `reconnecting → closed` + resets backoff without disabling future reconnects. Queue stays intact; next `connect()` flushes.
619
+ `SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
620
+ `socket_status_to_async_status(status, revoked)` collapses to fuz_util's
621
+ 4-way `AsyncStatus`.
906
622
 
907
- `SocketStatus` is `'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`. Terminal only when `revoked: true` or auto-reconnect is disabled.
623
+ ### `RequestTracker` (`actions/request_tracker.svelte.ts`)
908
624
 
909
- `socket_status_to_async_status(status, revoked): AsyncStatus` collapses
910
- the 5-way `SocketStatus` to fuz_util's 4-way `AsyncStatus` for UI
911
- indicators: `reconnecting 'failure'`, `closed` splits by `revoked`
912
- (`failure` if revoked, else `initial` the "not connected, not trying"
913
- state).
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).
914
630
 
915
- ### `RequestTracker` (`request_tracker.svelte.ts`)
631
+ ## RPC client (`actions/rpc_client.ts`)
916
632
 
917
- Public utility reactive pending-request state with timeouts. `SvelteMap`
918
- keyed by `JsonrpcRequestId`, each entry a `RequestTrackerItem` with `id`,
919
- `deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
920
- `timeout`. Default `request_timeout_ms = 120_000`.
633
+ `create_rpc_client({peer, environment, actions?, transport_for_method?})`
634
+ returns a Proxy-based typed API. Per-kind dispatch:
921
635
 
922
- Methods: `track_request(id): Deferred`, `resolve_request(id, response)`,
923
- `reject_request(id, error_message)`, `handle_message(message)` (id-keyed
924
- dispatch of JSON-RPC responses, ignores notifications / id-less frames),
925
- `cancel_request(id)` (cleanup only, does not reject), `cancel_all_requests(reason?)`
926
- (rejects all with `internal_error`).
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}>`.
927
640
 
928
- Used by transports that don't delegate pending correlation to a
929
- `WebsocketRpcConnection`. `FrontendWebsocketTransport` does not use it
930
- (delegates to `FrontendWebsocketClient`'s own `#pending` map).
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).
931
645
 
932
- ## RPC client (`rpc_client.ts`)
933
-
934
- `create_rpc_client({peer, environment, actions?, transport_for_method?})` —
935
- returns a Proxy-based typed API. Method name → action method via
936
- `environment.lookup_action_spec`. Dispatches based on the spec's `kind`:
937
-
938
- - `local_call` (sync) — execute `parse().handle_sync()`, return value directly. Throws on error (sync methods can't return `Result`). Ignores `signal` (no cooperative interrupt mid-handler).
939
- - `local_call` (async) — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits with `internal_error`.
940
- - `request_response` — builds `ActionEvent`, runs `parse().handle_async()` to produce the outgoing `request`, calls `peer.send(request, {transport_name, signal, queue})`, transitions to `receive_response`, wires the response via `set_response`, parses (may transition to `receive_error`), runs handler, extracts `Result`.
941
- - `remote_notification` — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
942
-
943
- Per-call options: `RpcClientCallOptions extends ActionPeerSendOptions` —
944
- `{signal?, queue?, transport_name?}`. `transport_name` overrides
945
- per-method `transport_for_method` selector for this call.
946
-
947
- `transport_for_method: (method) => TransportName | undefined` — optional
948
- per-method transport selector. Useful when methods are registered on
949
- different backend dispatchers (e.g. streaming action on WS, rest on HTTP).
950
- Returning `undefined` falls through to the peer's default selection.
951
-
952
- `on_action_event: (event: ActionEvent<keyof TApi & string>) => void` —
953
- optional callback fired once per dispatched action with the live
954
- `ActionEvent`. Consumers wire reactive state inside the callback — e.g.
955
- zzz's `Actions` cell calls its own `add_from_json` +
956
- `listen_to_action_event` here so the history plumbing stays inside zzz
957
- instead of leaking onto the rpc_client surface. `event.spec.method` and
958
- `event.data.method` narrow to `keyof TApi & string` so consumers passing
959
- a generated `FrontendActionsApi` get the literal method-name union without
960
- an `as ActionMethod` cast at the call site.
961
-
962
- Cast the return to a generated `FrontendActionsApi` interface for full
963
- typing: codegen via `generate_actions_api_method_signature` keeps the
964
- shape consistent. See ../../docs/usage.md §Typed Client Codegen.
965
-
966
- ### Throwing variants — `create_throwing_rpc_call` + `create_throwing_api`
967
-
968
- Two helpers wrap a typed `create_rpc_client` Proxy so `{ok: false}` results
969
- throw an `Error` with `{code, message, data?}` (catch blocks read
970
- `err.data?.reason` — optional chaining required because JSON-RPC `data`
971
- is spec-level optional). Same hardening on both: only `{code, data}` cross
972
- onto the Error, leaving `name` / `stack` as the native Error's own so
973
- attacker-shaped `result.error` payloads cannot overwrite them.
646
+ ### Throwing variants
974
647
 
975
648
  | Helper | Shape | Use at |
976
649
  | -------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
977
- | `create_throwing_rpc_call` | `(method, input?) => Promise<T>` | adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map |
978
- | `create_throwing_api` | typed Proxy over `FrontendActionsApi` | direct call sites — `await api.foo(input)` keeps full inference |
979
-
980
- **Layered design.** Result is the protocol primitive — `create_rpc_client`
981
- returns `Result<{value}, {error}>` per call with no Error allocation. The
982
- throwing wrappers sit _above_ it as ergonomic adapters; both shapes share
983
- the same underlying transport and call sites pick per-site. `Result` is
984
- preferable when the call site inspects `error.data.reason` (no Error
985
- allocation, no try/catch nesting) or when overhead matters (reconnect
986
- storms, hot paths). Throwing is preferable when the call site doesn't
987
- inspect — `await api.foo()` reads cleaner than the `if (!r.ok) throw …`
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 …`
988
661
  ritual.
989
662
 
990
- `create_frontend_rpc_client` ships both shapes by default see
991
- [Frontend factory](#frontend-factory-frontend_rpc_clientts) below. Direct
992
- consumers of `create_rpc_client` pass their typed `FrontendActionsApi`
993
- as the generic to get the typed Result-shaped Proxy without casts, then
994
- build the throwing form on top:
995
-
996
- ```ts
997
- const api_result = create_rpc_client<FrontendActionsApi>({peer, environment});
998
- const api = create_throwing_api(api_result);
999
- // hot path: await api.foo(input)
1000
- // rare branch: const r = await api_result.foo(input); if (!r.ok) { … }
1001
- ```
1002
-
1003
- `create_throwing_rpc_call` is **not** a peer choice for direct call sites —
1004
- it's a niche primitive for method-name-mapping adapter factories
1005
- (`ui/admin_rpc_adapters.ts`) where the method string comes from a domain
1006
- mapping rather than a typed call site. Use it only at adapter boundaries.
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.
1007
666
 
1008
667
  `ThrowingApi<TApi>` (the mapped type returned by `create_throwing_api`)
1009
668
  strips `Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>` to
1010
- `Promise<T>` on every method that matches the `request_response` /
1011
- async `local_call` return shape; `remote_notification` (`=> void`) and
1012
- sync `local_call` methods pass through. The Proxy inspects each call's
1013
- result shape at runtime and only unwraps when it sees a Result, so
1014
- non-Result returns flow through unchanged.
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.
1015
673
 
1016
674
  Both helpers throw `"rpc method not found: <name>"` on invocation of an
1017
- unknown method. For `create_throwing_api` the thrower is returned from
1018
- the Proxy get trap so `api.missing()` errors with the same clear
1019
- message rather than the JS default `"api.missing is not a function"`.
1020
- Symbol props and `then` stay `undefined` so the Proxy doesn't get
1021
- probed as a thenable by `await`.
675
+ unknown method. Symbol props and `then` stay `undefined` so the Proxy
676
+ doesn't get probed as a thenable by `await`.
1022
677
 
1023
- ### Frontend factory (`frontend_rpc_client.ts`)
678
+ ### Frontend factory (`actions/frontend_rpc_client.ts`)
1024
679
 
1025
680
  `create_frontend_rpc_client<TApi>({specs, path?, transports?, transport_for_method?, on_action_event?})`
1026
- bundles the `ActionRegistry + ActionEventEnvironment + Transports +
1027
- ActionPeer + create_rpc_client + create_throwing_api` boilerplate every
1028
- consumer repeats — plus the `lookup_action_handler: () => undefined`
1029
- stub (frontend never registers `request_response` handlers; every
1030
- method dispatches over the wire). The `as unknown as TApi` cast happens
1031
- inside the helper, so call sites get a typed return without the cast
1032
- hostility.
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).
1033
686
 
1034
687
  Returns both Proxy shapes from one factory call:
1035
688
 
1036
689
  - `api: ThrowingApi<TApi>` — typed throwing Proxy. Default for hot-path call sites.
1037
690
  - `api_result: TApi` — typed Result-shaped Proxy. For sites that inspect `error.data.reason` without try/catch.
1038
- - `peer`, `environment` — exposed for advanced consumers that want to register more transports or share the environment with a separate dispatcher.
1039
-
1040
- ```ts
1041
- const {api, api_result} = create_frontend_rpc_client<FrontendActionsApi>({
1042
- specs: all_standard_action_specs,
1043
- });
1044
- // hot path: await api.account_verify()
1045
- // rare branch: const r = await api_result.account_verify(); if (!r.ok) { … }
1046
- ```
691
+ - `peer`, `environment` — exposed for advanced consumers.
1047
692
 
1048
693
  Default transport is `FrontendHttpTransport(path ?? '/api/rpc')`. Pass
1049
- `transports` for WS-first or mixed setups when supplied, the default
1050
- HTTP transport is **not** registered. `local_call` specs in `specs`
1051
- silently no-op because `lookup_action_handler` always returns
1052
- `undefined`; this factory targets wire-dispatched actions.
1053
-
1054
- `all_standard_action_specs` is transport-agnostic — when a consumer
1055
- spreads `create_standard_rpc_actions(ctx.deps, opts)` into both
1056
- `rpc_endpoints` AND `ws_endpoints` on `create_app_server`, every method
1057
- is reachable on both transports and `transport_for_method` can route
1058
- per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
1059
- `admin_*` methods to bind them to the live WS connection).
1060
-
1061
- `transport_for_method` and `on_action_event` are pure pass-throughs to
1062
- `create_rpc_client` — exposed so consumers needing per-method routing
1063
- (zap-style WS-for-actions / HTTP-for-rest split) or per-dispatch event
1064
- wiring (zzz-style reactive Cells observing `ActionEvent` lifecycle)
1065
- don't have to drop down to manual `create_rpc_client` construction
1066
- (which forfeits the bundled `api` / `api_result` pair).
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`.
1067
698
 
1068
699
  `all_standard_action_specs` (in `auth/standard_action_specs.ts`) is
1069
- the matching aggregate spec list mirroring `create_standard_rpc_actions`
1070
- on the backend see `auth/CLAUDE.md` §`standard_rpc_actions.ts`.
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.
1071
705
 
1072
- ## Broadcast API (`broadcast_api.ts`)
706
+ ## Broadcast API (`actions/broadcast_api.ts`)
1073
707
 
1074
708
  `create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
1075
709
  typed `{method: (input) => Promise<void>}` object from a list of action
@@ -1078,7 +712,7 @@ request-scoped dispatch, this handles backend-initiated broadcast.
1078
712
  Request-scoped streaming stays on `ctx.notify` inside a handler.
1079
713
 
1080
714
  Per-method call: validates input against `spec.input` (logs + returns on
1081
- failure), wraps in a `JsonrpcNotification`, sends via the peer's resolved
715
+ failure), wraps in `JsonrpcNotification`, sends via the peer's resolved
1082
716
  transport. `transport_name` on `peer.default_send_options` pins the target
1083
717
  deterministically — no fallback, because broadcast is 1→N over a specific
1084
718
  primary transport and "any ready transport" could reach an unexpected
@@ -1087,68 +721,19 @@ audience. Silently skips when no ready transport.
1087
721
  `should_deliver: (identity, method, input) => boolean` — optional
1088
722
  per-connection ACL predicate. When set, fans out via
1089
723
  `transport.broadcast_filtered` (feature-detected via
1090
- `is_filterable_broadcast_transport`). Errors during send are logged but
1091
- never thrown — broadcasts are fire-and-forget.
724
+ `is_filterable_broadcast_transport`). Errors logged but never thrown
725
+ broadcasts are fire-and-forget.
1092
726
 
1093
727
  Typed surface: consumers declare an explicit `interface BackendActionsApi`
1094
- and pin it via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
1095
- cast, so the interface and `specs` array must stay in sync (codegen is a
1096
- natural fit when consumers already generate per-method type maps).
1097
-
1098
- ## Shared type surface (`action_types.ts`)
1099
-
1100
- Sits above `action_spec.ts` (pure Zod) and below the dispatchers
1101
- (`register_action_ws.ts`, `action_rpc.ts`, `perform_action.ts`).
1102
- Extracted so composable primitives (e.g. `heartbeat_action`) can name the
1103
- types without pulling in server-only modules.
1104
-
1105
- This is the polymorphic `Action` shape only. The unified `ActionContext`
1106
- from `action_rpc.ts` is the single handler context across every
1107
- transport; `ActionHandler` is the single handler signature.
1108
-
1109
- - `Action<TSpec>` — `{spec: TSpec, handler?: ActionHandler}`. The composable unit passed to both sides' `actions` arrays. Polymorphic on `kind`: `request_response` specs require a handler for dispatch; `remote_notification` specs may declare a stub for symmetry but are dispatcher-handled (e.g. `cancel`); `local_call` specs never reach a network dispatcher. The WS dispatcher only invokes handlers on `request_response` actions; everything else is registry-only.
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).
1110
731
 
1111
- `RpcAction = Action<RequestResponseActionSpec> & {handler: ActionHandler}`
1112
- is the narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`)
1113
- and the `rpc_action` binder produces (the actor-axis narrowing lives
1114
- in `HandlerForSpec<TSpec>`).
732
+ ## Shared type surface (`actions/action_types.ts`)
1115
733
 
1116
- ## Shared dispatch core (`perform_action.ts`)
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.
1117
737
 
1118
- The transport-agnostic post-parse pipeline shared by HTTP RPC and
1119
- WebSocket. Each transport assembles a `PerformActionInput` from its wire
1120
- envelope + connection identity, calls `perform_action(input, deps)`,
1121
- and binds the discriminated `PerformActionResult` to its wire shape.
1122
-
1123
- Pipeline (401 → 400 → 403 → handler):
1124
-
1125
- 1. Pre-validation auth (401) — short-circuits unauthenticated callers on `'required'` axes before input validation.
1126
- 2. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules.
1127
- 3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch lives in the caller — pass `preset.request_context` to skip the live phase.
1128
- 4. Post-authorization auth (403) — credential-type gate first, role gate second.
1129
- 5. Rate limit (429) — per-action IP / account throttling, throttle-requests semantics (every invocation records).
1130
- 6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction (`deps.db.transaction`) vs pool. Handler throws roll back the transaction; `ThrownJsonrpcError` preserves code + data, generic throws become `internal_error`.
1131
-
1132
- `PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
1133
- `signal`, `notify`, optional `connection_id`, optional `preset`.
1134
- `PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
1135
- the two rate limiters. Audit writes are out-of-band: factories close over
1136
- `AppDeps.audit` independently. `PerformActionResult` is `{kind: 'ok',
1137
- result} | {kind: 'error', error, status}`; `perform_action_result_to_envelope(id, result)`
1138
- builds the JSON-RPC wire shape both transports send.
1139
-
1140
- ## DEV-only output validation — uniform across surfaces
1141
-
1142
- The critical invariant: every action-handler surface applies DEV-only
1143
- output validation and produces the **same failure mode** — log an error,
1144
- return the response unchanged, do not throw, do not mutate status.
1145
-
1146
- | Surface | Code location | Hot path under production |
1147
- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
1148
- | REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
1149
- | HTTP RPC + WebSocket dispatch | `actions/perform_action.ts` — `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core | short-circuit (no parse) |
1150
-
1151
- Caller-facing `input` schemas are validated **always** (DEV + production) —
1152
- they're the contract with external callers. Server-authored `output`
1153
- schemas are internal data. See ../../docs/architecture.md §DEV-only Output
1154
- Validation for the full rationale.
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.