Skip to content

Browser runtime: platform seam, SQLite WASM adapter, worker host - #18

Merged
cardmagic merged 21 commits into
mainfrom
worktree-browser-runtime-prd
Aug 23, 2026
Merged

Browser runtime: platform seam, SQLite WASM adapter, worker host#18
cardmagic merged 21 commits into
mainfrom
worktree-browser-runtime-prd

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Implements all four milestones of #17: the full Solid Objects runtime runs inside a browser, stores actor state in SQLite WASM on OPFS, shares one runtime across tabs, and syncs its outbox to a server runtime.

The shape

One import gives a browser worker the whole programming model, and multi-tab coordination is invisible. This code runs identically in every tab:

import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host"

class Counter extends Actor {
  static actorType = "Counter"

  count = 0

  increment({ amount = 1 } = {}) {
    this.count += amount
    return this.count
  }
}

const runtime = configure({
  database: sharedSqliteWasm({ path: "app.db" }),
  /* authorize callbacks */
})
await runtime.install()

await Counter.ref("page-hits").increment()

State survives page reloads (the same actor id loads the same durable OPFS state), and every tab sees the same counter: sharedSqliteWasm elects one database holder per origin through the Web Locks API, carries the other tabs' SQL to it over a BroadcastChannel, and fails over onto the same durable state when the holder's tab dies. For a single dedicated worker, sqliteWasm({ storage: "persistent" }) opens the database directly.

With the shared database, typed references work in every tab, because every tab runs a full runtime and the existing leases and fencing arbitrate their workers exactly as they arbitrate Node processes. The tab host below is the request-level alternative: one runtime on the leader, other tabs invoking by name over a channel.

Many tabs, one runtime (request-level alternative)

sharedSqliteWasm above is the primary multi-tab path. solid-objects/browser/tab-host remains for a different resource profile: exactly one worker set per origin. The Web Locks API elects a leader, only the leader runs a runtime, and the other tabs stay thin — no workers, no polling — invoking by name over a BroadcastChannel client with idempotent request ids (startTabHost / connectTabClient, documented in docs/api.md). Choose it when most tabs only issue commands and should not run background work; choose the shared database when every tab wants typed references and the full runtime. Failover works the same way in both: the leader's death releases the lock and the next candidate promotes on the same OPFS state.

Offline transmit through the transactional outbox

An actor stages a sync intent in the same transaction as its state change, with the same fluent shape as schedule() and sendTo(). The effect worker drains the outbox with at-least-once delivery, per-actor order, and retry backoff; the server ingest deduplicates on the effect id.

The browser side, in the worker that hosts the local runtime:

import { Actor, configure, registerTransmit, sharedSqliteWasm } from "solid-objects/browser/host"

class Counter extends Actor {
  static actorType = "Counter"

  count = 0

  increment({ amount = 1 } = {}) {
    this.count += amount
    this.transmit().increment({ amount })   // replay on the server twin, committed with the state change
    return this.count
  }
}

const runtime = configure({ database: sharedSqliteWasm({ path: "app.db" }) /* , authorize callbacks */ })
registerTransmit({
  runtime,
  deliver: async (envelope) => {
    const response = await fetch("/sync", { method: "POST", body: JSON.stringify(envelope) })
    if (!response.ok) throw new Error(`sync failed with ${response.status}`)   // throw while offline; the outbox retries
  },
})
await runtime.install()

The server side is one route in the host application. On a Node backend — shown Fetch-style, any HTTP framework works:

import { receiveTransmitEnvelope } from "solid-objects"

// POST /sync — the route the browser's deliver callback posts to
async function handleSync(request) {
  const sender = await authenticate(request)          // host-owned; internal delivery skips authorizeMessage
  if (!sender) return new Response("Forbidden", { status: 403 })
  const envelope = await request.json()
  await receiveTransmitEnvelope({ runtime: serverRuntime, envelope })   // idempotent: keyed on the effect id
  return Response.json({})
}

The backend does not have to be Node. cardmagic/solid-objects-ruby#47 proposes the same ingest for the Ruby gem over the identical wire contract, so a solid-objects-js browser front end can replay its operations onto Rails server actors:

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   # tells the browser outbox to dead-letter, not retry
  end
end

Both ingests dedup on the same transmit:<effectId> idempotency key and accept the same camelCase envelope, so the browser side cannot tell which runtime answered.

transmit() covers the common case (same actor type and id on both sides). emit(TRANSMIT_EFFECT, ...) stages the same intent with an explicit target when the server actor differs.

What is underneath

  • Platform seam (M1). Shared modules no longer import Node builtins. A registered factory supplies async context (AsyncLocalStorage in Node, a turn-scoped store in the browser), one seam supplies host identity, and check:browser-imports fails the build when a node: import or a server driver reaches the browser graph — including through export ... from edges.
  • Storage (M2). solid-objects/database/sqlite-wasm implements the Database contract on @sqlite.org/sqlite-wasm (optional peer dependency) with the same serialized-access, deadline, and transaction semantics as the Node SQLite adapter. Deadlines survive without ambient context: the adapter captures the absolute expiry at access entry and enforces it in every statement and before COMMIT.
  • Hosts (M3). solid-objects/browser/host registers the browser platform on import. solid-objects/browser/tab-host adds the runtime-level election and client.
  • Transparent multi-tab (beyond the PRD). solid-objects/database/shared-sqlite-wasm moves the election behind the Database seam: the holder opens the pool, other instances run SQL sessions over BroadcastChannel through the holder's serialized access queue, requests carry the holder epoch so failover surfaces as a fast rejection, a not-yet-executed session retries automatically (a partially executed one never replays), and an idle-session watchdog reclaims the slot when a tab dies mid-session. The PRD named a SharedWorker host; Web Locks election between dedicated workers replaced it, because OPFS sync access handles exist only in dedicated workers.
  • Transmit (M4). solid-objects/transmit rides the existing effects outbox. Per-actor order comes from an ordered drain: a claimed effect transmits every undelivered envelope for its actor up to its own mailbox sequence, oldest first, and the server dedups.

Tests

  • 321 Vitest tests pass, with new suites for the context store, host identity, WASM adapter contract, the shared database adapter (cross-instance statements, transactions, serialization, two full runtimes on one database, failover, session watchdog), the tab host (election, failover, error propagation, timeout, on real Node Web Locks), and the sync bridge (order under transmit failures, replay dedup, offline recovery, transmit and raw emit staging).
  • 8 Playwright tests pass in Chromium: OPFS persistence across reloads, the full runtime in a module worker, an expired transaction that rolls back under the browser context store, two tabs that share one durable runtime through the tab host and fail over, plain actor references incrementing one durable counter from two tabs through the shared database with failover, and a browser outbox that drains into the Node server runtime.
  • The PostgreSQL and MySQL CI jobs run the transmit round trip against real servers: two runtimes on one database under distinct table prefixes, a failed delivery that preserves per-actor order, and a doubled delivery the ingest deduplicates — so the drain query's portability is proven, not argued.
  • The floor CI job (Node 24.4.0) passes; the tab host and shared database suites skip there because navigator.locks arrived in Node 24.5, and the docs record that boundary.

Docs

The documentation set covers the browser runtime end to end: docs/api.md documents all four new entry points with failover fence tuning and offline dead-letter guidance; docs/browser-protocol.md documents the tab host BroadcastChannel protocol, the shared database channel protocol, and the sync envelope; docs/architecture.md describes the platform seam, the browser host layers, and the sync bridge; docs/support.md records the SQLite WASM peer dependency and the OPFS and Web Locks platform boundaries; docs/correctness.md records the browser guard boundary under limitations; docs/fit.md adds the local-first use case; the README gains a 'Solid Objects in the browser' section; docs/parity.md records the JavaScript-only capability with all milestones complete; and CHANGELOG.md carries the Unreleased notes.

Milestone M1 of the in-browser runtime plan (#17). The browser build
needs the shared modules free of Node built-in imports, so the seams
land before any WASM storage work:

- src/platform/context-store.ts defines a ContextStore interface with
  a registered factory. context.ts, transaction-context.ts, and
  deadline.ts no longer import node:async_hooks. Node entry points
  register an AsyncLocalStorage factory; TurnContextStore covers a
  future browser host with serialized turns.
- src/platform/uuid.ts routes UUID generation through the standard
  crypto.randomUUID(), so shared modules drop node:crypto.
- scripts/check-browser-imports.mjs walks the browser-safe import
  graph and fails the check when a node: module or server driver
  reaches it. Wired into pnpm run check.
Milestone M2 of the in-browser runtime plan (#17).
solid-objects/database/sqlite-wasm implements the Database contract
on @sqlite.org/sqlite-wasm (optional peer dependency), with the same
serialized-access, deadline, and transaction semantics as the Node
SQLite adapter and a distinct solid-objects-wasm-v1 schema identity.

Storage modes: temporary (default) and persistent, which uses the
OPFS SAH pool VFS and fails fast where OPFS is unavailable.

Coverage: the full runtime passes a round-trip test against the
adapter in Node, and a Playwright test drives the adapter inside a
module worker in Chromium, proving transactions, rollback, and OPFS
persistence across a page reload. The browser test server vends the
sqlite-wasm assets and rewrites the bare specifier for module
workers, which cannot read page import maps.
First stage of milestone M3 in the in-browser runtime plan (#17).

solid-objects/browser/host registers the browser platform on import
(turn-scoped context store, browser host identity) and re-exports the
core runtime API plus the WASM adapter, so a worker needs one import.

Two seams complete the node-free runtime graph:
- src/platform/host-identity.ts carries hostname, host process id,
  and runtime version; repository.ts drops node:os and process.pid.
  The node platform module (renamed to src/platform/node.js) registers
  the Node identity.
- serialization.ts sizes payloads with TextEncoder instead of the
  Buffer global, which the import-graph check cannot see.

A Playwright test runs the complete runtime inside a Chromium module
worker on OPFS storage and proves durable actor state across a page
reload. check:browser-imports now walks the whole runtime graph from
src/browser/host.ts, so a future node: import fails the check.
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces browser platform registration, SQLite WASM storage, multi-tab runtime hosting, and a transactional synchronization bridge.

  • Adds browser and Node platform seams for context, identity, and UUID generation.
  • Adds temporary and OPFS-backed SQLite WASM storage with serialized access and deadline enforcement.
  • Adds Web Locks leader election, BroadcastChannel invocation, failover support, and synchronization outbox delivery.
  • Extends package exports, documentation, import checks, and browser/runtime coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/database/sqlite-wasm.ts Implements serialized SQLite WASM access, OPFS persistence, transaction rollback, and captured deadline enforcement.
src/browser/tab-host.ts Implements Web Locks leadership, BroadcastChannel requests, idempotent runtime invocation, retries, and failover lifecycle.
src/sync-bridge.ts Implements ordered outbox transmission and idempotent server-side synchronization ingest.
src/platform/turn-context-store.ts Provides deliberately synchronous browser context scoping with a documented post-await boundary.
scripts/check-browser-imports.mjs Walks browser-safe import and re-export graphs to reject Node built-ins and server drivers.

Sequence Diagram

sequenceDiagram
    participant T as Browser Tab
    participant C as BroadcastChannel
    participant L as Elected Leader
    participant R as Solid Objects Runtime
    participant D as SQLite WASM / OPFS
    T->>C: Invoke with request ID
    C->>L: Deliver invocation
    L->>R: Enqueue idempotent message
    R->>D: Commit actor state and work
    D-->>R: Durable result
    R-->>L: Invocation result
    L-->>C: Broadcast result
    C-->>T: Resolve request
Loading

Reviews (3): Last reviewed commit: "fix: enforce transaction deadlines witho..." | Re-trigger Greptile

Comment thread src/platform/turn-context-store.ts
Comment thread src/database/sqlite-wasm.ts
Comment thread scripts/check-browser-imports.mjs Outdated
Comment thread src/platform/context-store.ts Outdated
Completes #17.

M3: solid-objects/browser/tab-host gives many tabs one runtime. Every
tab starts a candidate host; the Web Locks API elects one leader per
origin, and only the leader opens the OPFS database and runs workers.
Tabs invoke actors over a BroadcastChannel client that retries with
idempotent request ids, so a resend after failover applies once. The
plan named a SharedWorker host; Web Locks election between dedicated
workers replaced it because OPFS sync access handles exist only in
dedicated workers. A Playwright test proves shared durable state
across two tabs and continuation after the leader tab closes.

M4: solid-objects/sync-bridge drains the local effects outbox to a
server runtime. Actors stage sync intents with emit() in the same
transaction as their state change. The drain delivers at-least-once
and in per-actor order: a claimed effect transmits every undelivered
sibling up to its own mailbox sequence, oldest first, and the server
ingest deduplicates on the effect id. The first design held newer
effects with a retryable error; it burned retry attempts under
contention because effect claims tie-break on random UUIDs, so the
ordered drain replaced it.

Two supporting fixes came out of the browser end-to-end tests:
- TurnContextStore now scopes only the synchronous part of a callback.
  The old promise-scoped store leaked open transaction scopes into
  interleaved tasks and tripped inside-transaction guards on every
  concurrent invocation. Browser guards are best-effort; Node keeps
  AsyncLocalStorage semantics.
- The WASM adapter passes forceReinitIfPreviouslyFailed to the SAH
  pool, because the upstream module caches a failed initialization and
  a new leader could never claim the pool after the old tab died.
- Recheck the database deadline before COMMIT in the SQLite WASM
  adapter, so a transaction that outlives its budget rolls back with
  DatabaseDeadlineExceeded instead of committing (new regression test).
- Walk export-from edges in check:browser-imports. The check previously
  skipped re-export graphs, so a Node builtin behind
  'export ... from' could pass; src/index.ts now correctly fails when
  used as a root, and the browser-safe roots still pass.
- Remove the unknown-typed seams the review flagged: the context store
  factory is generic end to end, the tab host validates channel
  messages with a typed guard and returns DeepReadonly<JsonValue>, the
  WASM adapter types result rows as Record<string, SqlValue>, and the
  sync bridge validates parsed effect arguments instead of casting.
- Document the exact guard boundary of the turn-scoped context store:
  synchronous scoping restores in strict stack order, so interleaved
  turns cannot observe another turn's scope; ambient guards read as
  unset after the first await inside an actor operation.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptile-apps review

@cardmagic cardmagic changed the title Browser runtime: platform seam, SQLite WASM adapter, worker host (M1-M3a) Browser runtime: platform seam, SQLite WASM adapter, worker host Aug 23, 2026
The CI floor job runs Node 24.4.0, which predates navigator.locks.
The tab host is a browser feature; the Node tests need Node 24.5 or
newer, so the suite skips where the API is missing, the same way the
external-database suites skip without a server. docs/api.md records
the requirement, and startTabHost keeps its fail-fast error.
Comment thread src/database/sqlite-wasm.ts
Greptile round two found the browser gap in the round-one fix: the
turn-scoped context store drops the ambient deadline when a
transaction callback first suspends, so the pre-COMMIT check saw no
deadline in the browser and committed anyway.

The WASM adapter now captures the absolute expiry synchronously at
access entry, while the ambient store is still visible, and enforces
that captured deadline in every statement and before COMMIT. The
serialized access queue makes the instance-level deadline race-free.
Node keeps the ambient checks as before.

A Playwright regression runs the exact scenario in Chromium with the
turn-scoped store: a transaction that outlives its deadline now rolls
back and leaves no rows.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptile-apps review

The PR previously updated only the CI-enforced documents (api.md,
parity.md, CHANGELOG.md). This closes the gaps the audit found:

- support.md: add the SQLite WASM optional peer and browser runtime
  rows, the OPFS and Web Locks platform requirements with the Node
  24.5 navigator.locks boundary, and the browser runtime suites in
  the matrix description.
- browser-protocol.md: document the two new wire surfaces, the tab
  host BroadcastChannel protocol (versioned envelopes, idempotent
  request ids, origin trust boundary) and the sync envelope with its
  idempotent server ingest.
- architecture.md: describe the platform context-store seam, the
  SQLite WASM adapter's captured deadlines, the browser host layers
  (worker ownership, Web Locks election, failover), and the sync
  bridge; the closing scope line now names the browser.
- correctness.md: record the browser guard boundary under
  limitations; durable fencing and ordering do not depend on the
  ambient guards.
- fit.md: add the local-first use case.
- README.md: add the 'Solid Objects in the browser' section with the
  worker example and companions, the browser requirements, and the
  browser runtime in the early-release coverage note.
…ime-prd

# Conflicts:
#	README.md
#	docs/architecture.md
#	docs/correctness.md
package.json and src/version.ts already carry 0.14.0; the pending
release now includes the browser runtime, so the Unreleased section
folds into the 0.14.0 entry and the entry takes today's date. The
parity ledger section retitles from work-in-progress to the shipped
JavaScript-only capability.
solid-objects/database/shared-sqlite-wasm removes the visible tab
infrastructure. Every tab runs the ordinary configure -> install ->
ref flow against one shared database; the adapter hides the
coordination:

- The Web Locks API elects one holder per origin. Only the holder
  opens the OPFS pool; an election loop retries after a failed open
  so a broken candidate does not strand the lock.
- Every other instance carries its SQL over BroadcastChannel sessions
  (open, statements, commit/rollback/end) that run through the
  holder's serialized access queue, so cross-tab semantics equal the
  single-connection semantics.
- Requests carry the holder epoch. A new holder rejects stale-epoch
  requests, so clients learn about failover from a fast rejection
  instead of a timeout. A session that has not executed a statement
  retries automatically; later failures surface, because replaying
  partially executed work is not safe. An idle-session watchdog
  reclaims the slot when a tab dies mid-session.
- The runtime's existing leases and fencing arbitrate the tabs'
  workers, the same way they arbitrate Node processes.

Vitest covers cross-instance statements, transactions, serialization,
two full runtimes on one shared database, failover, and the session
watchdog. Playwright proves plain actor references incrementing one
durable counter from two tabs with failover after the holder tab
closes. The README example now leads with this API; tab-host remains
the request-level alternative.
Same boundary as the tab host: the CI floor runs Node 24.4.0, which
predates navigator.locks. The election loop also stops with one
onError report instead of a retry spin when the API is missing, so a
constructor on old Node stays quiet and close() works normally.
this.mirror().increment({ amount }) stages the sync effect the same
way schedule() and sendTo() stage their intents, so the common case
(replay the operation on the server twin of the same actor) needs no
effect name or nested arguments object. emit(SYNC_BRIDGE_EFFECT, ...)
remains the staging surface when the target differs from the source.
The constant moved to src/sync-effect.ts so the actor base class and
the bridge share it without a cycle.
sync already means synchronous in this package (SyncTimeout,
SyncInsideTransaction, syncPollingIntervalMilliseconds), and the
bridge family gave the word a second meaning while its own staging
verb was mirror(). One concept now carries one name end to end: an
actor mirrors an operation, the host registers the mirror with a
transmit callback, and the server receives mirror envelopes.

- solid-objects/sync-bridge -> solid-objects/mirror
- registerSyncBridge -> registerMirror (SyncBridgeOptions ->
  RegisterMirrorOptions); the transmit callback keeps its name, it is
  the transport, not the mirror
- receiveSyncEnvelope -> receiveMirrorEnvelope; SyncEnvelope ->
  MirrorEnvelope; InvalidSyncEnvelope -> InvalidMirrorEnvelope
- SYNC_BRIDGE_EFFECT -> MIRROR_EFFECT, value solid-objects.mirror

Nothing has shipped to npm, so the wire value change breaks nobody.
receiveMirrorEnvelope appeared as a bare call with no request context.
The api reference now shows it inside a Fetch-style POST handler with
authentication before ingest and a 422 note for rejected envelopes.
The same pass finishes the mirror rename in prose and code strings:
mirror intent, mirror effect, and a mirror:<effectId> idempotency key
instead of the leftover sync: prefix.
One name now covers the whole feature: this.transmit() stages the
intent, registerTransmit drains the outbox, TransmitEnvelope crosses
the wire, and receiveTransmitEnvelope applies it on the server. The
wire-attempt callback renames to deliver, so transmit keeps one
meaning (the durable staged relationship) and deliver keeps the other
(one network attempt that may fail and retry).

- solid-objects/mirror -> solid-objects/transmit
- registerMirror -> registerTransmit; RegisterMirrorOptions ->
  RegisterTransmitOptions with deliver instead of transmit
- receiveMirrorEnvelope -> receiveTransmitEnvelope; MirrorEnvelope ->
  TransmitEnvelope; InvalidMirrorEnvelope -> InvalidTransmitEnvelope
- MIRROR_EFFECT -> TRANSMIT_EFFECT, value solid-objects.transmit;
  ingest idempotency key transmit:<effectId>
- actor.mirror() -> actor.transmit()

Nothing has shipped to npm, so the wire value change breaks nobody.
@cardmagic

Copy link
Copy Markdown
Owner Author

The server-side counterpart for the transmit family in Ruby is proposed in cardmagic/solid-objects-ruby#47: SolidObjects::Transmission.receive gives a Rails application the ingest for browser transmit envelopes, over the same wire contract this PR defines (camelCase envelope keys, the transmit:<effectId> idempotency key, order owned by the browser's drain). Together they complete the local-first story: a solid-objects-js browser front end replaying its operations onto Ruby server actors.

The transmit drain and ingest were argued portable but only exercised
on SQLite. Each real-server suite now runs the full round trip: two
runtimes share one database under distinct table prefixes, an actor
stages through transmit(), the drain survives a failed delivery and
keeps per-actor order, and the server ingest deduplicates a doubled
delivery. Verified locally against PostgreSQL and MySQL 8.4 before
push; the existing CI database jobs now cover it on every run.
@cardmagic

Copy link
Copy Markdown
Owner Author

Manual cross-runtime QA against the Rails backend (cardmagic/solid-objects-ruby#49): a Node runtime staged transmits into a real Rails app over HTTP, and a Rails app staged transmits into a Node ingest. One contract bug surfaced; everything else held.

Bug: receiveTransmitEnvelope rejects an envelope without arguments. The Ruby ingest treats arguments as optional and defaults it to {}. The JS staging side does the same in parseTransmitEnvelope (argumentsValue.arguments ?? {}), so the JS ingest disagrees with both the Ruby ingest and its own staging. The shared fixture "increment without arguments" (compatibility/transmit-envelopes.json in the Ruby repo) returns 200 from Rails and 422 from Node. Suggested fix in receiveTransmitEnvelope:

const argumentsValue = envelope.arguments ?? {}
if (!isJsonObject(argumentsValue)) {
  throw new InvalidPayload("sync envelope arguments must be a JSON object")
}

and enqueue argumentsValue. The fixture file lands here with a consuming test when this PR and the Ruby PR pair up, and that test pins the contract.

What held in both directions:

  • Node → Rails: per-actor order, at-least-once redelivery deduped on transmit:<effectId>, UUID effect ids accepted verbatim.
  • Rails → Node: same-actor default target, explicit actorType/actorId raw-emit target, per-actor order.
  • Both ingests skip message authorization for the internal enqueue (authorizeMessage: () => false on the Node side did not block ingest), so the host must authenticate before the call.
  • Malformed envelopes (missing effectId, empty actorId, snake_case keys, non-string operation, non-object arguments) reject on both sides.
  • A conflicting replay (same effectId, different arguments) raises IdempotencyConflict on both sides; the host route must map it to 422, or the sending outbox retries a permanently unappliable envelope forever. The Ruby docs controller example now includes it.

Cross-runtime QA (solid-objects-ruby#49) found the one contract
disagreement: the Ruby ingest and the JS staging side both default a
missing arguments field to an empty object, while the JS ingest
rejected it with a 422. receiveTransmitEnvelope now applies the same
default, so the shared fixture case passes both ingests identically.

Two contract pins came out of the same QA:
- a test that an envelope without arguments applies with defaults;
- a test that a replay with changed arguments raises
  IdempotencyConflict and leaves the first application intact.

The api reference handler now maps InvalidPayload and
IdempotencyConflict to 422 explicitly, because both mark permanently
unappliable envelopes and a 500 would make the sending outbox retry
them forever. The leftover 'sync envelope' error strings become
'transmit envelope'.
Add compatibility/transmit-envelopes.json, the golden envelope file
the Ruby repo committed in solid-objects-ruby#49, with a consuming
suite. The valid fixtures apply exactly once, the duplicate pair
applies once, every malformed fixture rejects, and a staged envelope
matches the fixture byte for byte apart from the generated effect id.
The wire contract is now enforced from both sides of the repo
boundary by one shared file instead of two sets of inline literals.
@cardmagic
cardmagic force-pushed the worktree-browser-runtime-prd branch from 51dc949 to 78f49ea Compare August 23, 2026 07:18
Ruby PR solid-objects-ruby#49 adopts the whole transmit family, so
the parity ledger no longer files it under the JavaScript-only
browser runtime. A new section records the shared wire contract
(camelCase keys, optional arguments defaulting to an empty object,
the transmit:<effectId> idempotency key, conflict-on-changed-replay)
and the cross-runtime QA that validated it in both directions.

The branch already carried the shared fixture and its consuming
suite; this commit keeps that suite as the single consumer and adds
the one assertion it lacked: the idempotency key the ingest actually
stores matches the fixture's pinned key, row for row.
@cardmagic
cardmagic merged commit 4e71213 into main Aug 23, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant