Skip to content

feat: the transmit family, ingest and staging - #49

Merged
cardmagic merged 9 commits into
mainfrom
feat/transmission-receive
Aug 23, 2026
Merged

feat: the transmit family, ingest and staging#49
cardmagic merged 9 commits into
mainfrom
feat/transmission-receive

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Closes #47. Closes #48.

What this adds

Both sides of the transmit family, over one wire contract:

With both sides in place, Rails-to-Rails, Rails-to-Node, Node-to-Rails, and browser-to-Rails replication ride the same contract. docs/transmission.md documents both sides and the controller boundary.

API

  • SolidObjects::Transmission.receive(envelope, resolve_actor_type:, mailbox:) returns a MessageReference. resolve_actor_type: is a per-call proc for diverged actor type names; the default is identity.
  • Actor#transmit returns the same fluent dispatcher schedule returns. A raw emit "solid-objects.transmit" with explicit actorType / actorId targets a different actor, matching the JS staging surface.
  • SolidObjects.register_transmit(&deliver) wraps register_effect("solid-objects.transmit") with envelope construction and the ordered drain. The block raises to retry.
  • SolidObjects::InvalidTransmission < SolidObjects::Error is new and covers malformed envelopes on both sides. UnknownActorType, UnknownMessage, and PayloadTooLarge propagate from the existing primitives.
  • The engine mounts POST /solid_objects/transmit (the follow-up RFP: SolidObjects::Transmission.receive, the server ingest for browser transmit envelopes #47 deferred), an ActionController::API endpoint behind the new deny-by-default authorize_transmission policy. The policy receives the parsed envelope and the controller as authorization_context:; the new transmission_actor_type_resolver configuration maps diverged actor type names. 403 for an unauthorized envelope, 422 for a permanently unappliable one.

Correctness

  • The idempotency key is transmit:<effectId>, byte-identical in both runtimes. Mailbox#enqueue dedup makes a replayed or redelivered envelope apply once.
  • The drain keeps per-actor order across a failed delivery. Redelivery of an already-delivered sibling is safe because the ingest dedups.
  • Order also holds under concurrent effect workers on one actor, the hazard a Greptile review of this branch flagged as P1. Two workers can deliver overlapping envelope sets, which is the documented at-least-once overlap and dedups on transmit:<effectId>; a reorder cannot happen, because a drain sends older siblings first and only sends a later envelope after the earlier delivery returned successfully. A race test forces the exact interleaving (worker A claims the early effect and stalls mid-delivery, worker B claims the later effect and drains both) and fails with the predicted reorder against a neutered drain.
  • A raised delivery retries with backoff and dead-letters on exhaustion, like any other effect. A malformed staged effect raises InvalidTransmission, dead-letters, and is skipped by sibling drains rather than blocking them.
  • Ingest validation and registry lookup happen before persistence. A rejected envelope writes nothing.
  • Golden fixtures in compatibility/transmit-envelopes.json pin the wire contract, consumed by both the ingest suite and a staging test that asserts a Ruby-staged envelope matches the fixture. The JS side of solid-objects-js#18 now carries the same file with a consuming suite (four tests: valid fixtures apply once, the duplicate pair applies once, malformed fixtures reject, and a staged envelope matches the fixture), so the contract is enforced from both sides of the repo boundary. The parity ledger rows move when both PRs merge.

Security

  • The ingest enqueue uses delivery_mode: "internal", so it skips authorize_message by construction, the same semantics as the JS enqueueInternalMessage. The host application must authenticate the request before it calls receive. docs/transmission.md states this and shows the controller example with a 422 path, so the browser outbox dead-letters an unappliable effect instead of retrying it forever.
  • The payload byte cap applies through Serialization.dump before persistence.
  • Brakeman reports no warnings.

Migration and compatibility

  • No schema change and no migration. Both features compose existing primitives (effects, the mailbox, the effect executor).
  • No behavior change for existing callers. transmit and register_transmit are new surface; the effect name solid-objects.transmit is namespaced.
  • The engine route adds one route and two configuration defaults; existing applications keep their behavior because authorize_transmission denies by default. Bidirectional replication as a declared surface (the "replica" feature) stays out; the RFP issue records its design questions.

Test-first evidence

Both test files ran before their implementations existed and failed for the expected reasons:

NameError: uninitialized constant SolidObjects::Transmission
15 runs, 3 assertions, 3 failures, 12 errors, 0 skips

NoMethodError: undefined method 'register_transmit' for module SolidObjects
9 runs, 0 assertions, 0 failures, 9 errors, 0 skips

The drain-ordering test was additionally verified against a neutered drain that delivers only the claimed effect; it fails with Expected: [1, 2] Actual: [2], so the test observes the ordering guarantee it claims.

Validation

bundle exec ruby -Itest test/integration/transmission_test.rb
# 15 runs, 45 assertions, 0 failures, 0 errors, 0 skips

bundle exec ruby -Itest test/integration/transmit_staging_test.rb
# 11 runs, 28 assertions, 0 failures, 0 errors, 0 skips

bundle exec ruby -Itest test/integration/transmissions_controller_test.rb
# 10 runs, 28 assertions, 0 failures, 0 errors, 0 skips

bundle exec rake
# 572 runs, 1873 assertions, 0 failures, 0 errors, 15 skips (pre-existing adapter/browser skips)
# Standard, RuboCop, RBS generation and validation, Steep, Brakeman: all clean

Add SolidObjects::Transmission.receive, the server ingest for the
browser transmit family that solid-objects-js ships. A browser actor
stages a transmit intent in the same transaction as its state change;
the browser outbox posts one camelCase JSON envelope per effect to a
route the host application owns, and receive replays it onto a server
actor.

The gem already held every primitive: the ingest validates the
envelope, resolves the actor type (with a per-call resolve_actor_type:
escape hatch for diverged names), and enqueues one internal message
keyed transmit:<effectId>, so at-least-once delivery applies once.
Internal delivery skips authorize_message by construction, which is
why the host must authenticate before the call; docs/transmission.md
draws that boundary with a controller example.

Malformed envelopes raise the new SolidObjects::InvalidTransmission,
so a host can return 422 and let the browser outbox dead-letter the
effect instead of retrying forever. Golden fixtures in
compatibility/transmit-envelopes.json pin the wire contract; the JS
repo gets the same file with a consuming test when its transmit
branch lands.

Closes #47
Add Actor#transmit and SolidObjects.register_transmit, the staging
side of the transmit family and the counterpart of the ingest in the
previous commit. transmit returns the same fluent dispatcher schedule
returns and stages a solid-objects.transmit effect in the actor
commit; a raw emit with explicit actorType/actorId targets a
different actor, matching the JS staging surface.

register_transmit wraps register_effect with envelope construction
and the ordered drain ported from solid-objects-js: a claimed
transmit effect delivers every undelivered sibling for its actor up
to its own mailbox sequence, oldest first. Per-actor order therefore
survives a failed delivery, and redelivery is safe because the
receiving side dedups on transmit:<effectId>. A raised delivery
retries with backoff and dead-letters on exhaustion, like any other
effect; a malformed staged effect raises InvalidTransmission and is
skipped by sibling drains rather than blocking them.

The drain-ordering test was verified against a neutered drain that
delivers only the claimed effect: it fails with [2] where [1, 2] is
expected, so the test observes the guarantee it claims.

Closes #48
@cardmagic cardmagic changed the title feat: receive browser transmit envelopes feat: the transmit family, ingest and staging Aug 23, 2026
The transmit family is new public surface, so the release after
0.13.3 takes a minor bump. Date the changelog section accordingly.
The prompt-processing test bounded elapsed time at 0.4 seconds, and a
loaded CI runner measured 0.409 and failed the compatibility (3.4,
7.1) job. Wall time never observed the mechanism anyway: promptness
comes from the enqueue signal interrupting the idle wait, and that
interruption is visible as a polling interval reset with reason
:wake_up. A poll-driven completion resets with :work instead, so the
assertion still fails when the signal path breaks, without a wall
clock in the loop.
Manual QA in a fresh Rails app found the primary use case broken in
development: a controller calling Transmission.receive got
UnknownActorType for every envelope. The ingest looks actors up by
string, and a string cannot trigger the autoload that referencing the
class constant does, so a lazy-loading web process has an empty
registry. Only the CLI installed ApplicationActorLoader.

receive now loads the application's actor classes once on a registry
miss and retries, through an injectable actor_loader: that follows
the mailbox: injection pattern. The first fix attempt also raised
NameError in the web process because application_actor_loader was
required only by the CLI; the gem now requires it, and the load
contract ledger drops its used-only-by-the-CLI row.

QA also showed a conflicting replay returning 500 through the
documented controller, which would make a browser outbox retry a
permanently unappliable envelope forever. IdempotencyConflict joins
the documented 422 rescue list. The transmit docs gain the effect
retry budget: the defaults dead-letter an envelope after roughly
fifteen seconds offline, a dead effect has no retry API, and the
roadmap records that limitation.
@cardmagic

Copy link
Copy Markdown
Owner Author

The contract bug is fixed on the JS side in cardmagic/solid-objects-js@acdcba5 (PR cardmagic/solid-objects-js#18): receiveTransmitEnvelope now defaults a missing arguments to an empty object, matching the Ruby ingest and the JS staging side, so the "increment without arguments" fixture case passes both ingests identically. The same commit pins two contracts from this QA with tests — the optional-arguments default, and IdempotencyConflict on a replay whose arguments changed (with the first application left intact) — and the JS api reference handler now maps InvalidPayload and IdempotencyConflict to 422 explicitly, mirroring the Ruby docs controller.

Add POST /solid_objects/transmit, the engine-mounted ingest that #47
deferred. The route parses the body, authorizes it through the new
authorize_transmission policy, and hands it to Transmission.receive
with the new transmission_actor_type_resolver configuration.

The policy denies by default like the other five, because the ingest
skips authorize_message by design. It receives the parsed envelope
and the controller as authorization_context, so an application can
bind actorType and actorId to the authenticated caller rather than
only compare a shared token. An unauthorized envelope gets 403; a
permanently unappliable one (malformed, unknown type or operation,
oversized, conflicting replay, unparseable body) gets 422, so a
sending outbox dead-letters it instead of retrying forever.

The controller inherits ActionController::API: the endpoint is
token-authenticated machine traffic with no session, which keeps the
Brakeman scan warning-free where a null_session forgery setting does
not. The install generator template gains the policy with a bearer
token example, and the generator test pins six deny-by-default
policies.

Verified live in a scratch Rails app: 403 without or with a wrong
token, 200 with the token, 422 for malformed and unparseable bodies,
and the effect worker delivering through the engine route end to end
with the configured actor type resolver.
The static and sqlite CI jobs failed at RBS validation: the generated
signature for TransmissionsController names ActionController::API as
its superclass, and sig/support/framework.rbs only declared
ActionController::Base. The local run before the push looked green
because the exit code check read the tail of a grep pipeline, not
rake itself; a fresh clone reproduced the failure immediately. The
stub now declares the API class, and rake passes in a clean clone
with its own exit code checked.
A Greptile review of the branch flagged concurrent effect workers on
one actor as an ordering hazard: a later effect's drain deliberately
includes a processing sibling, so two workers can deliver overlapping
envelope sets. The overlap is the documented at-least-once design and
the ingest dedups it on transmit:<effectId>; the reorder cannot
happen, because a drain delivers older siblings first and only sends
a later envelope after the earlier delivery returned successfully.

This test forces the exact interleaving: worker A claims the early
effect and stalls inside its delivery, worker B claims the later
effect and drains both, then A finishes as a deduplicated replay. The
mirror applies [1, 2] and both effects complete. Against a neutered
drain that delivers only the claimed effect, the same interleaving
fails with the reordering the review predicted, so the test observes
the guarantee rather than the implementation.
A second Greptile pass flagged that a drained sibling's effect row
stays pending and later redelivers, a duplicate outbound request. The
duplicate is the documented at-least-once design: completing the
sibling would take over another worker's claim, skipping it would
break ordering, and the JS runtime behaves the same, so the docs now
state the trade explicitly instead of leaving it implied. The
concurrency race test already pins the behavior: order holds,
replays apply nothing, and every effect completes.
@cardmagic
cardmagic merged commit e49c694 into main Aug 23, 2026
40 checks passed
@cardmagic
cardmagic deleted the feat/transmission-receive branch August 23, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant