9. Channel Classes (normative)#
9.1 STATE — the shadow#
STATE channels carry idempotent full snapshots of a coherent group of fields.
- Full-snapshot rule: every STATE frame contains the complete current value of its channel. There are no deltas in
valence/1— a delta would make frame loss corrupting, destroying the property the whole design leans on. - MTU rule: a STATE payload MUST fit
min_transport_payload(242 B) unfragmented. This is a catalog design constraint: a state group that does not fit is split into multiple channels at catalog-design time (and, if they are settings, given the samecategoryso they render as one tab — §8.8). Conformance tooling SHOULD flag violations mechanically, since layout size is statically known. - Retained value: the hub keeps the latest value of every STATE channel and MUST push it immediately upon grant — connect, re-subscribe, reconnect — subject only to the readiness gate (§6.4). This is the device-shadow primitive; it is what "page load adopts device state" compiles to.
- Conflation: the hub maintains at most a depth-1 queue per (channel, subscriber) — a newer snapshot replaces a queued unsent one. Subscribers therefore see the freshest state their link can carry, never a backlog. Newest-wins by seq on receive (§7.3).
- Rate:
granted_rate_hzis a ceiling on push frequency. On-change channels (rate 0) push at most once per change, conflated. Periodic channels push atmin(grant, change rate). - Bitfields: flag-word channels use
bitfield8fields with catalog-enumerated bit meanings. A latched safety word is still a full snapshot like everything else. - First push after a grant is never shed (§10.4). A subscriber's very first snapshot is what takes it from READY to LIVE; shedding it would strand the session.
9.2 STREAM — the data plane#
STREAM channels carry timestamped sample bundles (§5.4) in either direction: telemetry h2c, motion input c2h.
stream_kindsays what a sample IS, and everything else follows from it:samples(0, default) — dense points reporting a value at an instant. A dropped sample is recoverable by interpolation from its neighbors. Decimable.segments(1) — each sample commands a time extent: it carries its own duration and is not a point on a continuous curve. A dropped segment is a permanently lost command, not a recoverable interpolation gap. Not decimable (§10.4). A segments stream wants lookahead: a client that cannot see its future gets the rest-without-successor rule (§9.6) at the end of each bundle, which is correct and visibly stop-start, and such an application belongs onsamples(RFC-087). This is an explicit registered property, not an inference. An earlier heuristic classified segment channels by looking for a time unit in the layout — butunitis a free-form string, so two conforming hubs could disagree (msvsmsecvsmillis) and therefore shed differently under identical congestion, which is exactly the divergence §10.4 exists to eliminate.- Ordering: guaranteed only on ordered bindings. On datagram bindings the consumer rules of §7.3 — drop-not-newer, timestamp-driven consumption — are the whole contract. STREAM consumers MUST be written against the weakest line of the §13.1 matrix.
- No per-sample acknowledgments, in either direction. At 333 Hz an ACK would be a storm; §9.3 explains why motion input correctness does not need one.
- Grants bound sample rate, not frame rate. A 240 Hz grant delivered as ~48 fps × 5-sample bundles is conformant and expected. (At 240 Hz five samples span 16.7 ms; a sixth would exceed the 20 ms span cap — the caps interlock.)
- Inbound (c2h) ingress validation. A hub accepts a bundle only on a channel the sending session was granted as a publication (§6.2, §6.7). A bundle on an unknown, ungranted, wrong-class, or wrong-direction channel is silently dropped and counted. Before acting on an accepted bundle the hub MUST re-validate the §5.4 caps against its own catalog; a bundle violating any cap is dropped whole, never parsed part-way. A bundle from a session that is not yet ready (§6.4) is likewise dropped and counted.
- Two sanctioned NACK carve-outs. STREAM is otherwise never NACKed, but silence is a bad answer to a client that is structurally broken rather than merely fast:
RATE_LIMITEDfor sustained ingress overage (§10.5), throttled;SOURCE_CONFLICTon the first bundle dropped because another live session owns the source (§11.4), throttled the same way, once per (session, source). Without it, a producer whose source is owned by someone else is silently dead: every bundle dropped, zero wire signal. Producers SHOULD also subscribe thecontrol-ownerchannel for the full picture.
9.3 INTENT / ECHO — the control plane#
INTENT is the only way a client changes anything. CBOR: channel_id (15) naming an INTENT-class channel, intent_id (18), value (20) per the channel's schema, optional precondition (30), optional takeover (32), optional trial (51).
- ECHO is mandatory and truthful. The hub replies ECHO
{intent_id, applied (19), cfg_gen}— or NACK.appliedcarries the post-clamp values actually in effect, which MAY differ from what was requested. The client's shadow updates from ECHO and the ensuing STATE broadcast, never from its own request. All other subscribers learn of the change via STATE; ECHO goes only to the sender. ECHO is key-complete over what was applied:appliedcarries every key from the intent'svaluemap that the hub applied; a key absent from the ECHO means NOT applied, and the client MUST fall back to reported truth (the ensuing STATE) for it. This is what makes "silently accepted" and "silently ignored" distinguishable on every hub. - Idempotency:
intent_idis session-scoped, client-assigned, monotonically increasing. The hub keeps a ring of the lastidempotency_ring_depth(32)id → ECHOpairs per session; a duplicate id re-emits the stored ECHO and MUST NOT re-apply. The ring dies with the session (§6.8) — which is safe because: - Absolute values only. Intent schemas MUST express target state ("set speed 400"), never operations on current state ("add 20"). A client wanting an increment computes the absolute target from its shadow and MAY guard against races with
precondition= expectedcfg_gen; mismatch → NACKCONFLICT, client re-reads and retries. This one rule is what makes the reconnect story (§6.8) sound and two-operator racing merely annoying instead of corrupting. - Rate limiting: hub-enforced per session,
intent_ingress_default_per_s(50) by default; excess → NACKRATE_LIMITED. Generous for UIs, hostile to accidental loops. Role-exempt safety ops are rate-limited too (§11.2). - Trial writes (RFC-099). An INTENT carrying
trial=trueis a trial write: applied, clamped, echoed, published and counted towardcfg_genexactly as any write, but never persisted. Absent orfalse, the write is durable. A hub supports trial writes iff its catalog declares the core INTENT channelsettings-trial(0x0016); a client MUST NOT sendtrialto a hub that does not, because §4.3 makes that hub ignore the key and persist the write. - The trial set. The hub records, per session, each (channel, key) the session trial-wrote and that key's pre-trial value, its value before the session's first trial write of it; later trial writes keep that baseline. A key joins only when the ECHO carries it. A durable write by the same session to a key in its set applies, persists, and ends that key's trial.
- Exclusive keys. While a key is in one session's trial set, a write to it from any other session, trial or durable, MUST be refused whole with NACK
TRIAL_CONFLICT, no key applied. - Commit and revert.
settings-trialcarries one op select (key 1, roleaction.trial,trial_ops),controlfloor, handled by the hub, acting only on the sender's own set.commitpersists every trialed value of the set and clears it; no effective value changes, socfg_gendoes not move.revertrestores every pre-trial value and clears the set; a restored value that differs advancescfg_genand republishes its STATE. Both ECHO{1: op}, and both are accepted no-ops on an empty set. - Lifecycle. A session's trials revert when it ends by any §6.9 door and when it goes
STALE(§6.6): a trial never outlives the session that could commit it. A reboot loses them by construction. ESTOP and PAUSE revert nothing: the values are live settings, and the latch is separate. - Refusals. A trial write on a channel or key the hub cannot restore unconditionally (a verb, a motion command, a value whose write the hub gates on live machine state) is refused
UNSUPPORTED_OP. Revert itself is never refused: where a constraint between values no longer admits a pre-trial value, the hub restores the nearest legal one and publishes it. - Persistence. A hub that persists settings MUST NOT persist a trial value before its commit; through every other persist, a trialed key's stored value stays its pre-trial value. Settings STATE layouts SHOULD carry
meta.trial_pending(§8.8) so every client sees which values are on trial. - Streams are not intents. High-rate motion input rides STREAM and is never echoed per-sample. Its observable truth is the position telemetry the hub publishes — you see what the machine actually did, which is the only truth that matters. Only discrete state changes ride INTENT.
- Actions. A schema field whose
roleisaction.<name>is a verb, not a value:action.home,action.reset_stats. ECHO echoes the op. Two rules make an action observable rather than private to its sender: - a resettable counter group's twin STATE channel carries a field tagged
meta.reset_gen, incremented on every applied reset, so all subscribers observe the reset; - classification: an action that restores configuration values bumps
cfg_genandreset_gen; an action that only clears counters bumpsreset_genalone. - Procedures. A long-running guarded operation — a multi-step device programming sequence, a verified write-then-readback — cannot be expressed by intent-and-echo alone, because its real result arrives later. It is not a new frame type; it is a documented catalog pattern:
- start it with an action intent; ECHO means accepted, not complete;
- progress and outcome ride a twin STATE channel carrying
{procedure, phase, progress, result}, wherephaseis aprocedure_phasesvalue (idle/running/succeeded/failed/abortedregistered; 128+ device-defined intermediate steps that a client renders asrunningif it does not recognize them). Full snapshots make it reconnect-safe by construction; - completion also emits an EVENT;
- one procedure STATE channel per concurrently-runnable procedure — full-snapshot semantics can represent exactly one;
- reboot-commit: where accepting the intent commits by rebooting, the ECHO's
appliedmap carriesreboot_in_ms(43); the hub then GOODBYEs every session withREBOOTINGbefore going down, and the changedboot_idtells returning clients what happened.
9.4 EVENT — edges, not levels#
EVENT channels carry discrete occurrences. CBOR: event_kind (33), timestamp (21), optional seq_of_state (34), and body (40) — a sub-map whose integer keys come from the channel's own catalog schema, exactly as INTENT's value does.
The body sub-map is what makes device-authored EVENT channels possible at all. With kind-specific fields at the top level, every device wanting an event channel would have needed a registry PR to name its own fields — the precise coupling the self-describing catalog exists to prevent. event_kind and seq_of_state stay at the top level because they are protocol framing, not payload.
- Best-effort. Events are conflated and bounded like everything else and are NOT replayed on reconnect — except where a channel's catalog entry declares a
replay_depth, in which case the hub MAY replay up to that many entries from its ring tail when the channel is granted. The log channel (§16.2) is the sanctioned use; the exception exists so "what went wrong just before I connected" is answerable without making every grant a burst. - The event/state duality rule (safety-critical). Any event a client could not afford to have missed MUST have a latched STATE twin: the event says "this just happened", the state says "this is (still) true". E-stop is the canonical pair — the safety-events channel for the edge, the
safetychannel for the latch. A reconnecting client adopts the latch and needs no history. No safety behavior may depend on EVENT delivery. Events are UX (toasts, logs, timelines); states are truth. - Edges are emitted on transitions only. A repeated ESTOP frame re-broadcasts the STATE — that is §11.2's only loss-recovery mechanism and it must keep working — but it does not re-emit the edge. An edge that did not happen is a lie.
- Overflow: per-subscriber event queues are bounded (
event_queue_depth_per_subscriber, 16); overflow drops oldest and increments a visibleevents_droppedcounter on the hub-status channel. There is exactly one home for that counter; a per-channel duplicate would drift. - Purpose and kind labels for device-authored channels (RFC-065). Spec-core EVENT channels are bound by id and their kinds are registry tables. A device-authored EVENT entry names its purpose with the entry-level
role(§8.1, registrychannel_roles) and labels its kinds with the entry-levelevent_kindstable.events.anomalymarks edges reporting that the machine did something other than what it was asked (a clamped command, a planner fallback, a rejected plan); its latched counters, where present, are the duality rule's STATE twin and carry the channel roleanomaly.summary, so log and counters bind together. A client that gives anomaly events a dedicated surface MUST select those channels byevents.anomalyor by core identity, never by name; a channel without the role renders as an ordinary event stream. A client renders an event's kind by itsevent_kindslabel, and a kind with no label (or a channel with no table) as its decimal number: never dropped, never guessed frombody. The kind is always taken fromevent_kind(33); a hub MAY also mirror it intobody, but that mirror is never the label's home.event_kindsis append-only (MUST): across firmware versions a released kind value is never reassigned a different meaning, and a retired kind keeps its entry.
9.5 STORE — collections#
STORE-class entries declare blob stores; their semantics are §8.7. A STORE entry carries no layout and no schema, is never subscribed, and never emits frames: its dynamic half is an ordinary STATE channel and its items move over the blob verb.
9.6 The motion input surface (normative)#
This section states, as protocol obligation, where kinematic work lives. It exists because the natural pull when a client sends bad motion is to make the client smarter — and for an ecosystem protocol that is a trap. Every kinematic rule pushed into clients is re-implemented subtly differently by every integrator, is unverifiable by the device, and is a reason not to adopt the protocol at all. It also cannot be right in general: a client cannot know the hub's planner shape, its live limit set, or its stroke window, and all three change at runtime.
- The motion input surface is CLOSED and small. A hub accepts motion in exactly three modes: native samples (a
samples-kind STREAM of dense points), native segments (asegments-kind STREAM of timed{target, duration, end_velocity}commands), and TCode passthrough (§15.1). Everything a client does is adapting its source material into one of those three. Adding a fourth mode is a deliberate specification act, not something that accretes. - Write-once rule. If every conforming client would otherwise have to implement a given piece of kinematic work, that work belongs on the machine — written once, verifiable, identical for all clients. A client SHALL be able to send its content as authored within one of the three modes and receive good motion, with no feasibility analysis of its own.
- No per-client case logic on the motion plane. A hub MUST NOT branch on client identity when planning or executing motion. If a hub appears to need such a branch, this specification is underspecified and the fix is a rule here, not a device-side special case. Scope: authorization is identity-branching by definition and is the named carve-out — tiers, the trust ledger and the served-page sideband are authorization. The motion plane stays identity-blind.
- Client-side feasibility adaptation is always OPTIONAL — quality of implementation, never required for correctness. No conformance test may demand it.
- Carry intent, not pre-chewed motion. Wire design prefers the sender's authored
{target, duration, end_velocity}over a pre-rendered approximation. A hub can always degrade intent; it can never recover information the client threw away.
Motion-input field roles (RFC-071). The layout fields of a c2h STREAM entry carry the §9.6 vocabulary as registered field_roles: input.target (the commanded position of the sample or segment, in the field's own unit and scale), input.velocity (a samples-kind point's instantaneous velocity hint), input.duration (a segments-kind sample's commanded time extent) and input.end_velocity (a segments-kind sample's velocity at its end, the handoff). A c2h STREAM entry that accepts motion MUST tag input.target; the other three are tagged where the layout has the field. A client finds the motion-input channel as the c2h STREAM entry of the wanted stream_kind carrying an input.target field, never by name; a c2h STREAM without input.target is some other input, never motion. A client fills every field it has no value for, untagged or not understood (including input.velocity, which stays optional), with its unspecified value: the §5.4 sentinel registered by RFC-058, one home, never a guessed zero.
Resolving unspecified, and the dwell rule (RFC-058). A segment whose end velocity is unspecified (§5.4) leaves the boundary velocity to the hub. With a scheduled successor the hub MAY derive it from the adjoining chords (the handoff guard's lookahead, below). Without a scheduled successor the hub MUST resolve unspecified to rest (0), never to an estimate derived from prior motion: arrival before a hold, a gap or the end of content is rest by definition, and an estimate of past motion cannot know that (measured: a stale estimate coasted past a hold and darted back at 300-800 mm/s). A segment whose target lies within segment_dwell_span (registry limits, normalized units) of the previous accepted segment's target on the same source is a hold: a declared nonzero end velocity on it SHOULD be bounded to zero and surfaced as its own anomaly kind, distinct from a handoff bound (the reference hub's dwell_zeroed, kind 10, labeled per §9.4). The test is against the previous target, never position: each whip displaces position, so a position test never re-arms. A client MAY emit explicit hold segments across gaps and MAY declare rest explicitly; neither is required for good motion, and a gap with no segment settles the machine (§6.6).
The direction flip (RFC-088). A hub whose rail can be mounted either way round MAY offer a stored, writable layout field (bool, or a two-option select) carrying the role axis.flipped: a setting (§8.8 setting_key), persisted across reboot because it describes how the machine is mounted. The spec binds the role, never the channel (the reference places it on its machine-modes entry, appended at the tail per §5.4). Effect: home swaps ends. With the flip on, position 0 is the far end, and the hub mirrors against the homed travel (geometry.measured_travel): position telemetry reads travel minus position, the travel window (window.min/window.max) reports the same physical window in the flipped frame, and every c2h stream and intent target is mirrored on the way in. No client needs to know the state to behave; presets keep their meaning because they are stored in the frame they were authored in. Gate (hub MUST): a write that changes the flip is refused SOURCE_CONFLICT while any source owns the rail (the flip is a between-streams act), NOT_HOMED while the hub is unhomed (the mirror needs a measured travel), and INTERLOCK while override is latched (§11.1) or the machine is moving; it never acts mid-motion. A write that leaves the value unchanged is an ordinary no-op ECHO (§4.2: cfg_gen unchanged). Rendering: RENDERING.md §8.4 axis.
Limits discovery is for display and optional pre-adaptation. A hub SHOULD tag its kinematic ceilings and window bounds with field_roles (limit.*, window.*) so a client can find them on any hub without hardcoding a channel number. But the normative word for a client acting on them is MAY, never SHOULD: a client MUST NOT be required to reason about feasibility in order to produce good motion. Limits are shown to the operator; the machine's job is to play back whatever it is fed as well as it possibly can.
Note in particular that knowing vmax/amax/jmax is not sufficient to predict feasibility, because peak-versus-mean depends on the shape the hub plans. A minimum-jerk quintic over a chord d in time T peaks at 1.875·d/T in velocity — a client applying the naive d/T ≤ vmax test concludes a stroke is fine when the profile actually needs 1.875× that. This is precisely why clause 4 exists and why clause 2 puts the work on the hub.
Curve family declaration (segment streams). A segment-class publish MAY declare a curve_family (key 45; registry curve_families: 0 unspecified, 1 c1_cubic, 2 c2_quintic, 3 step) on its publishes/granted_publishes entry map. The declaration rides HELLO and PUBLISH, so a sender switching interpolators mid-session renegotiates without dropping the stream. It exists because {target, duration, end_velocity} uniquely determines a cubic — a segment stream is a complete encoding of the sender's curve — but only if both ends agree on the smoothness class: a C2 quintic cannot reproduce a C1 cubic across a knot, because the script's acceleration genuinely steps there, and smoothing that corner erases something the author put there on purpose. The grant echo carries the EFFECTIVE family — the declaration after the machine's own curve policy — and MAY additionally carry requested_curve_family (48), the original wish echoed verbatim, so a client can see the honored-vs-downgraded fact directly instead of inferring it from what it remembers sending (RFC-049b; implementation: Phase D). The machine override outranks the declaration; unspecified MUST behave exactly as pre-declaration behavior, so the key is purely additive; an unknown family value is treated as unspecified, never parroted back. No clamping semantics are implied by the family — overshoot, feasibility and window legality stay the machine's existing machinery. step (3) is status: reserved (RFC-049a): the number is allocated and never renumbered, but no reference engine has a step renderer, so it is declarable and not yet actionable — §18-20's honesty note is now stated machine-checkably by the registry's status field, not only in prose.
The client onramp (RFC-044, corrected 2026-07-27). The onramp is ordered by how little an existing ecosystem client must change to reach a Valence hub at all, and its easiest rung is a CLIENT-SIDE adapter, not a hub-side mode. TCode passthrough means a client that already generates TCode pipes it through a small local shim — a reference implementation ships as a Phosphor kernel module, with a C# helper planned for MFP-class apps — that translates the client's own TCode into native segments (or samples) locally, before anything reaches the wire. The hub never parses TCode and no Valence channel carries it. From the hub's side the adapted traffic is ordinary native motion, so the gain — a session's identity, deadman bookkeeping, source ownership and the safety taxonomy — comes for free with zero protocol surface. This is unrelated to §15.1's legacy text-edge synthetic-session mechanism, which stays the only place a hub itself ever sees TCode bytes, and only because those bytes arrive over a transport (serial, BLE-NUS) that was never a Valence frame to begin with. Native segments is the next rung, trading a small format change for {target, duration, end_velocity}'s deadline-honoring precision. Native samples is the dense-streaming rung a client graduates to only when it wants that. The strategy this encodes: Valence is meant to win by being the easiest protocol in the room to adopt, never by requiring a client to rewrite its motion pipeline before it is allowed to connect. A hub MUST NOT require a client to skip a rung to participate at all.
Machine-side handoff sanity. A hub that accepts an end-velocity with a scheduled successor SHOULD bound it against both adjoining chords, not just the current one: a pathological handoff is typically sane relative to its own span and absurd relative to the next. The reference bound is |end_vel| ≤ k · min(|chord_in|, |chord_out|) with k = segment_handoff_k (registry limits, 1.5 — the shape-preserving value, RFC-049c), where chord_in is measured from the machine's actual position rather than the sender's geometry. Pinning k in the registry, rather than leaving it reference-implementation-only, is what lets a second implementation match the reference's handoff shape without reverse-engineering it. Every bounded handoff SHOULD be surfaced — a counter, a log line, and an EVENT — so a client can see its content being reshaped. HONESTY CLAUSE (H11): this guard is lookahead-bounded. It can only act when the successor is already scheduled, i.e. when the current segment is shorter than the client's scheduling lookahead; a segment with no successor in hand is accepted unchanged, deliberately, because guessing a chord the hub does not have would trim well-behaved senders. The hub's own legality checks remain the backstop. A hub-side per-source scheduling-depth backstop that does not depend on client lookahead discipline is a named future direction (RFC-049c), not yet specified. See §18.