upkeep-rails 0.1.6

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,192 @@
1
+ # Single Subscriber And Cold Fixed-Cost Roadmap
2
+
3
+ This roadmap targets the cases where Upkeep has little or no render sharing
4
+ amortization: one active subscriber, cold page setup, cable activation, and
5
+ short-lived anonymous pages.
6
+
7
+ Last updated: 2026-05-21.
8
+
9
+ Current code state:
10
+
11
+ - Base runtime/docs commit: `474fb9c` (`Instrument capture and document Upkeep Rails`).
12
+ - Current pass adds gated request/action profiling, operation-scoped capture
13
+ metrics, mutation action/delivery timings, client/server phase correlation,
14
+ benchmark phase labels, lightweight subscription-shape tracing, static
15
+ template metadata caching, shared-stream signature memoization, and queued
16
+ dependency flushing.
17
+ - Latest verification: `bundle exec rake test` passed with `186` runs and
18
+ `1172` assertions.
19
+ - Latest single-subscriber benchmark data is the 2026-05-21 identity-free
20
+ report below.
21
+
22
+ ## Environment
23
+
24
+ From a fresh zsh shell in any worktree:
25
+
26
+ ```sh
27
+ cd /path/to/upkeep-rails
28
+ eval "$(mise activate zsh)"
29
+ ruby -v
30
+ bundle exec rake test
31
+ ```
32
+
33
+ Do not use system Ruby. The repo expects Ruby from mise, currently `3.4.7`.
34
+
35
+ ## Baseline
36
+
37
+ Latest identity-free 1-subscriber report:
38
+
39
+ - Report: `benchmark/results/identity-free-feed-compare-20260521114026.md`
40
+ - Upkeep setup p95: `140ms`
41
+ - Turbo setup p95: `104ms`
42
+ - Upkeep page render p95: `91ms`
43
+ - Turbo page render p95: `73ms`
44
+ - Upkeep WebSocket connect p95: `38ms`
45
+ - Turbo WebSocket connect p95: `17ms`
46
+ - Upkeep subscribe call p95: `10ms`
47
+ - Turbo subscribe call p95: `14ms`
48
+ - Upkeep subscribe ack p95: `10ms`
49
+ - Turbo subscribe ack p95: `13ms`
50
+ - Upkeep write POST p95: `38.85ms`
51
+ - Turbo write POST p95: `66.37ms`
52
+ - Upkeep update-settled p95: `90ms`
53
+ - Turbo update-settled p95: `96ms`
54
+ - Subscription shape cache: `1` miss, `0` hits, `0` bypasses
55
+ - Shape miss timing: total `4.221ms`, key `1.743ms`, index template `1.003ms`
56
+ - Setup page server timing: Upkeep p95 `83.88ms`; Turbo p95 `66.02ms`
57
+ - Write request server timing: Upkeep p95 `23.78ms`; Turbo p95 `32.72ms`
58
+ - `GET /feed` request-capture timing: total p95 `82.921ms`; action p95
59
+ `70.594ms`; view p95 `55.578ms`; template p95 `42.352ms`; collection render
60
+ p95 `13.226ms`; SQL p95 `1.577ms`; register p95 `12.129ms`
61
+ - `GET /feed` recorder timing: dependency p95 `5.783ms`; frame p95 `2.418ms`;
62
+ shape trace p95 `4.264ms`
63
+ - Subscribe channel server timing: total `2.085ms`, activation `1.95ms`
64
+
65
+ With one subscriber there is still no render dedup savings. The latest cold
66
+ report is a one-sample fixed-cost probe and is noisier than the shared report.
67
+ It still shows the right target: the remaining gap is not SQL, shape cache hit
68
+ cost, or server-side channel subscribe. This pass reduced recorder dependency
69
+ and shape tracing substantially, so the dominant cold fixed cost is now the
70
+ first page/action path, especially Action View template and collection capture,
71
+ plus first registration.
72
+
73
+ ## Priorities
74
+
75
+ 1. Decompose cold page/capture setup
76
+
77
+ Upkeep page render p95 is now `91ms` versus Turbo `73ms`, while setup p95
78
+ is `140ms` versus Turbo `104ms`. `GET /feed` request-capture action p95 is
79
+ `70.594ms`, view p95 is `55.578ms`, and template p95 is `42.352ms`.
80
+
81
+ Candidate work:
82
+
83
+ - Reduce Action View capture overhead around template and collection render
84
+ instrumentation. SQL is only `1.735ms` in this run.
85
+ - Keep ActionCable open/confirmation separate from server-side channel
86
+ subscribe; latest server-side subscribe total is only `2.085ms`.
87
+ - Continue reducing Action View template capture overhead; template p95 is
88
+ `42.352ms` in the latest one-subscriber probe.
89
+ - Investigate whether page/layout frame capture can be represented with one
90
+ page boundary without losing layout identity safety.
91
+ - Keep queued dependency flushing; the recorder shape trace has already
92
+ dropped from `10.04ms` to `4.264ms`.
93
+
94
+ Target outcome:
95
+
96
+ - 1-subscriber page render p95 closes the current `18ms` gap to Turbo.
97
+ - Setup telemetry explains the remaining `36ms` setup gap.
98
+
99
+ 2. Finish shape miss/key cleanup
100
+
101
+ Shape keys no longer serialize the full recorder snapshot, and the
102
+ subscription-shape trace now records normal capture shape terms in
103
+ DAG/runtime. Shared-hit key p95 is `0.031ms`; the single-subscriber miss path
104
+ is still `1.743ms` and total miss is `4.221ms`, but it is a first-shape
105
+ cost.
106
+
107
+ Remaining work:
108
+
109
+ - Preserve the rolling trace digest for hot hits.
110
+ - Identify why first-shape key generation still costs `1.743ms`.
111
+ - Move template digest and manifest fingerprint normalization out of the
112
+ miss path where possible.
113
+ - Keep cache misses exact without replay-payload hashing.
114
+
115
+ Target outcome:
116
+
117
+ - Shape-key generation is sub-millisecond for both miss and hit paths.
118
+ - Cache misses stay exact, and false misses remain safe.
119
+
120
+ 3. Reduce subscribe ack fixed cost
121
+
122
+ Server-side subscribe is no longer the main cost, but client-observed suback
123
+ can still be noisy. Keep the server/client split visible.
124
+
125
+ Candidate work:
126
+
127
+ - Keep cable subscribe phase telemetry: fetch, authorization, activation,
128
+ active-index register, stream attach, confirmation.
129
+ - Keep benchmark phase labels on WebSocket open, cable open, subscription
130
+ registration, and confirmation.
131
+ - Keep durable writer and persistent index work outside the ack critical path.
132
+ - Preserve strict activation semantics; no compatibility shims for old rows.
133
+
134
+ Target outcome:
135
+
136
+ - Server-side subscribe p95 stays within a few milliseconds of Turbo in both
137
+ 1- and 200-subscriber identity-free reports.
138
+
139
+ 4. Single-subscriber write fast path
140
+
141
+ The latest one-subscriber run recovered on write and update-settled, but the
142
+ one-sample p95 is noisy. Keep this lane explicit: if planning resolves to
143
+ exactly one active subscriber, skip shared-group work that only pays off for
144
+ cohorts.
145
+
146
+ Candidate checks:
147
+
148
+ - No render dedup accounting on the hot path for a single target unless the
149
+ runtime already produced the shared group.
150
+ - No batch fanout structure for one connection.
151
+ - No persistent index dependency for active, in-process delivery.
152
+
153
+ Target outcome:
154
+
155
+ - 1-subscriber update-settled returns to at or below Turbo.
156
+ - Planning/build telemetry shows one lookup, one plan, one render, one
157
+ transmit.
158
+
159
+ 5. Cold benchmark separation
160
+
161
+ Keep cold burst capacity separate from warm update economics.
162
+
163
+ Benchmark shapes:
164
+
165
+ - Cold burst capacity: simultaneous new users, accepts, page render, cable
166
+ open, subscribe ack.
167
+ - Cold ramp setup: staged subscribers without accept queue loss.
168
+ - Warm single update: subscribers are ready, one mutation settles.
169
+
170
+ ## Success Criteria
171
+
172
+ - 1-subscriber first-write miss behavior is explained and bounded.
173
+ - `steady_state_setup_leaks` is zero in warm single-subscriber runs.
174
+ - 1-subscriber setup p95 closes most of the current `140ms` versus Turbo
175
+ `104ms` gap.
176
+ - 1-subscriber page render p95 closes most of the current `91ms` versus Turbo
177
+ `73ms` gap.
178
+ - Shape miss telemetry shows where first-render cost remains.
179
+ - 1-subscriber update-settled p95 remains at or below Turbo.
180
+
181
+ ## Validation
182
+
183
+ Use the mise environment in every shell:
184
+
185
+ ```sh
186
+ eval "$(mise activate zsh)"
187
+ bundle exec rake test
188
+ BENCH_FAMILY=render_dedup BENCH_WORKLOAD=identity_free_feed_compare BENCH_TIER=report BENCH_VUS=1 ruby benchmark/bin/run
189
+ ```
190
+
191
+ When changing cold setup behavior, also run the relevant cold benchmark instead
192
+ of using the warm shared report as a proxy.
@@ -0,0 +1,310 @@
1
+ # Stress-test findings, fixes, and remaining decisions
2
+
3
+ **Gem under test:** `upkeep-rails` v0.1.0 (installed from RubyGems)
4
+ **How found:** end-to-end browser stress test in the companion app at
5
+ `../stress-test-upkeep/stress_app/` (Rails 8 + importmap + propshaft, real Chrome via Ferrum).
6
+ Run instructions and the harness are in that app's `STRESS_TEST.md` and `lib/stress/harness.rb`.
7
+ **Source confirmed against:** this repo (file:line references are relative to the repo root).
8
+
9
+ All line numbers are from upkeep-rails v0.1.0 (this repo).
10
+
11
+ ---
12
+
13
+ ## 2026-05-21 follow-up on v0.1.1 and v0.1.2
14
+
15
+ The companion app at
16
+ `/Volumes/FelipeSSD/professional/upkeep/rails/stress-test-upkeep/stress_app`
17
+ verified that 0.1.1 fixed importmap wiring, cold first-paint activation, scalar
18
+ page replay, identity rejection, and token expiry. The remaining correctness
19
+ failure was `shared stream survives remove`: after a rendered member was
20
+ removed, the next shared render-site replay reached the browser but used Turbo
21
+ Stream `replace` against a custom render-site wrapper while the template
22
+ contained only child `<li>` elements. Turbo removed the target container, so
23
+ later streams had no target.
24
+
25
+ The fix is to emit Turbo Stream `update method="morph"` for render-site replay
26
+ fallbacks and keep `replace method="morph"` only for member/fragment
27
+ replacements. In 0.1.2, render sites are legal existing HTML elements marked
28
+ with `data-upkeep-render-site`; Upkeep no longer emits a custom structural
29
+ wrapper. `update` swaps the render site's children and preserves the real
30
+ container that future streams target. Page-level fallbacks now use Turbo Stream
31
+ `refresh method="morph" scroll="preserve"` so Turbo owns page refresh behavior
32
+ instead of Upkeep replacing `<html>` or calling `document.write`.
33
+
34
+ Turbo interop was tightened at the client boundary too: the injected marker is
35
+ now a body-scoped `<upkeep-subscription-source data-upkeep-subscription
36
+ data-turbo-temporary>` custom element. The generated browser client registers it
37
+ as a Turbo stream source via `Turbo.session.connectStreamSource`, and DOM
38
+ connect/disconnect handles ActionCable subscribe/unsubscribe.
39
+
40
+ Two upgrade traps were also confirmed:
41
+
42
+ - `app/javascript/upkeep/subscription.js` is intentionally vendored, but it must
43
+ be refreshed when the server-side subscription payload changes. The generated
44
+ client now logs channel rejection, and the README documents rerunning the
45
+ installer or comparing the file after gem upgrades.
46
+ - `config.upkeep.*` set from `config/initializers/upkeep.rb` is too late for the
47
+ Railtie path. The installer now writes `Upkeep::Rails.configure do |config|`
48
+ in the initializer. `config.upkeep.*` remains appropriate in Rails
49
+ application or environment config that runs before initializers.
50
+
51
+ ---
52
+
53
+ ## Summary
54
+
55
+ | # | Finding | Type | Status |
56
+ |---|---------|------|--------|
57
+ | 1 | importmap installer never pins the browser client | Bug (clear fix) | Fixed + test |
58
+ | 2 | member `remove` broadcasts to a shared stream nobody listens on | Bug (clear fix) | Fixed + test |
59
+ | 3 | a delivery error turns an already-committed write into HTTP 500 | Bug (clear fix, safety half) | Fixed + test |
60
+ | 4 | scalar/page-level replay target can't be found → raises | Bug (clear fix) | Fixed + test |
61
+ | P1 | benchmark suite: AR-store `fetch` raises `RecordNotFound`, test expects `KeyError` | Pre-existing failure | Fixed + test |
62
+ | P2 | benchmark suite: identified-user streamed update delivers 0 broadcasts | Pre-existing failure (corroborates A) | Fixed + test |
63
+ | A | identity-bound pages need an explicit request-to-connection bridge | Framework limitation + decision | Implemented + documented |
64
+ | B | first paint should activate without creating an app guest identity | Framework limitation + decision | Fixed + test |
65
+ | C | commit-triggered delivery needs a job boundary | Architecture decision | Implemented + test |
66
+
67
+ ### What already works well (don't regress these)
68
+ - Anonymous-public collection pipeline (capture → commit → invalidate → broadcast → Turbo
69
+ render) is correct and fast: p50 ~50–60 ms, p95 ~80–130 ms on localhost, **0 cross-board
70
+ leaks in every run**; append/replace/remove all 100% when a board has a single subscriber.
71
+ - Opaque relations are refused exactly as documented (no marker injected; the page still
72
+ renders as plain Rails HTML under `refused_boundary_behavior = :warn`).
73
+
74
+ ---
75
+
76
+ ## Part 1 — Unequivocal fixes
77
+
78
+ These have a single defensible fix and now have regression coverage. This
79
+ section records the diagnosis so the changes can be reviewed.
80
+
81
+ ### Fix 1 — importmap installer never pins the browser client (critical)
82
+ - **Symptom:** in a stock Rails 8 + importmap + propshaft app, after
83
+ `bin/rails generate upkeep:install`, the cable client asset 404s, no `/cable` WebSocket
84
+ opens, and **nothing is ever delivered** (0 WebSocket upgrades observed).
85
+ - **Root cause:** `lib/generators/upkeep/install/install_generator.rb` —
86
+ `append_application_import` writes `import "./upkeep/subscription"` into
87
+ `app/javascript/application.js`, but `pin_action_cable` only pins
88
+ `@hotwired/turbo-rails` and `@rails/actioncable`. The `upkeep/subscription` module is
89
+ never pinned, so under propshaft's digested URLs the relative specifier resolves to
90
+ `/assets/upkeep/subscription` (no digest) → 404.
91
+ - **Fix:** generator should pin the module (`pin "upkeep/subscription", to:
92
+ "upkeep/subscription.js"`, or `pin_all_from "app/javascript/upkeep", under: "upkeep"`)
93
+ and import it by bare specifier.
94
+ - **Impact:** this breaks the *default* Rails 8 stack, i.e. essentially every new adopter.
95
+
96
+ ### Fix 2 — member `remove` not delivered to *shared* (2+) subscribers
97
+ - **Symptom:** with 2+ anonymous subscribers sharing a collection page, deleting a rendered
98
+ member never reaches them. With 1 subscriber it works. append/replace work in both cases.
99
+ - **Root cause:** subscriptions register shared streams **only for render-site frames** —
100
+ `lib/upkeep/shared_streams.rb` `names_for_graph` skips frames where
101
+ `kind != "render_site"`, keying the stream on the render-site target. But delivery computes
102
+ a remove's shared stream from the operation's **own** target —
103
+ `lib/upkeep/delivery/turbo_streams.rb:244` `shared_stream_name_for` →
104
+ `SharedStreams.stream_name(target: planned_target.target, …)`, and for a remove that target
105
+ is the member (`#card_<id>`), not the render-site. Different `target.kind/id` → different
106
+ SHA → broadcast to `upkeep:shared:<member-hash>` while subscribers listen on
107
+ `upkeep:shared:<render-site-hash>`. (append/replace work because they deopt to the
108
+ render-site target, whose hash matches.)
109
+ - **Fix:** member-level shared delivery should resolve to the enclosing render-site's shared
110
+ stream (the one the subscription registered).
111
+ - **Repro:** `SUBSCRIBERS=2 BOARDS=1 bin/rails stress:run` → "collection remove" goes 0/2.
112
+
113
+ ### Fix 3 — a delivery error turns an already-committed write into HTTP 500 (safety half)
114
+ - **Symptom:** once a page-replay subscriber exists (see Fix 4), an unrelated `create` that
115
+ has already **committed** returns 500 because Upkeep's after-commit delivery raises.
116
+ - **Root cause:** delivery runs **inline in the writer's request**. `lib/upkeep/runtime.rb:1005`
117
+ registers `ActiveRecord::Base.after_commit { ChangeLog.record(...) }`, and
118
+ `lib/upkeep/rails/controller_runtime.rb:99` calls
119
+ `Upkeep::Rails.deliver_changes!(changes)` synchronously at the end of the action. Any
120
+ exception in rendering/targeting propagates out of the committed request → 500. One broken
121
+ subscriber page poisons unrelated writers.
122
+ - **Fix (unequivocal part):** fault-isolate inline delivery — a render/target error must be
123
+ rescued and surfaced via instrumentation, never propagate to the committed write; other
124
+ targets still deliver.
125
+ - **Out of scope here (see Decision C):** moving delivery off the request entirely (async).
126
+
127
+ ### Fix 4 — scalar / page-level replay target can't be found → raises
128
+ - **Symptom:** a page-level (`pluck`/scalar) dependency fails to update and intermittently
129
+ raises `RuntimeError: target not found in full rerender`.
130
+ - **Root cause:** `lib/upkeep/targeting.rb:92` raises when `node_for` can't locate the target.
131
+ A page-level dependency yields a page target with the **inner template** id, e.g.
132
+ `page:rails:boards/stats`, and `node_for` (kind `"page"`, line ~99) looks for
133
+ `[data-upkeep-page-frame="page:rails:boards/stats"]`. But the rendered HTML only contains
134
+ `data-upkeep-page-frame="page:rails:layouts/application"` — **the inner template's
135
+ page-frame marker is never emitted into the DOM** (confirmed: the stats page's only
136
+ page-frame attribute is the layout's). So the target node never exists.
137
+ - **Fix:** make the emitted DOM page-frame markers consistent with the page targets Upkeep
138
+ records (emit the inner-template marker), or resolve page targets to the page frame that
139
+ actually exists.
140
+
141
+ ---
142
+
143
+ ## Part 2 — Decisions / framework limitations
144
+
145
+ These are the higher-level constraints found by the stress app. Decisions A, B,
146
+ and C are implemented in this worktree.
147
+
148
+ ### Decision A — identity-bound pages need an explicit request-to-connection bridge
149
+
150
+ **Decision made.** Upkeep does not infer subscriber identity from method names
151
+ such as `Current.user`, `current_user`, or Devise helpers. Runtime heuristics are
152
+ too weak: the gem cannot know whether `user` means the subscriber, an author, an
153
+ impersonator, or unrelated display state.
154
+
155
+ Instead, identity is now a declared bridge:
156
+
157
+ ```ruby
158
+ Upkeep::Rails.configure do |config|
159
+ config.identify :viewer, current: ["Current", :user] do
160
+ subscribe { |connection| connection.current_user }
161
+ end
162
+ end
163
+ ```
164
+
165
+ The declaration has two sides:
166
+ - **capture side** (`current:`, `session:`, `cookie:`, or `warden:`): the value
167
+ observed while rendering the HTTP response;
168
+ - **subscribe side** (`subscribe { ... }`): the same value resolved from the
169
+ ActionCable connection when the browser subscribes.
170
+
171
+ The identity name (`:viewer`, `:account`, `:tenant`, etc.) is part of the
172
+ canonical subscriber id. If a page captures multiple declared identities, the
173
+ ActionCable subscription must match all of them.
174
+
175
+ Declared identity values can be absent. The default absence rule is `nil`, so a
176
+ logged-out page that checks `Current.user` or `session[:user_id]` can remain
177
+ anonymous-public. Apps that use another sentinel declare it with `absent_if`.
178
+
179
+ **What changed in the repo.**
180
+ - `Upkeep::Rails.configuration.identify` declares identities.
181
+ - `SubscriberIdentity` derives request identity only from configured mappings.
182
+ - `Upkeep::Rails::Cable::Channel` authorizes an identified subscription against
183
+ the identity names stored with that subscription.
184
+ - Undeclared non-absent `CurrentAttributes` and Warden identity reads refuse live
185
+ registration with `identity_setup_required` / `unidentified_identity` instead
186
+ of silently guessing.
187
+ - The install generator writes identity examples into
188
+ `config/initializers/upkeep.rb` and prints setup guidance when it detects
189
+ obvious request-side identity usage.
190
+ - The README and `docs/architecture/identity-and-sharing.md` document the API.
191
+
192
+ **Why this is better than Devise/Warden-only.** Devise remains easy to wire, but
193
+ the API also supports non-Devise apps, tenant/account identities, session-backed
194
+ apps, cookie-backed partitioning, and multiple identity components without
195
+ runtime name guessing.
196
+
197
+ ### Decision B — first paint should activate without creating an app guest identity
198
+
199
+ **Decision made.** Upkeep should not solve first-paint activation by inventing a
200
+ global guest cookie or by asking every app to decide whether each session/cookie
201
+ identity can be auto-created. That pushes too much identity design onto
202
+ developers and risks turning public pages private.
203
+
204
+ The cleaner fix is a transport-level activation token. Every injected marker now
205
+ includes a stateless signed token for that subscription id. The browser sends it
206
+ when subscribing over ActionCable, and the channel verifies it before fetching
207
+ or activating the subscription.
208
+
209
+ That token answers: "did this browser receive this exact rendered response?"
210
+ It does not answer: "which subscriber boundary is this?" Identified pages still verify
211
+ the declared identity bridge from Decision A after token verification.
212
+
213
+ This keeps cold first paint live for subscription activation without requiring
214
+ an Upkeep-owned guest cookie. If a page is truly identity-specific, the app
215
+ still declares that identity explicitly.
216
+
217
+ ### Decision C — commit→delivery should use the app's job backend
218
+
219
+ **Decision made.** Commit-triggered delivery now has an Active Job boundary.
220
+ When a request commits Active Record changes, Upkeep can enqueue
221
+ `Upkeep::Rails::DeliveryJob` instead of planning, rendering, and broadcasting in
222
+ the writer's request.
223
+
224
+ The default Rails production configuration is
225
+ `config.upkeep.delivery_adapter = :active_job`, with
226
+ `config.upkeep.delivery_queue = :upkeep_realtime`. The job backend is the app's
227
+ normal Active Job backend: Solid Queue, Sidekiq, GoodJob, or another adapter.
228
+ Upkeep does not depend directly on Sidekiq or Solid Queue.
229
+
230
+ ActionCable remains a separate layer. The job worker still broadcasts through
231
+ `ActionCable.server.broadcast`, so multi-process apps need a shared cable
232
+ adapter such as Redis, Solid Cable, or PostgreSQL. Solid Queue plus Solid Cable
233
+ is the no-Redis path. Sidekiq plus Redis-backed ActionCable is the traditional
234
+ Redis path.
235
+
236
+ If enqueueing fails after the write commits, Upkeep instruments
237
+ `delivery_enqueue_error.upkeep` and does not turn the committed write into an
238
+ HTTP 500. Once the job is enqueued, job execution failures are left to the
239
+ configured Active Job backend's retry/error policy.
240
+
241
+ Development and tests can still use `config.upkeep.delivery_adapter = :async`
242
+ for process-local batching or `:inline` for explicit synchronous debugging.
243
+
244
+ ---
245
+
246
+ ## Part 3 — Pre-existing test-suite failures (fixed)
247
+
248
+ The core unit suite is green: `bundle exec rake test` → **200 runs, 0
249
+ failures**. The benchmark app is green: `benchmark/upkeep-app/bin/rails test` →
250
+ **7 runs, 0 failures**. The broader `bundle exec rake proof` task is also
251
+ green, including both benchmark apps and the proof runner.
252
+
253
+ ### P1 — `UpkeepChannelTest#test_keeps_connection_state_out_of_transport_on_unsubscribe`
254
+ `test/channels/upkeep_channel_test.rb:35`
255
+ ```
256
+ [KeyError] exception expected, not
257
+ Class: <ActiveRecord::RecordNotFound>
258
+ Message: Couldn't find Upkeep::Subscriptions::ActiveRecordStore::SubscriptionRecord with 'id'=...
259
+ ```
260
+ **What it means:** after unsubscribe the subscription is gone, but `Upkeep::Rails.subscriptions.fetch(id)`
261
+ raises a **different exception class depending on the store backend** — `KeyError` (memory store, what
262
+ the test asserts) vs `ActiveRecord::RecordNotFound` (AR store, what actually runs). The store
263
+ abstraction leaks its backend through the not-found error type. Low severity (production code in
264
+ `cable/channel.rb#unsubscribed` already rescues *both*), but it's a real contract gap.
265
+ **Fix:** both stores now normalize missing subscriptions to
266
+ `Upkeep::Subscriptions::NotFound < KeyError`, and the channel only needs the
267
+ store-level not-found contract.
268
+
269
+ ### P2 — `BenchmarkSurfaceTest#test_delivers_a_streamed_update_through_the_derived_subscriber_stream`
270
+ `test/integration/benchmark_surface_test.rb:85`
271
+ ```
272
+ Expected: 1
273
+ Actual: 0
274
+ ```
275
+ **What it means:** a **signed-in user** loads a board, a card is updated, and the test expects exactly
276
+ one Turbo Stream broadcast on that user's derived subscriber stream — but **zero** are captured. This is
277
+ the gem's *own* test for identified-subscriber delivery, and it is red at HEAD. It is direct,
278
+ in-repo corroboration of **Decision A**: identity-bound (per-user) delivery does not currently produce
279
+ the expected broadcast. Treat P2 as the failing acceptance test that Decision A must turn green; it is
280
+ the best regression target for whatever identity approach is chosen.
281
+ **Fix:** the benchmark app declares a subscriber identity with `current: ["Current", :user]`
282
+ and resolves it from `cable.current_user`. The benchmark acceptance test now
283
+ activates the persisted subscription before capturing broadcasts, matching the
284
+ ActiveRecord store's on-subscribe indexing model.
285
+
286
+ ---
287
+
288
+ ## Appendix — reproducing
289
+
290
+ From the companion stress app:
291
+
292
+ ```sh
293
+ cd ../stress-test-upkeep/stress_app
294
+ bin/rails db:prepare
295
+ bin/rails server -p 3001 # terminal 1
296
+ bin/rails stress:run # terminal 2 (defaults)
297
+
298
+ # targeted repros:
299
+ SUBSCRIBERS=2 BOARDS=1 bin/rails stress:run # Fix 2 (shared remove): "collection remove" 0/2
300
+ SUBSCRIBERS=5 BOARDS=5 bin/rails stress:run # everything green for the unshared case
301
+ ```
302
+
303
+ Tunables: `URL BOARDS CARDS_PER_BOARD SUBSCRIBERS WRITES WRITE_CONCURRENCY SETTLE
304
+ RECV_TIMEOUT HEADLESS CHROME`. The harness reports delivery completeness, cross-board leaks,
305
+ latency percentiles, and per-boundary correctness.
306
+
307
+ **Deliberate stress-app choices:** the layout omits `csrf_meta_tags`/`csp_meta_tag` (both read
308
+ the session; with the explicit identity model, non-absent declared values partition delivery while
309
+ absent values remain anonymous-public); writes use `skip_forgery_protection`; single Puma process with the
310
+ async cable adapter (multi-worker needs a shared adapter per the README).
data/docs/testing.md ADDED
@@ -0,0 +1,143 @@
1
+ # Testing
2
+
3
+ The Rails package green bar includes the gem tests, the maintained benchmark
4
+ apps, and the proof runner. The benchmark apps live inside this repo under
5
+ `benchmark/`.
6
+
7
+ ## Commands
8
+
9
+ Run the gem test suite:
10
+
11
+ ```sh
12
+ mise exec -- ruby -S rake test
13
+ ```
14
+
15
+ Run the full gate:
16
+
17
+ ```sh
18
+ mise exec -- ruby bin/test
19
+ ```
20
+
21
+ `bin/test` runs:
22
+
23
+ - all tests under `test/`;
24
+ - `benchmark/upkeep-app`'s Rails test suite;
25
+ - `benchmark/turbo-app`'s Rails test suite;
26
+ - `bin/run`, which writes proof reports to `results/`.
27
+
28
+ Run the proof runner directly:
29
+
30
+ ```sh
31
+ mise exec -- ruby bin/run
32
+ ```
33
+
34
+ ## What The Gate Covers
35
+
36
+ Gem tests cover:
37
+
38
+ - Action View frame capture and replay;
39
+ - controller request replay;
40
+ - Active Record/Arel query dependency analysis;
41
+ - subscription storage and reverse-index lookups;
42
+ - refused opaque collection boundaries;
43
+ - column-scoped collection lookup keys;
44
+ - invalidation planning;
45
+ - Turbo Stream delivery partitioning;
46
+ - render grouping before replay;
47
+ - ActionCable subscriber identity and channel behavior;
48
+ - transport retry and backpressure behavior.
49
+
50
+ The Upkeep benchmark app covers:
51
+
52
+ - authenticated board rendering;
53
+ - room rendering;
54
+ - shared feed rendering;
55
+ - authorization boundaries;
56
+ - helper-hidden render idioms;
57
+ - automatic subscription registration;
58
+ - streamed delivery through canonical subscriber streams.
59
+
60
+ The Turbo benchmark app remains the comparison app for workload parity.
61
+
62
+ ## Store Choice
63
+
64
+ Generated apps use the in-process memory subscription store in `test` by
65
+ default. That is deliberate: app tests usually need to prove the public
66
+ behavior of the subscription lifecycle, not database durability.
67
+
68
+ Use memory-backed tests for the normal request/system suite:
69
+
70
+ - successful HTML GETs inject the subscription marker;
71
+ - the subscription can be activated;
72
+ - mutations select the right streams;
73
+ - broadcasts contain the expected rendered bytes;
74
+ - unauthorized or unrelated bytes are not delivered.
75
+
76
+ Keep a smaller ActiveRecord-backed test or CI path for storage-specific
77
+ coverage:
78
+
79
+ - the generated migration matches the required schema;
80
+ - subscriptions and reverse-index rows persist;
81
+ - lookup survives store reload;
82
+ - pruning removes durable rows;
83
+ - Active Job delivery works with the app's queue adapter.
84
+
85
+ Avoid asserting a store class in general app behavior tests. The memory and
86
+ ActiveRecord stores share the same public lifecycle contract, so app tests
87
+ should mostly assert markers, activation, streams, broadcasts, and rendered
88
+ bytes. Assert `Upkeep::Subscriptions::ActiveRecordStore` only in tests whose
89
+ purpose is durable storage.
90
+
91
+ ## App-Level Assertions
92
+
93
+ Maintained Rails apps should assert that successful HTML GETs register a
94
+ subscription:
95
+
96
+ ```ruby
97
+ include Upkeep::Rails::Testing
98
+
99
+ get board_path(board)
100
+ assert_response :success
101
+ assert_upkeep_subscription_registered
102
+ activate_upkeep_subscription!
103
+ ```
104
+
105
+ They should also assert that unauthorized bytes do not render:
106
+
107
+ ```ruby
108
+ get board_path(private_board)
109
+ assert_response :forbidden
110
+ refute_includes response.body, "Private card"
111
+ ```
112
+
113
+ For streamed delivery, include `ActionCable::TestHelper`, capture the
114
+ registered stream names through `Upkeep::Rails::Testing`, perform a mutation,
115
+ drain delivery through the testing namespace, and assert the broadcast payload:
116
+
117
+ ```ruby
118
+ include ActionCable::TestHelper
119
+ include Upkeep::Rails::Testing
120
+
121
+ broadcasts = capture_upkeep_broadcasts do
122
+ patch board_card_path(board, card), params: { card: { title: "Updated" } }
123
+ assert_response :ok
124
+ drain_upkeep_delivery!
125
+ end
126
+
127
+ assert_includes broadcasts.join, "Updated"
128
+ refute_includes broadcasts.join, "Other subscriber secret"
129
+ ```
130
+
131
+ ## Result Reports
132
+
133
+ The proof runner writes:
134
+
135
+ - `results/herb_surface.json`
136
+ - `results/active_record_surface.json`
137
+ - `results/end_to_end_proof.json`
138
+ - `results/identity_safety_proof.json`
139
+ - `results/auth_surfaces_proof.json`
140
+
141
+ These reports are debugging artifacts for maintained app work. They are useful
142
+ when a change alters frame coverage, dependency metadata, identity
143
+ partitioning, or target selection.