Skip to content

Commit 01b2981

Browse files
committed
fix: Align message results and authorization
Reauthorize message reads and retain bounded JSON results for background operations so Ruby and JavaScript provide the same outcome contract. Normalize polling telemetry units and preserve the matching transmit validation and ordering guarantees in shared fixtures and documentation.
1 parent 7149e9c commit 01b2981

23 files changed

Lines changed: 206 additions & 51 deletions

‎CHANGELOG.md‎

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

33
## Unreleased
44

5+
- Reauthorize every message-reference status, result, and outcome read against
6+
the original invocation. Pass `authorization_context:` on every read.
7+
- Retain immutable JSON results for background and internal messages as well as
8+
synchronous calls. All operations now enforce result serialization and size
9+
limits; return `nil` explicitly when an operation does not need a result.
10+
`result` raises terminal rejection/failure errors; `outcome` exposes them as data.
11+
- Preserve polling transition intervals in milliseconds and string reasons in
12+
portable telemetry. Pin transmit staging order and null-argument validation
13+
against the shared JavaScript contract.
14+
515
- Add portable telemetry, isolated observer hooks, metric definitions, and bounded authorized actor diagnostics matching JavaScript.
616

717

‎app/models/solid_objects/message.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ def dead?
4747
dead_letter.present?
4848
end
4949

50+
# @rbs () -> untyped
51+
def result!
52+
if rejected?
53+
raise Rejected.new(
54+
code: rejection.fetch("code"),
55+
message: rejection.fetch("message"),
56+
details: rejection.fetch("details"),
57+
message_id: id
58+
)
59+
end
60+
raise MessageFailed.new("actor message failed permanently", message_id: id, details: error || {}) if dead?
61+
62+
Serialization.readonly_copy(result)
63+
end
64+
5065
private
5166

5267
# @rbs () -> void

‎compatibility/transmit-envelopes.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@
8484
"operation": "increment",
8585
"arguments": [ 1 ]
8686
}
87+
},
88+
{
89+
"name": "null arguments",
90+
"envelope": {
91+
"effectId": "fixture-effect-0008",
92+
"actorType": "transmit-counters",
93+
"actorId": "fixture-counter",
94+
"operation": "increment",
95+
"arguments": null
96+
}
8797
}
8898
]
8999
}

‎docs/architecture.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,8 +431,16 @@ Each lookup runs the authorization hook the original call ran, against the
431431
stored operation and arguments, and answers `nil` for an absent row, an
432432
unregistered actor, and a refused caller alike, so it cannot be used to ask
433433
whether a request id exists. `MessageReference#outcome` reports the status, the
434-
result, the persisted error, the rejection, and the attempt count. A result is
435-
stored for `sync` delivery only.
434+
result, the persisted error, the rejection, and the attempt count. Every delivery
435+
mode stores its JSON result, including background messages. Result serialization
436+
and `max_result_bytes` apply before commit; a result that cannot be stored fails
437+
the turn. Return `nil` explicitly from operations that need no result.
438+
439+
`status`, `result`, and `outcome` reauthorize the stored operation on every read.
440+
Pass `authorization_context:` each time; references retain identity rather than
441+
caller permissions. `result` raises `Rejected` or `MessageFailed` for terminal
442+
errors and returns a deeply frozen successful value. `outcome` reports terminal
443+
errors as data.
436444

437445
An actor remembers the idempotency keys of its own finished turns. The executor
438446
already writes the instance row in the transaction that completes, rejects, or

‎docs/authorization.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ answers nothing until the host application defines its trust boundary.
1515
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
1616
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
1717

18-
Waiting again through `MessageReference#wait` reauthorizes the stored
19-
invocation as a message or query. Internal reminder, effect-callback, and
18+
Every `MessageReference#status`, `#result`, `#outcome`, and `#wait` call
19+
reauthorizes the stored operation and arguments as a message or query. Supply
20+
`authorization_context:` on each call, including after `find_by`; the reference
21+
does not retain the original caller's context. Internal reminder, effect-callback, and
2022
actor-to-actor deliveries come from
2123
already committed runtime rows and do not re-enter the public client policy.
2224

