Skip to content

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.

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.

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.

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.

KeyCurrent meaning
serverRequestsThe daemon can send serverRequest and serverRequestResolved, and accepts serverResponse. Current daemons always advertise it.
attentionThe daemon accepts presence, publishes attentionEvent, and supports push registration and attention acknowledgement. It is advertised only when the attention system is composed.
actThe daemon implements publish.compose. It is advertised only when that acting seam is composed.
review-cliThe 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.

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:

Terminal window
UPDATE_PUBLIC_SCHEMA=1 pnpm nx test rennet-protocol

A consumer adopts the regenerated fixture when it adopts the new projection shape.

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.

sessionFrameSchema is a discriminated union on type.

FrameDirectionPayload
helloclient to serverclient ID, client type, protocol version, optional device token
serverInfoserver to clientapp version, protocol window, feature flags
requestclient to serverrequest ID, registered command, input
responseserver to clientrequest ID, output
rpcErrorserver to clientrequest ID, code, message, optional details
progressEventserver to clientcommand ID and project-processing or project-detail event
askStreamEventserver to clientreview ID and ask-stream event
serverRequestserver to clientserver request ID, kind, payload
serverResponseclient to serverserver request ID, payload
serverRequestResolvedserver to clientserver request ID
presenceclient to serverfocus, visibility, device class, optional focused review
attentionEventserver to clientraised item or cleared IDs
boardEventserver to clientboard ID and newly appended board events
askProjectionserver to clientsession ID and the durable ask projection
roundProgressserver to clientreview 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.

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:

FamilyDelivery class
ask-pendinghigh priority
review-finishedhigh priority
turn-failedhigh priority
handoff-completednormal
publish-readynormal
processing-finishedin-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.