Skip to content

#23 Add durable peer store and forward#85

Draft
lucas-barake wants to merge 26 commits into
mainfrom
ship/issue-23-store-and-forward
Draft

#23 Add durable peer store and forward#85
lucas-barake wants to merge 26 commits into
mainfrom
ship/issue-23-store-and-forward

Conversation

@lucas-barake

@lucas-barake lucas-barake commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #23

This PR makes the durable store and forward relay the only RPC topology. Since the packages are not published yet, the durable protocol becomes version 1. The previous direct protocol variants and their version history are removed.

Architecture

Communication uses the typed Effect RPC protocol over Effect's generic Socket and SocketServer abstractions. The package defines one streaming Open RPC plus Push, Acknowledge, and Reject. It does not own an HTTP server, WebSocket upgrade route, or TLS configuration. Applications supply the socket transport and authentication boundary.

The backend relay depends on the semantic PeerRelayStore service. SqlPeerRelayStore implements that service against Effect's generic SqlClient, following the structure and naming of Effect Workflow's SQL storage. The implementation supports SQLite, PostgreSQL, and MySQL through the matching Effect SQL driver. Applications may provide another PeerRelayStore implementation when they need a different persistence system.

const backendStore = SqlPeerRelayStore.layer.pipe(
  Layer.provide(sqlClientLayer),
  Layer.provide(peerRelayLimitsLayer),
  Layer.provide(cryptoLayer)
)

Database portability is separate from relay server clustering. PostgreSQL and MySQL allow operators to choose a backend with different durability and operational properties. They do not make PeerRpcServer horizontally scalable.

The frontend remains SQLite backed. It owns the sender outbox, recipient receipts, replica state, and the transactional boundary that applies a delivered message before acknowledging it.

Custody And Replay

The sender writes every outgoing envelope to its local SQLite outbox before attempting network delivery. Each entry has a stable relay message identity and digest. On connection, RpcPeerTransport pushes all due outbox entries before accepting new sends. The frontend removes an entry only after the relay confirms durable custody and the returned identity matches.

Relay admission is transactional. It reserves the applicable quota scopes, stores the payload, assigns relay owned channel ordering, and records a stable deduplication identity. Repeating the same identity and digest returns duplicate success. Reusing the identity with a conflicting digest is rejected.

Open creates a streaming relay session. When an endpoint connects, the server replaces any previous session for that endpoint and schedules pending work. Delivery workers select the oldest eligible channel head, claim it with a random token, session generation, and lease deadline, then stream the stored message through a bounded queue.

The recipient applies or deduplicates the envelope and records a relay receipt inside the same SQLite transaction. Only after that transaction commits does it call Acknowledge. Acknowledgement or permanent rejection releases active payload custody while retaining terminal identity until the deduplication horizon expires.

Concurrency And Recovery

Live concurrency uses Effect scopes, fibers, bounded queues, semaphores, deferred values, interruption, and finalizers. Database concurrency uses transactions, channel sequences, claim tokens, session generations, and claim deadlines.

A stale session in the same server process cannot acknowledge a claim owned by its replacement. Disconnect cleanup releases known claims with retry backoff. If the process crashes first, maintenance recovers expired leases and makes those messages eligible again.

SQLite relay writes use the required durability settings and explicit write transactions. PostgreSQL and MySQL serialize competing relay mutations through locked database state. MySQL migrations use a pinned advisory lock and schema verification because its DDL commits implicitly.

Deployment Boundary

The current PeerRpcServer supports one active server process per custody shard. Its session registry, endpoint replacement, subject admission counters, work ownership, delivery queues, and wakeups are process local.

Running multiple PeerRpcServer instances against the same SQL database is not a supported active active deployment. SQL transactions can prevent some conflicting row transitions, but there is no distributed endpoint ownership, cross node session revocation, cross node work notification, global admission accounting, coordinated maintenance ownership, or cluster membership protocol.

Current scaling is explicit sharding. Each shard has one active PeerRpcServer owner and its own custody boundary. Applications route a tenant or peer deterministically to that shard. The database behind a shard may use its own replication and failover, but server failover and single owner enforcement remain deployment responsibilities.

True horizontal relay scaling requires a separate cluster design. That design must add globally fenced endpoint ownership, a distributed session directory, cross node wakeups, coordinated maintenance and limits, and routing or forwarding to the node that owns the live recipient connection.

Delivery Contract

The guarantee is durable custody with at least once delivery and durable recipient deduplication inside one custody shard.

A failed or ambiguous Push leaves the sender outbox entry available for retry. A recipient crash after its local commit but before Acknowledge can cause redelivery. The persisted receipt prevents the application message from being applied twice, after which the repeated delivery can be acknowledged safely.

Exactly once external effects, automatic server failover, active active relay operation, quorum replication, and distributed consensus are not claimed.

The shared relay storage contract is exercised against real SQLite, PostgreSQL, and MySQL databases. The production Effect RPC composition is covered through custody transfer, acknowledgement, interruption, reconnect replacement, replay, and legacy protocol rejection.

lucas-barake and others added 3 commits July 26, 2026 12:05
Working-tree snapshot pushed on behalf of the issue-23 session so the work is not
stranded locally. Adds relay store error typing and observability coverage
(`peerRelayStoreErrors.ts`, `PeerRelayStoreErrors.test.ts`,
`PeerRpcObservability.test.ts`), and broadens the relay, outbox, sync-envelope and
documentation surface across `local-rpc` and `local-sql`.

Known incomplete: `packages/local-rpc/test/PeerRelayStore.test.ts` has two
`Metric.MetricRegistry` type errors (value used as a type) that still need fixing.
@lucas-barake
lucas-barake force-pushed the ship/issue-23-store-and-forward branch from 4713d67 to 6aefe95 Compare July 26, 2026 17:07
@lucas-barake
lucas-barake force-pushed the ship/issue-23-store-and-forward branch from 627b674 to 203881d Compare July 26, 2026 17:16
The repository rules duplicated the global agent rules by naming
AGENTS.local.md as a second task ledger, so the two could drift and an
agent reading one would miss the other. Task tracking now lives only in
the worktree LEDGER.md the global rules already mandate.

Also close a loophole in the service rules. "Capture every service used
later by its methods or expose that service as a Layer requirement" let
an implementation push its dependencies into every method's requirement
channel, where they spread to every transitive caller and let a method
run under a context the Layer never validated. Dependencies are now
resolved once at construction, and leaking a requirement needs a stated
per-call reason.
The existing relay keeps endpoint ownership, the claim ledger, delivery
wakeups and admission counters in process local state, so only one server
process can own a custody shard. Modelling each recipient device as a
cluster entity moves all of that to sharding, which elects exactly one
owner per key across every runner.

Being the sole writer for its device is what makes the store contract
small. Claim tokens, lease deadlines, session generations and work
ownership exist only to arbitrate between competing processes, and there
are none. There is also no in-flight state: a message stays Pending until
terminal and what is being delivered lives only in memory, so losing a
runner loses that memory and nothing else and the next owner redelivers.
At-least-once falls out of the schema instead of a recovery protocol.

Delivery is stop-and-wait per sender channel and concurrent across
channels, so a stalled sender cannot hold up other senders to the same
device, and the connection epoch is part of the channel identity because
the sender's sequence restarts at zero on every reconnect.

Two details are load bearing and easy to lose. Subscribe is forked so it
does not hold the entity's single handler permit for the life of a
session, which would leave the settlement that ends each delivery unable
to run and stall the inbox with no error anywhere. And a session's
liveness is bounded by a server side deadline rather than by a disconnect
notification, because the cross node interrupt is best effort and is
never sent at all when the front door's own node fails.
Backs the inbox contract with a single table over a generic SqlClient, so
the operator chooses the database. Supports SQLite, PostgreSQL and MySQL,
matching the coverage of the store this replaces.

Every guard is a predicate inside the write it protects. The owning
entity is the sole writer for an inbox, but its rpc lane and its forked
dispatcher fibers issue statements concurrently, so a read followed by a
separate write is still a race within one process.

Ordering is indexed on a channel digest rather than the six raw channel
columns, which would exceed MySQL's key length limit once the subject is
sized for real input. The index is unique because head selection picks
the lowest sequence in a channel, and two rows sharing one would make
that choice arbitrary and could starve either indefinitely.

Replaying an identity the inbox already abandoned revives it rather than
reporting a duplicate. A duplicate is a success, and the sender deletes
its outbox entry on success, so reporting one for a dead lettered or
expired row would make the sender's own recovery destroy the last durable
copy of the message. Deduplication horizons only ever grow, since
shrinking one reopens a window a sender may still be replaying into.

Integers are decoded through a schema that accepts strings because
PostgreSQL returns BIGINT, COUNT and SUM that way and installs no default
parser. The MySQL index path checks information_schema instead of
ignoring errors: that dialect commits DDL implicitly and the migrator
writes its completion marker before running the body, so a swallowed
failure would leave a half built schema that later runs treat as done.
Terminates the client socket, authenticates and authorizes each request,
and forwards it to the cluster entity that owns the addressed device. The
wire contract is unchanged, so this is a swap of the server's internals
rather than a new protocol.

Which inbox a request reaches is derived per operation and never taken
from the request body. Open, Acknowledge and Reject address the caller's
own inbox, computed from the authenticated principal, so settling another
device's message is not expressible. Push addresses the counterparty the
handshake authorized, so a session can only ever send where it was
allowed to.

Handshake state is per connection. A socket lives on one node and dies
with it, so a client that loses this node reconnects and opens again.
What could not be process local is the recipient's delivery session,
because a message accepted on any node has to reach whichever node holds
that recipient's socket, and that is exactly what the entity provides.

The sender's message hash is recomputed rather than believed, since it is
the identity the recipient deduplicates on. Every entity call is bounded
by a timeout: sharding retries an unassigned entity or an unavailable
runner indefinitely, so an unbounded call would hang through a rebalance
and leak a fiber per in-flight request. A live session is kept alive by a
heartbeat driven from here, because what must be proven alive is this
node and its socket rather than the peer at the other end.
Two guards from the previous server were missing, and both let an
unauthorized peer keep receiving.

Receive authorization only ran at the handshake and its result was then
discarded, so the delivery stream was piped unfiltered. Anything holding
a Send grant to a device can write into that device's inbox, and the
entity has no concept of grants, so a recipient received documents it
never requested and was never authorized to receive. Every delivery is
now re-authorized, and a message that fails is withheld rather than
emitted, staying durable for a later session instead of erroring the
stream.

A session also outlived the credential and grants that created it. All
three expose an invalidated signal and a deadline, none of which the
client observes, so the relay is the only place that can act on them.
Nothing did, and the relay's own heartbeat kept the session alive for as
long as the socket stayed open. A session now ends as soon as any of its
authority is withdrawn or lapses.

Also: an Open beyond the per subject session cap is refused, since
sessions were the front door's only unbounded resource and an
authenticated client can open them in a loop; an envelope that decodes
but cannot be canonicalized is reported as permanently invalid rather
than as unavailability, which had the sender's outbox retrying it forever
at the cost of a canonical encode each time; and a heartbeat interval
that cannot outrun the entity's session deadline is refused at
construction, because that combination silently reaps every session.
Nineteen cases over the production store and its real migrations, so the
properties the design depends on are asserted rather than argued.

Most of these exist because a review found the corresponding defect by
running the store rather than reading it. The cross inbox sweep is the
clearest: the key is (inbox, id), the same relay message id can legally
address two devices, and a sweep filtered on the id alone destroyed a
message in an inbox that had most of its lifetime left.

Writing them found one more. The delivery budget off by one had been
reported and I believed I had fixed it, but the edit never applied and
nothing failed; a budget of one dead lettered the message on its first
delivery, wasting the only attempt the recipient could have settled. The
test is what noticed, which is the argument for having it.

Also covered: reviving a message the inbox gave up on rather than
reporting a duplicate the sender would treat as permission to drop its
last copy; charging that revive against the quota; keeping its identity
deduplicated across the sender's replay window; refusing a message whose
channel disagrees with its envelope; and reaching every channel
regardless of its own sequence numbering.
`pnpm check` has been failing on the branch since the suite landed, so the
`check` CI job was red while the test job passed.

`channel` declared its options as `readonly epoch?: string`, but every call
site forwards `options.epoch`, which is `string | undefined`. Under
`exactOptionalPropertyTypes` an optional property and an explicitly-undefined
one are different types, so passing the latter is an error. Widen the
parameter to accept `undefined` rather than making the call sites strip it,
because forwarding an absent field is exactly what these helpers are for.
Adds test/RelayInbox.test.ts against the explicit in-memory cluster and the
real SQL store over SQLite. Two harness notes worth keeping: TestRunner.layer
bakes ShardingConfig defaults that stall every test under virtual time, and
shard acquisition runs on scheduled fibers, so the clock has to be advanced
once before any entity is reachable.

The suite exposed three defects, each with a regression test that fails for
its own reason before the corresponding change.

deliverHead built the finalizer that fails a pending settlement as an eagerly
evaluated argument to Effect.ensuring. It ran when deliverHead was called,
before the body registered the settlement, so the lookup always missed and the
failure went to a throwaway Deferred. A recipient's Settle then waited forever
whenever the durable write did not land, and because Settle runs on the
entity's serialized lane that hung request blocked every later Deliver and
Settle for the device. Suspended so the map is read at finalization time.

channelId omitted senderConnectionEpoch while ChannelKey treats it as
identity. The store partitions heads by the full key, so two epochs of one
sender are two heads at once, but the coarser in-memory key marked both busy
from a single delivery. One unsettled head in a stale epoch held the live
epoch's entire stream for the life of the session, which is the reconnect path.

closeSession interrupted the Deferred that Settle awaits. Settle declares a
closed error union, and an interrupt is not in it: the front door cannot catch
it, so releasing a session took down the peer's acknowledgement fiber instead
of returning a retryable failure. It now fails with ServerUnavailable, while
the deferred only the delivering fiber awaits is still interrupted.
Both RpcTest.makeClient and Entity.makeTestClient hand the handler an already
decoded value, so every other relay suite exercises the schemas without ever
serializing them. That is the one thing this PR changed most: the bespoke
length-prefixed framing is gone and the relay now speaks standard Effect RPC.

This drives a full Open/Push/Acknowledge round trip between two clients and a
real RpcServer over a TCP socket with RpcSerialization.layerJson, and asserts
the payload survives byte identically in both directions. The payload carries
high bytes on purpose, since a Uint8Array is the part of the contract a JSON
codec is most likely to mangle.

The no-redelivery check pushes a second message and requires it to arrive
first, rather than sleeping and asserting nothing showed up: a redelivery of
the acknowledged message would be the older head and would necessarily arrive
ahead of it.
expire and collect are global sweeps that take no inbox key, and the entity
owning an inbox is passivated as soon as it goes idle, so no entity can host
them: the inboxes that most need expiring are exactly the ones with nobody
connected. Nothing called either operation, which made messageTtlMillis and
terminalRetentionMillis inert - undelivered messages were never expired,
terminal rows were never collected, and the retained row count would climb
until admission was refused for good.

A cluster singleton gives it one owner at a time that moves with the shard
rather than being pinned to a process. Interval, batch limit, retention window
and enabled are all required: enabling retention silently would have every
deployment delete durable rows on a schedule it never chose, and defaulting it
off would leave TTL quietly dead.

A sweep failure is caught and logged rather than allowed to escape. Sharding
converts a failed singleton into a defect, which would stop retention for the
whole deployment until a rebalance happened to move ownership.

index.ts is regenerated by pnpm codegen; it had not been run since the relay
modules landed, so five of them were missing from the package entry point.
RULES.md requires spans at meaningful workflow, I/O and remote boundaries, and
the new relay modules had none, so a stalled inbox or a slow sweep was
invisible.

Every store operation and every entity handler now carries a stable span name.
The store is spanned where the service is constructed rather than inside each
body, so the names sit together and the database round trips are all visible at
one boundary.

Attributes are limited to identifiers: inbox_key is a digest, and relay message
ids and outcomes are opaque. The envelope and payload are never recorded.

Subscribe spans the installation of the session rather than its lifetime, since
the handler returns as soon as the session is reachable and the stream long
outlives it.
Two guarantees the old server made were missing from the rebuild. Neither was
untested by accident; both were simply gone.

Settling was not authorized at all. The handler resolved the session and
forwarded to the entity, so a peer whose Receive authority had been withdrawn
could still perform a durable terminal mutation. The session monitor does end a
session whose grants lapse, but asynchronously, which leaves a window and
reports SessionUnavailable where the contract calls for AccessDenied. Receive
is now re-checked on every settle.

The unbounded-decode risk acknowledgement was never requested. The old server
required it before admitting a Push and before a delivery, on its own port that
denies by default, because relaying a document commits its recipient to a decode
that is not allocation bounded on the Automerge version this protocol targets.
The new front door inherited that risk from the ordinary Send grant instead,
which is not the same decision. It is now required on Push, on each delivery,
and on settle.

Also spans the four front-door RPCs, and makes retention part of
RelayServer.layer rather than a separate layer a deployment can forget: TTL and
collection are inert without it, and the failure mode is a table that grows
until admission stops succeeding.

The digest assertion now recomputes the outer envelope digest independently and
compares it, rather than checking it looks like a hex string - a digest over the
wrong inputs matches that pattern just as well as a correct one.
A broken authorization backend must not be reported as AccessDenied: that tells
a legitimate client it is unauthorized and makes it stop retrying, when the
truth is the relay cannot answer. The wire contract encodes a defect as
InternalError to keep the two distinct, so the test asserts the cause carries a
defect and no typed failure at all.

Credential invalidation ends the session by closing the stream rather than
erroring it. Nothing but the relay observes the credential, so it is the only
thing that can end a session when authority is withdrawn, and an orderly close
lets the client reconnect on its own terms. The delivered-but-unsettled message
stays durable across the revocation.
Removes PeerRpcServer, PeerRelayIngress, PeerRelayStore, SqlPeerRelayStore and
their migration and transaction helpers, along with the tests and bench that
exercised them. The packages are unpublished, so the cluster relay replaces
them outright rather than living alongside.

All 25 client-visible contract guarantees the deleted tests encoded were
audited first. Twenty are re-expressed against the new stack; five are waived
because their subject no longer exists - process-local runtime shutdown is now
cluster rebalance and passivation, there is no claim or payload-load split to
race, and the front door has no admission queue to drain across a revocation,
so exactly-once there is structural rather than a drain guarantee.

The store contract moves into its own module and now runs against SQLite,
PostgreSQL and MySQL. Multi-dialect parity was a decision, and a single dialect
cannot establish this contract: PostgreSQL returns BIGINT, COUNT and SUM as
strings, which made the store non-functional there while SQLite stayed green,
and MySQL compares identity columns case-insensitively without a binary
collation. Both were real defects in this store.

Each check owns a distinct inbox key and none asserts a sweep's returned count,
because expire and collect are deployment-wide and the dialect containers are
shared across the block. Asserting the effect on the check's own inbox is the
real guarantee anyway.
Re-authorizing every delivery introduced a stall. The front door takes a
message off the entity's rendezvous queue and only then decides the recipient
may not have it. By that point the delivering fiber has returned from the
offer, charged the delivery, and is parked waiting for a settlement that can
never arrive, because the client never saw the message. It holds its channel
the whole time.

The damage is not limited to that channel. Enough withheld heads occupy every
one of the session's delivery slots, at which point channels the recipient is
fully entitled to are never delivered at all - a permanent stall bounded only
by the session ending. A reviewer reproduced both halves against the real
composition.

The seam was the problem: Subscribe and Settle gave the front door no way to
say "not deliverable to this session, release it". Release does that. It ends
the attempt without settling, so the row stays Pending for a later session,
frees the channel, and records the channel as withheld for this session only -
skipping the whole channel rather than the message, because passing over a head
would break the ordering the channel exists to preserve. A reconnect starts
clean, so a grant that comes back is picked up.

The dispatcher also had to widen its poll by the number of withheld channels.
Heads come back oldest first, so once enough withheld ones accumulate the query
returned nothing else and the channels behind them were never looked at. Both
halves are load bearing: removing either fails the regression test.

The previous withhold test could not have caught any of this. It asserted an
absence one scheduler pass too early, so it passed whether the message was
withheld, delivered, or failed the stream. It now waits on a real rendezvous.
The risk-grant knob was also tied to the ordinary authorization knob, which hid
whether either port was enforced on its own; they are now independent.
Mutation testing found three guards that could be deleted outright with the
whole suite still green. All three are the kind that only ever matter when
someone is doing something they should not.

sessionFor's ownership check was one of them. A session id is unguessable, but
unguessable is not authenticated, and nothing else in the handler re-derives
the caller from the session - so a peer holding a perfectly valid credential of
its own could push and settle on another peer's session. No test had ever
presented one principal's session id under another principal's credential.

The tenant check was another: every principal in the harness lived in the same
tenant, so refusing a peer authenticated into a tenant this relay does not
serve was never exercised.

The third was the claimed-local-identity comparison. The existing case differed
in two fields at once, so the clauses masked each other and any one of them
could be removed unnoticed - including the peerId clause, which is what stops a
credential being reused for a sibling device of the same subject. Each field is
now checked on its own.

Each test was verified by neutralizing the guard it covers and confirming it
fails.
A test-reviewer ran 69 mutations against this suite and found that the
handshake's two grants, the recheck that runs after authorization returns,
the per-message re-authorization, the unbounded-decode risk port, the
heartbeat, the entity-call timeout and the per-subject cap could all be
deleted or weakened without turning it red. Authorization that nothing
pins is authorization that will be refactored away.

Each gap is now closed by a test that denies exactly one thing at exactly
one call site, and every one was verified by applying the mutation to a
copy of the repo and watching it fail. Reaching the post-authorization
rechecks needed a new seam: the authorization port already refuses a grant
that was expired when it answered, so the only way the relay's own recheck
can matter is if time passes while authorization is in flight, which is
what the `onAuthorize` knob simulates.

The risk-port delivery test survived its own mutation on the first attempt
because "assert the queue is empty" was racing the dispatcher rather than
observing a withheld message. It now requires a younger, authorized message
on another channel to arrive first, which is the only shape in this suite
that can distinguish withheld from not-yet-dispatched.

Four tests are deleted. Three were decided entirely by the authentication
middleware or by store-level deduplication, and one asserted a rejection
outcome that no store operation can read back. The protocol-version pin
moves to PeerRpc.test.ts, where the schema it exercises lives.
The per-channel dispatcher runs on a forked fiber that nothing observes, so a
defect in it made the fiber quietly disappear. Everything else went on looking
healthy: the session stayed installed, the front door kept heartbeating it,
Deliver kept reporting success to senders, and the device received nothing at
all until it happened to reconnect. A forked fiber's defect reaches neither
RpcServer's defect path nor the entity's own defect restart, so nothing in the
framework noticed either.

The dispatcher now hands its cause to the one thing the recipient is watching.
Failing the queue with the raw defect does not work - Subscribe declares a
closed error union, so a Die cause has nowhere to go across the entity RPC
boundary and the recipient still waited forever. The defect goes to the log at
fatal with its whole cause, and the recipient gets the one thing it can act on:
a retryable ServerUnavailable, after which the front door's session finalizer
ends the session and the client opens a fresh one.

Three entity calls that ran without a deadline are now bounded. The session
finalizer's EndSession is the serious one: finalizers run uninterruptibly and
Sharding retries an unassigned entity forever, so during a rebalance that call
never returned, the request fiber never exited, and the node hung on shutdown
over a connection whose client was already gone.

The entity finalizer's comment claimed it runs before the framework's
termination handshake. It does not, and it cannot: the build effect only ever
sees the ResourceRef's inner scope, while the handshake is registered on the
outer entity scope afterwards, and scope finalizers are last-registered-first.
The comment now states the real ordering and what it costs on a rebalance.
PeerRelayLimits carried the tuning surface of a single-process relay that owned
its own socket framing, claim leases, SQL lanes and cross-scope quota counters.
None of that machinery survived the move to cluster entities, so 87 of its 97
values had no reader at all - 79 of them referenced only by cross-field
validations that validated each other.

Dead configuration is worse than absent. An operator who sets
maxActiveMessagesPerTenant believes their deployment is quota bounded; nothing
reads it, so it is not. The ten values that are actually read stay, along with
the two relations that still mean something: the receipt retention nesting rule,
which the front door's Open validation mirrors and therefore has to agree with,
and the authentication burst floor. The test now pins the exact surviving key
set rather than a count, so a value cannot come back without a reader.

Two families are gone with no replacement, and both are accepted regressions
rather than oversights. Per-scope quotas span many inboxes, so no entity is
their sole writer and enforcing them would reintroduce the cross-process
arbitration this design removes; admission is now bounded per inbox, inside the
transaction that performs the write. Connection and frame accounting belonged to
the length-prefixed framing that standard RPC over a socket replaces; a single
payload is still bounded by PeerRpc's schema check, but concurrent connections
are now the deployment's socket server to bound.

The observability module loses nineteen exports for the same reason. Beyond the
session and queue metrics, the whole setRelay family and the six gauges behind
it had no production writer either - only their own test drove them, which is a
test keeping dead code alive. A gauge that is always zero reads as "no backlog"
rather than as "nobody is measuring".

internal/peerRpcProtocol.ts stays. Its two constants look inlinable, but
PeerRelayLimits reading them from PeerRpc would close a cycle through
PeerAuthentication. The leaf module is what breaks it.
A configured duration named somethingMillis puts the unit in the identifier
instead of the type, and makes every deployment do its own arithmetic to say
"seven days". Every configuration surface this branch owns now takes
Duration.Input, so a deployment can write Duration.days(7), "7 days", or a raw
number, and each is converted once where the Layer is built rather than on every
request.

Two things deliberately keep their unit in the name, and RULES.md now says why:
values that cross the wire contract, and values that feed a millisecond column.
Duration has no stable serialized encoding, so a protocol or a column has to
name its unit. The first attempt at this renamed the store's operation arguments
along with the configuration and the typechecker caught it - when the boundary
between configuration and protocol is a field name, a regex is the wrong tool.

The two duration schemas are Schema.declare guards over Duration.fromInput,
which is safe on arbitrary input, so InvalidPeerRelayLimits still names the
offending field.
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.

[P0] Add asynchronous store-and-forward so peers converge without overlapping online time

1 participant