‎docs/observability.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ Events describe local observations. Concurrent deletion, crashes, and failed
4343
exporters can omit events. Never infer exactly-once delivery from event counts.
4444
Additional existing runtime events retain their names.
4545

46+
Portable `polling.interval_changed` events carry `previousIntervalMilliseconds`,
47+
`currentIntervalMilliseconds`, and a string `reason`. Ruby converts its native
48+
second-based notification values while preserving the original notification.
49+
4650
## Metrics and tracing
4751

4852
Metrics are sample descriptions. Exporting them is opt-in: the runtime does not

‎docs/roadmap.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@
122122
batched and unbatched components, an inert replay of an applied revision,
123123
cancellation of the request left in flight by the drop, incarnation ordering
124124
after a destroy and recreate, and payload delivery exactly once per revision
125+
- Authorized message-reference reads recheck the original operation and arguments,
126+
return immutable JSON results for every delivery mode, and raise terminal errors
127+
from `result` while `outcome` exposes them as data.
125128
- Result lookup by request ID and by idempotency key, authorized with the hook
126129
the original call ran and against the stored operation and arguments. An
127130
actor remembers the idempotency keys of its own last `retained_idempotency_keys`

‎docs/security.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ delegate to the authorized synchronous invocation path. Keep implementation
1616
helpers private or protected. Query, attribute, observable, and committed
1717
`snapshot` reads use the separate query authorization policy. Explicit `async`
1818
message delivery uses the same message authorization policy as direct calls.
19-
Recovering a timed-out result through `MessageReference#wait` reauthorizes the
20-
stored operation.
19+
Message reference status, result, outcome, and wait reads reauthorize the stored
20+
operation and arguments with the context supplied to that read.
2121
`reference.destroy` delegates to `authorize_destroy` before checking whether
2222
the actor exists, so denial does not reveal actor existence.
2323

‎docs/transmission.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ actor does the same with `transmit.increment(amount:)`. Either ingest
1818
accepts either sender, so Rails-to-Rails, Rails-to-Node, Node-to-Rails,
1919
and browser-to-Rails replication all ride one contract.
2020

21+
An omitted `arguments` field defaults to `{}`. Explicit `null` and arrays are
22+
rejected by both runtimes. Shared fixtures cover this distinction.
23+
2124
## The sending side
2225

2326
```ruby
@@ -57,7 +60,7 @@ retries with backoff and dead-letters on exhaustion, like any other effect.
5760

5861
The drain keeps per-actor order across failures: a claimed transmit effect
5962
delivers every undelivered sibling for its actor up to its own mailbox
60-
sequence, oldest first. The receiving side dedups on `transmit:<effectId>`,
63+
sequence, oldest first, preserving staging order within each turn. The receiving side dedups on `transmit:<effectId>`,
6164
so a redelivered envelope applies once.
6265

6366
Delivery is at-least-once by design, and the drain accepts redundant sends

‎lib/solid_objects/client.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ def wait(message_reference, timeout:, authorization_context: nil)
9494
raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:)
9595
end
9696

97+
# @rbs (MessageReference, ?authorization_context: untyped) -> Message
98+
def read_message(message_reference, authorization_context: nil)
99+
Message.uncached do
100+
message = Message.includes(:ready_message, :claimed_message, :dead_letter).find(message_reference.id)
101+
validate_message_reference!(message_reference, message)
102+
raise Unauthorized, "message result is not authorized" unless authorized_to_read?(message, authorization_context:)
103+
104+
message
105+
end
106+
rescue ActiveRecord::RecordNotFound, ActorDestroyed
107+
raise Unauthorized, "message result is not authorized"
108+
end
109+
97110
# @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference?
98111
def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization_context: nil)
99112
unless [ request_id, idempotency_key ].compact.one?

0 commit comments

Comments
 (0)