ruact 0.0.5 → 0.0.7

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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +74 -18
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +79 -425
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +71 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/manifest_resolver.rb +149 -0
  41. data/lib/ruact/railtie.rb +70 -203
  42. data/lib/ruact/render_pipeline.rb +8 -1
  43. data/lib/ruact/server.rb +28 -6
  44. data/lib/ruact/server_functions/codegen.rb +49 -188
  45. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  46. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  47. data/lib/ruact/server_functions/error_payload.rb +1 -1
  48. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  49. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  50. data/lib/ruact/server_functions/query_source.rb +35 -8
  51. data/lib/ruact/server_functions/route_source.rb +3 -4
  52. data/lib/ruact/server_functions/snapshot.rb +12 -139
  53. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  54. data/lib/ruact/server_functions.rb +21 -25
  55. data/lib/ruact/signed_references.rb +162 -0
  56. data/lib/ruact/string_distance.rb +72 -0
  57. data/lib/ruact/validation_errors_collector.rb +139 -0
  58. data/lib/ruact/version.rb +1 -1
  59. data/lib/ruact/view_helper.rb +109 -0
  60. data/lib/ruact.rb +20 -19
  61. data/lib/tasks/ruact.rake +10 -53
  62. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  63. data/spec/ruact/client_manifest_spec.rb +36 -0
  64. data/spec/ruact/component_contract_spec.rb +119 -0
  65. data/spec/ruact/configuration_spec.rb +51 -34
  66. data/spec/ruact/controller_request_spec.rb +264 -0
  67. data/spec/ruact/controller_spec.rb +70 -328
  68. data/spec/ruact/doctor_spec.rb +201 -0
  69. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  70. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  71. data/spec/ruact/errors_spec.rb +0 -45
  72. data/spec/ruact/html_converter_spec.rb +50 -0
  73. data/spec/ruact/install_generator_spec.rb +591 -4
  74. data/spec/ruact/manifest_resolver_spec.rb +159 -0
  75. data/spec/ruact/query_request_spec.rb +109 -1
  76. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  77. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  78. data/spec/ruact/server_function_name_spec.rb +1 -1
  79. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  80. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  81. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  82. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  83. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  84. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  85. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  86. data/spec/ruact/server_spec.rb +8 -9
  87. data/spec/ruact/signed_references_spec.rb +164 -0
  88. data/spec/ruact/string_distance_spec.rb +38 -0
  89. data/spec/ruact/validation_errors_spec.rb +116 -0
  90. data/spec/ruact/view_helper_spec.rb +79 -0
  91. data/spec/spec_helper.rb +15 -5
  92. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
  93. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  94. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  95. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  96. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  97. data/vendor/javascript/vite-plugin-ruact/index.js +372 -6
  98. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  99. data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -0
  100. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  101. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  102. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  103. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  104. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  105. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  106. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  107. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  108. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  109. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  110. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  119. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  120. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  121. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  122. metadata +58 -15
  123. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  124. data/lib/ruact/server_action.rb +0 -131
  125. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  126. data/lib/ruact/server_functions/registry.rb +0 -148
  127. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  128. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  129. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  130. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  131. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  132. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  133. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  134. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  135. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -1777,3 +1777,405 @@ kwargs contract is enforced server-side. Append-only addendum to the
1777
1777
  `server-functions.ts` is 9.8). Request de-duplication for `useQuery` is 9.6
1778
1778
  (the hook fetches once per mount; a `useSyncExternalStore` refactor can layer
1779
1779
  dedup later). The v1 substrate is untouched (demolition is 9.9).
1780
+
1781
+ ### 2026-06-25 — Story 13.1 — Serialize-only invariant (FR97)
1782
+
1783
+ Epic 13 ("Server-Function Contract & Safety") opens by turning a *happy accident
1784
+ of architecture* into a **defended invariant**. The 2026-06-15 technical research
1785
+ report established that ruact is **serialize-only**: its Ruby side *emits* React
1786
+ Flight (`text/x-component`) but has **no inbound Flight deserializer**, so it sits
1787
+ structurally **outside** the React2Shell / **CVE-2025-55182** class — a Flight-
1788
+ deserialization RCE (CVSS 10, Dec 2025) in which an attacker-supplied Flight
1789
+ payload is deserialized into live server objects. Append-only addendum; supersedes
1790
+ nothing.
1791
+
1792
+ 1. **The invariant (the rule).** ruact **may emit** Flight (`text/x-component`)
1793
+ from the Ruby server, but must **never deserialize externally-supplied Flight
1794
+ into live Ruby objects.** No inbound `*Deserializer`, no `parse_flight` /
1795
+ `from_flight` / `deserialize_flight` / `decode_flight` reading a request body,
1796
+ no Ruby call to a React Flight reader (`createFromNodeStream` /
1797
+ `createFromReadableStream` / `createFromFetch`). Reads stay GET-with-primitives (FR88); writes stay
1798
+ form/JSON over the host's own CSRF — neither path parses Flight inbound.
1799
+
1800
+ 2. **Out of scope — the client `createFromFlightPayload`.** The generated
1801
+ client entry (`lib/generators/ruact/install/templates/application.jsx.tt`)
1802
+ calls `createFromFlightPayload` to deserialize the server's **own trusted**
1803
+ payload in the **browser**. That is normal RSC on the client — not the Ruby
1804
+ server, not attacker-controlled inbound Flight — and is explicitly **outside**
1805
+ this invariant. The tripwire scans Ruby source only and excludes the
1806
+ generators' client-side templates.
1807
+
1808
+ 3. **Enforcement — a `Ruact::Doctor` tripwire, not a parser.** `check_serialize_only`
1809
+ greps ruact's own `lib/**/*.rb` (default root `Ruact.gem_path/lib`, injectable
1810
+ for specs) for a curated list of structural inbound-deserialization signals and
1811
+ **fails** on any unannotated hit, naming `file:line` + this invariant. The raw
1812
+ `text/x-component` token is deliberately **not** a signal — the gem legitimately
1813
+ *emits* that media type (`controller.rb` / `server.rb`), so matching it would
1814
+ false-fail the (invariant-holding) current tree. Today the check **passes
1815
+ silently**. AST parsing was rejected as over-engineering for a tripwire whose
1816
+ job is to *stay at zero* (consistent with the single-dependency / "make invalid
1817
+ states unconstructible, simply" discipline). The signal literals are assembled
1818
+ from fragments via `Array#join` and `doctor.rb` is excluded from its own scan
1819
+ by exact file path — not basename, so a differently-located future namesake is
1820
+ still scanned (mirrors the Story 5.1 F4 self-reference lesson).
1821
+
1822
+ 4. **Escape hatch — `# ruact:allow-flight-deserialization <reason>`.** A line
1823
+ carrying that annotation is treated as guarded, so a deliberate, reviewed
1824
+ deserializer is allowed without failing. This is what makes the check a
1825
+ **guard** rather than a blanket ban.
1826
+
1827
+ 5. **Middleware warn (non-failing).** `check_flight_middleware` introduces a new
1828
+ `:warn` doctor status (rendered `⚠`; a `:warn` does **not** flip the run to
1829
+ failure — the pass computation moved from `status == :pass` to an allowlist,
1830
+ `SUCCESS_STATUSES = %i[pass warn]`, so a `:warn` passes while any unexpected
1831
+ status fails loudly rather than being silently treated as a pass).
1832
+ It warns when a response-transforming middleware (`Rack::Deflater` + a small
1833
+ curated list) is mounted, since recompressing/mutating a streamed
1834
+ `text/x-component` body breaks the Flight wire contract / streaming
1835
+ (React-on-Rails ops lesson). Rack middleware is app-global, not per-route, so
1836
+ the warn cannot be scoped precisely to Flight routes — it points the operator
1837
+ at the remedy (exclude `text/x-component` from compression).
1838
+
1839
+ 6. **Where it earns its keep.** The primary venue is the gem's own CI (the
1840
+ `:story_13_1` spec runs the tripwire against a fixture tree). In a host app,
1841
+ `rails ruact:doctor` scanning `Ruact.gem_path/lib` is defense-in-depth — a
1842
+ released gem won't contain a deserializer, so it passes; its value there is
1843
+ catching a hand-patched/vendored gem.
1844
+
1845
+ 7. **Scope guards.** This story ships ONLY the ADR addendum + the two doctor
1846
+ checks + specs. No SignedGlobalID (13.2 / FR96), no error round-trip (13.3 /
1847
+ FR98), no TS emission (13.4 / FR99), no component contract (13.5 / FR100), no
1848
+ playground (13.6). The client-side React deserialization path is untouched.
1849
+
1850
+ ### 2026-06-26 — Story 13.2 — SignedGlobalID record references (FR96)
1851
+
1852
+ The inbound-safety counterpart to the serialize-only invariant (13.1). The
1853
+ serialize-only guard keeps ruact from *deserializing* hostile Flight; this story
1854
+ keeps a controller from handing the client a *forgeable* record reference in the
1855
+ first place. Decisions:
1856
+
1857
+ 1. **The canonical record-reference primitive is a SignedGlobalID, opt-in via an
1858
+ explicit helper.** `Ruact.signed_global_id(record, for:, expires_in:)` returns
1859
+ `record.to_sgid(for:, expires_in:).to_s` (a plain `String` → the Flight
1860
+ serializer carries it unchanged, no serializer branch). `Ruact.locate_signed(
1861
+ token, for:)` wraps `GlobalID::Locator.locate_signed`. Chosen over (a)
1862
+ hand-rolled `to_sgid` (no loud-omission guard, no symmetry) and (c)
1863
+ auto-serializing every `ActiveRecord::Base` (magic; conflicts with the
1864
+ `ruact_props` explicit-allowlist grain; can't carry a per-site `for:`). The
1865
+ opt-in is "the developer reaches for the helper," surfaced in docs as the
1866
+ canonical pattern.
1867
+
1868
+ 2. **Inbound resolution is explicit, never automatic.** The action/query calls
1869
+ `Ruact.locate_signed` itself; the strict param allowlist (`query_dispatch.rb`)
1870
+ is untouched — no auto-detect-and-resolve of token-shaped strings (a param may
1871
+ legitimately be a string that merely looks like a token; auto-resolution would
1872
+ couple the param layer to ActiveRecord and surprise developers).
1873
+
1874
+ 3. **Loud-omission invariant — never a silent insecure default.** Purpose and
1875
+ expiry resolve call-arg → config default → **raise** `Ruact::Error`. Purpose
1876
+ is always required (an unscoped token is never acceptable, so even an explicit
1877
+ `for: nil` raises). Expiry distinguishes *omission* from a deliberate choice
1878
+ via a private `UNSET` sentinel: pure omission with no configured default
1879
+ raises; an explicit `expires_in: nil` is honored as a reviewed non-expiring
1880
+ token. `expires_in:` must be an `ActiveSupport::Duration` (globalid calls
1881
+ `#from_now`).
1882
+
1883
+ 4. **Tampered/expired/wrong-purpose → clean 400.** `locate_signed` returns `nil`
1884
+ on any verification failure; the helper raises `Ruact::InvalidSignedGlobalIDError`,
1885
+ mapped to **400** in `__ruact_status_for` (alongside `BadRequestError`). 400
1886
+ over 404 because the token is an invalid client *credential*, not a missing
1887
+ record — and verification fails before any DB lookup, so record existence is
1888
+ never revealed either way. No `ActiveRecord::RecordNotFound` leak; the
1889
+ rejection message never echoes the raw token.
1890
+
1891
+ 5. **Single-dependency discipline preserved.** `globalid` ships with every Rails
1892
+ app but is not a gemspec dependency (only `nokogiri` is). It is **lazily
1893
+ required** inside the helpers on first call (mirroring the class-name-string
1894
+ matching in `ErrorRendering` that avoids requiring ActiveRecord at load), so
1895
+ the gem stays loadable in pure-Ruby contexts and the gemspec is unchanged.
1896
+
1897
+ 6. **Scope guards.** This story ships ONLY the two helpers + the error class +
1898
+ 400 mapping + two config keys + security docs + specs. No error round-trip
1899
+ (13.3 / FR98), no TS emission (13.4 / FR99), no component contract (13.5 /
1900
+ FR100), no playground (13.6). The Epic 10 scaffold generator does not exist
1901
+ yet — the docs worked-example is the canonical pattern it will later emit.
1902
+
1903
+ #### 2026-06-26 — Story 13.2 review patch (dev⇄Codex)
1904
+
1905
+ Three findings, all resolved before merge:
1906
+ - **(Patch)** `InvalidSignedGlobalIDError`'s YARD comment was inserted between
1907
+ `UploadTooLargeError`'s doc block and its class, detaching the upload docs →
1908
+ moved the new error + comment to sit right after `BadRequestError`.
1909
+ - **(Patch)** the production-mode no-leak spec asserted `not_to have_key("backtrace")`,
1910
+ but the dev-only payload keys are `app_frames` / `gem_frames` / `suggestion` →
1911
+ the spec now asserts the response is exactly the four baseline keys.
1912
+ - **(Decision, Luiz)** a **valid** token whose record was since **deleted** is
1913
+ out of AC2's three rejection cases (tampered/expired/wrong-purpose, which never
1914
+ reach the finder). **Resolved: do NOT normalize.** The finder's
1915
+ `ActiveRecord::RecordNotFound` propagates as the host's ordinary not-found
1916
+ concern (identical to a raw `Model.find`); the primitive owns only signature/
1917
+ expiry/purpose verification. Documented on `InvalidSignedGlobalIDError` and
1918
+ pinned by a spec.
1919
+ - YARD `{Ruact.signed_global_id}` / `{Ruact.locate_signed}` cross-references are
1920
+ unresolvable (the methods are mixed onto `Ruact`'s singleton via `extend`), so
1921
+ they are written as plain code spans, not doc links.
1922
+
1923
+ #### 2026-06-26 — Story 13.2 review patch R2 (dev⇄Codex)
1924
+
1925
+ One new finding (all three layers, same root): `expires_in: false` — and a
1926
+ configured default of `false` — silently minted a **non-expiring** token, because
1927
+ globalid treats a falsy `expires_in` as "no expiry". That was a second silent
1928
+ path around the "only an explicit `nil` is the reviewed non-expiring choice"
1929
+ contract. **Fixed:** `__ruact_resolve_expiry` now type-checks the resolved value
1930
+ — only `nil` or an `ActiveSupport::Duration` (anything responding to `#from_now`,
1931
+ what globalid actually calls) is allowed; `false`, a bare Integer, or any other
1932
+ type raises `Ruact::Error` loudly (closing both the call-arg and the configured-
1933
+ default path). Bonus: a bare Integer now gets a clear message instead of
1934
+ globalid's cryptic `NoMethodError: from_now`.
1935
+
1936
+ ### 2026-06-26 — Story 13.3 — Inertia-style validation `errors` round-trip (FR98)
1937
+
1938
+ **Context.** Stories 8.4 / 9.1 handle the **exception** path: a `save!` /
1939
+ `create!` that raises `ActiveRecord::RecordInvalid` bubbles past the host's
1940
+ `rescue_from` into the structured **error payload** (`_ruact_server_action_error:
1941
+ true`, mapped to **422**, with a dev-only flat `full_messages` **array** under
1942
+ `validation_errors`) — for the dev overlay. FR98 is the complementary
1943
+ **non-exception happy-failure** path: the idiomatic `if record.save … else …`
1944
+ where `save` returns `false` and the action does NOT raise. That path needs a
1945
+ field-level `errors` **prop** (not an error payload) so a form re-renders with
1946
+ messages and a normal 200 / redirect — exactly how a Rails dev expects
1947
+ `record.invalid?` to behave. **13.3 EXTENDS, does not replace, the 8.4/9.1
1948
+ chain**; `error_payload.rb`'s dev-only `validation_errors` array is left
1949
+ untouched (different shape, different purpose).
1950
+
1951
+ **Decision — design (B), explicit opt-in helper.** The contract escalation in
1952
+ the story (auto-inject a reserved `errors` prop into EVERY response — variant
1953
+ (A) — vs. an explicit opt-in helper — variant (B)) was resolved by Luiz in
1954
+ favour of **(B)**. Rationale: (A) would reserve the prop name `errors` globally
1955
+ AND change Story 9.2's Bucket-2 `204 No Content` empty contract (a genuinely
1956
+ empty response would become `{"errors": {}}`); that is magic, in tension with
1957
+ the explicit-allowlist grain that 13.1/13.2 just hardened. (B) preserves the
1958
+ grain and the 9.2 contract.
1959
+
1960
+ **(a) Canonical shape — a pure normalizer.**
1961
+ `Ruact::ServerFunctions::ValidationErrors.normalize(source)` is a pure function
1962
+ (no Rails / `Ruact.config` / request reads — same caller/builder split as
1963
+ `ErrorPayload` / `BucketTwoPayload`) producing `Hash{String=>Array<String>}`:
1964
+ attribute names as strings (a `base`-level error keys under `"base"`), values
1965
+ arrays of human-readable **full messages** (`ActiveModel::Error#full_message`,
1966
+ e.g. `"Title can't be blank"`). It accepts an ActiveModel-ish record (responds
1967
+ to `#errors`), a raw `ActiveModel::Errors`, or a pre-shaped Hash, and is
1968
+ idempotent on already-canonical input; `nil` / a valid record → `{}`. Full
1969
+ messages (not partial `errors.messages`) are the default — self-describing, and
1970
+ faithful to the AC. Note this diverges from inertia-rails' single-string-per-
1971
+ attribute: FR98 mandates `string[]` because one attribute can carry multiple
1972
+ errors.
1973
+
1974
+ **(b) Opt-in collector helper.** `ruact_errors` is a single dual-purpose
1975
+ controller helper on the shared `Ruact::ValidationErrorsCollector` concern
1976
+ (included by both `Ruact::Server` and `Ruact::Controller`). `ruact_errors(@post)`
1977
+ normalizes + merges into a per-request collector (the private ivar
1978
+ `@__ruact_errors`, NOT a `view_assigns` ivar) and returns the normalized hash;
1979
+ the no-arg `ruact_errors` reads the always-present collector (`{}` default), for
1980
+ binding to a form component (`<PostForm errors={ruact_errors} />`, exposed to
1981
+ the view via `helper_method`). Because the shape derives from `record.errors`
1982
+ (empty on a valid record), one `ruact_errors(@post)` call after the save yields
1983
+ `{}` on success and the populated map on failure — the "always present, same
1984
+ code path" invariant, **per-action and explicit**, with no global prop.
1985
+
1986
+ **(c) Bucket-2 (imperative `await`).** When `__ruact_function_call?` and the
1987
+ collector was **touched** this request, `Ruact::Server#default_render` injects
1988
+ the collector under the reserved JSON key `"errors"` alongside the serialized
1989
+ ivars (even on an otherwise-empty assigns set, so an opted-in success surfaces
1990
+ `{"errors": {}}` and the client reads `result.errors` uniformly). The two
1991
+ collector internals and a stray dev ivar literally named `@errors` are dropped
1992
+ from the serialized assigns (`RESERVED_ASSIGN_KEYS` — Rails' `view_assigns` only
1993
+ filters a fixed set of `@_` ivars, not every `@__` one); the collector is the
1994
+ source of truth for the key. An **untouched** collector changes nothing — the
1995
+ 9.2 `204 No Content` empty-Bucket-2 contract is preserved. No client-runtime
1996
+ change: the `errors` key rides `parseResponse` verbatim.
1997
+
1998
+ **(d) Bucket-1 redirect-back (native form / navigation).** The Inertia "redirect
1999
+ back with errors" pattern: on a Flight `redirect_to`
2000
+ (`Ruact::Controller#redirect_to`) where the collector was touched, the canonical
2001
+ errors are stashed in `flash[:ruact_errors]` (single-use, session-backed — the
2002
+ exact Inertia semantics). The re-rendered page (`ruact_render`) reads the flash
2003
+ back into the collector before the view evaluates, so `ruact_errors` returns the
2004
+ surviving errors as a prop on the re-render. Guarded on `respond_to?(:flash)`:
2005
+ a session-less API-only host degrades to the Bucket-2 body path rather than
2006
+ crashing. Bucket-2 does NOT redirect-to-convey-failure — errors ride the body.
2007
+
2008
+ **Append-only invariant preserved.** The Story 8.0 accessor lock is untouched
2009
+ (`import { createPost } from "@/.ruact/server-functions"` unchanged); FR98 adds
2010
+ a Ruby-side collector + a reserved response key, no codegen / runtime change.
2011
+ The raised-`RecordInvalid` → 422 path (8.4) is unchanged and still covered by
2012
+ `server_rescue_request_spec.rb`.
2013
+
2014
+ ### 2026-06-26 — Story 13.4 — TS type emission for query `params` (FR99)
2015
+
2016
+ FR99 closes the **parameter/accessor `any` gap**: before this story a query that
2017
+ declares keyword arguments emitted `(params: Record<string, unknown>) =>
2018
+ Promise<unknown>` — the client got no named keys, no autocomplete, no
2019
+ missing-required error, no unknown-key error. The codegen now narrows that
2020
+ `params` object to the query method's **actual declared keyword arguments**.
2021
+ Append-only addendum; no locked accessor shape changes (the import surface —
2022
+ `import { searchUsers, useQuery } from "@/.ruact/server-functions"` — is
2023
+ untouched; only the inline TS type annotation on the emitted `export const`
2024
+ gets narrower).
2025
+
2026
+ **The reflection-honesty decision (the one real contract call) — RESOLVED (A).**
2027
+ Ruby is dynamically typed; `Method#parameters` returns **names + required/optional
2028
+ only** (`[[:keyreq, :term], [:key, :limit]]`) — never types, never default
2029
+ values. So the epic's illustrative `{ q: string; limit?: number }` (where
2030
+ `number` would come from `limit: 10`) is **not honestly reachable by pure
2031
+ reflection**. Decision (locked by Luiz): each param's **value type is the FR88
2032
+ wire-serializable union `string | number | boolean | null`** — the exact
2033
+ query-string contract the kwargs sanitizer (`query_dispatch.rb`) already enforces
2034
+ — while **keys and optionality are EXACT** (`:keyreq` → required prop, `:key` →
2035
+ optional prop). This kills the `Record<string, unknown>`/`any` gap truthfully:
2036
+ named keys (autocomplete), exact required-ness (**missing-required is a compile
2037
+ error**), and excess-property checking (**unknown-key is a compile error**).
2038
+ Rejected alternatives: (B) parsing default-value literals to infer scalar types —
2039
+ fragile, Ruby-version-dependent, a byte-stability hazard, and gives `term:` (no
2040
+ default) no type anyway; (C) an explicit param-type DSL (`param :limit, :integer`)
2041
+ for true scalar precision — a new feature surface, deferred to a future story.
2042
+ Per-param scalar narrowing is therefore explicitly **out of scope** here.
2043
+
2044
+ **Action scope boundary — actions stay byte-identical.** Actions are Rails
2045
+ controller methods that read `params` dynamically; there is **no reflectable
2046
+ declared-input source** (the action entry hash carries no kwargs), and the action
2047
+ signature is already a sensible `FormData | Record<string, unknown>` intersection
2048
+ (not bare `any`). Inventing an action-input declaration DSL is a new feature
2049
+ surface, out of scope. Action accessors — and therefore every action-only and
2050
+ empty `server-functions.ts` module — are emitted **byte-for-byte unchanged**
2051
+ (regression-pinned in the parity suite). Return types stay `Promise<unknown>`
2052
+ (per-function return precision is Phase 3; `useQuery<T>` remains the return-typing
2053
+ lever).
2054
+
2055
+ **`**keyrest` fails open.** A query declaring `**opts` keeps an open
2056
+ `Record<string, unknown>` (intersected with any named keys:
2057
+ `{ scope: <union> } & Record<string, unknown>`) so a legitimate dynamic key is
2058
+ never narrowed away. A `**keyrest`-only query is just `Record<string, unknown>` —
2059
+ byte-identical to its pre-13.4 `accepts_params: true` output.
2060
+
2061
+ **Pipeline + dual-codegen byte parity reaffirmed.** Per-kwarg metadata is derived
2062
+ in `QuerySource.build_entry` (`params`: an ordered `[{ "name", "required" }]`
2063
+ array + a `params_rest` Boolean, alongside the retained `accepts_params` Boolean,
2064
+ now derived as "named keys present OR keyrest"), carried verbatim through the
2065
+ `Snapshot` JSON (string keys, declaration order → byte-stable), and consumed
2066
+ **byte-identically** by BOTH the Ruby `Codegen::V2.render_query_export` and the JS
2067
+ `renderQueryExportV2`. The cross-implementation parity test
2068
+ (`server-functions-codegen.test.mjs`) is extended with a typed-query fixture
2069
+ (required + optional + keyrest + keyrest-only) asserting `jsOutput ===
2070
+ rubyOutput`. The new `params` metadata is a **trust boundary** (it becomes TS
2071
+ object keys): both renderers reject a malformed/corrupted snapshot (non-Array
2072
+ `params`, a non-object element, an empty/multi-line param name, a non-Boolean
2073
+ `required`) rather than emit broken — or injected — TS, and any param name that
2074
+ is not a valid bare JS identifier is JSON-quoted. The AC6 proof is a `tsc
2075
+ --noEmit` type-level test fixture in `vendor/javascript/vite-plugin-ruact/`
2076
+ (`type-tests/`, `typescript` added as a build-only devDependency) asserting that
2077
+ missing-required / unknown-key / wrong-typed / wrong-arity all fail to compile and
2078
+ a clean call type-checks — wired into a new `js` CI job. The runtime `_makeQuery`
2079
+ declaration needed **no change**: the emitted typed-`params` `export const`
2080
+ assigns to `_makeQuery`'s declared return without a cast (proven by a second
2081
+ type-test fixture).
2082
+
2083
+ ### 2026-06-26 — Story 13.5 — Compile-time component contract, HEEx-style `attr`/`slot` (FR100)
2084
+
2085
+ FR100 closes the **component-boundary contract gap** — the consumer-side mirror
2086
+ of `ruact_props`. `ruact_props` validates the **producer** at class-load (a
2087
+ serializable model only exposes real methods, `serializable.rb`); until now the
2088
+ **consumer** — a `<Component prop={...} />` call site in an ERB view — was
2089
+ unchecked, so a missing required prop, a typo'd prop name (`postID` vs `postId`),
2090
+ or a slot misuse produced a **silent `undefined` in the browser** (the worst
2091
+ failure: discovered at runtime in production). 13.5 makes the call site a checked
2092
+ contract that fails **at preprocess time**, naming the component, file:line, the
2093
+ offending prop, and a "did you mean?" suggestion — before the page renders.
2094
+
2095
+ **Contract source = opt-in `.tsx` export → manifest `contract` field (design A,
2096
+ locked by Luiz).** A component opts in by exporting `__ruactContract` from its own
2097
+ module (HEEx spirit — the declaration lives next to the component, in the
2098
+ component's own language):
2099
+
2100
+ ```ts
2101
+ export const __ruactContract = {
2102
+ props: { title: "required", subtitle: "optional" },
2103
+ slots: { header: "optional" }, // optional; { name: "required"|"optional" } or ["name", ...]
2104
+ passthrough: false, // optional; true allows undeclared props
2105
+ };
2106
+ ```
2107
+
2108
+ The Vite plugin's existing component scanner (`buildManifest`) — which already
2109
+ opens every component file for `"use client"` + export names — extracts this
2110
+ **names-only** (a brace-balanced object scan + targeted regexes; **NOT** a TS-AST
2111
+ pass, **NOT** `eval`) into an optional `contract` field on the manifest entry.
2112
+ A component without the export emits **no** `contract` field (byte-additive;
2113
+ every existing manifest + reader is unaffected). A malformed/partial declaration
2114
+ is **warned + skipped** in the plugin, so the Ruby side sees "no contract" and
2115
+ fails open. The brace-balancer that bounds the object literal is string- and
2116
+ comment-aware (a `{` inside a comment or string never throws off the balance).
2117
+ A single `__ruactContract` describes the file's **one** component — a file that
2118
+ exports more than one PascalCase component has the contract **warned + skipped**
2119
+ (it cannot be attributed), reaffirming the one-component-per-file convention;
2120
+ per-export contract syntax is deferred. Rejected alternatives: **(B)** a Ruby-side DSL and **(C)** a JSON
2121
+ sidecar both invent a parallel registry that drifts from the `.tsx` (the source
2122
+ of truth); **(D)** a full TS-AST `Props` parse is heavy, version-fragile, and
2123
+ over-delivers (preprocess-time validation needs names + required + slots, not
2124
+ value types — and component prop value typing is a separate, later concern; the
2125
+ server-boundary value-typing win already landed in 13.4 / FR99).
2126
+
2127
+ **Check location = Ruby preprocess-time (forced, not a choice).** ERB
2128
+ `<Component .../>` tags are transformed by the **Ruby** ERB preprocessor
2129
+ (`erb_preprocessor.rb`), which already detects PascalCase tags, parses prop
2130
+ **names**, and computes each call site's line. A TypeScript/codegen-time check
2131
+ **cannot see ERB call sites at all** — they live in `.html.erb`, not `.tsx`. So
2132
+ the validator (`Ruact::ComponentContract`) is necessarily Ruby-side, hooked into
2133
+ the existing `COMPONENT_TAG_RE` transform loop. The only missing input was the
2134
+ template **file path**, now threaded through `ErbPreprocessorHook#call` as
2135
+ `template.identifier` (and `ErbPreprocessor.transform(source, identifier:,
2136
+ registry:)` — the contract registry is an **injectable seam** defaulting to the
2137
+ process-loaded, frozen `Ruact.manifest`, stubbable in specs per the Story 7.1
2138
+ explicit-context grain; no `Thread.current`, no per-call disk read).
2139
+
2140
+ **Granularity = NAME-level only (required / unknown / slot).** Prop **values** are
2141
+ arbitrary render-time Ruby expressions the preprocessor never evaluates
2142
+ (`postId={@post.id}` → it emits `"postId" => @post.id`), so a preprocess-time
2143
+ check can validate **names + presence + slots** — *not* value types. This is the
2144
+ same reflection-honesty discipline 13.4 used: deliver exactly what the static
2145
+ phase can truthfully see. The three checks map precisely to the epic's three
2146
+ failure cases: **missing required** prop/slot, **unknown** prop name (closed by
2147
+ default; `passthrough: true` reopens it), and **slot misuse**.
2148
+
2149
+ **Slots = minimal / name-level, deeper semantics deferred.** Children/slot passing
2150
+ is thin today (only `<Suspense>` is paired in the preprocessor; generic
2151
+ `<Component>…</Component>` children are not yet first-class). A slot is therefore
2152
+ expressed at the ERB call site as a **named prop attribute** (render-prop style:
2153
+ `<Card header={...} />`); the validator treats declared slot names as valid
2154
+ attributes and enforces **required** slots by attribute presence. A contract that
2155
+ declares no slots makes slot checking a **no-op**. A typed slot runtime / slot
2156
+ rendering / children-based slot capture is explicitly **deferred**.
2157
+
2158
+ **Opt-in / fail-open / byte-identical (the load-bearing regression).** A
2159
+ contract-less component gets **zero** validation and **byte-identical** emitted
2160
+ output (`<%= __ruact_component__("Name", { … }) %>`), and the no-component-tag
2161
+ fast path reads no registry — both regression-pinned. Validation runs **only**
2162
+ inside the `COMPONENT_TAG_RE` block, never on the fast path. When the manifest is
2163
+ absent (fresh app, pre-build) or has no contract for a component, validation is
2164
+ skipped. This follows the gem's whole grain — explicit allowlisting, never magic
2165
+ (`ruact_props`, the SGID helper, the strict query allowlist are all opt-in).
2166
+
2167
+ **Error surface.** The validator raises `Ruact::ComponentContractError`, a
2168
+ **subclass of `Ruact::PreprocessorError`** (so it flows through the same dev error
2169
+ overlay, NFR30 lineage) — the distinct subclass lets the preprocessor re-raise it
2170
+ **as-is** (its message already carries component + file:line + offending prop +
2171
+ suggestion) instead of re-wrapping it with the generic "at line N: snippet" tail
2172
+ the preprocessor appends to malformed-tag errors. The "did you mean?" suggestion
2173
+ reuses the **Damerau-Levenshtein** closest-match (Story 7.4), factored out of
2174
+ `ClientManifest` into a shared `Ruact::StringDistance` module so the prop-typo
2175
+ hint and the unknown-component hint share one algorithm.
2176
+
2177
+ **Scope boundary (this story does NOT do).** Prop value-type checking; a full
2178
+ TS-AST `Props` parse; a Ruby DSL / JSON sidecar; a new slot runtime; moving
2179
+ validation to render time. The **Epic 10 scaffold** consuming contracts and the
2180
+ **playground build-fails CI proof** are **Story 13.6** (`epics-phase-2.md`).
2181
+ ADR addendum (2026-06-26).