Skip to content

Commit 4530819

Browse files
committed
fix: Keep queries and projections read-only
Reject actor state changes and durable intents from queries and observable projections before they can commit. Fail these violations without retrying, matching the JavaScript runtime. Pin reserved JSON names with shared fixtures and correct reminder limits and dead-transmit recovery documentation.
1 parent 01b2981 commit 4530819

15 files changed

Lines changed: 192 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
- Reject query and observable state mutation and staged durable work with
6+
terminal `QueryMutatedState` errors. Cover individual snapshot projections and
7+
preserve ordinary operations' already-staged work while reading projections.
8+
- Pin reserved JSON property names with shared Ruby/JS fixtures. Document the
9+
reminder-name limit difference and the authorized dead-transmit retry API.
10+
511
- Reauthorize every message-reference status, result, and outcome read against
612
the original invocation. Pass `authorization_context:` on every read.
713
- Retain immutable JSON results for background and internal messages as well as

‎compatibility/json-values.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"name": "object prototype key",
4+
"value": { "__proto__": { "role": "ordinary data" }, "value": 1 }
5+
},
6+
{ "name": "scalar prototype key", "value": { "__proto__": "ordinary data" } },
7+
{ "name": "null prototype key", "value": { "__proto__": null } },
8+
{
9+
"name": "nested reserved names",
10+
"value": {
11+
"items": [{ "__proto__": { "constructor": "data" }, "prototype": true, "hasOwnProperty": 1 }]
12+
}
13+
}
14+
]

‎docs/architecture.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,12 @@ creating a message or activation, applies required state migrations in memory,
167167
and returns deeply frozen declared attributes. It can race with an in-flight
168168
turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
169169

170-
`message` and `query` both execute as durable mailbox turns. A query may not
171-
mutate state. The executor detects query mutation and fails the message. An
172-
observable is a named projection of state used by server rendering and realtime
173-
updates. Its durable broadcast row stores only an empty invalidation marker by
170+
`message` and `query` both execute as durable mailbox turns. Queries and
171+
observables must not mutate state or stage effects, recovery checks, commit
172+
actions, reminders, or outbound messages. Violations raise `QueryMutatedState`
173+
and fail the message without retrying or committing its work. Individual snapshot
174+
projections enforce the same rule. An observable is a named projection used by
175+
server rendering and realtime updates. Its durable broadcast row stores only an empty invalidation marker by
174176
default. `broadcast: :value` explicitly opts into storing and sharing the
175177
projected value.
176178

‎docs/reminders.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ key only decides which alarm is which.
6565

6666
A key must be non-empty, and the name it becomes must fit the 191-character
6767
column, which is checked on the composed name rather than the key alone so a
68-
long operation and a short key are caught too.
68+
long operation and a short key are caught too. JavaScript allows 255 UTF-16 code
69+
units for the same combined name. These existing schema limits remain different;
70+
use at most 191 ASCII characters for names shared across runtimes.
6971

7072
The key is separated from the operation by a colon, so an operation may not hold
7173
one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be

‎docs/roadmap.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
- Rails engine, install generator, migration, and CLI
99
- Explicit actor registry, references, JSON state, and state migrations
10+
- Queries and observable projections reject state mutation and staged durable
11+
work with terminal `QueryMutatedState` errors, matching JavaScript. Shared JSON
12+
fixtures preserve reserved property names as ordinary data in both runtimes.
1013
- Fluent direct synchronous RPC, configured `sync`, and durable `async`
1114
- Durable message history plus ready/claimed membership tables
1215
- Concurrent sequence allocation and actor creation. An enqueue finds the

