Skip to content

Commit 183cbc8

Browse files
committed
fix: secure realtime projections
Keep private observable values out of durable broadcasts while retaining\ncomponent invalidation. Align effect callbacks, rejection failures, and\nreminder testing with the cross-runtime contracts for version 0.12.1.
1 parent a01b6f5 commit 183cbc8

36 files changed

Lines changed: 405 additions & 75 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 0.12.1 - 2026-08-13
4+
5+
- Add invalidation-only observables with `broadcast: :invalidation`. They still
6+
detect changes and refresh reactive components, but persist `{}` and send no
7+
scalar value over Action Cable. Document that ordinary observable values are
8+
shared with every authorized actor subscriber and that subscriber-specific
9+
state belongs in a payload projection.
10+
- **Breaking:** include the originally staged `arguments:` in effect success
11+
and failure callbacks so actors can correlate concurrent effects.
12+
- Accept identifier-style rejection codes, including camelCase and symbols,
13+
and fail malformed codes once with non-retryable
14+
`SolidObjects::InvalidRejectionCode` diagnostics.
15+
- Add `run_due_reminders(now:)` to `SolidObjects::TestHelper` for deterministic
16+
reminder tests without sleeping or mutating runtime rows.
17+
318
## 0.12.0 - 2026-08-13
419

520
- Replace positional actor dispatch with fluent operation selection. Direct

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.12.0)
4+
solid_objects (0.12.1)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -383,7 +383,7 @@ CHECKSUMS
383383
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
384384
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
385385
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
386-
solid_objects (0.12.0)
386+
solid_objects (0.12.1)
387387
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
388388
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
389389
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,21 @@ dependencies changes:
205205
<% end %>
206206
```
207207

208+
An observable's value is shared with every authorized subscriber by default
209+
and is stored in `solid_objects_broadcasts`. Use an invalidation-only observable
210+
for component dependencies whose value is private or subscriber-specific:
211+
212+
```ruby
213+
observable :player_one, broadcast: :invalidation do
214+
player_in_seat(1)
215+
end
216+
```
217+
218+
Its value is still available to the authorized component renderer, but the
219+
durable row and Action Cable frame carry only invalidation metadata. It cannot
220+
be rendered as a scalar `<span>`. Put per-viewer state in `broadcast_payload`,
221+
which computes a fresh projection for each connection.
222+
208223
Component names can repeat when each instance has a stable key. Signed
209224
JSON-compatible locals let one conventional partial render the matching
210225
projection:
@@ -736,8 +751,8 @@ JSON-compatible details. The rejected message remains durable for audit, actor
736751
state is rolled back, and no later mailbox turn is blocked.
737752

738753
`Rejected#code` is a `String`, even when `reject` receives a symbol. Codes must
739-
match `\A[a-z][a-z0-9_]*\z`; invalid codes raise `ArgumentError` when the
740-
handler calls `reject`.
754+
match `\A[A-Za-z_][A-Za-z0-9_]*\z`. Invalid codes raise
755+
`SolidObjects::InvalidRejectionCode` and fail the turn without retrying.
741756

742757
### Redelivery
743758

@@ -819,11 +834,11 @@ def checkout(payment_id:, amount_cents:)
819834
)
820835
end
821836

822-
def payment_succeeded(effect_id:, result:)
837+
def payment_succeeded(effect_id:, arguments:, result:)
823838
self.checkout_status = "paid"
824839
end
825840

826-
def payment_failed(effect_id:, error:)
841+
def payment_failed(effect_id:, arguments:, error:)
827842
self.checkout_status = "failed"
828843
end
829844
```
@@ -842,6 +857,10 @@ end
842857

843858
The provider call can repeat if a process dies after external success but
844859
before recording completion. The stable effect ID is the idempotency key.
860+
Success callbacks receive `effect_id:`, the originally staged `arguments:`,
861+
and `result:`. Failure callbacks receive `effect_id:`, `arguments:`, and
862+
`error:`, so an actor can correlate concurrent effects without storing a
863+
separate callback ledger.
845864

846865
## Reminders
847866

app/models/solid_objects/broadcast.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,13 @@ class Broadcast < Record
66

77
belongs_to :message, class_name: "SolidObjects::Message"
88
belongs_to :instance, class_name: "SolidObjects::Instance"
9+
10+
# @rbs () -> bool
11+
def broadcasts_value?
12+
return false if observable_name == PayloadBroadcast::REVISION_OBSERVABLE
13+
14+
actor_class = SolidObjects.registry.fetch(instance.actor_type)
15+
actor_class.definition.broadcasts_observable_value?(observable_name)
16+
end
917
end
1018
end

docs/architecture.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ The supervisor starts configured worker, effect, reminder, and broadcast thread
113113

114114
### Effect worker
115115

116-
An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages.
116+
An effect worker claims due effect rows through the database coordination adapter, invokes a registered handler outside a database transaction, then records success or retryable failure. The handler receives the effect UUID as its idempotency key. Optional outcome messages are normal actor mailbox messages and receive the effect ID, originally staged arguments, and result or error.
117117

118118
### Reminder scheduler
119119

@@ -169,7 +169,8 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
169169
`message` and `query` both execute as durable mailbox turns. A query may not
170170
mutate state. The executor detects query mutation and fails the message. An
171171
observable is a named projection of state used by server rendering and realtime
172-
updates; it is not independently persisted.
172+
updates. Its durable broadcast row stores the projected value by default;
173+
`broadcast: :invalidation` stores only an empty invalidation marker.
173174

174175
Lifecycle hooks are deterministic local hooks:
175176

docs/database-schema.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
9292
### `broadcasts`
9393

9494
Durable observable-change outbox. The unique message/observable key prevents
95-
duplicate rows for one actor turn. Rows contain the observable JSON value and
96-
message/instance references used to derive invalidation metadata, never
95+
duplicate rows for one actor turn. Rows contain the observable JSON value, or
96+
`{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
9797
personalized rendered HTML. Claim and delivery indexes support retries and
9898
cleanup.
9999

docs/development.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ assert_equal "completed", message.status
6565
Pass `roles: [:actors]` when a test intentionally wants to leave outboxes or
6666
reminders pending.
6767

68+
Rails time travel does not move the database clock used by reminder claims. Run
69+
future reminders against an explicit test instant instead of updating runtime
70+
rows or sleeping:
71+
72+
```ruby
73+
assert_equal 1, run_due_reminders(now: 5.minutes.from_now)
74+
assert_equal 1, drain_solid_objects(roles: [ :actors ])
75+
```
76+
77+
The explicit instant controls due selection and recurring schedule advancement.
78+
Claim timestamps and stale-process recovery still use database time.
79+
6880
`SolidObjects::TestHelper.reset_actors!` is also available for explicit suite
6981
boundaries. It deletes every actor-owned row itself rather than deleting actor
7082
instances and letting the database cascade remove the rest: SQLite has to be

docs/realtime.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,27 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
6565
invalidation correct and prevents a partial from silently depending on state
6666
that cannot wake it.
6767

68+
Observable values are shared projections. By default, each changed value is
69+
stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
70+
every subscriber that passes `authorize_subscription`. Authorization to the
71+
actor stream is not a per-viewer projection.
72+
73+
For a component dependency whose value must never enter the durable outbox or
74+
Action Cable frame, declare it invalidation-only:
75+
76+
```ruby
77+
observable :player_one, broadcast: :invalidation do
78+
player_in_seat(1)
79+
end
80+
```
81+
82+
The runtime still compares the value around each successful turn and uses a
83+
change to refresh components, but stores `{}` and renders no scalar Turbo
84+
replacement. An invalidation-only observable therefore cannot be used as a
85+
scalar value such as `actor.player_one`. The component endpoint reads the
86+
latest committed value and authorizes it again; subscriber-specific state
87+
belongs in `broadcast_payload`.
88+
6889
```erb
6990
<ul>
7091
<% actor.recent_messages.each do |message| %>
@@ -416,7 +437,8 @@ component key, locals, or DOM ID.
416437
## Broadcast durability
417438

418439
The actor's fenced commit compares observables before and after the turn and
419-
inserts one broadcast row per changed value. The actor state, monotonic
440+
inserts one broadcast row per changed observable. Value-broadcast observables
441+
store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
420442
`state_revision`, message completion, and broadcast rows commit atomically. A
421443
rolled-back or fenced-out turn therefore cannot invalidate a component.
422444

@@ -456,8 +478,8 @@ response revision fencing.
456478
## Cost model
457479

458480
The durable row cost is unchanged: one broadcast row per changed observable,
459-
containing its JSON value and the message/instance references needed to derive
460-
invalidation metadata. No rendered document is stored. Each affected component
481+
containing either its JSON value or an empty invalidation marker plus the
482+
message/instance references needed to derive invalidation metadata. No rendered document is stored. Each affected component
461483
adds one authorized GET and one partial render per non-coalesced state
462484
revision. A repeated keyed component adds one GET and render per key. Signed
463485
locals increase page and Cable subscription bytes but do not create durable

docs/roadmap.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
- Bounded activation passes, idle cache, hot-actor yield, and process records
1313
- At-least-once retries, terminal domain rejection, strict poison ordering,
1414
dead letters, and tail retry
15-
- Transactional effects with success/failure actor messages
15+
- Transactional effects with success/failure actor messages carrying the
16+
originally staged arguments for callback correlation
1617
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
1718
portable status rows with polling indexes and database check constraints on
1819
status, which works on all three adapters; a future version may add narrow
@@ -23,8 +24,10 @@
2324
listed here while broken in that worker: the scheduler reached a constant the
2425
caller path happened to load, so reminders never fired in production and
2526
every in-process test still passed
26-
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
27-
components, signed component locals, and authorized replace or morph refresh
27+
- Durable value or invalidation-only observable broadcasts, scalar Turbo
28+
replacement, keyed ERB components, signed component locals, and authorized
29+
replace or morph refresh. Invalidation-only observables retain component
30+
change detection while storing and broadcasting no projected value
2831
- Batched component refreshes: components sharing a signed `batch:` collapse to
2932
one browser request per revision, served as HTML frames in a JSON envelope
3033
- Personalized state payload broadcasts computed per subscriber under that
@@ -45,7 +48,8 @@
4548
- Bounded message/process pruning, actor-type opt-in instance expiration,
4649
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
4750
helper that clears every actor-owned table itself rather than relying on the
48-
database cascade, which a host application may not enforce
51+
database cascade, which a host application may not enforce, and runs due
52+
reminders against an explicit test time without moving the database clock
4953
- Supervisor role replacement: a role whose thread dies is restarted until
5054
shutdown is requested, and dead process records plus expired message and
5155
process history are pruned on their own intervals without an application

docs/security.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
3636
authorization. Signed stream tokens are readable by their recipient and prove
3737
integrity only.
3838

39+
Every normal observable value is stored in the broadcast outbox and can reach
40+
every subscriber that passes `authorize_subscription` for the actor. Never put
41+
credentials, session identifiers, private cards, hidden library order, or any
42+
other subscriber-specific state in a value-broadcast observable. Declare a
43+
component dependency with `broadcast: :invalidation` when only change metadata
44+
may cross the shared stream, or use `broadcast_payload` for a projection that
45+
must be computed separately for each authorized connection.
46+
3947
## Serialization
4048

4149
The built-in serializer accepts JSON-compatible data, normalizes keys to

0 commit comments

Comments
 (0)