@ceralive/control-protocol
v2026.7.0
Published
Canonical device/hub control-channel Zod schemas + §14 wire fixtures + strict/tolerant parse helpers for the Remote Control Plane v2.0 (openspec/specs/remote-relay-support).
Downloads
228
Maintainers
Readme
@ceralive/control-protocol
Canonical device/hub control-channel Zod schemas, §14 wire fixtures, and
strict/tolerant parse helpers for the Remote Control Plane v2.0
(openspec/specs/remote-relay-support/spec.md).
This package is the single canonical schema source for the control channel that
connects a paired CeraLive device (CeraUI/apps/backend) to the cloud platform hub
(ceralive-platform). It replaces the two previously-independent, hand-written
per-repo protocol.ts derivations — both consumers pin a specific CalVer version of
this package rather than re-deriving their own schema by hand.
Decision record — 2026-07-07. This supersedes the prior per-repo "each side writes its own Zod from the spec, no shared package" stance for this contract only. Root
AGENTS.mdRule D ("repos are self-contained; mirror, don't link") is otherwise unchanged: this is a registry-dependency exception, not a path-reference exception. Precedent:@ceralive/cerastream,@ceralive/srtla-send.
Install
bun add @ceralive/control-protocolUsage
import {
// constants + enums
COMMAND_REGISTRY, STATUS_TYPES, FRAME_KINDS, PROTOCOL_VERSION,
// schemas (see "Strict / tolerant contract" below)
FrameSchema, CommandSchema, StatusSchema, DeviceCapsSchema,
} from "@ceralive/control-protocol/schemas";
import {
strictParseFrame, // HUB posture — closed registries
tolerantParseFrame, // DEVICE posture — open `type`
strictParseIngestSlots, tolerantParseIngestSlots,
strictParseSetProfilePayload, tolerantParseSetProfilePayload,
} from "@ceralive/control-protocol/parse";
import { FRAME_FIXTURES, FIXTURE_14_1 } from "@ceralive/control-protocol/fixtures";The root export (@ceralive/control-protocol) re-exports all three subpaths.
Evolution policy
Evolution is additive-optional forever: the hub-strict parser MUST NEVER require a field that the oldest supported device firmware does not emit — a change that would break that guarantee is not a version bump of this package, it is a new protocol
v.
Concretely: adding a new optional field, a new type to the command/status
registry, or a new frame kind is additive and ships as a CalVer bump of this
package. Making a currently-optional field required, removing a field, or
repurposing one is a breaking wire change — it is a new protocol v
(PROTOCOL_VERSION), negotiated at the handshake (§13), NOT a version bump here.
This invariant is enforced empirically by the v1-minimal skew test
(test/skew-policy.test.ts): a frame carrying only the required envelope fields and
no post-v1 optional field is accepted by the tolerant parser AND is never
rejected by the strict parser.
Strict / tolerant contract
The device and the hub deliberately parse the wire with different strictness, and this package preserves that asymmetry byte-for-byte rather than collapsing it:
| Posture | Who | Helper prefix | Behaviour |
|---------|-----|---------------|-----------|
| strict | hub (ceralive-platform) | strictParse* | Closed type registries, closed ingest.slots slot shape, UUID-v4 commandId. Validates what the hub emits — fails loudly at the producer. |
| tolerant | device (CeraUI/apps/backend) | tolerantParse* | type left open, ingest.slots descriptive fields nullable/absent, commandId any non-empty string. Applied to frames the device receives — an additively-extended hub is never rejected by an older device. |
The three asymmetry axes (each has an explicit *Strict*/*Tolerant* schema pair):
- Frame
type— strict closes it to the relevant registry (COMMAND_REGISTRY/STATUS_TYPES/ACK_TYPES); tolerant leaves it any non-empty string. ingest.slotsslot — strict requires every descriptive field (region/state/default) and bounds strings/ports; tolerant makes the descriptive fields optional,obsInstanceIdfreely nullable, strings/ports unbounded.device.setProfilecommandId— strict is a UUID v4 (the hub mintscid === commandId); tolerant is any non-empty string.
Un-suffixed alias resolution
Both source repos export un-suffixed names. Where the two collide with different
strictness, the un-suffixed canonical alias resolves to the strict (hub /
spec-reference) variant; the tolerant variant is always reachable under its
explicit *Tolerant* name.
| Un-suffixed alias | Resolves to | Notes |
|-------------------|-------------|-------|
| CommandSchema, ResultSchema, StatusSchema, AckSchema, DeliveryAckSchema, FrameSchema | *StrictSchema | Device consumers use *TolerantSchema. |
| HandshakeSchema, HandshakeDeviceSchema, HandshakeHubSchema | full frame schemas (hub) | See "Handshake name collision" below. |
| IngestSlotSchema | IngestSlotTolerantSchema | CeraUI's IngestSlotSchema was the tolerant single slot. |
| IngestSlotPayloadSchema | IngestSlotStrictSchema | Platform's name for the strict single slot. |
| IngestSlotsPayloadSchema | IngestSlotsStrictPayloadSchema | Both repos export this name; canonical = strict (what the hub emits). |
| EnvelopeSchema, ResultPayloadSchema, DeviceCapsSchema | shared (no split) | Identical on both sides. |
Documented same-concept aliases (both names → same value)
| Names | Value |
|-------|-------|
| KINDS (platform) · FRAME_KINDS (CeraUI) | the frame-kinds array |
| SELF_FENCING_COMMANDS (platform) · SELF_FENCING_TYPES (CeraUI) | the self_fencing set |
| SELF_FENCING_WATCHDOG_SECONDS = 30 · SELF_FENCING_WATCHDOG_MS = 30_000 | the same 30-second watchdog window (documented equivalence: 30 s === 30_000 ms) |
Handshake name collision (recorded)
The two repos use the same three names for opposite shapes:
ceralive-platform—HandshakeDeviceSchema/HandshakeHubSchemaare full frames ({v, kind:"handshake", type, cid, payload});HandshakeSchemais the union of the two frames.CeraUI—HandshakeSchemais a single body-agnostic frame;HandshakeDeviceSchema/HandshakeHubSchemaare the body schemas ({v, supportedTypes, deviceCaps}and{v, role}).
The un-suffixed canonical names here take the platform / spec-fixture-aligned meaning (full frames — §14.2/14.3/14.18 are frames). The CeraUI body-schema meaning is preserved under distinct names:
HandshakeDeviceBodySchema/HandshakeHubBodySchema— the hello bodies (device-tolerant:supportedTypesnotmin(1)).HandshakeEnvelopeSchema— the single body-agnostic handshake frame (CeraUI'sHandshakeSchema).
Todos 6/9 (the CeraUI + platform protocol.ts migrations) decide per-symbol which
name each repo adopts; this package provides all of them so either choice is
possible.
device.setProfile ack — no wire payload
Per the amended REQ-RRS-028 / §5.1, the device.setProfile acknowledgement is a
payload-less delivery.ack correlated purely by cid === commandId. No frame on
the wire carries a { commandId, applied } delivery-ack payload.
SetProfileAckPayloadSchema is still exported only for API-compatibility with
ceralive-platform's current type export of that name — it is annotated
⚠️ NON-WIRE in schemas.ts and does NOT correspond to any actual wire shape. The
real per-push outcome ({commandId, status, reason?, effectiveActiveProfile,
effectiveLatencyMs}) rides the subsequent result frame's applied field and is
exported as SetProfileResultAckSchema (type SetProfileAck).
Conformance fixtures
The fixtures/*.json files are the §14 JSON vectors, copied verbatim (spec key-order
preserved) and re-exported from src/fixtures.ts as typed consts. A parity test
(test/fixtures.test.ts) asserts the TS consts are byte-for-value identical to the
JSON files, so the two can never drift. 20 vectors are wire frames
(FRAME_FIXTURES); the §14.15 control-channel PASETO claim set is a non-frame vector
(NON_FRAME_FIXTURES) and is never routed through the frame parsers.
Divergences from the frozen spec.md §14 bytes
This package's fixtures are not byte-frozen the way spec.md's §14 bytes are, so
two known spec.md fixture defects are corrected here (both documented at the top of
src/fixtures.ts):
1. Fixture 14.20 (device.setProfile delivery.ack) — payload removed. spec.md
§14.20 still shows a stale payload: { commandId, applied } object that contradicts
the corrected REQ-RRS-028 prose (payload-less delivery.ack). It is exported here in
its corrected, payload-less form, matching the amended prose and matching every
other delivery.ack fixture (14.17). (See
CeraUI/.omo/notepads/coherence-contract-pass/decisions.md §2 + §5.)
2. Fixtures 14.18–14.21 cid — non-RFC-4122-v4 variant nibble corrected. The
profile-related fixtures added in the srt-receive-profiles effort carry cid values
whose UUID variant nibble is invalid for v4 (the 4th group starts with 1/2/3
where v4 requires 8/9/a/b). Both repos validate the envelope cid with
z.uuidv4(), and §3 mandates "cid MUST be a UUID v4", so those spec vectors do not
conform to the very schema they are meant to validate — a defect that went unnoticed
because neither repo's protocol.contract.test.ts ever parsed 14.18–14.21. Rather
than loosen cid validation (which would diverge from both repos' faithful
z.uuidv4()), this package corrects the single offending variant nibble to a valid v4
value (and the matching payload.commandId in 14.19/14.21, preserving the
cid === commandId correlation). Every other digit is untouched.
Both corrections are deliberate and consistent with what the downstream migrations (todos 6/9/7) need: their v1-minimal / unknown-field-tolerance tests parse these vectors and must not choke on a schema-invalid fixture.
Build & test
Plain tsc (no bundler), matching the sibling @ceralive/cerastream and
@ceralive/srtla-send npm packages. Emits ESM + .d.ts to dist/.
bun install # also runs `prepare` → build
bun test # bun:test contract suite
bun run typecheck # tsc --noEmit (source + tests)
bun run build # tsc -p tsconfig.build.json → dist/ (ESM + types)Versioning
CalVer (YYYY.MINOR.PATCH), matching the sibling @ceralive packages and the
versions.yaml convention. The wire PROTOCOL_VERSION (currently 1) is independent
of this package's CalVer — see "Evolution policy" above.
Release & prerelease (npm publish)
This package is published to the public npm registry by the root repo's
.github/workflows/publish-control-protocol.yml workflow. It is CI-triggered only —
never npm publish by hand. Auth is a scoped npm automation token (NPM_TOKEN secret)
plus --provenance; the federation/apt signing keys are never used here.
Two lanes, one tag namespace
The package versions on its own tag namespace control-protocol-v<CalVer>
(deliberately distinct from the workspace's other release tags), and the tag's version
MUST equal the committed package.json "version" (the workflow refuses to publish
otherwise). CalVer is YYYY.M.PATCH, matching @ceralive/cerastream and
@ceralive/srtla-send.
| Tag shape | Example | npm dist-tag | Purpose |
|-----------|---------|--------------|---------|
| control-protocol-vYYYY.M.PATCH | control-protocol-v2026.7.0 | latest | Stable release |
| control-protocol-vYYYY.M.PATCH-rc.N | control-protocol-v2026.7.0-rc.1 | next | Release candidate — the W2/W3 integration bridge |
An -rc.N version publishes under next and never moves latest. A
workflow_dispatch run performs build + test + a registry --dry-run only — it never
publishes.
RC hygiene — the rules (non-negotiable)
Exact-pin rule. Consumers pin the exact prerelease version in their committed
package.jsonand lockfile — e.g."@ceralive/control-protocol": "2026.7.0-rc.1". Never the floatingnextdist-tag, never a semver range (^,~,>=,*,next). A floating pin would let an unreviewed rc bump land silently on a consumer at install time; an exact pin makes every protocol-schema change a reviewed, committed diff. This rule applies identically to@ceralive/cerastreamprereleases when a consumer is on the same integration bridge.Dist-tag cleanup/advancement after the final release. Once the stable
YYYY.M.PATCHships (published underlatest), retire the bridge rc fromnextsonextnever dangles at a stale/older rc:# after control-protocol-vYYYY.M.PATCH (stable) is published under `latest`: npm dist-tag rm @ceralive/control-protocol next # drop the bridge rc # (optional) if a NEW integration bridge opens on the next rc, point next at it: npm dist-tag add @ceralive/control-protocol <next-rc> next # e.g. 2026.8.0-rc.1Leaving
nextpointed at a superseded rc is the failure mode this step prevents.Merge gate — each consumer repo rejects rc pins on the merge-target branch. A PR into a consumer repo's canonical branch (CeraUI, ceralive-platform) MUST NOT merge while it still pins a prerelease of the shared schema/engine packages. Each consumer repo adds a CI guard (a simple grep, added in that repo's own wave — todos 6/9) that fails the build if any committed
package.json/lockfile carries an-rc.pin of@ceralive/control-protocolor@ceralive/cerastream:# merge-gate guard (fails, i.e. exits non-zero, if ANY rc pin is present) ! git grep -nE '"@ceralive/(control-protocol|cerastream)"[^"]*"[^"]*-rc\.' \ -- '**/package.json' '**/bun.lock' 'bun.lock' 'package.json'The rc pin is legitimate only on the short-lived integration bridge branch; the gate ensures it is swapped for the exact stable CalVer pin before the PR merges, so a stable branch can never carry a prerelease dependency.
Release procedure
# 1. Bump the version in control-protocol/package.json (CalVer YYYY.M.PATCH,
# or YYYY.M.PATCH-rc.N for an integration-bridge prerelease).
# 2. Commit the bump.
git add control-protocol/package.json && git commit -m "release(control-protocol): vYYYY.M.PATCH"
# 3. Tag on the package's own namespace and push the tag — CI does the publish.
git tag control-protocol-vYYYY.M.PATCH # or ...-rc.N
git push origin control-protocol-vYYYY.M.PATCHThe publish job is gated on the test job (needs: test), so a red contract suite
can never publish.
License
GPL-3.0.
