Skip to content

RFP: SolidObjects::Transmission.receive, the server ingest for browser transmit envelopes #47

Description

@cardmagic

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

  1. 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.
  2. 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.
  3. 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).
  4. 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

  1. 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.
  2. Implement lib/solid_objects/transmission.rb and the error class.
  3. Add the golden fixtures to both repositories with a consuming test on
    each side.
  4. 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.
  5. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions