Summary
Add SolidObjects::Transmission.receive(envelope): the server-side ingest
for the browser transmit family that
solid-objects-js#18
ships (plan in
solid-objects-js#17).
A solid-objects-js actor in a browser stages a transmit intent with
this.transmit().increment({ amount }) in the same transaction as its
state change. The browser's effect worker drains that outbox with
at-least-once delivery, per-actor order, and retry backoff, and posts one
JSON envelope per effect to a route the host application owns. This
proposal gives a Rails application the receiving side, so a local-first
browser front end can replay its operations onto Ruby server actors.
Why this is small
The gem already holds every primitive the ingest needs:
- Mailbox#enqueue accepts delivery_mode: and idempotency_key:, and
find_idempotent_message / validate_idempotent_message! give the
dedup semantics the envelope contract needs.
- delivery_mode: "internal" exists (the effect executor uses it), and
authorization lives in Client, not in Mailbox. An internal enqueue
therefore skips authorize_message by construction, which matches the
JS enqueueInternalMessage semantics exactly.
- SolidObjects.registry.fetch raises UnknownActorType, and
Serialization.dump enforces the payload byte cap.
The feature is one module (~40 lines), one error class, and tests.
Wire contract (owned by the JS side; must match byte for byte)
- Envelope keys arrive camelCase, because a browser produced them:
effectId, actorType, actorId, operation, arguments.
The Ruby ingest accepts them verbatim. No snake_case dialect.
- The idempotency key is transmit:<effectId>, byte-identical to the JS
server ingest. A replayed envelope applies once.
- Delivery is at-least-once and per-actor ordered by the browser's drain.
The server preserves mailbox order; it adds no ordering of its own.
Code shape
# lib/solid_objects/transmission.rb
module SolidObjects
module Transmission
REQUIRED_FIELDS = %w[effectId actorType actorId operation].freeze
def self.receive(envelope, resolve_actor_type: :itself.to_proc, mailbox: Mailbox.new)
validate!(envelope)
actor_type = resolve_actor_type.call(envelope["actorType"])
actor_class = SolidObjects.registry.fetch(actor_type)
operation = envelope["operation"].to_sym
unless actor_class.definition.messages.key?(operation)
raise UnknownMessage, "unknown operation #{envelope["operation"].inspect}"
end
mailbox.enqueue(
reference: Reference.new(actor_type:, actor_id: envelope["actorId"]),
operation:,
arguments: envelope["arguments"] || {},
delivery_mode: "internal",
idempotency_key: "transmit:#{envelope["effectId"]}"
)
end
end
end
Plus InvalidTransmission < Error in errors.rb for malformed envelopes.
The host application owns HTTP and authentication, the same boundary the
gem draws for realtime transport:
class TransmitController < ApplicationController
skip_forgery_protection
def create
head :forbidden and return unless authenticated_device?
SolidObjects::Transmission.receive(JSON.parse(request.body.read))
head :ok
rescue SolidObjects::InvalidTransmission, SolidObjects::UnknownMessage, JSON::ParserError
head :unprocessable_entity
end
end
A 422 tells the browser outbox to dead-letter the effect instead of
retrying it forever. Internal delivery skips authorize_message, so the
host must authenticate before the receive call.
Naming
Transmission (noun) rather than Transmit (verb), because the gem names
modules with nouns (Mailbox, Activation, Serialization,
Administration) and Transmission.receive reads as noun plus verb the
way Mailbox#enqueue does. Parity is the guarantee, not the identifier;
the JS side keeps its verb family (this.transmit(), registerTransmit,
TransmitEnvelope), and the wire contract above is what both runtimes
share.
Design decisions
- Actor type mapping. Zero configuration when both runtimes use the
same actor type strings. resolve_actor_type: is the per-call escape
hatch for diverged names. No global configuration.
- No engine route in v1. The core method ships first; an
engine-mounted POST /solid_objects/transmit with an
authenticate_transmit hook is a possible follow-up, but it carries
authentication, CSRF, and rate-limit decisions that deserve their own
review.
- Golden fixtures pin the wire format. A shared
compatibility/transmit-envelopes.json (valid, duplicate, and
malformed cases) is committed to both repositories. The JS suite
asserts its bridge produces and accepts them; the Ruby suite asserts
Transmission.receive accepts and rejects the same ones. Both repos
already have cross-runtime machinery for this (compatibility/ruby.yml
in the JS repo, test/javascript/ here).
- Out of scope here, proposed separately. The staging side in Ruby
(Actor#transmit and register_transmit, for Rails-to-Rails and
Rails-to-Node replication) is RFP: Actor#transmit and register_transmit, the staging side of the transmit family #48, which builds on this issue's
envelope validation and fixtures. This issue is ingest only.
Plan
- Failing Minitest first: a valid envelope enqueues an internal message
and the actor applies it; the same envelope twice applies once;
camelCase keys accepted verbatim; malformed envelopes raise
InvalidTransmission; unknown actor type and unknown operation raise;
the payload byte cap holds.
- Implement lib/solid_objects/transmission.rb and the error class.
- Add the golden fixtures to both repositories with a consuming test on
each side.
- Documentation: a gem docs section with the controller example; a
docs/roadmap.md entry here; the parity ledger row in
solid-objects-js/docs/parity.md moves the server ingest to native in
both runtimes while browser staging stays JavaScript-only; both
changelogs.
- Both suites green: bundle exec rake here, pnpm run check and
pnpm test in the JS repository.
Open questions
- Same-name actor types as the cross-runtime convention, with
resolve_actor_type: as the escape hatch?
- Land after solid-objects-js#18 merges (the fixture file touches both
repositories), as a paired follow-up?
- InvalidTransmission or InvalidTransmitEnvelope for the error class
name?
Summary
Add SolidObjects::Transmission.receive(envelope): the server-side ingest
for the browser transmit family that
solid-objects-js#18
ships (plan in
solid-objects-js#17).
A solid-objects-js actor in a browser stages a transmit intent with
this.transmit().increment({ amount }) in the same transaction as its
state change. The browser's effect worker drains that outbox with
at-least-once delivery, per-actor order, and retry backoff, and posts one
JSON envelope per effect to a route the host application owns. This
proposal gives a Rails application the receiving side, so a local-first
browser front end can replay its operations onto Ruby server actors.
Why this is small
The gem already holds every primitive the ingest needs:
find_idempotent_message / validate_idempotent_message! give the
dedup semantics the envelope contract needs.
authorization lives in Client, not in Mailbox. An internal enqueue
therefore skips authorize_message by construction, which matches the
JS enqueueInternalMessage semantics exactly.
Serialization.dump enforces the payload byte cap.
The feature is one module (~40 lines), one error class, and tests.
Wire contract (owned by the JS side; must match byte for byte)
effectId, actorType, actorId, operation, arguments.
The Ruby ingest accepts them verbatim. No snake_case dialect.
server ingest. A replayed envelope applies once.
The server preserves mailbox order; it adds no ordering of its own.
Code shape
Plus InvalidTransmission < Error in errors.rb for malformed envelopes.
The host application owns HTTP and authentication, the same boundary the
gem draws for realtime transport:
A 422 tells the browser outbox to dead-letter the effect instead of
retrying it forever. Internal delivery skips authorize_message, so the
host must authenticate before the receive call.
Naming
Transmission (noun) rather than Transmit (verb), because the gem names
modules with nouns (Mailbox, Activation, Serialization,
Administration) and Transmission.receive reads as noun plus verb the
way Mailbox#enqueue does. Parity is the guarantee, not the identifier;
the JS side keeps its verb family (this.transmit(), registerTransmit,
TransmitEnvelope), and the wire contract above is what both runtimes
share.
Design decisions
same actor type strings. resolve_actor_type: is the per-call escape
hatch for diverged names. No global configuration.
engine-mounted POST /solid_objects/transmit with an
authenticate_transmit hook is a possible follow-up, but it carries
authentication, CSRF, and rate-limit decisions that deserve their own
review.
compatibility/transmit-envelopes.json (valid, duplicate, and
malformed cases) is committed to both repositories. The JS suite
asserts its bridge produces and accepts them; the Ruby suite asserts
Transmission.receive accepts and rejects the same ones. Both repos
already have cross-runtime machinery for this (compatibility/ruby.yml
in the JS repo, test/javascript/ here).
(Actor#transmit and register_transmit, for Rails-to-Rails and
Rails-to-Node replication) is RFP: Actor#transmit and register_transmit, the staging side of the transmit family #48, which builds on this issue's
envelope validation and fixtures. This issue is ingest only.
Plan
and the actor applies it; the same envelope twice applies once;
camelCase keys accepted verbatim; malformed envelopes raise
InvalidTransmission; unknown actor type and unknown operation raise;
the payload byte cap holds.
each side.
docs/roadmap.md entry here; the parity ledger row in
solid-objects-js/docs/parity.md moves the server ingest to native in
both runtimes while browser staging stays JavaScript-only; both
changelogs.
pnpm test in the JS repository.
Open questions
resolve_actor_type: as the escape hatch?
repositories), as a paired follow-up?
name?