Skip to content

Commit 21c35bb

Browse files
cardmagicclaude
andcommitted
feat: gate the pruned answer on the query hook
An actor's remembered idempotency keys are actor state, so a caller the policy refuses must not learn from them that a key was used. The pruned answer now runs `authorize_query` with the same `__snapshot__` operation that `snapshot` uses, and a refused caller reads `nil` for a pruned message and for one that never existed alike. Without the gate the new test fails with: SolidObjects::MessagePruned: the message for idempotency key "checkout-7f3a" was pruned Validation: bundle exec rake (781 runs, 0 failures). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c6e9447 commit 21c35bb

6 files changed

Lines changed: 48 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
store and no second write. `reference.find_by(idempotency_key:)` raises
2424
`SolidObjects::MessagePruned` for a key the actor remembers and whose message
2525
retention removed, and still answers `nil` for a key no caller ever sent.
26+
The memory is actor state, so `authorize_query` gates the pruned answer and a
27+
caller the policy refuses reads `nil` for both.
2628
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for
2729
each actor. A lookup by request id cannot make the distinction, because the
2830
runtime, not the caller, generates a request id and no actor remembers one.

‎docs/architecture.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,9 @@ than in a separate tombstone table, which needs no second store, no second
442442
write, and no separate retention. `reference.find_by(idempotency_key:)` raises
443443
`MessagePruned` for a key the actor remembers and whose message retention
444444
removed, and answers `nil` for a key no caller ever sent, so a client can tell
445-
a lost result from a request that never arrived.
445+
a lost result from a request that never arrived. The memory is actor state, so
446+
`authorize_query` gates the pruned answer the way it gates `snapshot`, and a
447+
caller the policy refuses reads `nil` for both.
446448
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
447449
actor. Only a lookup by idempotency key can make the distinction. A request id
448450
is generated by the runtime rather than by the caller, so no actor remembers

‎docs/operations.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,8 @@ observe it.
549549
names survives retention. A lookup by idempotency key still tells the two cases
550550
apart after pruning, because the actor remembers the keys of its own last
551551
`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key
552-
the actor remembers and answers `nil` for a key no caller ever sent. Raise
552+
the actor remembers and answers `nil` for a key no caller ever sent. The
553+
memory is actor state, so `authorize_query` gates the pruned answer. Raise
553554
`retained_idempotency_keys` above the default of 64 when an actor finishes more
554555
keyed turns than that inside the window in which a caller may retry. A lookup
555556
by request id answers `nil` in both cases, so a caller that must tell them apart

‎lib/solid_objects/client.rb‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization
105105

106106
return readable_message(requested_message(request_id), authorization_context:) if request_id
107107

108-
readable_message(remembered_message(reference, idempotency_key), authorization_context:)
108+
readable_message(
109+
remembered_message(reference, idempotency_key, authorization_context:),
110+
authorization_context:
111+
)
109112
end
110113

111114
# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
@@ -183,8 +186,8 @@ def requested_message(request_id)
183186
Message.uncached { Message.find_by(request_id:) }
184187
end
185188

186-
# @rbs (Reference, String) -> Message?
187-
def remembered_message(reference, idempotency_key)
189+
# @rbs (Reference, String, authorization_context: untyped) -> Message?
190+
def remembered_message(reference, idempotency_key, authorization_context:)
188191
instance = Instance.find_by(
189192
actor_type: reference.actor_type,
190193
actor_id: reference.actor_id
@@ -193,9 +196,24 @@ def remembered_message(reference, idempotency_key)
193196

194197
message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) }
195198
return message if message
196-
raise MessagePruned, idempotency_key if Array(instance.completed_idempotency_keys).include?(idempotency_key)
199+
return nil unless Array(instance.completed_idempotency_keys).include?(idempotency_key)
200+
return nil unless readable_state?(reference, authorization_context:)
201+
202+
raise MessagePruned, idempotency_key
203+
end
197204

198-
nil
205+
# @rbs (Reference, authorization_context: untyped) -> bool
206+
def readable_state?(reference, authorization_context:)
207+
authorize!(
208+
hook: SolidObjects.configuration.authorize_query,
209+
reference:,
210+
operation: "__snapshot__",
211+
arguments: {},
212+
authorization_context:
213+
)
214+
true
215+
rescue Unauthorized
216+
false
199217
end
200218

201219
# @rbs (Message?, authorization_context: untyped) -> MessageReference?

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ module SolidObjects
3535
# @rbs (String) -> Message?
3636
def requested_message: (String) -> Message?
3737

38-
# @rbs (Reference, String) -> Message?
39-
def remembered_message: (Reference, String) -> Message?
38+
# @rbs (Reference, String, authorization_context: untyped) -> Message?
39+
def remembered_message: (Reference, String, authorization_context: untyped) -> Message?
40+
41+
# @rbs (Reference, authorization_context: untyped) -> bool
42+
def readable_state?: (Reference, authorization_context: untyped) -> bool
4043

4144
# @rbs (Message?, authorization_context: untyped) -> MessageReference?
4245
def readable_message: (Message?, authorization_context: untyped) -> MessageReference?

‎test/integration/result_lookup_test.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,19 @@ def reject_checkout
234234
assert_nil reference.find_by(idempotency_key: "never-used")
235235
end
236236

237+
test "does not tell a refused caller that a key was pruned" do
238+
reference = CartActor.ref("alice")
239+
reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1)
240+
run_actors
241+
SolidObjects::Message.delete_all
242+
SolidObjects.configuration.authorize_query = ->(**) { false }
243+
244+
assert_nil reference.find_by(
245+
idempotency_key: "checkout-7f3a",
246+
authorization_context: "stranger"
247+
)
248+
end
249+
237250
test "remembers a key whose message was rejected" do
238251
reference = CartActor.ref("alice")
239252
reference.async(idempotency_key: "rejected-7f3a").reject_checkout

0 commit comments

Comments
 (0)