@claudewerk/bridge-substrate
v0.2.2
Published
The L2 substrate: named, durable coordination objects that live on both sides of a bun-reliable-ws connection. Composes bun-mq + bun-reliable-ws + envelope-codec + mutual-key-auth.
Readme
bridge-substrate -- the L2 named-object engine (F1 CORE)
The keystone. It composes four proven, independently-built packages into the substrate: named, durable coordination objects that live on both sides of a connection -- "named queues that live on both sides." It is claudewerk- agnostic; it knows nothing about any broker. It is the engine a larger system (or a GATE) drives.
A Substrate sits on each side of one authenticated connection, owns named
objects (F1: session + queue), and speaks the substrate verbs over the
wire -- with per-object strict-FIFO sequencing, two acknowledgment levels, a
durable outbox + dedupe for exactly-once, and the futility / authorized-finality
laws.
bun install # links the four packages via workspace:* (run at the monorepo root)
bun test # 19 tests, all green (254 across the workspace)
# two-node CLI demo (see examples/README.md):
bun examples/client.ts --name C1 --port 7801
bun examples/client.ts --name C2 --port 7802 --connect 7801There is a runnable, drive-it-by-hand two-node CLI
(open / send / req / close / disconnect / connect) that visualizes the
object lifecycle, the two ack levels, and exactly-once across a disconnect.
The four packages it composes
| Package | Role in the substrate |
|---|---|
| bun-reliable-ws | the L1 carrier: binary wire, staged handshake, reconnect, WS delivery-acks, AuthContext with the full Negotiated tuple + connId + transcriptHash |
| envelope-codec | the frame codec (cborCodec): {env, body} -> CBOR bytes, the opaque body rides binary with zero base64 |
| mutual-key-auth | the AuthProvider (asAuthProvider): mutual challenge/response, role-absolute, cross-layer downgrade binding -- now zero-glue (see FINDINGS) |
| bun-mq | the durable per-object outbox + the dedupe ledger (receipt-fenced ack, StaleReceiptError, 7-day dedupe window) |
Wiring
SUBSTRATE A (dialer) SUBSTRATE B (listener)
───────────────────── ───────────────────────
app: open / req / send / close open? -> accept/reject; req -> res
│ ▲ ▲ │
▼ │ answer (accept/res/end/err) end-to-end, keyed to id │ ▼
┌──────────┐ per-object durable outbox dedupe ledger ┌──────────┐
│ Outbox │ (bun-mq, one seq authority) (bun-mq (peer,id) >= 7d) │ Sequencer│
└──────────┘ at-most-one-un-acked-in-flight in-seq delivery ────└──────────┘
│ ▲ ▲ │
│ │ ◀──────────── ack (hop delivery-ack, receipt-fenced) ─┘ │
▼ │ ──────────── open/send/req/res/accept/reject/close ──────▶│
Link: bun-reliable-ws ═══ ws (binary), may drop + reconnect ═══
Codec: envelope-codec frame = { env{v,kind,obj,objKind,id,seq,receipt}, body } -> CBOR
Auth: mutual-key-auth challenge/response, bound to connId + full cross-layer tupleThe same stack + composition as a flow:
flowchart LR
subgraph A["Substrate A · dialer"]
AApp["app: open / req / send / close"]
AOut["Outbox<br/>durable bun-mq · 1 seq authority<br/>at-most-1-unacked-in-flight"]
ASeq["Sequencer<br/>in-seq · gap buffer · resync"]
AApp --> AOut
ASeq --> AApp
end
subgraph B["Substrate B · listener"]
BApp["app: accept/reject · req→res"]
BOut["Outbox"]
BDed["Dedupe ledger<br/>(peer,id) ≥ 7d"]
BSeq["Sequencer"]
BApp --> BOut
BDed --> BSeq
BSeq --> BApp
end
AOut ==>|"open/send/req/res/accept/reject/close"| BDed
BOut -.->|"ack · hop delivery-ack · receipt-fenced"| AOut
BOut ==>|"answer · accept/res/end/err · keyed to id"| ASeq
AOut <--> L
BOut <--> L
L["LINK<br/>bun-reliable-ws — binary ws, drop+reconnect<br/>envelope-codec — {env,body} → CBOR, zero base64<br/>mutual-key-auth — bound to connId + full cross-layer tuple"]Storage is pluggable (where durable state lives)
Every peer's outbox, inbox, dedupe ledger and object registry go into one
Store (bun-mq's persistence contract). Defaults need no configuration; a host
that wants the state in ITS OWN database plugs a store in:
| Option | Where state lives |
|---|---|
| nothing | memory, not durable |
| dbPath / acceptor dbPathFor(peerId) | a SQLite file the substrate opens and closes itself |
| storage / acceptor storageFor(peerId) | a Store the host built; the substrate never closes it |
Precedence on an acceptor: storageFor, then storage, then dbPathFor, then
dbPath. Every durable key is scoped by peer id, so ONE store can serve every
peer. To keep bridge state in the host's database, next to the host's tables and
in the host's transactions:
import { createPeerAcceptor, sqliteStore } from "@claudewerk/bridge-substrate";
const bridgeStore = sqliteStore(appDb, { tablePrefix: "bridge_" }); // appDb opened { strict: true }
const peers = createPeerAcceptor({ peerId: "broker", auth, authorize, storageFor: () => bridgeStore });A queue object accepted with acceptQueue() then keeps its durable inbox in
the host's database: the host can count it, inspect it and manage it like the
rest of its data. Releasing a peer leaves its rows in place for the host to keep
or delete.
Exactly-once across a forced disconnect (the money flow)
sequenceDiagram
participant A as Substrate A (Outbox)
participant Wire as ws link
participant B as Substrate B (Dedupe→app)
A->>A: enqueue send(id=M-1, seq=n) — durable, BEFORE the wire
A->>Wire: send{id:M-1, receipt:r1}
Wire->>B: send{id:M-1, receipt:r1}
B->>B: admit(peer,M-1)=fresh → commit, deliver to app once
Note over Wire: 💥 connection drops — ack lost
B--xWire: ack{M-1, r1} (never arrives)
Wire-->>A: reconnect (new connId, same peer → rebind)
A->>Wire: RESEND send{id:M-1, receipt:r1} (same producer-stable id)
Wire->>B: send{id:M-1}
B->>B: admit(peer,M-1)=DUP → skip app (dedupe wins)
B->>Wire: ack{M-1, r1} (branchless re-ack)
Wire->>A: ack{M-1, r1}
A->>A: q.ack(r1) receipt-fenced → outbox row freed
Note over A,B: delivered exactly once · FIFO intactObject model
An object is a named coordination endpoint that exists on both sides:
- Address -- an opaque token the substrate MATCHES, never parses.
Charset
[a-z0-9._/-]{1,128}; an@isrouting_not_supported.objKind(session|queue) is an explicit envelope field, not parsed from the name. - Lifecycle --
open->accept/reject, then live, thenclose(or a terminalerr). Both endpoints track the object's participants; a killing verb is honoured only from a verified participant. - Session -- supports
req/res(end-to-end answer) and one-waysend. - Queue -- the same machinery, send-oriented (one-way
send+ack).
Verbs (env.kind)
| Verb | Direction | Meaning |
|---|---|---|
| open / accept / reject | opener ⇄ acceptor | establish or refuse an object |
| close | either participant | terminate an object (authorized finality) |
| send + ack | producer -> peer | one-way message; ack is the hop delivery-ack |
| req / res | caller ⇄ callee | request + end-to-end answer |
| err | callee / substrate | terminal error (class derived by the receiver from the code) |
| end | callee | terminal end-of-answer |
| chunk / resync | -- | chunk is an F2 seam; resync an internal control seam |
The two acknowledgment levels
- Hop delivery-ack (
ack, QoS-1, this connection). Sent by the receiver the moment an op is durably committed to the dedupe ledger. It frees the sender's outbox row, receipt-fenced via the echoedenv.receipt. Lost to a dropped connection -> the sender resends the same frame on reconnect -> the receiver dedupes the redelivery and re-acks. This is what makes delivery exactly-once and what enforces strict FIFO (at most one un-acked op in flight per object). - End-to-end answer (
accept/reject/res/end/err), keyed to the producer-stableid, connection-agnostic -- it resolves the caller's promise whenever it can be delivered, across any number of reconnects.
Per-object sequencing + strict FIFO
Each object has a single seq authority per direction (the Outbox). Frames go
out strictly one-un-acked-at-a-time, so with an ordered carrier the receiver's
Sequencer sees them in order and passes through; a reorder/redelivery is buffered
within [nextExpected, +window], a below-frontier seq is a duplicate (re-ack only),
and a gap older than 30s asks for a bounded resync.
Durable outbox + dedupe (exactly-once)
Every open/send/req (and every answer) is durably queued before it hits
the wire. id is a producer-stable idempotency key -- minted once, reused on
every retry, never per-attempt. The receiver dedupes by (peer, id) for >= 7 days
via bun-mq's persistent dedupe table (which outlives consume + restart), so
dedupe wins over tombstone and a redelivery collapses even after the original
was delivered.
Futility / bounce laws
- (a) Receiver-derived error class -- there is no wire
finalflag; the receiver classifies a code asterminalorretryable(finality.ts). - (b) Authorized finality --
close/errthat kills an object is honoured ONLY from a verified participant; an unauthorized finality frame is rejected before it can even perturb the seq frontier. - (c) Exactly one bounce -- a frame to an unknown object (or a bad address)
yields exactly one terminal
err, tombstoned per-op-id (not per name), so a later legitopenof the same name still works. - (d) An
errnever begets anerr-- an unroutableerris never bounced. - (e) Purge on terminal -- a terminal answer/bounce purges the sender's op and
emits a
bridge/futileevent.
Handshake (the full cross-layer tuple)
The L1 stands up via bun-reliable-ws + cborCodec + asAuthProvider. The auth
transcript binds the complete negotiated tuple -- version, features, the chosen
name in every slot (auth, e2eSeal, e2eSign, codec), dedupeScope
(carried as a negotiated feature token), and connId -- so downgrade binding is
complete across every layer.
The auth choice is one value, and it is required
createDialerSubstrate / createListenerSubstrate take auth: () =>
AuthProvider or auth: "none". There is no default and no third option,
so a deployment cannot arrive at an unauthenticated link by leaving something
out; writing auth: "none" is as deliberate as SSH's Ciphers none, and it is
logged at warn when it resolves. Both the advertised auth name-list and the
wired provider are derived from that one value, so they cannot disagree.
The authorize gate is required too, and for the same reason
auth settles WHO the peer is. authorize settles WHAT it may do, and it is
required on the same factories with no default, so authorization cannot end up
off through an omission. It takes an interceptor: (ctx) => Denial | void,
where returning nothing allows and deny(code) refuses.
import { createListenerSubstrate, scopeAuthorizer, compositeInterceptor, deny, ALLOW_ALL } from "@claudewerk/bridge-substrate";
createListenerSubstrate({
peerId: "gate",
// One key record per peer, chosen by the id the peer claimed in its hello.
auth: substrateAuth({ myKey, peerRecordFor: (peer) => keyRecords.get(peer), method: AUTH_METHOD }),
authorize: scopeAuthorizer({
scopesOf: (peer) => keyRecords.get(peer)?.scopes, // unknown peer -> refused
matrix: { producer: ["open", "send", "close"], admin: ["*"] },
}),
});Where the gate sits is the whole value of it. Every inbound operation is offered
to the chain after address and verb validation and before object lookup, the
sequencer, the durable commit and the hop-ack -- so a denied peer creates no
object here, does not move the receive frontier, and is never told its operation
was accepted. An inbound stream establish passes the same chain and is refused
with establish_fail{unauthorized} before a label is allocated; objKind:
"stream" is not a way around the gate.
It is a CHAIN because observing is the common case: an interceptor that returns nothing allows, so tracing and audit are members alongside the policy, and the first denial stops the rest.
authorize: compositeInterceptor([
(ctx) => void log.info("inbound", { peer: ctx.peer, verb: ctx.verb, obj: ctx.obj }),
(ctx) => (ctx.obj.startsWith("admin/") ? deny("unauthorized") : undefined),
scopeAuthorizer({ scopesOf, matrix }),
]);The shipped policy is deny-by-default and calls mutual-key-auth's scopeAllows
rather than restating the rule -- one definition, two callers. ALLOW_ALL is the
named opt-out and gives back the pre-A1 posture: any authenticated peer may do
anything on this connection. ctx.cap carries an opaque capability token the
opener presented (open(kind, addr, { cap }) / openStream(addr, { cap })); the
substrate ferries it and never parses it.
The end-to-end crypto seam
src/e2e.ts is what the e2eSeal and e2eSign slot names MEAN. One registry
per slot holds the suites this build implements; a host advertises exactly
those names and resolves a negotiated name back through the same map, so the
list and the wiring cannot drift. A negotiated name with no implementation is
refused at ready, never skipped.
The only suite shipped today is none, which is the honest answer for a
point-to-point deployment: TLS already covers every hop, and the real suites
exist for a relay that must carry what it cannot read. Adding one is two
registry entries and no change to negotiation.
Observability (pluggable, vendor-neutral)
The substrate stays dependency-free but is instrumented. Four narrow concerns --
Logger / Metrics / Events / Tracer -- each with a null default (zero
cost) and a composite (fan-out to N). Pass an observability bag to the host
factories; heavy sinks (OTel, Prometheus) are adapters BEHIND the interfaces, never
deps.
import { createListenerSubstrate, observe, inMemoryMetrics, ALLOW_ALL } from "@claudewerk/bridge-substrate";
const metrics = inMemoryMetrics(); // snapshot() feeds a dashboard
createListenerSubstrate({ peerId: "host", authorize: ALLOW_ALL, auth: "none", observability: observe({ metrics }) });
// ... later:
metrics.snapshot().gauges.filter(g => g.name === "substrate.outbox.depth"); // per-object backlogDefault is NO_OBSERVABILITY -- uninstrumented is the norm and free. Emitted
signals: substrate.ops.enqueued/acked, substrate.outbox.depth{obj} (per-object
backlog), substrate.ack.latency_ms / substrate.answer.latency_ms,
substrate.ingress.op{verb}, substrate.dedupe.duplicate, substrate.seq.deferred,
substrate.authorize.denied{verb,objKind,code}, substrate.bounce,
substrate.futile{code}, substrate.conn.ready/ended. Adapters:
consoleLogger/Metrics/Events (debug), inMemoryMetrics (dashboards); an OTel
adapter is the reserved next one. See src/observe.ts +
src/observe-adapters.ts.
Layout
src/
wire.ts Env / Frame types, verbs, objKind, address validation
observe.ts pluggable observability: Logger/Metrics/Events/Tracer, null + composite
observe-adapters.ts console + in-memory sinks (OTel deferred)
codec.ts cborCodec as a WS Codec (zero glue)
auth.ts asAuthProvider as a WS AuthProvider (zero glue)
host.ts createDialer/ListenerSubstrate; capabilities; per-peer rebind
link.ts the structural Link surface (subset of ReliableConnection)
substrate.ts the Substrate: object registry, routing, the two ack levels, laws
object.ts SubstrateObject: state machine + app-facing verbs
outbox.ts durable per-object outbox, strict-FIFO one-in-flight, receipt-fenced
sequencer.ts per-object in-seq delivery, gap buffer, out_of_seq, resync
dedupe.ts durable (peer,id) dedupe ledger over the bun-mq Store
finality.ts error-class derivation + per-op-id tombstones
test/
handshake.test.ts four packages compose; connId agrees on both ends
session.test.ts open+accept, req->res, send->ack, reject, close
reliability.test.ts exactly-once across a forced disconnect; strict FIFO under redelivery
futility.test.ts non-participant close rejected; unknown-object bounce+tombstone; bad address
auth.test.ts wrong key fails; tampered tuple breaks the proof
primitives.test.ts unit tests for outbox / sequencer / dedupeWhat this proves
Over a real reliable-ws connection between two Substrate instances:
- session
openA->B (accept),req->res, one-waysend->ack; - exactly-once across a forced disconnect + reconnect (resend + dedupe);
- strict FIFO per object preserved under a redelivery;
- a
close/errfrom a non-participant is rejected (object stays alive); - a frame to an unknown object yields exactly one terminal
err+ a per-op-id tombstone (a later legitopenof the same name still works); - auth/downgrade: a wrong key fails; a tampered negotiated tuple (connId / cross-layer set) breaks the direction proof.
Deferred (clean seams, NOT built)
F1 CORE stops here. Later phases have named seams in the code:
- F2 bulk transfer -- the
chunkverb +sendChunkon the carrier (declared, not wired). - F3 peer lifecycle -- active/unreachable/severing/terminated (the substrate is per-peer + rebindable, which is the hook).
- F4 discovery + propose/consent/elicit -- new verbs on the same router.
- F5 state@1 (Yjs) -- a third
objKindon the same object machinery.
Sequencer state and tombstones are in-memory (the durable outbox + dedupe carry
exactly-once across restart); durable sequencer/tombstone persistence is a
later-phase seam. See FINDINGS.md for what composing revealed.
