ruact 0.0.1 → 0.0.3

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.codecov.yml +31 -0
  3. data/.github/workflows/ci.yml +160 -94
  4. data/.github/workflows/server-functions-bench.yml +54 -0
  5. data/.rubocop.yml +19 -1
  6. data/.rubocop_todo.yml +175 -0
  7. data/CHANGELOG.md +86 -5
  8. data/README.md +2 -0
  9. data/RELEASING.md +9 -3
  10. data/bench/server_functions_dispatch_bench.rb +309 -0
  11. data/bench/server_functions_dispatch_bench.results.md +121 -0
  12. data/docs/internal/README.md +9 -0
  13. data/docs/internal/decisions/server-functions-api.md +1680 -0
  14. data/lib/generators/ruact/install/install_generator.rb +43 -0
  15. data/lib/generators/ruact/install/templates/application.jsx.tt +1 -1
  16. data/lib/generators/ruact/install/templates/initializer.rb.tt +1 -1
  17. data/lib/ruact/client_manifest.rb +125 -12
  18. data/lib/ruact/configuration.rb +264 -23
  19. data/lib/ruact/controller.rb +459 -32
  20. data/lib/ruact/doctor.rb +34 -2
  21. data/lib/ruact/erb_preprocessor.rb +6 -6
  22. data/lib/ruact/errors.rb +89 -0
  23. data/lib/ruact/flight/serializer.rb +2 -2
  24. data/lib/ruact/html_converter.rb +131 -31
  25. data/lib/ruact/query.rb +107 -0
  26. data/lib/ruact/railtie.rb +220 -3
  27. data/lib/ruact/render_context.rb +30 -0
  28. data/lib/ruact/render_pipeline.rb +201 -59
  29. data/lib/ruact/routing.rb +81 -0
  30. data/lib/ruact/serializable.rb +11 -11
  31. data/lib/ruact/server.rb +341 -0
  32. data/lib/ruact/server_action.rb +131 -0
  33. data/lib/ruact/server_functions/backtrace_cleaner.rb +32 -0
  34. data/lib/ruact/server_functions/bucket_two_payload.rb +109 -0
  35. data/lib/ruact/server_functions/codegen.rb +330 -0
  36. data/lib/ruact/server_functions/codegen_v2.rb +176 -0
  37. data/lib/ruact/server_functions/endpoint_controller.rb +237 -0
  38. data/lib/ruact/server_functions/error_payload.rb +93 -0
  39. data/lib/ruact/server_functions/error_rendering.rb +188 -0
  40. data/lib/ruact/server_functions/error_suggestion.rb +38 -0
  41. data/lib/ruact/server_functions/name_bridge.rb +113 -0
  42. data/lib/ruact/server_functions/query_context.rb +62 -0
  43. data/lib/ruact/server_functions/query_dispatch.rb +248 -0
  44. data/lib/ruact/server_functions/registry.rb +148 -0
  45. data/lib/ruact/server_functions/registry_entry.rb +26 -0
  46. data/lib/ruact/server_functions/route_source.rb +201 -0
  47. data/lib/ruact/server_functions/snapshot.rb +195 -0
  48. data/lib/ruact/server_functions/snapshot_writer.rb +65 -0
  49. data/lib/ruact/server_functions/standalone_context.rb +103 -0
  50. data/lib/ruact/server_functions/standalone_dispatcher.rb +178 -0
  51. data/lib/ruact/server_functions.rb +75 -0
  52. data/lib/ruact/version.rb +1 -1
  53. data/lib/ruact/view_helper.rb +17 -9
  54. data/lib/ruact.rb +85 -6
  55. data/lib/rubocop/cop/ruact/no_shared_state.rb +1 -1
  56. data/lib/tasks/benchmark.rake +15 -11
  57. data/lib/tasks/ruact.rake +81 -0
  58. data/spec/benchmarks/render_pipeline_benchmark_spec.rb +1 -1
  59. data/spec/fixtures/flight/README.md +55 -7
  60. data/spec/fixtures/flight/bigint.txt +1 -0
  61. data/spec/fixtures/flight/infinity.txt +1 -0
  62. data/spec/fixtures/flight/nan.txt +1 -0
  63. data/spec/fixtures/flight/negative_infinity.txt +1 -0
  64. data/spec/fixtures/flight/undefined.txt +1 -0
  65. data/spec/fixtures/story_7_9_views/controller_request_spec_support/demo/show.html.erb +3 -0
  66. data/spec/ruact/client_manifest_spec.rb +108 -0
  67. data/spec/ruact/configuration_spec.rb +501 -0
  68. data/spec/ruact/controller_request_spec.rb +204 -0
  69. data/spec/ruact/controller_spec.rb +427 -39
  70. data/spec/ruact/doctor_spec.rb +118 -0
  71. data/spec/ruact/erb_preprocessor_hook_spec.rb +3 -3
  72. data/spec/ruact/erb_preprocessor_spec.rb +7 -7
  73. data/spec/ruact/errors_spec.rb +95 -0
  74. data/spec/ruact/flight/renderer_spec.rb +14 -3
  75. data/spec/ruact/flight/serializer_spec.rb +129 -88
  76. data/spec/ruact/html_converter_spec.rb +183 -5
  77. data/spec/ruact/install_generator_spec.rb +93 -0
  78. data/spec/ruact/query_request_spec.rb +446 -0
  79. data/spec/ruact/query_spec.rb +105 -0
  80. data/spec/ruact/railtie_spec.rb +2 -3
  81. data/spec/ruact/render_context_spec.rb +58 -0
  82. data/spec/ruact/render_pipeline_concurrency_spec.rb +78 -0
  83. data/spec/ruact/render_pipeline_spec.rb +784 -330
  84. data/spec/ruact/serializable_spec.rb +8 -8
  85. data/spec/ruact/server_bucket_request_spec.rb +352 -0
  86. data/spec/ruact/server_function_name_spec.rb +53 -0
  87. data/spec/ruact/server_functions/backtrace_cleaner_spec.rb +63 -0
  88. data/spec/ruact/server_functions/bucket_two_payload_spec.rb +200 -0
  89. data/spec/ruact/server_functions/codegen_spec.rb +429 -0
  90. data/spec/ruact/server_functions/csrf_request_spec.rb +380 -0
  91. data/spec/ruact/server_functions/dispatch_request_spec.rb +819 -0
  92. data/spec/ruact/server_functions/error_payload_spec.rb +222 -0
  93. data/spec/ruact/server_functions/error_suggestion_spec.rb +79 -0
  94. data/spec/ruact/server_functions/name_bridge_spec.rb +188 -0
  95. data/spec/ruact/server_functions/query_context_spec.rb +72 -0
  96. data/spec/ruact/server_functions/railtie_integration_spec.rb +345 -0
  97. data/spec/ruact/server_functions/rake_spec.rb +86 -0
  98. data/spec/ruact/server_functions/registry_spec.rb +199 -0
  99. data/spec/ruact/server_functions/route_source_spec.rb +202 -0
  100. data/spec/ruact/server_functions/snapshot_spec.rb +256 -0
  101. data/spec/ruact/server_functions/snapshot_writer_spec.rb +71 -0
  102. data/spec/ruact/server_functions/standalone_action_spec.rb +224 -0
  103. data/spec/ruact/server_functions/standalone_context_spec.rb +142 -0
  104. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +273 -0
  105. data/spec/ruact/server_rescue_request_spec.rb +416 -0
  106. data/spec/ruact/server_spec.rb +180 -0
  107. data/spec/ruact/server_upload_request_spec.rb +311 -0
  108. data/spec/ruact/view_helper_spec.rb +23 -17
  109. data/spec/spec_helper.rb +52 -1
  110. data/spec/support/fixtures/pixel.png +0 -0
  111. data/spec/support/flight_wire_parser.rb +135 -0
  112. data/spec/support/flight_wire_parser_spec.rb +93 -0
  113. data/spec/support/matchers/flight_fixture_matcher.rb +356 -0
  114. data/spec/support/matchers/flight_fixture_matcher_spec.rb +250 -0
  115. data/spec/support/rails_stub.rb +75 -5
  116. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +139 -0
  117. data/vendor/javascript/ruact-server-functions-runtime/index.js +438 -0
  118. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +827 -0
  119. data/vendor/javascript/ruact-server-functions-runtime/package.json +22 -0
  120. data/vendor/javascript/vite-plugin-ruact/index.js +3 -2
  121. data/vendor/javascript/vite-plugin-ruact/package-lock.json +1429 -0
  122. data/vendor/javascript/vite-plugin-ruact/package.json +15 -0
  123. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +761 -0
  124. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +866 -0
  125. data/vendor/javascript/vite-plugin-ruact/vitest.config.mjs +15 -0
  126. metadata +87 -6
  127. data/Users/luiz/workspace/rails-rsc/gem/vendor/javascript/vite-plugin-ruact/index.js +0 -163
  128. data/lib/ruact/component_registry.rb +0 -31
  129. data/lib/tasks/rsc.rake +0 -9
@@ -0,0 +1,1680 @@
1
+ # Server Functions API — React-side accessor shape
2
+
3
+ | Field | Value |
4
+ | --- | --- |
5
+ | Date | 2026-05-12 |
6
+ | Status | Accepted |
7
+ | Story | 8.0 — Server functions API design spike (planning artifact in the workspace monorepo at `_bmad-output/implementation-artifacts/8-0-server-functions-api-design-spike.md` — link omitted because this file ships in the published gem and the planning path is workspace-only) |
8
+ | Inspected | `react@19.2.0` (latest 19.x line; gem pins `react ^19.0.0` per Story 8.0 AC1's `react@19.0.0` floor), `next@15.4.0-canary`, `eslint-plugin-react-hooks@v6` (the v5 → v6 bump landed in 2026-05; AC1 specified `5.x` at story-creation time — the upgrade was a no-op for the rule-of-hooks behaviour the spike inspected), `vite@6.x`. **AC1 floor versions (`react@19.0.0`, `hooks@5.x`) were NOT separately re-inspected on a side-by-side basis;** the spike accepts the drift because the inspected behaviour (named-import resolution, rule-of-hooks compliance, `<form action>` semantics) is stable across the 19.0.0 ↔ 19.2.0 patch range and across the hooks v5 ↔ v6 release (per the React + plugin changelogs). Re-inspect if a future regression contradicts this assumption. |
9
+ | Locks | Stories 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 9.1, 9.2, 9.3, 9.4, 9.5 |
10
+ | Append-only | Yes — see "When to revisit" below |
11
+
12
+ ## Context
13
+
14
+ Epic 8 (`ruact_action`) and Epic 9 (`ruact_query`) both need a way for a React
15
+ component to obtain a reference to a server-side function declared in a Rails
16
+ controller. Before this decision, Story 8.1's AC1 carried a placeholder for the
17
+ accessor surface (a deferred `server_actions[:create_post]`-style indexed
18
+ lookup with the call shape left undetermined) which was implicitly inherited by
19
+ every downstream story that needs the same reference.
20
+
21
+ A symmetric, ergonomic accessor must be picked once, shared by actions and
22
+ queries, and locked before implementation begins so that 11 downstream stories
23
+ consume a stable contract instead of re-litigating the API in each PR.
24
+
25
+ ## Decision
26
+
27
+ **Option C — Named imports from a generated TypeScript module.**
28
+
29
+ A bundled extension to `vite-plugin-ruact` reads `Ruact.action_registry` and
30
+ `Ruact.query_registry` at Rails boot, and emits one virtual module:
31
+ `app/javascript/.ruact/server-functions.ts`. React components import each
32
+ function by name. Each export carries a per-function TypeScript signature
33
+ derived from the Ruby declaration.
34
+
35
+ ```ts
36
+ // app/javascript/.ruact/server-functions.ts (auto-generated)
37
+ export declare function createPost(args: { title: string; body: string }):
38
+ Promise<{ id: number; slug: string }>;
39
+ export declare function categories():
40
+ Promise<Array<{ id: number; name: string }>>;
41
+ ```
42
+
43
+ ```tsx
44
+ // app/javascript/components/PostForm.tsx (hand-written)
45
+ import { createPost, categories } from "@/.ruact/server-functions";
46
+
47
+ export function PostForm() {
48
+ return <form action={createPost}>...</form>;
49
+ }
50
+ ```
51
+
52
+ Same accessor mechanics for actions and queries. No hook. No context provider.
53
+ No prop drilling. The import IS the accessor.
54
+
55
+ > **Note (2026-05-13, append-only):** the `export declare function` sketch
56
+ > above is superseded. The real codegen (Story 8.0a) emits runtime exports
57
+ > via `_makeRef("<symbol>")` with conservative TypeScript signatures
58
+ > (actions: `(args?: Record<string, unknown>) => Promise<unknown>`; queries:
59
+ > `() => Promise<unknown>`), not `export declare`. The `<form action={fn}>`
60
+ > sketch above also under-specifies the React-form path: when React invokes
61
+ > a server reference via `<form action>`, it passes a single `FormData`
62
+ > argument — Story 8.1 / 8.2 own how that `FormData` is unwrapped into Rails
63
+ > `params`. Query transport is POST (not GET); `useQuery(ref, params?)`
64
+ > returns `{ data, loading, error }` (not Suspense-only). See the Decision
65
+ > log entries from 2026-05-13 for the full resolutions.
66
+
67
+ ## Alternatives considered
68
+
69
+ The decision was driven by a scoring matrix built in Task 2 of the spike
70
+ across nine axes: TypeScript support, bundle size, ESLint friction, hook-
71
+ rule compliance, nested-layout future-readiness, regeneration triggers,
72
+ debugging clarity, Rails-dev-learning-React curve, React-dev-learning-
73
+ ruact curve. The matrix lived in a scratch file (`/tmp/8-0-matrix.md`)
74
+ that was not committed to version control — the Task 2 checklist in the
75
+ Story 8.0 spike artifact (workspace-only path `_bmad-output/implementation-artifacts/8-0-server-functions-api-design-spike.md`)
76
+ records the axes; the per-option summary below preserves the qualitative
77
+ outcome. If the decision is ever re-litigated, rebuild the matrix from
78
+ scratch — do not trust a recovered scratch file. Each rejected option
79
+ below cites at least one pitfall from the spike's Context Bundle.
80
+
81
+ ### Option A — Prop drilling from layout
82
+
83
+ Layout receives `server_actions` / `server_queries` from a gem-injected helper
84
+ and passes them as props down the tree.
85
+
86
+ ```tsx
87
+ function App({ server_actions }) {
88
+ return <PostForm createPost={server_actions.createPost} />;
89
+ }
90
+ ```
91
+
92
+ **Pros:** zero codegen; no hooks; props are the most universal React idiom.
93
+ **Cons:** every nested layout has to re-inject; refactoring a system function's
94
+ location through a tree is the textbook "props vs context" anti-pattern; per-call
95
+ TypeScript types degrade as drilling depth grows.
96
+ **Rejected** citing pitfall #4 (the accessor shape constrains the rendering model).
97
+ Phase 3's nested layouts would force a re-design.
98
+
99
+ ### Option B — `useServerActions()` / `useServerQueries()` hooks
100
+
101
+ The gem installs a Context Provider at the root of the layout; components call a
102
+ hook to retrieve the reference object.
103
+
104
+ ```tsx
105
+ function PostForm() {
106
+ const actions = useServerActions();
107
+ return <form action={actions.createPost}>...</form>;
108
+ }
109
+ ```
110
+
111
+ **Pros:** survives nested layouts via Context inheritance; no codegen; hot reload
112
+ trivially correct.
113
+ **Cons:** Rule of Hooks (`eslint-plugin-react-hooks` v6) requires top-level call,
114
+ so invoking from an event handler needs the two-line dance
115
+ (`const actions = useServerActions(); ...; <button onClick={() => actions.x()} />`);
116
+ hook returns one shared `actions` object whose typed shape is computed from a
117
+ single declaration, weakening per-function type inference.
118
+ **Rejected** citing pitfall #2 (hook-rule friction) and pitfall #3 (weaker per-function
119
+ type safety than C).
120
+
121
+ ### Option C — Named imports from a generated module — **CHOSEN**
122
+
123
+ See "Decision" above. Validated end-to-end in `/tmp/8-0-sandbox/` (V1–V4):
124
+ codegen + `tsc --noEmit` + typo-detection + naming-bridge edge cases all pass.
125
+
126
+ **Pros:** strongest per-function TypeScript surface of any option (each
127
+ export is independently typed; rename triggers `TS2724` with a suggestion
128
+ across every call site); zero hook-rule friction (it's a plain import,
129
+ callable from event handlers, top-level, or anywhere); tree-shakable
130
+ (unused functions are dead-code-eliminated by Vite); jump-to-definition
131
+ works in any TS-aware editor; refactoring a Ruby symbol surfaces at
132
+ typecheck and at module-resolve time (not silently at runtime).
133
+ **Cons:** requires a codegen step (Story 8.0a — Vite plugin + Railtie
134
+ hook + rake task; the implementation surface is non-trivial and adds a
135
+ second source of truth that the Ruby↔JS parity test must keep aligned);
136
+ generated module is build-time state, so it has a regeneration-trigger
137
+ matrix (`config.to_prepare` in dev; rake task in CI/prod) that the spike
138
+ had to design explicitly; introduces a new file path (`app/javascript/.ruact/`)
139
+ that host apps must gitignore.
140
+ **Chosen** citing pitfall #3 (type safety) and pitfall #4 (nested-layout
141
+ future-readiness) — the codegen cost is paid up-front, in one place, by
142
+ the gem maintainer, in exchange for the strongest ergonomic + TS surface
143
+ across all eleven downstream stories.
144
+
145
+ ### Option D — `useServerFunction("name")`
146
+
147
+ A single hook keyed by symbolic name.
148
+
149
+ ```tsx
150
+ function PostForm() {
151
+ const createPost = useServerFunction("createPost");
152
+ return <form action={createPost}>...</form>;
153
+ }
154
+ ```
155
+
156
+ **Pros:** simpler than B (one hook, no two-flavor split for actions vs queries);
157
+ no codegen.
158
+ **Cons:** string-keyed lookup defeats TypeScript almost completely (no per-function
159
+ parameter or return types without per-name overload tables); typos surface only at
160
+ runtime; jump-to-definition does not work; refactoring a Ruby symbol silently
161
+ breaks call sites.
162
+ **Rejected** citing pitfall #3 (type safety cuts both ways). The simplicity
163
+ argument does not compensate for the TS regression — and the entire reason ruact
164
+ ships TS support in Phase 2 (Story 6.1) is to make this kind of shape possible.
165
+
166
+ ### Option E — Global `window.__ruactServerActions`
167
+
168
+ Considered and discarded immediately: pollutes global scope, not tree-shakable,
169
+ no TypeScript surface, conflates accessor with serialization, breaks SSR/test
170
+ isolation. One sentence and out.
171
+ **Rejected** citing pitfall #3 (type safety) and pitfall #4 (the global
172
+ accessor binds to a specific layout/runtime instance, fragmenting if
173
+ Phase 3 introduces nested layouts or per-route shells — same future-
174
+ coupling cost that disqualified Option A, only worse since `window`
175
+ mutations cannot be partitioned per shell).
176
+
177
+ ## Applied
178
+
179
+ ### Action use case (Story 8.2 — "Create Post" form)
180
+
181
+ ```ruby
182
+ # app/controllers/posts_controller.rb
183
+ class PostsController < ApplicationController
184
+ ruact_action :create_post do |params|
185
+ Post.create!(title: params[:title], body: params[:body])
186
+ end
187
+ end
188
+ ```
189
+
190
+ ```tsx
191
+ // app/javascript/components/PostForm.tsx
192
+ //
193
+ // SUPERSEDED — see the 2026-05-16 + 2026-05-17 addenda below. The
194
+ // direct `useActionState(createPost, …)` shape does NOT typecheck
195
+ // against the codegen-emitted intersection signature under React 19's
196
+ // strict types. The canonical pattern is the explicit wrapper that
197
+ // forwards FormData to the action and shapes the resulting state.
198
+ import { createPost } from "@/.ruact/server-functions";
199
+ import { useActionState } from "react";
200
+
201
+ type PostState = { id: number; slug: string } | null;
202
+
203
+ async function postAction(_prev: PostState, formData: FormData): Promise<PostState> {
204
+ return (await createPost(formData)) as PostState;
205
+ }
206
+
207
+ export function PostForm() {
208
+ const [state, formAction, pending] = useActionState<PostState, FormData>(
209
+ postAction,
210
+ null,
211
+ );
212
+ return (
213
+ <form action={formAction}>
214
+ <input name="title" required />
215
+ <textarea name="body" required />
216
+ <button disabled={pending}>Create</button>
217
+ {state && <p>Created post #{state.id} (slug: {state.slug})</p>}
218
+ </form>
219
+ );
220
+ }
221
+ ```
222
+
223
+ ### Query use case (Story 9.2 — "populate dropdown")
224
+
225
+ ```ruby
226
+ # app/controllers/posts_controller.rb
227
+ class PostsController < ApplicationController
228
+ ruact_query :categories do
229
+ Category.all
230
+ end
231
+ end
232
+ ```
233
+
234
+ ```tsx
235
+ // app/javascript/components/CategoryPicker.tsx
236
+ import { useQuery } from "ruact";
237
+ import { categories } from "@/.ruact/server-functions";
238
+
239
+ export function CategoryPicker() {
240
+ const items = useQuery(categories); // suspends; throws Ruact.SuspenseError on stale read
241
+ return (
242
+ <select>
243
+ {items.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
244
+ </select>
245
+ );
246
+ }
247
+ ```
248
+
249
+ > **Note (2026-05-13, append-only):** the `useQuery(categories)` sketch
250
+ > above is the Suspense-based shape the spike originally drafted. Per
251
+ > the 2026-05-13 Review-patch clarifications (see Decision log), Story
252
+ > 9.2's hook signature is `useQuery(reference, params?) → { data, loading,
253
+ > error }`. The corrected sketch is:
254
+ >
255
+ > ```tsx
256
+ > const { data: items, loading, error } = useQuery(categories);
257
+ > if (loading) return <Spinner />;
258
+ > if (error) return <p>Could not load categories: {error.message}</p>;
259
+ > return <select>{items.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</select>;
260
+ > ```
261
+ >
262
+ > Suspense-aware queries (`use(promise)` + `<Suspense>` boundaries) are
263
+ > reserved for a Phase 3 ADR addendum once React 19's stable `use()`
264
+ > semantics + adoption signal land.
265
+
266
+ The accessor mechanics are identical (one named import, no hook on the
267
+ import path, one type per function). Actions integrate with native React
268
+ `<form action>` + `useActionState`; queries integrate with ruact's
269
+ `useQuery` (Story 9.2). The symmetric-coverage invariant is preserved.
270
+
271
+ ## Implementation surface
272
+
273
+ | # | Machinery | Owning story | "Done" signal |
274
+ |---|---|---|---|
275
+ | 1 | `Ruact.action_registry` + `Ruact.query_registry` (Ruby) — empty storage stubs + module-level accessors | **Storage: Implemented in Story 8.0a** (empty `Ruact::ServerFunctions::Registry` instances, `register` / `entries` / `clear!` + collision detection); **Population: Story 8.1 (actions) + Story 9.1 (queries)** — the DSL macros write into the storage 8.0a defined | `Ruact.action_registry # => Ruact::ServerFunctions::Registry` (empty at 8.0a merge); after Story 8.1's `ruact_action` evaluates, `Ruact.action_registry.entries[:create_post]` returns a populated `RegistryEntry` |
276
+ | 2 | DSL macros `ruact_action :name do |params| ... end` and `ruact_query :name do ... end` | Story 8.1 + 9.1 | Defining a macro at controller-class load time both registers the symbol and defines the matching method (visibility and CSRF rules per Story 8.2 / 9.4) |
277
+ | 3 | Server-function endpoint (single Rails route mounted by the gem; resolves by symbolic name from registries 1+2; no per-function entries in `routes.rb`) | Story 8.1 | `POST /__ruact/fn/:name` returns the action OR query result (POST-for-everything per 2026-05-13 Decision-log clarification #3 — supersedes the original GET/POST split sketched in this row); both reuse `Ruact::Controller` security/CSRF |
278
+ | 4 | `vite-plugin-ruact` extension that emits `app/javascript/.ruact/server-functions.ts` from a Rails-side dump (JSON written by a Railtie initializer) | **Implemented in Story 8.0a** | Generated file present, `tsc --noEmit` green on a freshly-installed playground |
279
+ | 5 | Rails `config.to_prepare` hook that triggers regeneration of #4 in dev | **Implemented in Story 8.0a** | `bin/rails server`, edit a controller's `ruact_action`, file at `app/javascript/.ruact/server-functions.ts` updates without restart |
280
+ | 6 | `bin/rails ruact:server_functions:generate` rake task (manual + CI/production hook) | **Implemented in Story 8.0a** | Task succeeds on a clean checkout; file is byte-identical to dev-mode output |
281
+ | 7 | `rails generate ruact:install` updates: add `app/javascript/.ruact/.gitkeep`, add `app/javascript/.ruact/server-functions.ts` to `.gitignore`, run the generate rake task once | **Implemented in Story 8.0a** (originally tagged Story 8.1; landed early because the codegen surface lives in 8.0a) | Fresh `rails new` + `rails generate ruact:install` results in a working playground that can call a stub action without further setup |
282
+ | 8 | Naming-bridge implementation in #4 (Ruby → JS identifier) | **Implemented in Story 8.0a** | The 6 edge cases enumerated in "Naming bridge" below all behave per spec |
283
+ | 9 | `useQuery(reference, params?)` hook (consumes the named import) | Story 9.2 | `const { data, loading, error } = useQuery(categories)` works inside any function component; see Decision-log entry "2026-05-13 — Review-patch clarifications" for the supersession of the original Suspense-only sketch |
284
+
285
+ Every machinery item has a story assignment. As of 2026-05-13, rows #1
286
+ (storage layer), #4–#8 are **implemented in Story 8.0a** (`gem` commit
287
+ `862f07c`); rows #2, #3, #9 remain assigned to Stories 8.1 / 9.1 / 9.2.
288
+ Empty registries from row #1 are populated by the DSL macros in row #2.
289
+
290
+ ## Naming bridge
291
+
292
+ ### Rule
293
+
294
+ Ruby symbol → JS identifier:
295
+
296
+ ```ruby
297
+ def to_js_identifier(symbol)
298
+ s = symbol.to_s
299
+ raise Ruact::ConfigurationError,
300
+ "ruact_action / ruact_query symbol :#{symbol} must match /^[a-z_][a-z0-9_]*$/" \
301
+ unless s.match?(/\A[a-z_][a-z0-9_]*\z/)
302
+ leading_underscore = s.start_with?("_")
303
+ body = leading_underscore ? s[1..] : s
304
+ camel = body.gsub(/_+(.)/) { $1.upcase }
305
+ leading_underscore ? "_#{camel}" : camel
306
+ end
307
+ ```
308
+
309
+ The rule is implementable in 5 lines (above; the validation guard adds 2). Failure
310
+ mode is loud: invalid symbols raise `Ruact::ConfigurationError` at controller-class
311
+ load time (boot), not at runtime, not silently.
312
+
313
+ ### Edge cases (validated in `/tmp/8-0-sandbox/`)
314
+
315
+ | Ruby symbol | JS identifier | Notes |
316
+ |---|---|---|
317
+ | `:create_post` | `createPost` | Standard snake_case → camelCase |
318
+ | `:categories` | `categories` | Single word — pass-through |
319
+ | `:_internal_dump` | `_internalDump` | Leading underscore preserved |
320
+ | `:foo__bar` | `fooBar` | Consecutive underscores collapse |
321
+ | `:RECALCULATE` | (rejected at boot) | `Ruact::ConfigurationError` — SCREAMING_SNAKE not allowed |
322
+ | `:CreatePost` | (rejected at boot) | `Ruact::ConfigurationError` — must start with lowercase letter or underscore |
323
+ | `:_` / `:__` | (rejected at boot) | `Ruact::ConfigurationError` — underscore-only symbols carry no semantic content (added 2026-05-13 via Story 8.0 review patch) |
324
+ | `:class` / `:export` / `:await` | (rejected at boot) | `Ruact::ConfigurationError` — the translated JS identifier collides with an ES2020+ reserved word (added 2026-05-13 via Story 8.0 review patch). Escape hatch: prefix with underscore (`:_class` → `"_class"`, accepted). |
325
+
326
+ The "rejected at boot" choice (vs silent normalization) is deliberate: ruby_symbol
327
+ → JS-identifier conversion is one-way, so accepting `:CreatePost` and emitting
328
+ `createPost` would create two co-equal Ruby names for the same JS symbol —
329
+ collision-prone, hard to reverse-engineer in stack traces. The cost of the
330
+ strict rule is one early failure for the 1% of devs who use unusual casing,
331
+ which is the right trade.
332
+
333
+ The 2026-05-13 reserved-word + underscore-only additions follow the same
334
+ principle: a symbol that would produce JS that fails `tsc --noEmit` or trips
335
+ common ESLint configurations fails LOUDLY at controller-class load time
336
+ instead of silently shipping. The implementation lives in
337
+ `gem/lib/ruact/server_functions/name_bridge.rb` — `RESERVED_JS_IDENTIFIERS`
338
+ is the canonical list (ES2020+ keyword set + strict-mode reserved +
339
+ contextual reserved at module top level: `await`, `async`).
340
+
341
+ ### Identifier collision
342
+
343
+ If two different Ruby symbols map to the same JS identifier (e.g., `:foo_bar`
344
+ and `:foo__bar` both → `fooBar`), the codegen step raises
345
+ `Ruact::ConfigurationError` listing both controllers. Validated in the spike
346
+ sandbox.
347
+
348
+ ## When to revisit
349
+
350
+ This decision is **append-only**. When revisiting, add a dated addendum below
351
+ "Decision log"; do not rewrite the original.
352
+
353
+ Revisit if any of:
354
+
355
+ 1. **React introduces an official `useServerReference` hook** (or equivalent
356
+ accessor in the React core) in a stable release.
357
+ *Action:* revisit, log a new ADR addendum, decide whether to migrate (or to
358
+ provide both shapes during a transition).
359
+ 2. **The Server Components specification adds new ergonomics** (e.g., the React
360
+ team standardizes a reference-passing mechanism beyond `'use server'` import).
361
+ *Action:* same as #1.
362
+ 3. **≥ 3 external issues** (post-v0.1.0) request a different ergonomic from
363
+ real users.
364
+ *Action:* revisit; the threshold-of-3 is the bar to avoid one-voice
365
+ over-rotation.
366
+ 4. **A Phase 3 epic explicitly addresses ergonomics revisit** (e.g., a planned
367
+ "ruact 1.0 ergonomics polish" epic).
368
+ *Action:* this ADR is a required input to that epic.
369
+
370
+ Deviation from the chosen shape during Epic 8/9 implementation requires an ADR
371
+ addendum in this same file before the deviating PR is merged. The file is
372
+ append-only, never rewritten — so the history of the contract is preserved
373
+ even if the contract itself evolves.
374
+
375
+ ## Decision log
376
+
377
+ ### 2026-05-12 — Initial decision (Option C)
378
+
379
+ Chosen via the matrix in Task 2 of Story 8.0. Validated in
380
+ `/tmp/8-0-sandbox/` (Task 3): codegen + `tsc --noEmit` + typo-detection +
381
+ all 6 naming-bridge edge cases pass. Implementation surface enumerated; new
382
+ Story 8.0a created for the Vite plugin extension that emits the generated
383
+ module. No deviation from the spike's draft personal-opinion candidate, but
384
+ the matrix scoring is what locked the decision (not the gut feel).
385
+
386
+ ### 2026-05-13 — Implementation (Story 8.0a)
387
+
388
+ Implementation surface rows #4, #5, #6, and #8 landed (plus #7's install-
389
+ generator extension, which was originally scoped to Story 8.1 but the
390
+ codegen scaffolding belongs alongside the rest of the 8.0a surface).
391
+ Ruby-side modules live under `gem/lib/ruact/server_functions/` (`NameBridge`,
392
+ `Registry`, `RegistryEntry`, `Snapshot`, `SnapshotWriter`, `Codegen`); the
393
+ Vite-plugin sidecar is `gem/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs`
394
+ with a vitest harness alongside; the placeholder runtime at
395
+ `gem/vendor/javascript/ruact-server-functions-runtime/` ships an
396
+ intentionally-failing `_makeRef` so absent Story 8.1 wiring fails loudly at
397
+ call time. The JSON bridge lands at `tmp/cache/ruact/server-functions.json`,
398
+ the TS module at `app/javascript/.ruact/server-functions.ts`; both are
399
+ write-if-changed and gitignored. Cross-implementation parity (Ruby ↔ JS
400
+ codegens emit byte-identical output) is enforced by a vitest test that
401
+ shells out to `ruby -Ilib -rruact/server_functions/codegen -e ...` on a
402
+ literal fixture (`server-functions-codegen.test.mjs` → "Story 8.0a — Ruby
403
+ parity"). Empty registries are valid: 8.0a merges them as `[]` and Stories
404
+ 8.1 / 9.1 populate them later. **No deviation from the locked contract**:
405
+ the import specifier remains `"ruact/server-functions-runtime"`; the
406
+ runtime alias is auto-registered by the Vite plugin's `config` hook against
407
+ the bundled placeholder package. See
408
+ Story 8.0a (workspace-only path `_bmad-output/implementation-artifacts/8-0a-vite-plugin-server-functions-codegen.md`)
409
+ for the full task breakdown and AC mapping.
410
+
411
+ ### 2026-05-13 — Review-patch clarifications (Story 8.0 review pass)
412
+
413
+ The Story 8.0 code-review surfaced four patch findings that target stale
414
+ sketches in the ADR body. These clarifications are append-only — the body
415
+ sketches stay so the history of the contract is preserved, but the
416
+ following points override them where they disagree:
417
+
418
+ 1. **Runtime/type contract (supersedes `## Decision` sketch lines 35–41).**
419
+ The codegen emits **runtime** exports, not `export declare function`.
420
+ Each entry is `export const <jsId>: <signature> = _makeRef("<rubySym>");`.
421
+ The default signatures locked by Story 8.0a are:
422
+ - Actions: `(args?: Record<string, unknown>) => Promise<unknown>`
423
+ - Queries: `() => Promise<unknown>`
424
+ These are the **direct-callable surface** — they describe how the ref
425
+ appears to JS event handlers and to `await someRef(args)` call sites.
426
+ Per-function precision (e.g. `(args: { title: string }) => Promise<{ id: number }>`)
427
+ is **not** generated from the Ruby DSL in Phase 2 — devs annotate manually
428
+ if they want stronger types. Evolution to Ruby-side type metadata is
429
+ reserved for a Phase 3 ADR addendum.
430
+
431
+ 2. **`<form action={fn}>` semantics + FormData → Rails params.** React
432
+ invokes a server reference passed to `<form action>` with a single
433
+ `FormData` argument, not the `args: { title, body }` shape the body
434
+ sketch implies. Story 8.1 owns the controller-side unwrapping
435
+ (`FormData` → `params`); Story 8.2 owns the React-side path with
436
+ `useActionState` and the error overlay. **Open design decision deferred
437
+ to Story 8.2:** the conservative `Record<string, unknown>` signature
438
+ above is NOT structurally compatible with `FormData` in TypeScript —
439
+ `<form action={createPost}>` will fail `tsc --noEmit` against the
440
+ 8.0a-emitted module as-is. Story 8.2 decides whether to (a) widen the
441
+ codegen signature to `(args?: FormData | Record<string, unknown>) =>
442
+ Promise<unknown>`, (b) export a sibling `.formAction` method on each
443
+ ref typed `(formData: FormData) => Promise<unknown>`, or (c) require
444
+ an explicit cast at the form's call site (`<form action={createPost
445
+ as (fd: FormData) => Promise<unknown>}>`). 8.0a does not pre-commit
446
+ to one — picking it here would lock the 8.2 design without the
447
+ implementation context that disambiguates the trade-offs.
448
+
449
+ 3. **Query transport is POST for everything (supersedes Implementation
450
+ Surface row #3's `GET /__ruact/fn/:name?args=...`).** Both actions and
451
+ queries POST to `/__ruact/fn/:name` with params in the request body —
452
+ CSRF symmetric, no URL-replay, no intermediate caching. Trade-off
453
+ accepted: queries lose HTTP-level cacheability, which is irrelevant for
454
+ the internal RPC use case.
455
+
456
+ 4. **`useQuery` shape (supersedes Implementation Surface row #9's "Suspense-
457
+ only" wording).** Story 9.2's hook signature is
458
+ `useQuery(reference, params?) → { data, loading, error }`. Suspense-aware
459
+ queries (`use(promise)` + `<Suspense>` boundaries) are reserved for a
460
+ Phase 3 ADR addendum once React 19's stable `use()` semantics + adoption
461
+ signal land.
462
+
463
+ 5. **Query params contract.** The hook accepts `useQuery(ref, params?)`
464
+ while the 8.0a-emitted query ref's direct-callable signature is
465
+ `() => Promise<unknown>` (no params on the callable surface). The two
466
+ shapes coexist because **the hook does NOT invoke the ref as a
467
+ function**; it reads the ref's `$$id` metadata and POSTs to
468
+ `/__ruact/fn/:id` with `params` in the request body itself. The ref
469
+ stays callable for parity with action refs (so devtools / tooling can
470
+ treat both uniformly), but for queries the canonical access path is
471
+ the hook, not the call. **Open design decision deferred to Story 9.2:**
472
+ whether to (a) keep the no-args callable signature and have `useQuery`
473
+ read `$$id` (current intent), (b) widen the callable to
474
+ `(args?: Record<string, unknown>) => Promise<unknown>` and have
475
+ `useQuery` invoke the ref directly (symmetric with actions, simpler
476
+ runtime), or (c) introduce a typed `ServerRef<TParams, TResult>`
477
+ metadata wrapper. 8.0a's codegen does not pre-commit; option (b)
478
+ would require widening the emitted signature and is the most likely
479
+ pick if symmetry wins, but 9.2's implementation context will decide.
480
+
481
+ These clarifications were registered through Story 8.0's Review Findings
482
+ section (the original four `[Review][Patch]` items + the 2026-05-13 Re-run
483
+ batch). The Decision log is append-only — do not rewrite the body sketches
484
+ above. If a future story conflicts with these clarifications, add a new
485
+ dated entry here.
486
+
487
+ ### 2026-05-16 — Story 8.2 — codegen signature widening for `<form action>`
488
+
489
+ **Resolves:** the 2026-05-13 clarification #2 "Open design decision deferred
490
+ to Story 8.2" (FormData wire shape vs. action signature). Story 8.2 picks
491
+ **option (a) — widen the codegen-emitted action signature** to accept
492
+ either a `FormData` instance or a plain `Record<string, unknown>` argument.
493
+
494
+ **New action signature (Ruby + JS codegens in lockstep):**
495
+
496
+ ```ts
497
+ export const createPost: (args?: FormData | Record<string, unknown>) => Promise<unknown> =
498
+ _makeRef("create_post");
499
+ ```
500
+
501
+ Query signatures are UNCHANGED — `() => Promise<unknown>`. The widening
502
+ applies to actions only because only actions are reachable via
503
+ `<form action={fn}>`; queries are read-only and reach the wire through
504
+ `useQuery` (Story 9.2), not a `<form>`.
505
+
506
+ **Why option (a) over (b) — sibling `.formAction` property:**
507
+
508
+ | Axis | (a) widen primary export | (b) sibling `.formAction` |
509
+ | --- | --- | --- |
510
+ | Call-site ceremony | None — `<form action={createPost}>` works | Two import shapes (`createPost.formAction` for forms; `createPost` for direct calls) |
511
+ | TS type surface | One signature, one accepted-types union | Two signatures must stay in sync per ref |
512
+ | Codegen complexity | Single ternary update | Per-ref dual export (doubles emitted line count) |
513
+ | Discoverability | Auto-complete suggests one symbol | Auto-complete branches on `.formAction` |
514
+ | Failure mode | Direct caller passing a plain object — narrowed at runtime by `instanceof FormData` | Passing `.formAction(plainObj)` typechecks but breaks at runtime |
515
+
516
+ The two TS-friendliness concerns option (b) was meant to mitigate — call
517
+ sites that pass plain objects shouldn't be widened to accept FormData —
518
+ were judged less load-bearing than the codegen + import + auto-complete
519
+ duplication cost of option (b). Direct callers retain the option of
520
+ explicit narrowing at the call site (`createPost(args as Record<string, unknown>)`)
521
+ if they want stricter typing inside their own code.
522
+
523
+ **Why not option (c) — explicit cast at the form site:**
524
+
525
+ Rejected as documented in the body. The whole point of the codegen layer
526
+ is to eliminate call-site ceremony; a project-wide `<form action={createPost as (fd: FormData) => Promise<void>}>`
527
+ convention defeats the design.
528
+
529
+ **`useActionState` integration:**
530
+
531
+ React 19's `useActionState(action, initialState)` calls the action as
532
+ `(prevState, formData) => state`. The widened single-arg signature does
533
+ NOT structurally match this two-arg shape. The runtime supports the
534
+ two-arg invocation (Story 8.2 AC3 — `_makeRef` accepts up to two
535
+ positional args and picks the FormData-typed candidate) so a wrapper is
536
+ not required at runtime, but TypeScript-strict consumers must wrap when
537
+ binding to `useActionState`:
538
+
539
+ ```tsx
540
+ import { createPost } from "@/.ruact/server-functions";
541
+ import { useActionState } from "react";
542
+
543
+ export function PostForm() {
544
+ const [state, formAction, pending] = useActionState(
545
+ (_prevState, formData: FormData) => createPost(formData),
546
+ null,
547
+ );
548
+ return (
549
+ <form action={formAction}>
550
+ <input name="title" />
551
+ <button disabled={pending}>Create</button>
552
+ </form>
553
+ );
554
+ }
555
+ ```
556
+
557
+ The wrap-per-call pattern is the documented contract. If a future React
558
+ release converges `<form action>` and `useActionState` on a single call
559
+ shape, this addendum should be revisited. The codegen is not generating
560
+ overload signatures for the two-arg shape in Phase 2; that's reserved
561
+ for a future iteration if call-site friction becomes load-bearing.
562
+
563
+ **Worked typecheck example (SUPERSEDED — see 2026-05-17 R1 addendum):**
564
+
565
+ > **2026-05-17 update:** the single-signature widening below does NOT
566
+ > actually let `<form action={createPost}>` typecheck — `Promise<unknown>`
567
+ > is invariant to `Promise<void>` so React 19's `(formData: FormData) =>
568
+ > void | Promise<void>` prop rejects the assignment. The 2026-05-17 R1
569
+ > addendum below refines to a TypeScript intersection that satisfies
570
+ > both call sites. The block below stays as historical record.
571
+
572
+ ```tsx
573
+ // @/.ruact/server-functions emits (pre-R1 — STALE):
574
+ // export const createPost: (args?: FormData | Record<string, unknown>) => Promise<unknown>
575
+ //
576
+ // 1. Direct call with a plain object (event handler) — STILL typechecks.
577
+ await createPost({ title: "Hi" });
578
+
579
+ // 2. Direct call with FormData — STILL typechecks.
580
+ const fd = new FormData();
581
+ fd.append("title", "Hi");
582
+ await createPost(fd);
583
+
584
+ // 3. <form action={createPost}> — REJECTED by tsc under strict mode
585
+ // because Promise<unknown> is not assignable to Promise<void>. See
586
+ // the 2026-05-17 R1 addendum below for the intersection refinement
587
+ // that makes this site typecheck.
588
+ ```
589
+
590
+ **`revalidate()` re-export (new in Story 8.2):**
591
+
592
+ The codegen now appends a fixed re-export AFTER all per-function exports
593
+ (also emitted when the registry is empty — the helper is unconditional):
594
+
595
+ ```ts
596
+ export { revalidate } from "ruact/server-functions-runtime";
597
+ ```
598
+
599
+ `revalidate(path?)` is implemented in the runtime; it reads
600
+ `globalThis.__ruact_revalidate` (published by `ruact-router.js#setupRouter`)
601
+ and triggers a Flight refetch of the supplied path or the current URL.
602
+ See the Story 8.2 file for the runtime API spec and the docs page for
603
+ end-user-facing documentation.
604
+
605
+ **Impact on Story 9.x:**
606
+
607
+ Open clarification #5 ("Query params contract") is unaffected. The
608
+ action-side widening here does NOT pre-commit Story 9.2 to one option
609
+ or the other for queries — query signatures remain narrow
610
+ (`() => Promise<unknown>`) until Story 9.2 picks (a) / (b) / (c) in its
611
+ own ADR addendum. The byte-parity tests track action and query branches
612
+ independently.
613
+
614
+ ### 2026-05-17 — Story 8.2 code-review patch R1 — intersection-type refinement
615
+
616
+ **Resolves:** option (a) as documented in the 2026-05-16 addendum above
617
+ was wrong on the typecheck side. `(args?: FormData | Record<string, unknown>)
618
+ => Promise<unknown>` is NOT structurally assignable to React 19's
619
+ `<form action>` prop type `(formData: FormData) => void | Promise<void>`
620
+ — Promise generics are invariant, so `Promise<unknown>` is not
621
+ assignable to `Promise<void>` even via the void-discard rule. Empirical
622
+ typecheck probe (`playgrounds/demo/spec/typecheck/form-action-direct.tsx`)
623
+ surfaced the error:
624
+
625
+ ```
626
+ Type 'Promise<unknown>' is not assignable to type 'Promise<void>'.
627
+ Type 'unknown' is not assignable to type 'void'.
628
+ ```
629
+
630
+ The original 2026-05-16 decision shipped without a direct
631
+ `<form action={createPost}>` typecheck site in the playground (only the
632
+ `useActionState` wrapper pattern was tested, which sidesteps the issue
633
+ by routing through a closure with a `void` return). Code review caught
634
+ this gap and asked for a real `<form action={createPost}>` site that
635
+ typechecks WITHOUT a cast.
636
+
637
+ **Refined option (a) → (a′) — intersection type.** The codegen now emits:
638
+
639
+ ```ts
640
+ export const createPost:
641
+ ((args?: FormData | Record<string, unknown>) => Promise<unknown>)
642
+ & ((formData: FormData) => Promise<void>) =
643
+ _makeRef("create_post");
644
+ ```
645
+
646
+ The intersection's first call signature satisfies direct callers (event
647
+ handlers, `useActionState` wrappers); the second satisfies React 19's
648
+ `<form action>` prop. Same export, both call sites typecheck. Runtime
649
+ behavior is unchanged — `_makeRef` always resolves with the JSON-decoded
650
+ value at runtime; the `Promise<void>` overload is a TYPE-ONLY surface,
651
+ selected only when React invokes the function from a `<form action>`
652
+ prop (where React discards the return value anyway).
653
+
654
+ **`_makeRef` declaration follows the intersection.** The `_makeRef`
655
+ return type in `gem/vendor/javascript/ruact-server-functions-runtime/index.d.ts`
656
+ is now also an intersection so the codegen's emitted annotation is
657
+ satisfied by the RHS without a cast. Declaration-only; the JS
658
+ implementation is untouched.
659
+
660
+ **Why not function overloads?** TypeScript supports declaration-style
661
+ function overloads (`function foo(x): Y; function foo(): Z;`) but NOT
662
+ on `export const` bindings. Intersection of two call signatures on a
663
+ type literal is the idiomatic TS equivalent and is what every
664
+ production-grade overloaded-callable library (Lodash, Ramda, etc.) emits
665
+ under its types.
666
+
667
+ **Reserved-name protection (review patch R2, same date).** With
668
+ `revalidate` now unconditionally re-exported by the codegen from BOTH
669
+ the empty-registry and populated-registry branches, a controller
670
+ declaring `ruact_action :revalidate` would emit a duplicate `export
671
+ const revalidate` next to the helper re-export and crash at module
672
+ load. `NameBridge.RESERVED_BY_RUACT = %w[revalidate].to_set` rejects
673
+ the symbol at controller-class load time with a clear error message.
674
+ Escape hatches: `:revalidate_post` (suffix) and `:_revalidate`
675
+ (leading underscore) both pass.
676
+
677
+ **`<form action={...}>` empirical validation steps (replicable):**
678
+
679
+ 1. `cd playgrounds/demo && npx tsc --noEmit -p tsconfig.json`
680
+ 2. The probe at `spec/typecheck/form-action-direct.tsx` declares three
681
+ patterns:
682
+ - `<form action={demoEcho}>` directly (no cast, no wrapper)
683
+ - `await demoEcho({ message: "Hi" })` direct call returning `unknown`
684
+ - `useActionState<unknown, FormData>((_prev, fd) => demoEcho(fd), null)`
685
+ 3. All three patterns typecheck under `strict: true`. If any single
686
+ pattern fails, the codegen's intersection emission has regressed.
687
+
688
+ The 2026-05-16 addendum body (option (a) narrative + worked example)
689
+ stays as the historical record — replace any direct `(args?: ...)`
690
+ quotation in future stories with the intersection form. The
691
+ `useActionState` wrapper pattern documented in 2026-05-16 remains the
692
+ canonical approach for `useActionState` because the intersection's
693
+ single-arg signature is NOT structurally compatible with the two-arg
694
+ shape React's hook expects.
695
+
696
+ ### 2026-05-17 — Story 8.3 — standalone host execution context
697
+
698
+ **Status:** Resolved (Story 8.3 landed standalone host shape + dispatcher branch + CSRF policy + `current_user_resolver` config).
699
+
700
+ **Scope:** Closes 2026-05-13 clarification #4 ("Standalone host execution context — deferred to Story 8.3"). Captures (a) the `Ruact::ServerAction` extend pattern + why it was chosen over `class << self`-style or `include`-style; (b) the `StandaloneContext` attribute set + why `render`/`redirect_to`/`head` are excluded; (c) the `current_user_resolver` lambda contract; (d) why the registry's `controller` field name was kept despite the semantic widening; (e) the CSRF policy migration to the gem side for the standalone branch.
701
+
702
+ **No code-shape changes to the locked accessor.** `import { createPost } from "@/.ruact/server-functions"` still works identically; the codegen output is byte-identical regardless of host shape. The 2026-05-16/17 intersection signature still applies. The only new piece on the Ruby side is `Ruact::ServerAction` (an extend module) + `StandaloneDispatcher` (a branch inside `EndpointController#dispatch_action`).
703
+
704
+ **(a) Why `extend Ruact::ServerAction`?**
705
+
706
+ ```ruby
707
+ module CreatePost
708
+ extend Ruact::ServerAction
709
+
710
+ ruact_action :create_post do |params|
711
+ ...
712
+ end
713
+ end
714
+ ```
715
+
716
+ Considered alternatives:
717
+
718
+ 1. `class << self; extend Ruact::ServerAction; ...; end` — verbose, idiomatically used for adding class methods to a class, not for marking a top-level module as a server-action host.
719
+ 2. `include Ruact::ServerAction` — would put `ruact_action` on instances; standalone modules don't have instances (no `.new` semantics, the block runs against a per-request `StandaloneContext`). Confusing.
720
+ 3. `Ruact::ServerAction.declare(MyModule) { ... }` — declarative API outside the module body. Loses the parallel with `Ruact::Controller`'s `include`-then-`ruact_action`-in-class-body shape that controllers use.
721
+ 4. **Chosen:** `extend Ruact::ServerAction` puts the `ruact_action` macro on the module's singleton class. The DSL inside the module body reads naturally; the `ruact_action` registration call sees `self == TheModule` and registers `controller: self`.
722
+
723
+ The `extend` choice also lets the `EndpointController.standalone_host?(host)` predicate use a positive check: `host.is_a?(Module) && !host.is_a?(Class) && host.singleton_class.include?(Ruact::ServerAction)`. The check is robust against a host that happens to be `extend`ed by both `Ruact::ServerAction` AND some other module — order doesn't matter; what matters is that `Ruact::ServerAction` is in the singleton ancestry.
724
+
725
+ **(b) `StandaloneContext` attribute set:**
726
+
727
+ Exposed: `params`, `current_user` (resolver-backed, memoized), `session`, `cookies`, `headers`, `request`.
728
+
729
+ Excluded — `render` / `redirect_to` / `head` raise `NoMethodError` with a documented hint. Reasoning: those are controller-context methods. A dev who writes `render(json: ..., status: ...)` inside a standalone block has a wrong mental model — the standalone path's response contract is "return a value, or raise `Ruact::ActionError(status:, body:)`". Letting `render` partially work (e.g. write to `response`) would create a second response-shape source-of-truth inside the dispatcher; the loud `NoMethodError` makes the contract unambiguous. The error message names the alternatives so the fix is immediate.
730
+
731
+ `StandaloneContext` is NOT a `Data.define` — it carries lazy/memoized state (`current_user`'s resolution flag, the read-tracking flag for the dev-only "unread current_user" warning) that doesn't fit Data's immutability contract.
732
+
733
+ **(c) `current_user_resolver` lambda contract:**
734
+
735
+ - **Argument:** receives `request.env` (Hash). Documented contract — the lambda MUST NOT receive the full `ActionDispatch::Request` (would let the dev mutate request state in ways the dispatcher can't track).
736
+ - **Return:** the authenticated user (any Ruby object) or `nil`.
737
+ - **Lookup precedence:** (1) `request.env['ruact.current_user']` if the key is PRESENT (even if `nil`); (2) the configured lambda. The env-key path is for hosts that set the current user via upstream Rack middleware (Devise's Warden uses `env['warden'].user`; the resolver can read it OR the dev can set `env['ruact.current_user']` from a custom middleware to bypass the resolver entirely).
738
+ - **Memoization:** the `StandaloneContext` caches the first `current_user` call for the duration of one dispatch; repeated reads don't re-invoke the resolver. Documented in YARD.
739
+ - **Failure mode:** when neither path produces a value AND the block actually reads `current_user`, raises `Ruact::CurrentUserNotConfiguredError` at dispatch time (NOT at boot — only fires for blocks that actually depend on `current_user`). The error message names both worked examples (Devise + hand-rolled) so the dev fixes the configuration without leaving the stack trace.
740
+
741
+ **(d) Registry `controller` field name retained:**
742
+
743
+ `Ruact::ServerFunctions::RegistryEntry.controller` was `controller:` (a Class) pre-Story-8.3. Story 8.3 widens the SEMANTIC: the field now stores either a Class (controller host) or a Module (standalone host). The FIELD NAME stays `:controller` for two reasons:
744
+
745
+ 1. **Back-compat with existing specs / fixtures.** `RegistryEntry`'s `Data.define(:ruby_symbol, :js_identifier, :kind, :controller, :block)` is referenced by name in `Snapshot.functions_payload`, `Snapshot.read_for_codegen`, and dozens of spec assertions. Renaming would be a churn-heavy mechanical migration with no behavioural benefit.
746
+ 2. **Snapshot output is unchanged.** The JSON snapshot reads `js_identifier` / `ruby_symbol` / `kind` from each entry; it does NOT serialize `controller`. The codegen output is byte-identical regardless of host shape — so the field name choice is invisible outside the gem's own code.
747
+
748
+ Future stories that need to disambiguate may add an explicit `host_kind: :class | :module` field; that's an additive change, not a rename. For now, "host" is the semantic concept and `controller` is the field that stores it.
749
+
750
+ **(e) CSRF policy migration to the gem side for the standalone branch:**
751
+
752
+ Story 8.1 deliberately skipped `verify_authenticity_token` on `EndpointController` (the gem-mounted controller backing `POST /__ruact/fn/:name`) precisely because the host controller's `protect_from_forgery` would re-enforce it inside `host_class.dispatch`. For standalone actions there is no host chain. The dispatcher must enforce CSRF itself OR explicitly opt out per-app.
753
+
754
+ **Decision:** enforce by default. The gem's `EndpointController` carries a `protect_from_forgery with: :exception, if: :dispatching_standalone?` declaration. This installs:
755
+
756
+ - The forgery_protection_strategy = :exception (idiomatic Rails wiring; using a bare `before_action :verify_authenticity_token` would crash because the strategy class is otherwise nil).
757
+ - A conditional `before_action :verify_authenticity_token` that fires only when `dispatching_standalone?` returns true (the resolved entry's host is a Module extending `Ruact::ServerAction`).
758
+
759
+ The condition is resolved EARLY via a `prepend_before_action :resolve_ruact_entry!` that runs before the conditional CSRF callback. The controller-action branch keeps `skip_forgery_protection`-equivalent behavior (the `if:` condition is false, the callback skips, and the host controller's own `protect_from_forgery` handles CSRF as before).
760
+
761
+ Rails' `verified_request?` short-circuits when `allow_forgery_protection = false` globally, so API-mode hosts accept standalone POSTs without a token — same observable behavior as controller-hosted actions in API mode.
762
+
763
+ **Why not handle CSRF inside `StandaloneDispatcher`?** Doing it as a Rails callback lets the framework's existing instrumentation, exception classes, and logging all participate. `ActionController::InvalidAuthenticityToken` is the canonical exception class; raising it inline from a custom dispatcher would lose that. The conditional `before_action` is the smallest possible change to the gem's request-cycle wiring that preserves user-visible parity with the controller-hosted matrix.
764
+
765
+ **Append-only invariant preserved.** No code-shape changes to the locked accessor. The Story 8.0 ADR's "single accessor mechanism" decision is intact; this addendum adds a second host shape that funnels through the same accessor.
766
+
767
+ ### 2026-05-18 — Story 8.4 — structured server-action error payload
768
+
769
+ **Context.** Story 8.1 (controller-hosted dispatch), Story 8.2 (`<form action={fn}>` runtime), and Story 8.3 (standalone dispatcher) all let a raised exception bubble back to Rails' default `ActionDispatch::ShowExceptions` middleware on the unhappy path — producing an HTML error page that the runtime received as `RuactActionError({ status, body: "<html>..." })`. Story 8.4 closes NFR30 by introducing a structured wire body so the dev overlay can render a meaningful diagnostic view AND the production React component can render its own UI from the same shape.
770
+
771
+ **Decision.** Add an OUTERMOST `rescue_from StandardError` (and explicit `rescue_from ActionController::InvalidAuthenticityToken`) on `EndpointController` that renders a JSON Hash with a `_ruact_server_action_error: true` discriminator. The Story 8.0 accessor lock is UNTOUCHED; the new wire surface lives entirely in the `body` field of the existing `RuactActionError`, which the runtime treats as opaque JSON.
772
+
773
+ **(a) Wire shape — backward-compatible body refinement.**
774
+
775
+ | Field | Dev mode | Prod mode |
776
+ | --- | --- | --- |
777
+ | `_ruact_server_action_error` (bool) | ✅ | ✅ |
778
+ | `action_name` (string) | ✅ | ✅ |
779
+ | `error_class` (string) | ✅ | ✅ |
780
+ | `message` (string) | ✅ | ✅ |
781
+ | `app_frames` (array of string) | ✅ | absent (key not present) |
782
+ | `gem_frames` (array of string) | ✅ | absent |
783
+ | `suggestion` (string \| null) | ✅ | absent |
784
+ | `validation_errors` (array of string) | only for `ActiveRecord::RecordInvalid` | absent |
785
+
786
+ The discriminator field (`_ruact_server_action_error: true`) is the load-bearing piece: the React overlay uses it to decide whether to render the structured branch or fall back to the existing `<pre>{error.message}</pre>` rendering. Existing callers that read `body.error` (the Story 8.3 malformed-JSON case) or treat the body as opaque keep working — no field rename, no key removal.
787
+
788
+ The dev/prod gate is `Ruact.config.dev_error_payload_enabled`, default `nil` (the endpoint resolves nil to `Rails.env.development? || Rails.env.test?`). Setting `c.dev_error_payload_enabled = false` inside `Ruact.configure` forces production-shape errors locally — useful for verifying what the React component receives in prod.
789
+
790
+ **(b) Host `rescue_from` precedence rule.**
791
+
792
+ The endpoint's `rescue_from StandardError` is the OUTERMOST catch — it only fires for exceptions the host did NOT handle. A host that declares `rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid` continues to render its own response unchanged. The structured payload is the FALLBACK, not the override. This invariant is what lets hosts opt into the gem's diagnostics for unhandled classes while keeping their own error UI for owned domain errors.
793
+
794
+ **(c) Suggestion table is a gem-published surface.**
795
+
796
+ `Ruact::ServerFunctions::ErrorSuggestion::SUGGESTIONS` is a frozen Hash keyed by error-class-name strings. Today it carries the two NFR30 mandated mappings (`ActiveRecord::RecordInvalid`, `ActionController::InvalidAuthenticityToken`). The table is NOT host-configurable for now: extending it requires an ADR amendment + a constant update. Rationale: making it runtime-configurable would require a Configuration knob that lives in the seal contract (Story 7.3), expanding the public surface for a feature without a cross-host demand signal. A future story that adds (e.g.) `ActiveRecord::StaleObjectError` opens this paragraph.
797
+
798
+ **(d) Status code mapping.**
799
+
800
+ | Exception | HTTP status |
801
+ | --- | --- |
802
+ | `ActiveRecord::RecordInvalid` | `422` |
803
+ | `ActionController::InvalidAuthenticityToken` | `403` |
804
+ | any other `StandardError` | `500` |
805
+
806
+ **Breaking change vs Story 8.3 R2:** standalone CSRF rejections previously returned `422` (Rails' default `InvalidAuthenticityToken` → 422 mapping in `ShowExceptions`); they now return `403`. The change is intentional — 403 Forbidden is semantically the right code for "you are not authorised to make this request" (CSRF mismatch == missing/invalid credentials), while 422 is reserved for "the request was syntactically valid but the entity could not be processed" (validation errors). The Story 8.3 R2 spec assertion is updated in lockstep; production hosts that branched on the 422 status for CSRF-specific handling MUST migrate to either the 403 status or the `error_class === "ActionController::InvalidAuthenticityToken"` field on the structured body.
807
+
808
+ **(e) `BacktraceCleaner.split` semantics.**
809
+
810
+ `Ruact::ServerFunctions::BacktraceCleaner.split(error.backtrace)` returns `{ app: [...], gem: [...] }` by classifying each frame on a prefix match against `Ruact.gem_path` (a memoised gem-root accessor added to `lib/ruact.rb`). The implementation is ~10 LoC with zero ActiveSupport dependency — `ActiveSupport::BacktraceCleaner`'s silencer/filter API was deemed heavyweight for the single-purpose need; the lean implementation also loads cleanly in AR-less specs. The app/gem split is what powers the overlay's "App backtrace shown by default, gem frames behind a toggle" UX (NFR30). Frame caps (`MAX_FRAMES_PER_BUCKET = 25`) keep the wire payload bounded; the full backtrace is in `Rails.logger.error` regardless.
811
+
812
+ **(f) Pure-function ErrorPayload module.**
813
+
814
+ `Ruact::ServerFunctions::ErrorPayload.build(action_name:, error:, mode:)` has zero I/O — no `Rails.env` read, no `Ruact.config` read. The caller (`EndpointController#__ruact_render_action_error`) resolves `mode` and passes it in. That keeps the module trivially unit-testable without stubbing Rails env, and isolates "what the wire shape is" from "what env are we in". Same design choice as the standalone dispatcher's `Result` value object (Story 8.3).
815
+
816
+ **Append-only invariant preserved.** The Story 8.0 accessor lock is untouched. The `RuactActionError` constructor signature on the runtime side is untouched (still `{ name, status, body, response }`). The new wire surface is entirely a refinement of what `body` can contain, gated by a discriminator that pre-existing callers do not read.
817
+
818
+ ### 2026-05-23 — Story 8.5 — file uploads + `max_upload_bytes` guard
819
+
820
+ **Context.** Story 8.1 (controller-hosted dispatch) and Story 8.3 (standalone dispatcher) both already routed `multipart/form-data` bodies through `request.request_parameters`; React 19's `<form action={fn}>` auto-FormData (Story 8.2) already includes `<input type="file">` entries in the FormData; the runtime's `pickWirePayload` / `buildFetchInit` (Story 8.1) already POST FormData as `multipart/form-data` with the browser-set boundary. So the "wire path for file uploads" was, on paper, already in place. Story 8.5 closes FR84 by (a) pinning the round-trip behavior with explicit gem-side specs so a future refactor can't silently regress UploadedFile delivery, and (b) introducing a controller-boundary size guard so an oversized request gets the Story 8.4 structured 413 instead of timing out at the multipart parser.
821
+
822
+ **Decision.**
823
+
824
+ **(a) No accessor surface change.** The Story 8.0 ADR lock is intact. The React side continues to import `{ createPost } from "@/.ruact/server-functions"` and pass FormData; nothing new is exported. The runtime is unmodified — `pickWirePayload` already routes FormData to the multipart branch, `buildFetchInit` already lets the browser set the boundary header. This story's delta lives ENTIRELY on the Ruby side.
825
+
826
+ **(b) New `Ruact.config.max_upload_bytes` attribute.** Integer (bytes). Default `10 * 1024 * 1024` (10 MB). Set to `nil` to disable the gem-side guard (the host's reverse proxy / middleware then owns the operational cap). Carries the standard `Ruact::Configuration` seal contract (Story 7.3) — direct post-boot mutation raises `Ruact::ConfigurationError`.
827
+
828
+ **(c) New `Ruact::UploadTooLargeError` exception class.** Inherits from `Ruact::Error` so the Story 8.4 `rescue_from StandardError` chain on `EndpointController` catches it cleanly. Carries `received_bytes` and `limit_bytes` attr_readers so the structured payload can surface both numbers without re-parsing the message string. The class lives in the gem's public namespace (`lib/ruact/errors.rb` next to `ActionError`/`CurrentUserNotConfiguredError`) because docs reference it by name and the `ErrorSuggestion::SUGGESTIONS` table gets a new keyed entry.
829
+
830
+ **(d) Pre-parse `Content-Length` guard.** `EndpointController` gains `prepend_before_action :__ruact_enforce_upload_limit!` ABOVE the existing `:resolve_ruact_entry!` callback (so the guard fires FIRST in the chain). The check uses `request.content_length` — NOT body inspection — so it fires BEFORE Rack's multipart parser runs. That's the cheapest possible reject: a 100 MB upload's headers are parsed and we 413 it without touching the body. Short-circuits: `max_upload_bytes = nil`; content type not `multipart/form-data` / `application/x-www-form-urlencoded`; `Content-Length` absent (chunked transfer). The dispatch-independence of the guard is why it lives as a callback, not inside `dispatch_action`.
831
+
832
+ **(e) Why `Content-Length`, not body inspection?** Body inspection (e.g., `Rack::Request#tempfile_for_each_part`) would defeat the purpose of "reject before parsing" — the parser would already be buffering bytes to disk. `Content-Length` is the cheapest pre-parse reject; the cost is two carve-outs: (1) chunked-transfer clients bypass the guard because `Content-Length` is absent (rare for browsers, common for some HTTP libraries), and (2) the reported `received_bytes` is the wire Content-Length including multipart boundary overhead, NOT the parsed file size. A 9.5 MB file uploaded via multipart reports `received_bytes ≈ 9.7 MB`. The 10 MB default has headroom for the boundary overhead in the common case; docs call out the edge.
833
+
834
+ **(f) Operational cap belongs to the reverse proxy.** `max_upload_bytes` is a controller-level "fail fast at the boundary" knob, NOT a memory-safety guarantee. Rack's multipart parser will still buffer bodies up to its own limits before the controller callback could possibly run on a request without a `Content-Length` header. The docs page (`website/docs/api/server-actions.md` "File uploads" section) explicitly recommends `client_max_body_size` in nginx / `LimitRequestBody` in Apache for the operational cap, plus Active Storage Direct Upload / presigned S3 URLs for large files. The `max_upload_bytes` knob is the "polite reject for the common case", not the load-balancer.
835
+
836
+ **(g) Status code mapping extends Story 8.4's table.**
837
+
838
+ | Exception | HTTP status |
839
+ | --- | --- |
840
+ | `ActiveRecord::RecordInvalid` | `422` |
841
+ | `ActionController::InvalidAuthenticityToken` | `403` |
842
+ | `Ruact::UploadTooLargeError` | `413` (new in 8.5) |
843
+ | any other `StandardError` | `500` |
844
+
845
+ **(h) `ErrorPayload.build` gains a dev-only `upload_limit` block.** When `error.class.name == "Ruact::UploadTooLargeError"` AND `mode == :development`, the payload includes `upload_limit: { received_bytes: <int>, limit_bytes: <int> }` alongside the four baseline keys and the existing dev-mode fields (`app_frames`, `gem_frames`, `suggestion`). The production-mode payload is unchanged (still the four baseline keys only) — `upload_limit` is gated the same way as `app_frames` / `suggestion`. The `_ruact_server_action_error: true` discriminator is preserved.
846
+
847
+ **(i) `ErrorSuggestion::SUGGESTIONS` gains one entry.** `"Ruact::UploadTooLargeError" => "Upload exceeded the configured size limit. Increase Ruact.config.max_upload_bytes or use Active Storage Direct Upload / a presigned S3 URL for large files."` — the table is frozen at class-load time; runtime mutation continues to be unsupported per the Story 8.4 surface decision.
848
+
849
+ **(j) Guard fires BEFORE CSRF (standalone branch).** Because `prepend_before_action :__ruact_enforce_upload_limit!` is added LAST among the prepend_before_action calls in the source, it lands at the FRONT of the chain — running before `:resolve_ruact_entry!` AND before the conditional `verify_authenticity_token` callback. An oversized standalone request without a CSRF token returns 413, not 403. This is correct (cheaper reject; the attacker learns nothing about CSRF state from a 413) and pinned by `endpoint_controller_upload_spec.rb`'s Pitfall #4 example.
850
+
851
+ **(k) `__ruact_render_action_error` action_name fallback.** Because the upload guard runs BEFORE `:resolve_ruact_entry!`, `@__ruact_name_sym` is nil when a 413 fires. The renderer falls back to `request.path_parameters[:name]` so the structured payload's `action_name` still carries the URL-routed name (`"upload_post"`) instead of `"(unknown)"`.
852
+
853
+ **Append-only invariant preserved.** The Story 8.0 accessor lock is untouched. The runtime is unchanged (no new exports, no signature changes to `RuactActionError`, no new helper on `pickWirePayload` / `buildFetchInit`). The new wire surface is a refinement of what the existing `body` field can carry (one new optional dev-only key, `upload_limit`), gated by a discriminator pre-existing callers do not read.
854
+
855
+ **Playground demo carve-out.** The playground demo + Active Storage end-to-end exercise (epic AC2/AC6) are deferred to Story 8.5a — a dedicated `rails new`-generated playground at `playgrounds/epic-8-server-actions/`. The existing `playgrounds/demo/` has no ActiveRecord / SQLite / `config/database.yml`, and retrofitting the DB layer would obscure this story's actual delta. The gem-side request-cycle specs in `endpoint_controller_upload_spec.rb` (controller-hosted + standalone branches, AC1/AC3/AC4 + Pitfalls #1/#4/#12) provide the empirical proof for the gem boundary; the Active Storage attach round-trip lives in 8.5a.
856
+
857
+ ### 2026-06-02 — Story 9-0 — Server Functions API redesign (route-driven) — supersedes the authoring + dispatch layers
858
+
859
+ **Trigger.** Correct Course run, 2026-06-02 (workspace artifact:
860
+ `_bmad-output/planning-artifacts/sprint-change-proposal-2026-06-02.md`). Story 9.1's
861
+ seven re-run review rounds — nearly all spent on cross-registry collision /
862
+ rollback / atomicity machinery — exposed that the block-DSL + synthetic-endpoint
863
+ substrate fights itself, and that `POST /__ruact/fn/:name` is a parallel routing
864
+ mechanism that contradicts the project thesis ("Rails routes are the single
865
+ source of truth"). Approved scope: **Major**. Epic 9 was repurposed as "Server
866
+ Functions (route-driven redesign)" (Scheme A — Epic 8 stays `done` as the
867
+ historical record of the v1 design; no downstream epic renumber). This addendum
868
+ is the Story 9-0 re-spike deliverable: it locks the v2 contract the redesigned
869
+ Epic 9 stories implement. Per the append-only rule, the superseded sections
870
+ below remain in the body for history.
871
+
872
+ **What survives (reaffirmed).**
873
+
874
+ - **Option C accessor — UNCHANGED.** Named imports from the generated module:
875
+ `import { createPost, categories } from "@/.ruact/server-functions"`. The
876
+ entire React-side import surface is untouched; everything in this addendum is
877
+ about the Ruby authoring layer and the wire dispatch layer.
878
+ - **NameBridge** snake_case → camelCase rule + reserved-word guards — now applied
879
+ to route-derived action names and query-method names instead of DSL symbols.
880
+ - **`useQuery(reference, params?) → { data, loading, error }`** (the Story 9.2
881
+ shape from clarification #4) — consumes query-class references unchanged.
882
+ - **Salvaged subsystems** (migrate, do not rewrite): structured error payload +
883
+ `ErrorSuggestion` table + `BacktraceCleaner` (Story 8.4); `max_upload_bytes`
884
+ guard + `UploadTooLargeError` + 413 mapping (Story 8.5); the runtime fetch
885
+ core (`_makeRef`'s FormData branching, CSRF meta-tag injection, text-first
886
+ parsing, `RuactActionError`, `redirect: "error"` handling); `revalidate()`;
887
+ serialization via `ruact_props` / `Ruact::Serializable`.
888
+
889
+ **What is superseded.**
890
+
891
+ - The `ruact_action` / `ruact_query` controller block-DSL macros (Stories 8.1 /
892
+ 9.1). Implementation Surface row #2 is void.
893
+ - The gem-mounted synthetic endpoint `POST /__ruact/fn/:name` (Implementation
894
+ Surface row #3) AND the 2026-05-13 clarification #3 ("POST for everything").
895
+ Mutations ride real REST routes; queries ride explicitly mounted GET routes
896
+ (Decision 2). Queries regain HTTP GET semantics; reads no longer carry CSRF.
897
+ - The dual `Ruact.action_registry` / `Ruact.query_registry` and the
898
+ cross-registry collision detector (`Snapshot.functions_payload`'s
899
+ cross-registry branch, `__ruact_check_cross_dsl_clobber!`). Single-namespace
900
+ validation moves into codegen, over the route table + query classes.
901
+ - **Standalone actions (Story 8.3, PRD FR63) — DROPPED from the v0.1.0 MVP.**
902
+ Mutations belong to controllers; the standalone module host loses its
903
+ rationale (its `current_user_resolver` pattern is also superseded by
904
+ Decision 2's host-controller dispatch). Revisit post-signal (≥ 1 external
905
+ issue requesting it). `Ruact::ServerAction`, `StandaloneDispatcher`,
906
+ `StandaloneContext`, `current_user_resolver` are removed with it.
907
+
908
+ **Decision 1 — Mutations are normal controller actions.**
909
+
910
+ ```ruby
911
+ class PostsController < ApplicationController
912
+ include Ruact::Server # the ONLY marker — no per-action declaration
913
+
914
+ def new; end # GET → page (implicit render via default_render)
915
+ def show; @post = Post.find(params[:id]); end # GET → page
916
+
917
+ def create # non-GET routed action → callable server function
918
+ @post = Post.create!(title: params[:title])
919
+ @post.cover.attach(params[:cover]) if params[:cover].present?
920
+ redirect_to @post
921
+ end
922
+ end
923
+ ```
924
+
925
+ - **Verb rule, no per-action marker:** on a controller that includes
926
+ `Ruact::Server`, every **non-GET routed** action (POST/PATCH/PUT/DELETE,
927
+ RESTful or custom member/collection routes) is exposed as a callable server
928
+ function in codegen. GET routes are pages, reached by navigation — never
929
+ emitted as callables.
930
+ - **`routes.rb` carries nothing ruact-specific for mutations.** Standard
931
+ `resources :posts`. The Rails route table is the single source of truth.
932
+ - **Data flows via instance variables** — standard Rails. No `render json:`
933
+ required, no `respond_to`, no block params.
934
+ - **The full host controller chain runs natively** (`before_action`,
935
+ `rescue_from`, Pundit, `protect_from_forgery`) because these ARE controller
936
+ actions. The Story 8.4 structured-error rendering and the Story 8.5 upload
937
+ guard migrate from `EndpointController` into the `Ruact::Server` concern
938
+ (`rescue_from` + `prepend_before_action`), preserving the wire contract
939
+ (discriminator, status table, dev/prod payload split) byte-for-byte where
940
+ possible.
941
+
942
+ **Decision 2 — Queries are classes under `app/queries/`, mounted explicitly, dispatched through a host controller.**
943
+
944
+ ```ruby
945
+ # app/queries/catalog_query.rb
946
+ class CatalogQuery < ApplicationQuery # ApplicationQuery < Ruact::Query
947
+ def categories
948
+ Category.active.pluck(:id, :name).map { |id, name| { value: id, label: name } }
949
+ end
950
+
951
+ def my_categories
952
+ current_user.categories.pluck(:id, :name)
953
+ end
954
+
955
+ def search_users(q:, limit: 10)
956
+ User.search(q).limit(limit)
957
+ end
958
+ end
959
+ ```
960
+
961
+ ```ruby
962
+ # config/routes.rb — explicit mount (decision A = option b)
963
+ Rails.application.routes.draw do
964
+ ruact_queries CatalogQuery # draws named GET routes, visible in `rails routes`
965
+ resources :posts
966
+ end
967
+ ```
968
+
969
+ - **Authoring:** plain public `def` methods on a `Ruact::Query` subclass — each
970
+ method is one query. No block DSL, no mandatory unused params. Method keyword
971
+ args are the query's parameters (consumed by the Story 9.3 sanitization
972
+ contract).
973
+ - **Transport (A = b):** the `ruact_queries` router macro draws one **named GET
974
+ route per public method** (default path scheme `GET /q/<jsIdentifier>`,
975
+ prefix configurable) pointing at the gem's internal dispatch controller. The
976
+ route table knows every query — thesis-aligned, no hidden endpoint.
977
+ - **Security context (B = ii):** the internal dispatch controller **inherits
978
+ from the host's parent controller** (`Ruact.config.query_parent_controller`,
979
+ default `"ApplicationController"` — the Devise `parent_controller` pattern).
980
+ The request therefore runs the host's REAL callback chain
981
+ (`authenticate_user!`, tenant scoping, Pundit) **before** the gem
982
+ instantiates the query class and injects the context. The dev never sees this
983
+ controller. This replaces old Story 9.4's controller-hosted-block contract
984
+ AND Story 8.3's resolver-lambda pattern.
985
+ - **Context injection:** `Ruact::Query#initialize(context)` receives a context
986
+ object exposing `current_user`, `params`, `request`, `session` — delegating
987
+ to the dispatching controller instance. Queries are unit-testable as
988
+ `CatalogQuery.new(fake_context).categories` with no Rails boot.
989
+ - **Per-request instance** (like controllers) → thread-safe (NFR8).
990
+
991
+ **Decision 3 — Dual-bucket response negotiation on the same action.**
992
+
993
+ One mutation action serves both interaction models, discriminated by how it was
994
+ called, reading the same instance variables:
995
+
996
+ | Bucket | Caller | `Accept` | Response |
997
+ | --- | --- | --- | --- |
998
+ | 1 — form / navigation | `<form>` submit, link navigation | `text/x-component` | Flight re-render or Flight redirect — **the existing Story 3.3 / 3.4 mechanism, unchanged** |
999
+ | 2 — imperative | `await createPost(formData)` via the generated ref | `application/json` | Exposed ivars serialized (Decision 5); `redirect_to` surfaces as `{ "$redirect": "<path>" }` for the caller to follow (precedent: Story 8.1's `redirect: "error"` runtime handling) |
1000
+
1001
+ Bucket 1 requires no new code — it is Phase 1 behavior. Bucket 2 is the genuine
1002
+ Epic-8 delta and is what the generated refs target (real route + verb instead of
1003
+ `/__ruact/fn/:name`).
1004
+
1005
+ **Decision 4 — Codegen reads the route table + query classes.**
1006
+
1007
+ - **Sources:** (a) `Rails.application.routes` filtered to non-GET routes whose
1008
+ controller includes `Ruact::Server`; (b) `Ruact::Query` subclasses under
1009
+ `app/queries/` (their public instance methods). The JSON bridge →
1010
+ `server-functions.ts` pipeline (Story 8.0a) survives with its sources swapped;
1011
+ write-if-changed and `config.to_prepare` regeneration are retained.
1012
+ - **Naming (decision D):** action names derive from the route —
1013
+ `posts#create` → `createPost` (action verb + singularized resource;
1014
+ collection routes keep the resource plural where natural, e.g.
1015
+ `posts#publish_all` → `publishAllPosts`). Query names derive from the method
1016
+ name via NameBridge (`search_users` → `searchUsers`). Any collision in the
1017
+ merged JS namespace fails codegen loudly at boot (same failure mode as
1018
+ today's collision detector) and requires an explicit per-action/per-method
1019
+ rename via an override macro (exact macro name owned by the implementing
1020
+ story; the override is the escape hatch, not the default).
1021
+ - **Signatures:** actions keep the Story 8.2 intersection type (FormData +
1022
+ args-object callable); queries keep `() => Promise<unknown>` /
1023
+ `(params) => Promise<unknown>`. Per-function return-type precision remains a
1024
+ Phase 3 candidate (unchanged from clarification #1).
1025
+
1026
+ **Decision 5 — Bucket-2 return payload (decision C).**
1027
+
1028
+ The imperative response body serializes **all exposed instance variables**
1029
+ (everything not `@_`-prefixed, the same filter Rails uses for view assigns) as
1030
+ a JSON object keyed by ivar name without the `@` (`{ "post": {...} }`),
1031
+ each value serialized through the existing `ruact_props` /
1032
+ `Ruact::Serializable` / `strict_serialization` rules. No single-ivar magic
1033
+ unwrap — predictable over clever. An action that sets no ivars and does not
1034
+ redirect returns `204 No Content` → the ref resolves `null` (matching the
1035
+ runtime's existing empty-body contract from Story 8.1).
1036
+
1037
+ **Open items the implementing stories must resolve (not contract-level).**
1038
+
1039
+ 1. Per-query callback opt-out (e.g. a public query on an app whose
1040
+ `ApplicationController` forces `authenticate_user!`) — sketch: class-level
1041
+ macro on `Ruact::Query` forwarded to the internal controller's
1042
+ `skip_before_action`. Owned by the query-dispatch story.
1043
+ 2. Query route path scheme + prefix configuration default (`/q/...` proposed).
1044
+ 3. The exact rename-override macro name for JS-identifier collisions
1045
+ (Decision 4).
1046
+ 4. Whether `include Ruact::Server` is implied by `ruact_render` usage or always
1047
+ explicit (explicit proposed — one line, no magic).
1048
+
1049
+ **Append-only invariant preserved.** The Option C accessor lock (named imports
1050
+ from `app/javascript/.ruact/server-functions.ts`) is untouched — React code
1051
+ written against Epics 8/9 imports does not change its import statements. The
1052
+ superseded body sections and prior decision-log entries remain verbatim above;
1053
+ where they conflict with this addendum, this addendum governs. Deviations
1054
+ during the redesigned Epic 9 implementation require a further dated addendum
1055
+ here before merge.
1056
+
1057
+
1058
+ ### 2026-06-05 — Story 9.1 — `Ruact::Server` concern lands (Phase A salvage transplant) — in-story decisions
1059
+
1060
+ **Scope.** Implements the Phase A salvage transplant from the 2026-06-02
1061
+ addendum: the `Ruact::Server` concern (`gem/lib/ruact/server.rb`, loaded by
1062
+ the Railtie's `ruact.load_controller` initializer alongside
1063
+ `Ruact::Controller`) becomes the v2 home of the Story 8.4 structured-error
1064
+ chain and the Story 8.5 upload guard, installed on the host controller's own
1065
+ callback chain. The v1 `EndpointController` stays alive untouched as the
1066
+ strangler-fig safety net (removed in Story 9.9). At this story the concern is
1067
+ a pure marker + salvage host: it registers nothing, emits nothing to codegen
1068
+ (Story 9.3), and performs no dual-bucket response negotiation (Story 9.2).
1069
+
1070
+ **Shared core.** Both homes include
1071
+ `Ruact::ServerFunctions::ErrorRendering` — the extracted 8.4/8.5 bodies
1072
+ (structured renderer, status mapping, payload-mode resolution, logging,
1073
+ upload guard). Behavioral differences are expressed ONLY through three
1074
+ private hooks (`__ruact_error_action_name`,
1075
+ `__ruact_render_structured_error?`, `__ruact_upload_guard_applicable?`),
1076
+ whose defaults preserve v1 endpoint semantics. The wire contract is therefore
1077
+ byte-for-byte identical across both homes by construction. Story 9.9 deletes
1078
+ the endpoint home; the module survives as the concern's implementation.
1079
+
1080
+ **Decisions taken in this story (delegated by the epic / 2026-06-02 addendum):**
1081
+
1082
+ 1. **Predicate name + semantics (AC2).** The function-call discrimination
1083
+ point is the private helper **`Ruact::Server#__ruact_function_call?`**:
1084
+ true iff the raw `Accept` request header contains `application/json`
1085
+ (the exact shape the 8.1 runtime sends on every `_makeRef` fetch).
1086
+ Deliberately NOT `request.format` — Rails' format negotiation is
1087
+ influenced by path extensions (`/posts.json`) and `params[:format]`,
1088
+ neither of which may flip the bucket. Story 9.2 MUST reuse this helper
1089
+ verbatim as the dual-bucket discriminator; it lives in one place only.
1090
+ 2. **D1 — structured-render gating.** The concern's rescue handler renders
1091
+ the structured payload only when `__ruact_function_call?` is true;
1092
+ otherwise it re-raises, so non-function-call requests keep stock Rails
1093
+ error behavior (AC1 byte-for-byte). ONE documented exception:
1094
+ `Ruact::UploadTooLargeError` renders the structured 413 for EVERY request
1095
+ shape — the guard only exists on requests that opted into the concern,
1096
+ and a meaningful 413 beats a re-raised 500 for native multipart form
1097
+ submits. Pinned by `server_upload_request_spec.rb` ("D1 — a native form
1098
+ submit…").
1099
+ 3. **D2 — upload-guard verb gate.** On host controllers the guard skips
1100
+ GET/HEAD requests (`__ruact_upload_guard_applicable?`); the v1 endpoint
1101
+ was POST-only so this carve-out is new surface, required by the AC1
1102
+ byte-for-byte rule for page actions. All non-GET actions are guarded
1103
+ regardless of bucket (AC4 says "non-GET action", not "function call").
1104
+ 4. **Open item 4 (2026-06-02 addendum) — RESOLVED: explicit include.**
1105
+ `include Ruact::Server` is always explicit — one line, no magic, never
1106
+ implied by `ruact_render` usage. The concern is also independent of
1107
+ `Ruact::Controller` (neither includes the other); hosts include both.
1108
+
1109
+ **Spec re-anchoring.** The 8.4/8.5 behavior matrix moved to
1110
+ `spec/ruact/server_spec.rb` + `server_rescue_request_spec.rb` +
1111
+ `server_upload_request_spec.rb` (tagged `:story_9_1`), mounted on REAL host
1112
+ routes on the shared Story-7.9 Rails app — no `/__ruact/fn/` anywhere, no
1113
+ dependency on the v1 spec files that Story 9.9 demolishes. The superseded
1114
+ `endpoint_controller_rescue_spec.rb` / `endpoint_controller_upload_spec.rb`
1115
+ were removed in the same commit; the v1 endpoint's observable contract
1116
+ remains covered by `dispatch_request_spec.rb` / `csrf_request_spec.rb` until
1117
+ 9.9.
1118
+
1119
+ ### 2026-06-07 — Story 9.1 — code-review patches (D1 verb gate, inherited precedence, standalone load path)
1120
+
1121
+ Three patches from the Story 9.1 code review (gem PR #2), amending the
1122
+ 2026-06-05 in-story decisions. All three were resolved with Luiz on
1123
+ 2026-06-06; spec-pinned and landed on the same PR as follow-up commits
1124
+ (GitHub Flow — no amend/force-push).
1125
+
1126
+ 1. **D1 amended — GET/HEAD excluded from structured error rendering.**
1127
+ `__ruact_render_structured_error?` now requires a non-GET/HEAD verb in
1128
+ addition to the function-call predicate. Function calls are non-GET by
1129
+ the verb rule (epic contract decision #1), so a GET/HEAD carrying
1130
+ `Accept: application/json` (a `fetch()` against a page action, an API
1131
+ probe) is NOT a function call — an error there keeps stock Rails
1132
+ behavior instead of being swallowed into the structured payload.
1133
+ **`__ruact_function_call?` itself is UNCHANGED** (raw `Accept` header,
1134
+ verb-agnostic) — Story 9.2 still reuses it verbatim as the dual-bucket
1135
+ discriminator; the verb gate lives only in the error-rendering scope.
1136
+ The `UploadTooLargeError` exception is unaffected (the guard already
1137
+ skips GET/HEAD per D2, so the combination is unreachable).
1138
+ 2. **Inherited host `rescue_from` precedence.** The 2026-06-05 "host wins"
1139
+ claim only held for handlers declared in the host's own class body AFTER
1140
+ the include — handlers inherited from a parent class sat EARLIER in
1141
+ `rescue_handlers` and lost Rails' most-recently-registered walk to the
1142
+ concern's `StandardError` entry. The concern now moves its two entries to
1143
+ the FRONT of the array at include time
1144
+ (`self.rescue_handlers = (rescue_handlers - inherited) + inherited`), so
1145
+ every host handler — inherited or declared after the include — stays more
1146
+ recent and keeps precedence. The concern-internal Pitfall #1 order
1147
+ (StandardError before InvalidAuthenticityToken) is preserved; the parent
1148
+ class's own registry is untouched (class_attribute write lands on the
1149
+ child). Idempotent under repeated include along an inheritance chain.
1150
+ 3. **Standalone load path.** `lib/ruact/server.rb` now does
1151
+ `require_relative "../ruact"` so a direct `require "ruact/server"`
1152
+ resolves everything the salvaged chains touch at request time
1153
+ (`Ruact.config` is defined in `ruact.rb`, not `configuration.rb`;
1154
+ `Ruact::UploadTooLargeError`; the ErrorPayload pipeline). Acyclic by
1155
+ construction: the gem root never requires `server.rb` back (the bare
1156
+ `require "ruact"` path stays ActionController-free; the Railtie loads the
1157
+ concern). Pinned by a subprocess spec (`ruby -I lib -e 'require
1158
+ "ruact/server"'`).
1159
+
1160
+ ### 2026-06-08 — Story 9.1 — code-review patches, round 2 (predicate split, GET/HEAD upload-error gate, CSRF-ordering invariant)
1161
+
1162
+ Three further patches from the Story 9.1 code review (gem PR #2), refining the
1163
+ 2026-06-07 round. Spec-pinned (red→green), landed as a follow-up commit on the
1164
+ same PR (GitHub Flow — no amend/force-push).
1165
+
1166
+ 1. **Predicate split — raw header vs. semantic function-call.** The 2026-06-07
1167
+ round left `__ruact_function_call?` keyed purely on the raw `Accept` header
1168
+ (verb-agnostic) while the verb gate lived only inside
1169
+ `__ruact_render_structured_error?`. But the addendum also designated
1170
+ `__ruact_function_call?` as THE helper Story 9.2 reuses — so the helper
1171
+ encoded the wrong contract (a GET carrying `Accept: application/json` would
1172
+ read as a function call). Now split in two:
1173
+ - `__ruact_json_accept?` — the raw, verb-agnostic header check.
1174
+ - `__ruact_function_call?` — the SEMANTIC predicate:
1175
+ `__ruact_json_accept? && !(request.get? || request.head?)`.
1176
+ The verb rule (function calls are non-GET, epic contract decision #1) now
1177
+ lives in ONE place — the predicate itself — so Story 9.2 inherits the
1178
+ correct contract verbatim. This SUPERSEDES the 2026-06-07 note that
1179
+ "`__ruact_function_call?` itself is UNCHANGED"; the raw check survives under
1180
+ its new name.
1181
+ 2. **GET/HEAD upload-error gate.** `__ruact_render_structured_error?` now checks
1182
+ the verb FIRST (`return false if request.get? || request.head?`), so the
1183
+ `UploadTooLargeError` exception to the re-raise rule is also verb-gated. The
1184
+ guard never produces that error on a GET/HEAD (it skips them — D2), so the
1185
+ only way one reaches the handler on a GET is a manual `raise` inside a page
1186
+ action — which must keep stock Rails behavior (AC1 byte-for-byte), not be
1187
+ swallowed into a structured 413. The non-GET case is unchanged: a
1188
+ `UploadTooLargeError` from a native multipart form submit (Bucket 1, no JSON
1189
+ Accept) still renders a meaningful 413.
1190
+ 3. **CSRF-ordering invariant made executable (AC4 / Pitfall #4).** The upload
1191
+ guard is `prepend_before_action`, so it normally beats
1192
+ `verify_authenticity_token`. But a host can re-order CSRF ahead of it with
1193
+ `protect_from_forgery prepend: true`, after which an oversized tokenless
1194
+ multipart request is 403'd before the intended 413 — silently breaking AC4.
1195
+ Rather than document this as the host's responsibility, the concern now
1196
+ detects the inversion in the compiled callback chain
1197
+ (`__ruact_csrf_precedes_upload_guard?` — pure, unit-testable inspection) and
1198
+ fails loudly with a `Ruact::ConfigurationError` from
1199
+ `__ruact_verify_upload_guard_precedence!` (a new no-op hook on
1200
+ `ErrorRendering`, overridden by the concern, invoked before the guard's
1201
+ verb short-circuit). Because CSRF verification is a no-op for GET/HEAD, the
1202
+ guard still runs on page loads, so a misordered controller fails on its
1203
+ first request in development. Note the limit: an oversized tokenless POST to
1204
+ an already-misordered controller is still 403'd at request time (CSRF runs
1205
+ first; the guard cannot run) — the detection makes the misconfiguration
1206
+ impossible to ship unnoticed, it does not silently repair the ordering.
1207
+
1208
+ ### 2026-06-08 — Story 9.1 — code-review patches, round 3 (ordering invariant on the POST path, ConfigurationError stays loud, conditional-CSRF detector, Accept parsing)
1209
+
1210
+ Five further patches from the Story 9.1 code review (gem PR #2), refining the
1211
+ round-2 work. Spec-pinned (red→green), landed as a follow-up commit on the same
1212
+ PR (GitHub Flow — no amend/force-push).
1213
+
1214
+ 1. **Ordering invariant now covers the oversized tokenless POST.** Round 2
1215
+ detected the `protect_from_forgery prepend: true` inversion from inside the
1216
+ upload guard, which only runs when reachable — so the exact broken shape
1217
+ (an oversized multipart POST without a CSRF token) still 403'd before the
1218
+ 413, because CSRF runs first and aborts the chain before the guard.
1219
+ `__ruact_verify_upload_guard_precedence!` is now ALSO invoked at the top of
1220
+ `ErrorRendering#__ruact_render_action_error`, so the rescue chain
1221
+ re-asserts the invariant on the request shape the guard never sees. Net
1222
+ effect: an inverted controller fails loudly (`Ruact::ConfigurationError`)
1223
+ on EVERY request — GET page loads (guard runs, CSRF no-ops), tokenless
1224
+ POSTs (CSRF raises → rescue re-checks), and valid-token POSTs (guard runs)
1225
+ alike. The misconfiguration can serve no request, which supersedes the
1226
+ round-2 note that "an oversized tokenless POST … is still 403'd at request
1227
+ time". Pinned by a request spec for an oversized tokenless POST on the
1228
+ inverted host.
1229
+ 2. **`Ruact::ConfigurationError` is never rendered as a structured error.**
1230
+ With the ordering verifier raising `Ruact::ConfigurationError` and the
1231
+ concern's `rescue_from StandardError` chain rendering structured JSON for
1232
+ JSON function calls, a valid-token `Accept: application/json` POST on an
1233
+ inverted host could fold the configuration failure into an ordinary
1234
+ `_ruact_server_action_error` 500. `__ruact_render_structured_error?` now
1235
+ returns false for `Ruact::ConfigurationError` (any source), so configuration
1236
+ invariants stay loud setup failures. Pinned by a non-GET JSON request spec.
1237
+ 3. **Detector narrowed to UNCONDITIONAL CSRF callbacks.**
1238
+ `__ruact_csrf_precedes_upload_guard?` reduced callbacks to raw filter names
1239
+ and ignored `if`/`unless`/`only`/`except`, so
1240
+ `protect_from_forgery prepend: true, if: -> { false }` (CSRF can never run)
1241
+ still raised. The detector now inspects the compiled before-callback's
1242
+ `@if`/`@unless` arrays (Rails compiles `only:`/`except:` into these) and
1243
+ only flags an unconditional CSRF callback. The genuine misconfig is
1244
+ unconditional, so it is still caught; false positives on conditional CSRF
1245
+ are eliminated. Pinned by `if: -> { false }` and `only:` specs.
1246
+ 4. **`Accept` header parsed into media ranges.** `__ruact_json_accept?` used
1247
+ `include?("application/json")`, which matched `application/jsonp` and
1248
+ `application/json;q=0` and was case-sensitive. It now parses comma-separated
1249
+ media ranges, matches `application/json` case-insensitively on token
1250
+ boundaries, and requires a positive q-value. This matters because
1251
+ `__ruact_function_call?` feeds Story 9.2's discriminator — a loose match
1252
+ would route ordinary requests into Ruact's structured payload. Pinned by
1253
+ `application/jsonp`, `application/json;q=0`, `Application/JSON`, and
1254
+ `application/json;q=0.5` specs.
1255
+ 5. **CHANGELOG corrected.** The `[Unreleased]` `Ruact::Server` note now states
1256
+ that function calls are non-GET/HEAD JSON-Accept requests and that the
1257
+ structured 413 applies to non-GET guarded requests (not "every caller
1258
+ shape"), matching the verb-gated round-2/round-3 behavior.
1259
+
1260
+ ### 2026-06-08 — Story 9.1 — code-review patches, round 4 (applicability-aware CSRF detector, nil-limit no-op, stricter Accept parser, v1 smoke spec)
1261
+
1262
+ Four further patches from the Story 9.1 code review (gem PR #2), refining the
1263
+ round-3 work. Spec-pinned (red→green), landed as a follow-up commit on the same
1264
+ PR (GitHub Flow — no amend/force-push).
1265
+
1266
+ 1. **CSRF-order detector evaluates applicability (supersedes round 3's
1267
+ "unconditional only").** Round 3 narrowed `__ruact_csrf_precedes_upload_guard?`
1268
+ to UNCONDITIONAL CSRF callbacks to kill false positives, but that created a
1269
+ false NEGATIVE: `protect_from_forgery with: :exception, prepend: true,
1270
+ only: [:create]` (on `create`) or `if: -> { true }` still runs CSRF ahead of
1271
+ the guard yet went unflagged. The detector now evaluates the CSRF callback's
1272
+ compiled `@if`/`@unless` conditions against the controller for the current
1273
+ request/action (`__ruact_callback_applies?` / `__ruact_condition_met?` —
1274
+ Symbol → `send`, Proc → arity-aware `instance_exec`, `ActionFilter` →
1275
+ `match?(self)`). An ACTIVE condition is caught; an inactive one
1276
+ (`only: [:other]`, `if: -> { false }`) is not. The detector is no longer
1277
+ purely static (it reads `action_name` / request-derived state), but both
1278
+ call sites — the guard and the rescue path — run inside a live request.
1279
+ Pinned: `only: [:create]` on `create` (flagged) vs on `index` (not flagged),
1280
+ `if: -> { true }` (flagged), plus the round-3 inactive cases.
1281
+ 2. **Ordering verifier is a no-op when `max_upload_bytes` is `nil`.** A nil cap
1282
+ is the documented carve-out that disables the gem-side guard, so there is no
1283
+ 413-before-CSRF invariant to enforce — failing an inverted host that has
1284
+ intentionally opted out is wrong. `__ruact_verify_upload_guard_precedence!`
1285
+ now returns early when `Ruact.config.max_upload_bytes.nil?`. Pinned: a GET
1286
+ page load on an inverted host with `max_upload_bytes = nil` renders normally
1287
+ instead of raising `Ruact::ConfigurationError`.
1288
+ 3. **Stricter, quote-aware Accept parser.** The round-3 parser still accepted
1289
+ out-of-range q-values (`q=2`, `q=1.5`) and split on commas without respecting
1290
+ quoted-strings (`application/json;note="a,b";q=0` split before the q-value
1291
+ and defaulted to 1.0). The q-value must now lie within `0.0 < q <= 1.0`, and
1292
+ media ranges / parameters are split with a quote-aware tokenizer
1293
+ (`__ruact_split_unquoted`). This matters because `__ruact_function_call?`
1294
+ feeds Story 9.2's discriminator — a malformed or explicitly-refused JSON
1295
+ Accept must not misclassify a request. Pinned: `q=2`, `q=1.5`, and a quoted
1296
+ comma before `q=0` (all rejected); quoted comma with `q=0.9` (accepted).
1297
+ 4. **v1 endpoint upload-limit smoke spec.** The v1 endpoint stays alive as the
1298
+ strangler-fig safety net until Story 9.9 and still shares the salvaged upload
1299
+ guard, but the deep upload matrix was re-anchored on the v2 concern (and
1300
+ `endpoint_controller_upload_spec.rb` removed). A minimal observable-contract
1301
+ smoke spec now pins the v1 413 path (oversized multipart
1302
+ `POST /__ruact/fn/:name` → 413 + `_ruact_server_action_error` +
1303
+ `upload_limit`) in `dispatch_request_spec.rb`, so the safety net cannot
1304
+ regress before demolition. Not the old implementation-coupled matrix — just
1305
+ the wire-visible contract.
1306
+
1307
+ ### 2026-06-08 — Story 9.1 — code-review patches, round 5 (escape-aware Accept tokenizer, strict qvalue grammar, GET/HEAD verifier no-op)
1308
+
1309
+ Three further patches from the Story 9.1 code review (gem PR #2), refining the
1310
+ round-4 work. Spec-pinned (red→green), landed as a follow-up commit on the same
1311
+ PR (GitHub Flow — no amend/force-push).
1312
+
1313
+ 1. **Escape-aware Accept tokenizer + unterminated-range rejection.** Round 4's
1314
+ quote-aware split toggled quote state on every `"` and ignored HTTP
1315
+ quoted-pair (`\"`) escaping and unterminated quoted strings:
1316
+ `application/json;note="a\",b";q=0` read as JSON-acceptable (the escaped
1317
+ quote ended the quoted-string early, the comma split the range, and the
1318
+ `q=0` was lost → default 1.0). `__ruact_split_unquoted` now honors backslash
1319
+ escapes inside quoted spans, and `__ruact_json_media_range?` rejects a range
1320
+ whose quotes are unbalanced (`__ruact_balanced_quotes?`) rather than parsing
1321
+ it as default-quality JSON. Pinned: escaped quote before `q=0` (rejected),
1322
+ escaped quote with positive q (accepted), unterminated quote hiding `q=0`
1323
+ (rejected).
1324
+ 2. **Strict RFC 7231 qvalue grammar.** Round 4's `Float`-range check accepted
1325
+ malformed q-values (`q=.5`, `q=01`, `q=1e-1`, `q=0.1234`). The value is now
1326
+ validated against the qvalue grammar (`QVALUE_FORMAT` — `0` / `0.`+≤3 digits
1327
+ / `1` / `1.`+≤3 zeros) before conversion; anything else is a rejecting 0.0.
1328
+ Pinned: leading-dot, leading-zero, exponent, and over-precision values (all
1329
+ rejected).
1330
+ 3. **Ordering verifier is a no-op on GET/HEAD (supersedes round-2 GET
1331
+ surfacing).** D2 says the upload guard never fires on GET/HEAD, and AC1 says
1332
+ GET page behavior stays byte-for-byte — but the round-2/3 verifier still ran
1333
+ on those verbs, so `protect_from_forgery prepend: true, only: [:index]` on a
1334
+ GET `index` raised `Ruact::ConfigurationError` even though no upload guard
1335
+ could fire. `__ruact_verify_upload_guard_precedence!` now returns early when
1336
+ `__ruact_upload_guard_applicable?` is false. The loud failure is preserved
1337
+ for guarded (non-GET) requests — including the oversized tokenless POST via
1338
+ the rescue path (round 3) — so the misordering still cannot ship unnoticed;
1339
+ it simply surfaces on the first NON-GET (function-call) request instead of
1340
+ on a page load. This SUPERSEDES the round-2 note that GET page loads fail
1341
+ immediately on a misordered host. Pinned: GET on an unconditionally-inverted
1342
+ host renders (200); GET whose CSRF is scoped to the GET action (`only:`)
1343
+ renders (200); non-GET inverted specs still fail loudly; unit GET/HEAD
1344
+ no-raise + nil-limit non-GET no-raise.
1345
+
1346
+ ### 2026-06-08 — Story 9.1 — contract simplification after review round 5
1347
+
1348
+ The repeated review loop on Story 9.1 exposed two over-engineered edges:
1349
+ request-time callback-order introspection for the upload guard, and a hand-
1350
+ rolled Accept parser that kept accreting RFC 7231 edge handling. The final
1351
+ decision is to simplify both contracts rather than keep patching them.
1352
+
1353
+ 1. **Accept is now exact.** The v2 concern treats only `Accept: application/json`
1354
+ as a JSON-Accept request. Exact header match, no qvalue parsing, no media-
1355
+ range splitting, no quoted-string handling. This matches the generated
1356
+ runtime shape and removes the parser surface entirely.
1357
+ 2. **Upload-order verification is documented, not enforced at runtime.** The
1358
+ concern still installs the upload guard, but it no longer introspects the
1359
+ callback chain to detect `protect_from_forgery prepend: true`. Hosts are
1360
+ expected to include `Ruact::Server` after `protect_from_forgery`; the order
1361
+ is documented in the changelog and story file instead of being enforced via
1362
+ request-time callback inspection.
1363
+
1364
+ Pinned by the simplified round-6 follow-up specs: exact `Accept:
1365
+ application/json` is the only function-call discriminator, and the upload guard
1366
+ still works on correctly ordered hosts while the misordered-host behavior is no
1367
+ longer special-cased.
1368
+
1369
+ ### 2026-06-09 — Story 9.2 — dual-bucket response negotiation on the same controller action
1370
+
1371
+ Phase B's contract story: one non-GET `Ruact::Server` action serves both
1372
+ form/navigation submits (Bucket 1) and imperative `await fn()` calls (Bucket 2),
1373
+ discriminated by `__ruact_function_call?` (locked in 9.1 — `Accept: application/json`,
1374
+ non-GET). 9.1 built the predicate; 9.2 builds the response side.
1375
+
1376
+ **D1 — Bucket-2 success seam (`default_render`).** `Ruact::Server` overrides
1377
+ `default_render`: `return super unless __ruact_function_call?` — so Bucket 1
1378
+ falls through to the host's `Ruact::Controller#default_render` (Flight re-render)
1379
+ and Rails, byte-for-byte unchanged. On Bucket 2 it serializes the action's
1380
+ exposed ivars as a JSON object (or `head :no_content`). The two concerns are
1381
+ siblings composed on the host; the super-chain works because `Ruact::Server` is
1382
+ included on the action's controller (subclass) while `Ruact::Controller` sits on
1383
+ a parent (`ApplicationController`), so `Server#default_render` precedes
1384
+ `Controller#default_render` in the ancestry and `super` reaches it. Documented
1385
+ include-order assumption: `include Ruact::Server` AFTER (or below in the
1386
+ ancestry) `Ruact::Controller`.
1387
+
1388
+ **Exposed ivars = Rails `view_assigns`, VERBATIM (decided with Luiz 2026-06-09 —
1389
+ "closest to how Rails does it").** No custom exclusion list. An early probe
1390
+ suggested `@marked_for_same_origin_verification` leaks, but that was a probe
1391
+ error: Rails 8.1.3 sets the PROTECTED `@_marked_for_same_origin_verification`
1392
+ (`request_forgery_protection.rb:447`, in `base.rb:322` protected ivars), so a
1393
+ real request's `view_assigns` is already clean. What remains is exactly what the
1394
+ action assigned — including `@current_user` IF the action calls the memoized
1395
+ helper (dev-controlled; same mental model as "what the view sees"). Keyed by
1396
+ ivar name without `@`; a single ivar stays keyed (no magic unwrap, decision C).
1397
+
1398
+ **Bucket-2 value serialization — `Ruact::ServerFunctions::BucketTwoPayload`
1399
+ (pure).** Mirrors the policy of `Flight::Serializer#serialize_unknown`
1400
+ (`Ruact::Serializable` → `ruact_props` only; under `strict_serialization` a
1401
+ non-Serializable domain object raises `Ruact::SerializationError`; otherwise a
1402
+ vetted `as_json` fallback with self-/raise-guards), but emits PLAIN JSON-ready
1403
+ values (Hash/Array/scalar) — `render json:` does the encoding, so JSON
1404
+ primitives incl. Time/Date pass through (not Flight-encoded). Pure function: the
1405
+ caller passes the resolved `strict` flag (= `Ruact.config.strict_serialization`),
1406
+ mirroring the `ErrorPayload` caller/builder split (NFR26 / `Ruact/NoIoInFlight`
1407
+ untouched). Policy MIRRORED (not extracted from `flight/serializer.rb`) to avoid
1408
+ refactoring the Flight hot path; the Flight serializer remains the canonical
1409
+ policy reference.
1410
+
1411
+ **D2 — `redirect_to` → `$redirect`.** `Ruact::Server` overrides `redirect_to`:
1412
+ `return super unless __ruact_function_call?` (Bucket 1 → Controller's Flight
1413
+ redirect row / Rails 302, unchanged). On Bucket 2 it renders
1414
+ `json: { "$redirect" => <path> }`. Same-origin targets collapse to a path
1415
+ (mirroring `Ruact::Controller#redirect_to`); external origins keep the absolute
1416
+ URL. **Server-only: the runtime does NOT follow `$redirect` today** — emitting it
1417
+ is 9.2; the client following it (and the route/verb re-target away from
1418
+ `/__ruact/fn/:name`) is Story 9.3's runtime work.
1419
+
1420
+ **D3 — `Vary: Accept` (the ADR had NOT specified it — 9.2 owns it).** A
1421
+ `prepend_before_action :__ruact_set_vary_on_accept!` appends `Accept` to `Vary`
1422
+ for non-GET requests (idempotent, preserves host-set `Vary`). It is prepended
1423
+ BEFORE the upload guard in source so the guard still lands first (Story 8.5
1424
+ "guard wins the race" invariant preserved). Consequence: `Vary` is present on
1425
+ the 200 ivar-JSON, 204, `$redirect`, Bucket-1 Flight, structured-500, and
1426
+ 403-CSRF shapes — the cacheable dual-representations — but NOT on the 413 upload
1427
+ rejection (the guard aborts the chain before the Vary callback). Acceptable: the
1428
+ 413 is a non-cacheable error, not a dual representation.
1429
+
1430
+ **Error routing (AC5).** A `Ruact::SerializationError` raised inside
1431
+ `default_render`'s Bucket-2 serialization propagates to the 9.1
1432
+ `__ruact_render_action_error` chain → `__ruact_render_structured_error?` is true
1433
+ (non-GET function call) → 500 structured payload. No new `rescue_from`.
1434
+
1435
+ **CSRF (AC7 / NFR27).** Entirely the host's `protect_from_forgery` — valid token
1436
+ → success; missing/invalid → 403 via the 9.1 chain; API-mode (forgery off) →
1437
+ accepted. No gem-side CSRF.
1438
+
1439
+ #### 2026-06-09 — Story 9.2 review (round 3) — Vary callback limitation accepted
1440
+
1441
+ The `Vary: Accept` mechanism is callback-based (`before_action` +
1442
+ `after_action`, see D3 / review rounds 1–2). One residual gap was identified
1443
+ and ACCEPTED (Luiz) rather than patched further: a host `before_action` that
1444
+ BOTH reassigns `Vary` AND performs the response in the same callback (e.g.
1445
+ `response.headers["Vary"] = "Cookie"; redirect_to "/login"`) yields a final
1446
+ response without `Accept` (Ruact's before-action set it, the host clobbered it,
1447
+ and Rails skips after-actions on a before-halt). Rationale: the combination is
1448
+ contrived (real auth callbacks redirect without reassigning `Vary`); a callback
1449
+ cannot unconditionally guarantee the final header, and a Rack-level mechanism
1450
+ was judged not worth the complexity for this edge. This mirrors the Story 9.1
1451
+ lesson — stop patching the edges of a mechanism once the remaining cases stop
1452
+ earning their complexity.
1453
+
1454
+ #### 2026-06-09 — Story 9.3 — Route-driven codegen for mutations + runtime re-target
1455
+
1456
+ Phase B. The codegen SOURCE swaps from the v1 registries to the Rails route
1457
+ table, and the runtime SWAPS from the synthetic `POST /__ruact/fn/:name` to real
1458
+ REST routes + verbs. Resolves the ADR open items left by Story 9-0 (namespace
1459
+ scheme, rename-override macro) and the `$redirect` client-follow 9.2 deferred.
1460
+
1461
+ **Source (AC1).** `Ruact::ServerFunctions::RouteSource.collect(route_set)` reads
1462
+ `Rails.application.routes`, keeps only non-GET/HEAD verbs (POST/PUT/PATCH/DELETE)
1463
+ whose controller `include Ruact::Server`, and emits version-2 snapshot entries:
1464
+ `{ js_identifier, kind: "action", http_method, path, segments, controller, action }`.
1465
+ GET routes are pages — never callables. The `update` PATCH/PUT pair collapses to
1466
+ one entry (`http_method: "PATCH"`, Rails' primary). Pure: the host predicate +
1467
+ override lookup are injected (default = constant resolution) so the derivation
1468
+ table is unit-testable without booting controllers.
1469
+
1470
+ **Naming derivation table (AC3 — LOCKED).**
1471
+ `js_identifier = lowerCamel(action) + Namespace*(Pascal) + Resource(Pascal)`,
1472
+ where `lowerCamel` is the existing `NameBridge` camelization (leading underscore
1473
+ preserved). Resource word: SINGULAR for the RESTful writes
1474
+ (`create`/`update`/`destroy`) and for any member route (path carries `:id`);
1475
+ PLURAL for a custom collection route.
1476
+
1477
+ | Route (`verb controller#action`) | js_identifier |
1478
+ |---|---|
1479
+ | `POST posts#create` | `createPost` |
1480
+ | `PATCH/PUT posts#update` | `updatePost` (PATCH) |
1481
+ | `DELETE posts#destroy` | `destroyPost` |
1482
+ | `GET posts#index/show/new/edit` | — (skipped; pages) |
1483
+ | `POST posts#publish` (member) | `publishPost` |
1484
+ | `POST posts#publish_all` (collection) | `publishAllPosts` |
1485
+ | `POST session#create` (`resource :session`) | `createSession` |
1486
+ | `DELETE account#destroy` (singular) | `destroyAccount` |
1487
+ | `POST admin/posts#create` (namespaced) | `createAdminPost` |
1488
+ | `POST admin/reports/posts#create` | `createAdminReportsPost` |
1489
+
1490
+ **Namespace = PREFIX, not flat (D3 — resolves 9-0 open item #2).** Namespace
1491
+ segments are PascalCased and inserted between verb and resource. Rationale:
1492
+ flattening guarantees `admin/posts#create` and `posts#create` collide on
1493
+ `createPost`, forcing rename-overrides for the common admin case; prefixing keeps
1494
+ the merged JS namespace collision-free by construction.
1495
+
1496
+ **Rename-override macro (D4 — resolves 9-0 open item #3).**
1497
+ `ruact_function_name :action, as: "jsIdentifier"` — a class macro on the
1498
+ `Ruact::Server` host (`Ruact::Server::ClassMethods`). Populates a per-controller
1499
+ `__ruact_function_name_overrides` map (action name → js identifier) that
1500
+ `RouteSource` consults before collision detection. The target is validated at
1501
+ class-load time against the JS-identifier shape + reserved-word /
1502
+ `RESERVED_BY_RUACT` rules (a bad override fails at boot, never at codegen).
1503
+
1504
+ **Collision detection (AC4).** Two distinct routes mapping to the same
1505
+ js_identifier (after overrides) raise `Ruact::ConfigurationError` at boot naming
1506
+ BOTH origins (`posts#create and comments#create both map to JS identifier
1507
+ "createPost"`), mirroring the v1 cross-registry collision raise.
1508
+
1509
+ **Transparency (AC2).** `Ruact::ServerFunctions.write_v2_snapshot!` always logs
1510
+ `[ruact] codegen: exposing <comma-list>` — a routed non-GET action never becomes
1511
+ a callable silently (the verb-rule's mitigation, epic Decision #1).
1512
+
1513
+ **Runtime re-target (AC7, D6).** New runtime export `_makeServerFunction({ method,
1514
+ path, segments })` for v2; v1 `_makeRef(name)` is BYTE-BEHAVIOR-IDENTICAL (still
1515
+ POSTs `/__ruact/fn/:name`). Both factor through a shared `ruactInvoke({ method,
1516
+ url, args, label })` core — same FormData branching, CSRF meta injection,
1517
+ `credentials: "same-origin"`, `redirect: "error"`, text-first parsing,
1518
+ `RuactActionError`. Adding a NEW export (not widening `_makeRef`) is what makes
1519
+ AC6 "no shared-runtime leak" true by construction: the v1 path consumes
1520
+ `_makeRef`, the v2 path consumes `_makeServerFunction`.
1521
+
1522
+ **Path-param interpolation (D7).** Member/`:id` routes need the id in the URL
1523
+ (the v1 synthetic endpoint never did). The descriptor carries the path template
1524
+ + segment names; at call time the runtime reads each segment BY NAME from the
1525
+ single FormData/object argument (`FormData.get("id")` / `args.id`),
1526
+ URL-encodes it, and substitutes into the path — the full argument is still sent
1527
+ as the body (Rails reads `params[:id]` from the path; the duplicate is ignored).
1528
+ The single-arg shape (8.2 `<form action>` / `useActionState`) is unchanged. A
1529
+ missing required segment throws a clear `TypeError` before any fetch.
1530
+
1531
+ **`$redirect` client-follow (D2 / AC8 — resolves the 9.2 deferral).** When a
1532
+ Bucket-2 mutation returns `{ "$redirect": "<path>" }`, the v2 runtime navigates
1533
+ via `globalThis.__ruact_navigate` (the same global handoff `revalidate()` uses),
1534
+ falling back to `window.location.assign(path)` when no router is installed, and
1535
+ resolves `null`. Applied ONLY on the v2 path — the v1 endpoint never emits
1536
+ `$redirect`, so `_makeRef` is unaffected. Publishing `__ruact_navigate` from
1537
+ `ruact-router.js#setupRouter` is playground wiring → Story 9.8.
1538
+
1539
+ **Codegen render (Ruby + JS byte-identical).** `Codegen.render` dispatches on
1540
+ `snapshot.version`: version 1 → the untouched v1 renderer; version 2 →
1541
+ `Codegen::V2.render` (separate module so the v1 singleton class stays within its
1542
+ size budget), emitting `_makeServerFunction({ method, path, segments })` with the
1543
+ SAME Story-8.2 intersection signature on every action. The JS-side `renderV2`
1544
+ mirrors it; the parity vitest ("Story 9.3 — route-driven (v2) render + parity")
1545
+ asserts byte equality against the Ruby renderer.
1546
+
1547
+ **Single-writer / `.next` parallel target (AC5).** The v2 codegen writes to a
1548
+ PARALLEL bridge `tmp/cache/ruact/server-functions.next.json` →
1549
+ `app/javascript/.ruact/server-functions.next.ts`, rendered by the Ruby codegen
1550
+ (Vite does not watch `.next`). Per AC5 this target is for parity tests +
1551
+ inspection ONLY — never imported by application code — so the real
1552
+ `server-functions.ts` (v1, rendered by Vite from the v1 bridge) is untouched
1553
+ (AC6). **Scoping decision (refines create-time D5):** in Story 9.3 the v2 codegen
1554
+ ALWAYS writes the parallel target — it never takes over the real file. The
1555
+ Decision-#6 ownership flip (an app with ZERO v1 declarations → route-driven
1556
+ codegen takes over the real `server-functions.ts`) is Story 9.8's job; the
1557
+ `Snapshot.v1_declarations?` primitive is provided + tested here for 9.8 to
1558
+ consume. This is the literal reading of AC5's "writes to a parallel target …
1559
+ never imported," and is strangler-safe (no cross-app behavior change in 9.3).
1560
+
1561
+ **Strangler invariant.** The v1 `POST /__ruact/fn/:name` endpoint, registries,
1562
+ `ruact_action`/`ruact_query` DSL, and v1 codegen all stay fully alive and
1563
+ untouched. Demolition is Story 9.9; playground migration is Story 9.8.
1564
+
1565
+
1566
+ ### 2026-06-09 — Story 9.4 — `Ruact::Query` base class + `ruact_queries` route macro — in-story decisions
1567
+
1568
+ **Scope.** Implements the v2 QUERY DISPATCH layer locked by the 2026-06-02
1569
+ addendum (Decision 2): `Ruact::Query` base class, the `ruact_queries` mapper
1570
+ macro, the internal per-query-class dispatch controller, constructor context
1571
+ injection, the per-query callback opt-out, and the two new configuration
1572
+ attributes. Resolves 2026-06-02 **open item 1** (callback opt-out) and **open
1573
+ item 2** (route prefix). Codegen of query entries, the `useQuery` hook, and the
1574
+ strict FR88 kwargs sanitization are Story 9.5; dedup is 9.6. The v1
1575
+ `Ruact.query_registry` is untouched (legacy, removed by 9.9) — the v2 class
1576
+ model never reads or populates it.
1577
+
1578
+ **Decisions taken in this story (delegated by the epic / 2026-06-02 addendum):**
1579
+
1580
+ 1. **Open item 1 — RESOLVED: `ruact_skip_before_action` (D1, AC4).** Class
1581
+ macro on `Ruact::Query` subclasses mirroring Rails' `skip_before_action`
1582
+ signature (`ruact_skip_before_action :authenticate_user!, only: :categories,
1583
+ raise: false`). Recorded per-class (NOT inherited — a skip describes the
1584
+ declaring class's own queries) and applied verbatim to that class's
1585
+ generated dispatch controller at route-draw time. Scoping is structural:
1586
+ each query class gets its OWN controller (decision 2 below), so a skip can
1587
+ never leak to sibling query classes; `only:`/`except:` further scope to
1588
+ individual query methods (one action per method). Unknown callbacks raise at
1589
+ route-draw unless `raise: false` — stock Rails semantics.
1590
+
1591
+ 2. **Dispatch-controller shape (D2): one generated controller subclass PER
1592
+ query class.** Built lazily by the routing macro (when `ruact_queries`
1593
+ runs inside `routes.draw`, the host's constants exist), inheriting
1594
+ `Ruact.config.query_parent_controller.constantize`, named under
1595
+ `Ruact::ServerFunctions::QueryDispatch` (e.g. `…::CatalogQueryController`)
1596
+ so string route targets and `rails routes` stay legible — the dev never
1597
+ sees it. One action per public query method (what makes per-action
1598
+ `skip_before_action` possible). Regeneration is idempotent
1599
+ (`remove_const` + rebuild on every draw) and the query class is
1600
+ re-constantized per request, so dev-mode code reloading never serves a
1601
+ stale class. The single-shared-controller alternative (query identity in a
1602
+ route default + conditional skips) was rejected: more complex, less
1603
+ Rails-idiomatic, and per-action callback scoping degrades.
1604
+
1605
+ **Namespace preservation (code-review round 4 refinement).** The generated
1606
+ controller's constant path MIRRORS the query class's namespace rather than
1607
+ flattening it: `Admin::CatalogQuery` →
1608
+ `Ruact::ServerFunctions::QueryDispatch::Admin::CatalogQueryController`
1609
+ (route target `…/query_dispatch/admin/catalog_query`). The controller
1610
+ constant is therefore an injective function of the query class's
1611
+ fully-qualified name, so two distinct query classes can never map to the
1612
+ same generated constant — a const overwrite / route cross-wire is
1613
+ impossible by construction, across any number of RouteSets or mounted
1614
+ engines sharing the global dispatch module. (The initial implementation
1615
+ flattened `::` out and tried to detect the resulting collisions; three
1616
+ review rounds of detection patches converged on removing the flattening
1617
+ instead — no collision to detect.)
1618
+
1619
+ 3. **Context source (D3): delegate to the dispatching controller, not a
1620
+ resolver lambda.** `Ruact::ServerFunctions::QueryContext` wraps the
1621
+ controller instance; `current_user` resolves through the controller's own
1622
+ method — public OR private (hand-rolled hosts commonly define it private)
1623
+ — because the controller inherits the host parent, that IS the host's
1624
+ Devise/Pundit/hand-rolled method (FR89). A host chain with no
1625
+ `current_user` raises a `NoMethodError` naming `query_parent_controller`
1626
+ and the fix. Mirrors `StandaloneContext`'s SHAPE (plain accessors,
1627
+ per-request instance, NFR8); the Story-8.3 `current_user_resolver` lambda
1628
+ plays no part (it is superseded with the standalone path).
1629
+
1630
+ 4. **Route path derivation (D4) + open item 2 — RESOLVED: `/q` prefix.** Path
1631
+ = `"#{Ruact.config.query_route_prefix}/#{NameBridge.to_js_identifier(method)}"`
1632
+ (`search_users` → `GET /q/searchUsers`), route name
1633
+ `:"ruact_query_<jsIdentifier>"` — every query visible in `rails routes`.
1634
+ New config attrs (both under the 7.3 freeze contract):
1635
+ `query_route_prefix` (String, default `"/q"`, must start with `/`, no
1636
+ trailing slash — validated at writer time) and `query_parent_controller`
1637
+ (non-empty String, default `"ApplicationController"`, constantized lazily
1638
+ at route-draw — never at configure time, when the constant may not exist).
1639
+ NameBridge reserved-word/shape failures and duplicate route names across
1640
+ query classes both fail loudly at route-draw.
1641
+
1642
+ 5. **GET error-chain gate (D5, AC5).** The generated controller includes the
1643
+ salvaged `ErrorRendering` chain with the same handler front-loading as
1644
+ `Ruact::Server` (host/parent `rescue_from` keeps precedence) and overrides
1645
+ `__ruact_render_structured_error?`: the mutation concern's gate returns
1646
+ false for GET/HEAD (GET *pages* keep stock Rails errors), but every query
1647
+ dispatch request is a GET *function call* — the override renders the
1648
+ structured 8.4 payload (same 422/403/413/500 mapping) for everything
1649
+ except `Ruact::ConfigurationError`, which still re-raises (a
1650
+ misconfiguration stays a loud setup failure). No upload guard callback is
1651
+ registered (GET bodies) and no `skip_forgery_protection` is added (Rails
1652
+ never CSRF-verifies GET — confirmed by spec, not by dead code).
1653
+
1654
+ 6. **Return-value serialization (D6): `BucketTwoPayload.serialize_value`.**
1655
+ The per-value branch of the Bucket-2 policy was extracted as a public
1656
+ `serialize_value(value, strict:)` and is now the single policy for BOTH
1657
+ the 9.2 ivar hash and the 9.4 query return value (`ruact_props` /
1658
+ `Serializable` / `strict_serialization`, primitives pass through,
1659
+ Hash/Array recursion). The controller resolves `strict_serialization` and
1660
+ passes it in (serializer stays pure, NFR26). The response body is encoded
1661
+ explicitly (`ActiveSupport::JSON.encode`) so scalar String and `nil`
1662
+ returns render valid JSON — **`nil` → `null` with 200** (not 204): simpler
1663
+ for a read; the 9.5 `useQuery` hook treats `data: null` as "no rows".
1664
+
1665
+ 7. **Param passing boundary (D7).** 9.4 passes GET query params as the
1666
+ keyword arguments the query method declares, by name, best-effort (values
1667
+ arrive as Strings). The strict FR88 sanitization contract
1668
+ (string/number/boolean/null allowlist, reject objects, 400 on invalid) is
1669
+ **Story 9.5's**, coupled to the `useQuery` wire format.
1670
+
1671
+ 8. **Install mechanism (D8).** `Ruact::Routing` is included into
1672
+ `ActionDispatch::Routing::Mapper` at require time (`ruact/routing`,
1673
+ required by the Railtie's `ruact.load_controller` initializer — before the
1674
+ host's routes file loads). This is a NEW mechanism for the gem: a Mapper
1675
+ *extension* the host calls explicitly, not a mounted route like the v1
1676
+ `app.routes.prepend` endpoint — decision A (explicit mount) made flesh.
1677
+
1678
+ **Deferred (noted, not ACs here):** install-generator scaffolding for
1679
+ `app/queries/application_query.rb`; codegen + `useQuery` + FR88 kwargs (9.5);
1680
+ dedup (9.6); docs rewrite (9.7); playground migration (9.8).