Protocol compatibility
The desktop, mobile client, CLI, and daemon can run builds from different
commits. packages/protocol/src/session/wire.ts defines their shared wire
vocabulary and compatibility rules. It is one of the package’s five contract
folders — board/, commands/, session/, delta/, and manifests/ — each
exporting through a single seam that the root index.ts re-exports. Beside them
the root carries declared contract modules that own one boundary each and have no
folder to sit in — app-owned-paths.ts, round-evidence.ts, forge.ts — plus the
parked legacy residue (domain.ts, wire.ts, sha256.ts) that migrates with the
changes reworking it. A root module is not automatically residue.
Evolve schemas append-only
Section titled “Evolve schemas append-only”An existing frame or command payload may gain an optional field. It may not lose a field, make an optional field required, narrow an accepted value, or change a field’s meaning within the same protocol version.
Local patchsets use that append-only rule for repository.reviewedTreeOid. An
older patchset omits it and repository-wide reads fall back to immutable
headOid; a new local capture carries the pinned full working-state tree while
headOid keeps its established meaning as the branch commit. Board source refs
similarly gain optional candidate identity, and stated Design decisions may add
optional inferred and source fields without changing legacy boards.
Own-branch publish composition adds an optional provider-qualified target to
both publish.compose and publish.submitPr. Current daemons always return it;
an older client may omit it on submit, in which case the daemon resolves the
live destination and verifies that it reproduces the target-bound composition
identifier before any push. A current client can still consume an older daemon’s
target-free preview and submit it back to that same daemon.
The commands registry (packages/protocol/src/commands/) is the single
validation authority for request inputs and response outputs: one table keyed by
command id, each row carrying its input and output schema alongside label,
exposure, and locus metadata. Session envelopes refer to those schemas rather
than copying command payload shapes. All wire payloads must be
JSON-representable.
Use a new protocol version for a change that cannot follow the append-only rule.
Negotiate a version window
Section titled “Negotiate a version window”PROTOCOL_VERSION is the version this build speaks.
MIN_COMPATIBLE_PROTOCOL_VERSION is the oldest version it accepts. Both are
currently 2; version 2 introduced the aggregate, byte-exact review artifact and post
descriptor used by the publish preview.
Two peers are compatible when each version is at least the other peer’s minimum:
import { checkProtocolCompatibility, MIN_COMPATIBLE_PROTOCOL_VERSION, PROTOCOL_VERSION,} from "@rennet/protocol";
const result = checkProtocolCompatibility( { version: PROTOCOL_VERSION, minCompatible: MIN_COMPATIBLE_PROTOCOL_VERSION, }, { version: serverInfo.protocolVersion, minCompatible: serverInfo.minCompatibleProtocolVersion, },);The helper returns either { compatible: true } or
{ compatible: false, reason }. The reason identifies which side falls outside
the version window.
The server checks hello.protocolVersion against its minimum. The client runs
the symmetric check after serverInfo supplies the server’s current and minimum
versions.
Advertise optional capabilities
Section titled “Advertise optional capabilities”serverInfo.features is an open Record<string, boolean>. A client reads it
after the handshake and enables only the advertised protocol path. Adding a key
does not require changing the serverInfo schema.
| Key | Current meaning |
|---|---|
serverRequests | The daemon can send serverRequest and serverRequestResolved, and accepts serverResponse. Current daemons always advertise it. |
attention | The daemon accepts presence, publishes attentionEvent, and supports push registration and attention acknowledgement. It is advertised only when the attention system is composed. |
act | The daemon implements publish.compose. It is advertised only when that acting seam is composed. |
review-cli | The daemon wires the headless rennet review seam: the repository.identify read that resolves a checkout path to its canonical owner/name (D11). Any daemon built with this code advertises it unconditionally; its absence is what a pre-review-cli daemon shows. |
The client does not send feature-specific frames or commands when the daemon did
not advertise the matching key. A client without act disables Stop and publish
composition with update-required copy. A client without attention stays silent
on presence and push registration. rennet review refuses at connect when
review-cli is absent, naming the minimum daemon version, rather than resolving
against the wrong repository or base.
Parse inbound frames tolerantly
Section titled “Parse inbound frames tolerantly”Every session frame is a default, non-strict Zod object. Unknown keys are stripped, so a peer can receive a frame with new optional fields. Tests clone each frame schema as strict and prove that only the strict clone rejects the additional field.
The checked-in public projection schema has a different contract. It uses
additionalProperties: false, so adding an optional projection field requires
regenerating the fixture in the same change:
UPDATE_PUBLIC_SCHEMA=1 pnpm nx test rennet-protocolA consumer adopts the regenerated fixture when it adopts the new projection shape.
Track compatibility shims
Section titled “Track compatibility shims”A temporary translation or default for a protocol version uses a greppable comment with a removal condition:
// COMPAT(example): explain the accepted shape and translation.// Remove when MIN_COMPATIBLE_PROTOCOL_VERSION is at least N.Remove the shim when the minimum compatible version makes it unreachable.
Frame vocabulary
Section titled “Frame vocabulary”sessionFrameSchema is a discriminated union on type.
| Frame | Direction | Payload |
|---|---|---|
hello | client to server | client ID, client type, protocol version, optional device token |
serverInfo | server to client | app version, protocol window, feature flags |
request | client to server | request ID, registered command, input |
response | server to client | request ID, output |
rpcError | server to client | request ID, code, message, optional details |
progressEvent | server to client | command ID and project-processing or project-detail event |
askStreamEvent | server to client | review ID and ask-stream event |
serverRequest | server to client | server request ID, kind, payload |
serverResponse | client to server | server request ID, payload |
serverRequestResolved | server to client | server request ID |
presence | client to server | focus, visibility, device class, optional focused review |
attentionEvent | server to client | raised item or cleared IDs |
boardEvent | server to client | board ID and newly appended board events |
askProjection | server to client | session ID and the durable ask projection |
roundProgress | server to client | review ID and one round-progress event |
progressEvent.event accepts the ProjectProgressEvent union. General project
processing uses onProgress(commandId); per-repository pull-request loading for
project detail uses onProjectDetailProgress(commandId). Both share the wire
frame and remain distinct bridge subscriptions.
roundProgress carries one live round-progress event, keyed by the review whose
round is running. It is a snapshot frame: each event re-states the whole of
its group’s rows rather than a delta, and the client’s run machine is a
forward-only fold, so a duplicated or re-ordered frame just restates rows the
fold already holds. The same events are readable as an ordered log through
session.roundEvents, which is what a client joining mid-round folds to catch
up — one reducer over one event vocabulary, so a late joiner and a live
subscriber can never disagree about the phase.
The run machine treats both composed and failed as terminal from any
in-flight phase, so a round that ends can always say so even when an
intermediate event — most often report, which only a round with a successor
account ever emits — never happened.
Each event carries a seq: its position in that review’s progress log,
monotonic across rounds. The read and the push are two writers over one log and
neither is complete on its own, so the client merges them by seq rather than
letting the later arrival install itself wholesale — otherwise an event emitted
while the catch-up read was in flight is dropped, and a dropped terminal event
leaves the surface reading “still working” over a finished round. Because a
dispatched starts a round, everything before the newest one belongs to a round
that is over and is discarded, so a late frame from the previous round cannot
settle the round now running. seq is optional on the wire: a daemon that
predates it emits none, and those events fold in arrival order exactly as before.
A round’s progress rows are two shapes, each a union on status so the illegal
states are unrepresentable rather than guarded at every read. A step row (a
prep line, the worker turn) settles done with its own account of itself, or
failed with a reason. A lens lane adds drafted — its board is written but
its carried/reworked verdict is not known yet — plus absent for a lens that
settled with nothing to show and said why. Its done state requires the carrying forward / reworked verdict; absent and failed both require their honest
reason. Step rows never use drafted or absent.
The same successful absence is durable. A generation may record a lens in
absentLenses: Design uses no-spec, Decisions uses no-decisions, Flagged
uses no-findings, and Noise uses no-noise. One reason is the host’s rather
than a seat’s: spec-only, recorded on Sequence, Decisions, Flagged and Noise
together when every changed path is a specification artifact, so that Design is
the whole review. Design’s no-material predates the spec respec and stays in
the reason enum so generations recorded before it keep parsing; nothing settles
it now. board.read then returns
board: null plus that optional absence code. Older generations and older
daemons omit the field, which remains the ordinary missing-board answer rather
than being reclassified as successful absence. The client polls missing boards
but treats the explicit absence as settled and keeps its lens selectable so the
reviewer can read the result.
The same rule applies to drafting failures added after the absence field. A
generation may carry a per-lens reason in failedLenses; board.read returns it
as failure beside board: null. Older generations omit the field and remain
ordinary missing-board answers. The wire addition is optional in both persisted
and command-output shapes.
A failure’s typed account arrived later still, and append-only beside the message
rather than inside it: failedLensAccounts on the generation, failureAccount
on board.read, each naming the attempt that failed and a retryable /
terminal classification. A generation or a daemon without the field answers the
message alone, and that absence means the classification is unknown — it is
not a licence to present the lens as beyond another attempt.
The generation’s per-phase timings follow the same rule. timings carries a
version and one record per phase — for a lane that ran more than one model, one
record per distinct (harness, model) that ran a turn, so Flagged’s two review legs
each contribute a lens-draft record rather than folding into one anonymous span; its
compiler merges into whichever leg shares its harness and model, adding a third record
only when the council routed it to a model neither review used. A record’s lens is discriminated by its phase: the lane-scoped
phases require it and the generation-wide ones refuse it, which is a constraint
on the record and never on the field’s presence. A generation or a daemon without
the field says nothing about duration. The coverage phase stays in the phase
vocabulary only so records measured before the cross-lens coverage gate was
removed still parse; nothing records it any more, and there is no coverage state
on the generation, the lens progress frame, or the session-preparation record —
a daemon that still emits one is older than the client, and the client ignores it
rather than rendering a state nothing computes.
The generation’s usage follows the same rule again. It sums every seat turn the
pipeline ran for that generation, retries included, on either harness: a turn count,
the four token counts, their total, and reportedUsd. The dollar figure is null
unless every turn ran on a metered credential and the provider priced it; a
subscription session shows tokens and no invented price. It rides the lens progress
frame beside coverage as a cumulative figure and lands on the durable generation as
the final one. Absent means not measured, never free.
The durable round operation’s report-drafting phase gained a second projected
report state, handed-off, beside drafting. It appears once the report’s durable
handoff exists — the boundary after which the lens drafters run — so a client can
tell report time from lens time on a phase that covers both. A daemon that predates
it projects drafting throughout, which older and newer clients both still parse.
A client can also outrun the daemon it is connected to. An older daemon does not
answer session.rounds or session.roundEvents at all, and the rounds surfaces
say so in the daemon’s own words rather than rendering the empty ledger that
would read as “no rounds have completed”. This is a statement, not a handshake:
there is no capability negotiation behind it and nothing for the reviewer to
clear.
hello.deviceToken carries a paired device’s bearer token. Loopback clients omit
it. The daemon hashes stored device tokens and uses the presented value to
classify a projected connection. See remote access.
Server requests use serverRequestId for correlation. The client answers with
serverResponse; serverRequestResolved removes a prompt that no longer needs an
answer. The wire and bridge are live even when no product flow raises a request.
rpcError.code accepts the known values invalid_input, command_failed,
incompatible_protocol, and unknown_command, plus other strings. New error
codes therefore remain append-only.
Attention contract
Section titled “Attention contract”The attention feature adds presence and attentionEvent frames. Presence
lets the daemon choose in-app delivery or push for each client. Attention raises
and clears are broadcast to authorized clients, so acknowledging an item clears
the corresponding state across connected surfaces.
The closed attention family set is:
| Family | Delivery class |
|---|---|
ask-pending | high priority |
review-finished | high priority |
turn-failed | high priority |
handoff-completed | normal |
publish-ready | normal |
processing-finished | in-app only |
An ask-pending item may carry up to four unique answer actions. Other families
may not carry actions. device.registerPush.disabledFamilies can mute normal
families for one device; high-priority families remain delivered.
Projected reviews may include attention: { needsYou, running }. The daemon
derives needsYou from active high-priority attention and running from its
in-flight review-turn registry. The field is optional because its availability
follows the advertised attention capability.
All WebSocket traffic uses this frame union. New transport behavior starts with an append-only schema, a documented feature key when negotiation is required, and compatibility tests for both sides of the version window.