‎docs/transmission.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,19 @@ SolidObjects.configure do |configuration|
8787
end
8888
```
8989

90-
These settings apply to every effect, not only transmits. A dead transmit
91-
effect has no retry API; the dashboard lists it, and recovery means
92-
returning its row to `pending` with a cleared `attempt_count`. Order
93-
survives that recovery, because the drain orders by mailbox sequence, not
94-
by retry time.
90+
These settings apply to every effect, including transmits. Retry a dead transmit
91+
through the authorized administration API:
92+
93+
```ruby
94+
SolidObjects.dead_letters.effects.retry(effect_id, authorization_context: operator)
95+
```
96+
97+
Retry resets attempts and returns the effect to pending with its stable identity,
98+
so the receiver still deduplicates replays. The administration policy must allow
99+
`retry` on `effect_dead_letters`; the action is recorded in the audit log. Use
100+
`SolidObjects.dead_letters.effects.redrive(authorization_context: operator)` to
101+
recover a scope in bounded batches. Order survives recovery because the drain
102+
orders by source sequence and staging order.
95103

96104
## Wire contract
97105

‎lib/solid_objects/actor.rb‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,9 @@ def invoke(operation, arguments)
459459

460460
# @rbs () -> Hash[String, untyped]
461461
def observable_values
462-
guard_application_writes("observables") do
462+
return {} if self.class.definition.observables.empty?
463+
464+
read_projection("observables") do
463465
self.class.definition.observables.each_with_object({}) do |(name, handler), values|
464466
values[name.to_s] = Serialization.dump(instance_exec(&handler.block))
465467
end
@@ -472,7 +474,7 @@ def observable_value(name)
472474
handler = self.class.definition.observables[observable_name]
473475
raise UnknownMessage, "unknown observable #{name.inspect}" unless handler
474476

475-
guard_application_writes("observable.#{observable_name}") do
477+
read_projection("observable.#{observable_name}") do
476478
Serialization.dump(instance_exec(&handler.block))
477479
end
478480
end
@@ -537,6 +539,12 @@ def discard_intents
537539
outbound_message_intents.clear
538540
end
539541

542+
# @rbs () -> Integer
543+
def intent_count
544+
effect_intents.length + effect_recovery_intents.length + commit_action_intents.length +
545+
reminder_intents.length + outbound_message_intents.length
546+
end
547+
540548
private
541549

542550
attr_reader :effect_intents,
@@ -545,6 +553,18 @@ def discard_intents
545553
:reminder_intents,
546554
:outbound_message_intents
547555

556+
# @rbs (String) { () -> untyped } -> untyped
557+
def read_projection(operation)
558+
state_before = state.to_h
559+
intents_before = intent_count
560+
result = guard_application_writes(operation) { yield }
561+
unless state.to_h == state_before && intent_count == intents_before
562+
raise QueryMutatedState, "observables must not mutate actor state or stage durable work"
563+
end
564+
565+
result
566+
end
567+
548568
# @rbs (String) { () -> untyped } -> untyped
549569
def guard_application_writes(operation, &block)
550570
ApplicationWriteGuard.call(

‎lib/solid_objects/errors.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ class Error < StandardError
77
class NonRetryableError < Error
88
end
99

10+
class QueryMutatedState < NonRetryableError
11+
end
12+
1013
class UnsupportedDatabase < Error
1114
end
1215

‎lib/solid_objects/executor.rb‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def call
3131
result = invoke_actor(message_context)
3232
observable_changes = changed_observables(observables_before, actor.observable_values)
3333
state_after = actor.state.to_h_with_byte_size
34-
ensure_query_did_not_mutate_state!(state_before, state_after.value)
34+
ensure_query_is_read_only!(state_before, state_after.value)
3535
complete(
3636
result,
3737
observable_changes,
@@ -70,12 +70,11 @@ def invoke_actor(message_context)
7070
end
7171

7272
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
73-
def ensure_query_did_not_mutate_state!(state_before, state_after)
74-
return unless message.delivery_mode == "sync"
73+
def ensure_query_is_read_only!(state_before, state_after)
7574
return unless actor.class.definition.queries.key?(message.operation.to_sym)
76-
return if state_after == state_before
7775

78-
raise InvalidActor, "query #{message.operation.inspect} mutated actor state"
76+
raise QueryMutatedState, "query #{message.operation.inspect} mutated actor state" if state_after != state_before
77+
raise QueryMutatedState, "query #{message.operation.inspect} staged durable work" if actor.intent_count.positive?
7978
end
8079

8180
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]

‎sig/generated/lib/solid_objects/actor.rbs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ module SolidObjects
348348
# @rbs () -> void
349349
def discard_intents: () -> void
350350

351+
# @rbs () -> Integer
352+
def intent_count: () -> Integer
353+
351354
private
352355

353356
attr_reader effect_intents: untyped
@@ -360,6 +363,9 @@ module SolidObjects
360363

361364
attr_reader outbound_message_intents: untyped
362365

366+
# @rbs (String) { () -> untyped } -> untyped
367+
def read_projection: (String) { () -> untyped } -> untyped
368+
363369
# @rbs (String) { () -> untyped } -> untyped
364370
def guard_application_writes: (String) { () -> untyped } -> untyped
365371

0 commit comments

Comments
 (0)