23 KiB
Slice 3a: remote objects over one link (spec, for the owner's approval)
Status: approved (owner, 2026-10-22), after Astra's and Fable's spec reviews. Guiding rule (owner): simple, fast, correct; where a choice was open, this spec takes the simplest option that is correct and cheap per hop.
Slice 3 of the plan is split: 3a (this) gets two hosts talking over one link; 3b makes the link survive drops
(re-holds, parking, ref.state, host.hold, ref.retry(), linger, host.root, state: false). Flow control, size
limits, compression, relays, routing by address and IPC/WebSocket stay in slice 4; trace carriers (R5) in slice 9. The
single source for building 3a, together with the feature map in slices.md, design.md read in full (most relevant:
§3 links and values, §4, §5, §7, §8, §22), the decision log (R1, R4, R6, R20, Q1, Q4, W1, D11), the carry-over (L5,
L6) and the root AGENTS.md style section. Builds on L1 and slices 1-2 (da3a16e9f).
What an app can do after this slice
type P = { kind: "owner" } | { kind: "guest"; session: string } | { kind: "server" };
const Notes = remote("notes", {
version: 1,
params: { session: str() },
variants: {
editor: { state: { notes: arr(Note), count: int() }, methods: { add: method(obj({ text: str() })) } },
reader: { state: { notes: arr(PublicNote), count: int() } },
},
});
const facet = defineFacet<P>((host) => {
host.serve(Notes, ({ session }, principal, self) => {
const store = self.get(NoteStore, { session }); // a local shared store (slice 1)
const count = self.derive([store.notes], ([n]) => n.length);
if (principal.kind === "guest") {
return { variant: "reader", members: { notes: self.derive([store.notes], redact), count } };
}
return { variant: "editor", members: { notes: store.notes, count, add: async (args, ctx) => store.add(args, ctx) } };
});
});
const server = createHost<P>({ principal: { kind: "server" }, onError });
await server.load([{ name: "@app/notes", facet }], ctx);
server.accept(link.a, { principal: { kind: "guest", session: "S1" } }); // the entry point authenticated it
const client = createHost<P>({ onError }); // a tab: no principal of its own
await client.load([], ctx);
client.connect(link.b, { principal: { kind: "server" } });
const notes = await client.get(Notes, { session: "S1" }, ctx);
if (notes.variant === "reader") console.log(notes.notes.value); // narrowed by variant
1. Hosts and links
-
createHost({ principal?, principalKey?, onError, … }): a host with aprincipalacts as it for its own requests; a host without one (a client) has no principal.principalKeydefaults to L1's canonical JSON. -
A link is an ordered, bidirectional channel of
Uint8Arraymessages:send(message),onMessage(listener),close(),onClose(listener). This slice shipsmemoryLinkPair(), which delivers each message one task later (as every link kind will).sendafter close is a no-op. -
host.accept(link, { principal }): a link from a child. Every hold and call arriving on it acts asprincipal, stamped by this host.host.connect(link, { principal }): this host's parent link (at most one; a secondconnectthrows). In 3a a host serves overacceptlinks and requests over itsconnectlink; theconnectprincipal is stored for 3b and slice 4 (nothing arrives for it yet). -
Handshake: each side sends
hello { protocol, incarnation }first and nothing else until it received the peer's. A message beforehello, a secondhello, or anotherPROTOCOL_VERSION(L1) closes the link.incarnationis a uuidv7 minted per host; calls arriving on anacceptlink get the peer's asctx.incarnation(R20). -
Where a reference goes:
Type Served on this host Not served here locallocal (slices 1-2) fails unavailable(local types never cross a link)remotelocal, as the requester's principal (table below) over the connectlink; without one,unavailableAny object may reference remote types, local objects included (a client's local view may
self.geta server type). Remote objects live while referenced, also with empty params;loadnever activates them (Q-A). -
Which principal a same-host remote reference uses:
Requester Principal host.getthe host's principal(a host without one failsforbidden)ctx.getin a handlerctx.principal(a guest's call gets the guest's view); a local handler: the host'sself.getin a remote object's factorythat object's principal self.getin a local object's factory or a facetthe host's -
Closing: a link closing (either side) ends what was bound over it: on the consumer, references through it end (
released, watchesended) and pending calls and gets rejectunavailable; on the provider, its holds are released.host.closecloses its links first, then ends its objects (nogoneto closed links). Surviving a drop is slice 3b.
2. Messages (R1, R4, Q1, Q4, §22)
| u32 header length, big-endian | header JSON (UTF-8) | part 0 | part 1 | … |
- One format on every link. The header is a JSON object, a tagged union over
kind, defined as a schema (TypeBox then, pi-schema since the schema switch); every received header is checked with the compiled schema (the wire is a trust boundary; skipping it on trusted IPC links comes with IPC in slice 4). The header lists its parts:parts: [{ json, bytes }], the JSON text's byte length and the byte lengths of the segments that follow it (bytes: []for state parts and arguments without bytes). - Kinds, consumer → provider:
hello,hold { hold, type, version, fingerprint, params },release { hold },call { call, hold, method }(1 part: the arguments),cancel { call }. Provider → consumer:hello,bound { hold, variant, failed? }(1 part: the snapshot),state { holds: [{ hold, failed? }] }(1 part per listed hold),failed { hold, cause },result { call }(1 part),error { call, error },gone { hold, error }. - Ids are numbers chosen by the sender, unique per link and direction, never reused while in use (a counter).
Ids a receiver does not know are ignored (Q1: a
resultcrossing acancel, astatepart crossing arelease). Aholdorcallreusing a live id is a protocol error. - One Delta encoder per state member (owner, 2026-10-22, after the code review): the snapshot part is
{ [member]: value }(each member's encoder restarted); astatepart is{ [member]: WireOp[] }with only the members that changed (a derive's change is itsdiffRevisionsops; a retired singleton is[["r", initial]]). - A failed member is listed in the header (
failed: [{ member, error }]) of theboundorstatethat carries the failure, never in the part; the consumer's replica of that member then fails (reads throw the error, watches endfailed), the others keep updating (W1). - Argument and result parts are JSON text followed by raw byte segments: a
Uint8Arrayleaf is written as{"$bytes": i}, segmenti's length in the part's header entry. Decoding replaces each reference with asubarrayview of the received buffer (no copy). Example:upload({ name: "chart.png", data: <1 MiB>, thumbs: [<2 KiB>, <512 B>] })travels as header…"parts":[{"json":74,"bytes":[1048576,2048,512]}], then the JSON{"name":"chart.png","data":{"$bytes":0},"thumbs":[{"$bytes":1},{"$bytes":2}]}, then the three raw segments. Encoding uses theJSON.stringifyreplacer only for methods whose schema contains abytes()leaf (decided once per method); others are plainJSON.stringify. The key$bytesis reserved in those methods only (see below). State parts are plain JSON (state is JSON only). - Protocol errors close the link: a header failing the schema, a part that is not valid JSON or whose wire ops fail
Delta's checks, an id reused while live, traffic before or a second
hello. These are structural checks of the wire, never checks of application state (state is never validated). - Errors on the wire are a tagged union:
{ kind: "chord", error }(aChordError's JSON, L1) or{ kind: "plugin", code, message, details? }(open point 3). Never a stack or a cause.
2b. Codecs, the Message type and checks (owner, 2026-10-22, after the code review)
Replaces the JSON-header framing of section 2 (| u32 header length | header JSON | parts |); the message kinds, ids,
races and rules of section 2 stay.
-
Messageis a tagged union defined once as a schema (TypeBox then, pi-schema since the schema switch) (MESSAGE;type Message = Static<typeof MESSAGE>):hello { protocol, incarnation },hold { hold, type, version, fingerprint, params },release { hold },call { call, hold, method, args? },cancel { call },bound { hold, variant, members: [{ member, value }], failed: [{ member, error }] },state { holds: [{ hold, members: [{ member, ops }], failed: [...] }] },failed { hold, cause },result { call, value? },error { call, error },gone { hold, error }. Values (params,value,ops, arguments) areunknown()in the schema.argsabsent: a method without arguments;valueabsent:null. A payload that may carry bytes is marked so by the protocol layer (decided once per method from its schema). -
Names are indexes:
member,methodandvariantare indexes into the hold's bound version's names, sorted per variant. Both sides hold that version's description (version and fingerprint matched athold). An index is valid only while its hold is bound to that version; a provider that stops accepting a version ends that version's holds withgone(contract_mismatch) before its new code serves anything (slice 7 builds the reload side; carry-over). -
Codec { name, encode(message): Uint8Array, decode(bytes): Message }, chosen per link (host.accept(link, { principal, codec? }, context),host.connect(...), defaultbinaryCodec); both ends use the same; a mismatch fails the firsthello(a protocol error naming the expected codec). No negotiation.binaryCodec: a kind byte, then that kind's fields as unsigned varints (ids, indexes, counts, lengths), length-prefixed UTF-8 strings, the incarnation as 16 raw bytes and the fingerprint as 8 raw bytes; values as varint length plus JSON text; byte segments (payloads marked as carrying bytes) as a count, lengths, raw bytes, referenced from the JSON by{"$bytes": i}. No outer length (links deliver whole messages).textCodec: one JSON tuple per message, Delta style (a short kind tag, positional fields, no field names):["h", protocol, incarnation],["H", hold, type, version, fingerprint, params],["R", hold],["c", call, hold, method, args?],["x", call],["b", hold, variant, [[member, value]…], [[member, error]…]?],["s", [[hold, [[member, ops]…], [[member, error]…]?]…]],["f", hold, "t"|"b", error],["r", call, value?],["e", call, error],["g", hold, error]; byte segments follow the tuple (their lengths in it).describeMessage(message)prints any message readably (the text tuple form) for logs and tests.
-
Who checks what (layers 1-3 failing is a protocol error that closes the link):
Layer Checks 1. codec decodelengths and varints fit, the buffer consumed exactly, a known kind, strict UTF-8, each JSON value parses, segment counts and references valid and only in payloads with bytes 2. MESSAGEschema (protocol layer, after any codec)the envelope: kinds, ids and indexes are integers, lists and error shapes; not the values 3. the hold's bound version (protocol layer) ids not reused while live (unknown ids ignored), indexes within the bound version's lists, order ( hellofirst,boundbeforestate)4. owners params:checkParams(a brokenfailed, link stays); arguments:checkArgs(error { call, invalid/args }, link stays); ops: Delta's decoder andapplyImmutablebefore anything is adopted (protocol error); errors:chordErrorFromJson(protocol error); snapshots and results: never validated (R9) -
Memory link: delivers with
queueMicrotask(portable, asynchronous, no reentrancy); no Node-specific APIs.
3. Remote objects (§4, §5, §6)
host.serve(remoteContract, (params, principal, self) => ({ variant, members })): the factory returns one variant of the contract with exactly that variant's members. L1'sImplementation<C>for a remote contract becomes the tagged union{ [V in Variants]: { variant: V; members: VariantImplementation<V> } }[Variants](an L1 change); a local contract's stays its members. An unknown variant, or missing or extra members of the chosen variant, fails the activation broken (the member check, slice 1).- Identity:
(type, params key, principal key)(L1identityKey). - Calls are checked against the binding's variant: a call whose method is not in that variant fails
invalid/memberbefore anything else (a reader cannot calladdby forging a message); then L1'scheckArgschecks the arguments with the current version. - Contract match (R6): a
holdbinds only when its version and fingerprint equal the provider's current ones (acceptsis slice 7); otherwisefailed { hold, cause: broken contract_mismatch }. - A hold's activation follows slice 2 on the provider: a broken failure is sent as
failed { hold, cause }and is final (the provider releases the hold); a transient one is sent asfailed { hold, cause }with the hold kept pending while the object retries; going live sendsbound. The provider has no deadline of its own for a hold: the consumer's getter deadline decides (it releases the hold when it gives up). A hold arriving beforeloadresolved waits for it; one arriving while the previous instance of its identity tears down waits for that (slice 2).
4. References over a link (§7)
host.get(Remote, params, ctx, { readyTimeout? })sendsholdand resolves with a live, hydrated reference onbound. Afailedwith a broken cause rejects at once; a transient one is remembered while the get keeps waiting; at its deadline (or cancellation) the get sendsreleaseand rejects with the last cause's error (orunavailableif none arrived).self.get(Remote)follows slice 2 (R18): its object waits forbound, and a broken or transientfailedfails it as a failed dependency.ref.variantis the variant's name and the discriminant TypeScript narrows on; a local reference has novariant.- Replicas: each state member of a remote reference is a watchable on the consumer, built from the snapshot and
then by Delta's
applyImmutableof its ops (no validation, no copy). Onestatemessage is applied as one batch of the consumer host, for all its holds, before any watch frame, so no consumer sees half a provider commit (W1). ref.method(args, ctx)(orref.method(ctx)without arguments) sendscalland resolves with itsresult, or rejects with itserror. A call beforeboundcannot be made (the reference does not exist yet); a forged one is answerederror { call, not_ready }. The caller's cancellation sendscanceland rejects the caller at once; the provider aborts that call's context.- Release (open point 1):
ref.release()rejects the pending calls made through that reference withreleased, sendsrelease, and the provider aborts the handler contexts of that hold's calls and unholds (an object with params ends with its last reference, local or remote). The same rule applies to local references (a change from slices 1-2, where a release left its pending calls running). gone { hold, error }(the provider object ended): the reference ends (released, watchesended) and its pending calls reject with the error; no per-call errors are sent.- Two references to one object from one host are two holds, each with its own encoder (two tabs are two clients).
5. The provider side: one message per link per commit (W1)
- A hold attaches to its object's state members as a synchronous subscriber:
Sourcegets a second set of subscribers besides its watches, fed(value, ops)inside the batch's delivery; a hold counts as watching, so exposed derives recompute in the batch. Taking the snapshot and attaching happen in one synchronous step, so the firststatepart continues exactly from the snapshot. - During a batch each touched hold collects its members' ops; at the end of the batch (a hook
Batch.commitruns after its delivery loop) each touched hold encodes its collected ops once into its part, and each link with touched holds sends onestatemessage. A link that closed meanwhile drops it. - A hold whose object ends sends
gone; a failed member is announced once in the nextstate(or thebound).
6. ctx (§8)
ctx.principal (the stamped caller; on a local handler the host's), ctx.incarnation (the calling host's over a link,
the own host's otherwise, R20), ctx.get(Type, params) (a reference released when the handler settles; a
ctx.get after that throws released), the abort signal and telemetry.
Races
- A
resultcrossing acancel; acallcrossinggone; astatecrossing arelease; aboundcrossing arelease(the consumer ignores aboundfor a hold it released): ignored by id (Q1). - A commit between binding a hold and sending
bound: the snapshot and the subscription are taken in one synchronous step. - A forged call before
bound:error { call, not_ready }(parking is slice 3b). - The provider object ends with calls pending:
gone; the consumer rejects those calls itself. - The consumer releases with calls pending: rejected locally
released; the provider aborts the hold's calls. releasewhile the provider is still activating for that hold: unhold (may end the object, slice 2);boundis never sent.- A
holdwhile the previous instance of its identity tears down: waits (slice 2's endings). - One object held over two links, one link closes: only that link's holds are released; the other keeps updating.
- A link closing during a batch: its
statemessage is dropped; holds and calls on it end (section 1). - A handshake that never completes: nothing else is accepted; a get over it waits up to its deadline.
- A
ctx.getfinishing after its handler settled: the late reference is released at once. - A failed member and healthy members of several holds in one batch: each hold's part carries its healthy ops, its header the failure.
Benchmarks (owner: light benchmarks for memory, timing and wire size)
A script packages/chord/test/next/bench/links.ts (run by hand, not in CI) prints one table per codec, side by
side (owner: both codecs, memory and compute); results are recorded in packages/chord/docs/next/benchmarks.md for
later slices to compare against. Per case and codec: wire bytes per message; compute (encode and decode time per
message over many messages, and commit-to-frame time end to end); memory (heap allocated per message while encoding and
decoding 10,000 messages, measured with --expose-gc, and memory retained per hold). Payloads modelled on p2:
| Case | Measured |
|---|---|
| hold of a notes list of 1,000 items; of a 500 KB document | snapshot encode/decode time, wire bytes |
| one appended item, one changed field, one removed item | commit-to-frame time on the consumer, wire bytes per message, encodes per hold |
| a streamed reply: 2,000 small text appends to one entry | time and bytes per append, total bytes |
| 1 and 10 holds of one object on one link | memory per hold (encoder and replica), messages per commit |
a call with a small argument; with a 1 MiB bytes() argument |
round trip, wire bytes (about 1 MiB, not 1.33) |
Carry-over taken by this slice
L6: PROTOCOL_VERSION in the handshake; one message per link per commit; bytes as raw segments; a malformed error
envelope closes the link; the plugin error shape. L5: ctx.get, ctx.principal. Moved to 3b in the feature map:
host.root, C8 (state: false).
Tests
- Messages: every kind round-trips; parts with byte segments;
$bytesrefused in methods with bytes and plain data elsewhere; each protocol error closes the link; unknown ids ignored; id reuse refused. - Handshake: traffic before
hello, a secondhello, protocol mismatch; incarnations stamped. - Remote objects: variant per principal; identity per principal key; a call outside the binding's variant fails
invalid/member(forged at the wire, not just in types); arguments checked; contract mismatch; invalid variant or members fail activation broken; remote objects with empty params are not activated byload. - Principals: the four same-host cases of section 1.
- References: hydrated on get; broken
failedrejects at once; transientfailedkeeps waiting and the deadline rejects with the last cause and releases the hold; replicas apply one message as one batch (a consumer derive over two members never sees half a commit); calls, results, cancel, errors of both kinds; release rejects pending calls (local and remote);gone. - One message per commit: three holds on one link, one message; a derive with its source; two links, two messages.
- Every race above.
- Cost guards: one encode per hold part; no validation or copy of state on either side.
- GC: a closed link and a released reference, retained by the test, hold no holds, encoders, replicas or objects.
- Examples:
08-remote.ts(two hosts, variants per principal),09-calls.ts(calls, results, errors, cancellation, release).
Decided with the owner (2026-10-22)
Slice 3 is split into 3a and 3b; a remote factory returns { variant, members }; ref.variant is the discriminant
(C15: no inVariant/expectVariant; variant reserved as a member name); headers are checked on every link for now.
Release rejects the calls made through the released reference, locally and over links (their handlers' contexts are
aborted). $bytes is reserved in the arguments and results of methods whose schema has a bytes() leaf (encoding
refuses it with invalid/args); elsewhere it is plain data, since only parts with byte segments resolve references
(owner, 2026-10-22, narrowing the first decision). Plugin errors: new PluginError(code, message, details?); any other thrown value crosses
as { kind: "plugin", code: "internal", message }; only code, message and details cross; same-host callers get the
thrown value; the consumer rethrows a PluginError.
Size
About 1,500-2,000 source lines (both reviewers), including the L1 change and the batch's subscriber hook.