Yoke Wire Protocol v1 (YWP/1)
Status: normative for v1, with compatible correctness clarifications in ADR 0012. The frame envelope and version remain YWP/1: existing clients can still send intents and observe multicast. An incompatible authority or wire change requires a new version; these fixes do not implement the planned per-device/source-authorized YWP/2 security model.
Transport
- UDP over IPv4 multicast.
- Group configuration MUST be an IPv4 multicast address; an explicitly
selected interface is local unicast IPv4 or
0.0.0.0, and the port is 1 through 65535. TTL remains 1. - Control group:
239.121.111.107, port9753(site-configurable; these are the defaults). TTL 1. Loopback enabled (snooper and agents may share a host). - One frame per datagram. Maximum encoded frame size: 1200 bytes;
encoders MUST reject larger frames, decoders MUST drop them (count:
oversize).
Encoding
A frame is canonical JSON, UTF-8, with:
- keys sorted lexicographically at every level,
- separators
,and:(no whitespace), - encoders MUST NOT emit non-integer numbers anywhere except the
tsfield (see below); decoders MUST tolerate them in bodies (robustness — the signature binds parsed content either way).
Canonical bytes = json.dumps(obj, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, allow_nan=False).encode("utf-8"). JSON must contain
finite numbers, unique object keys, valid Unicode and at most 32 nested
containers. Invalid inputs are dropped as malformed, never allowed to
escape the receive loop as parser or encoding exceptions.
Frame envelope
| Field | Type | Constraints |
|---|---|---|
v |
int | MUST be 1; other values dropped (count: version) |
t |
str | one of ann int ack evt bye |
id |
str | origin node id: [a-z0-9][a-z0-9-]{0,31} |
seq |
int | per-origin, strictly increasing, 1 through 2^63−1; bool is invalid |
ts |
number | finite sender Unix time, seconds; informational only |
body |
object | per-type schema below |
sig |
str | hex HMAC (see Authentication) or "" in open mode |
Unknown envelope fields ⇒ drop (count: malformed). Unknown fields
inside body MUST be ignored (forward compatibility).
Authentication
sig = HMAC-SHA256(psk, canonical_bytes(frame_without_sig)).hexdigest()
where frame_without_sig is the frame object with the sig key removed
entirely. The PSK is site-provisioned key bytes (from a config file,
mode 0600, at most one trailing newline stripped; hex is recommended so
no raw whitespace is ambiguous — see DEPLOYMENT.md). sig MUST match
^([0-9a-f]{64})?$ — 64 lowercase hex digits, or empty in open mode;
any other string ⇒ drop (count: auth) before the compare (a non-hex
sig must never reach the constant-time comparison, which can crash on
non-ASCII input — ADR 0010). A non-string sig ⇒ drop (malformed).
In open mode (empty PSK) a receiver requires sig == "" and drops any
signed frame as auth; a keyed receiver drops an unsigned frame as
auth. A bad signature ⇒ drop (count: auth). Constant-time compare.
Replay defense
Receivers track the highest seq seen per origin id. A frame with
seq <= that value ⇒ drop (count: stale), except seq equal AND
byte-identical canonical encoding ⇒ drop silently (count: dup) —
duplicates are normal on multicast.
Sequence units are Unix nanoseconds (ADR 0007), not seconds. A sender
durably reserves a block before emitting frames from it: persisted seq
is the reserved upper bound, not merely the last sequence already emitted.
After restart it starts above that bound, using the larger of that bound
and int(unix_time) * 1_000_000_000 as its floor, and persists a fresh
reservation before announcing. Unused reserved numbers are skipped. A
reservation failure releases no frames beyond the previous durable bound.
This prevents sequence reuse even when a process restarts in the same second
or its wall clock moves backwards while its durable state survives.
The reference implementation reserves 4096 numbers at a time. Receivers still reject values more than one hour ahead of their approximate wall clock. State loss, receive replay-map eviction and malicious site-key holders remain limitations: this is not cryptographic anti-replay across arbitrary reflashes or an ownership lease. State recovery is an explicit operator decision. Timer inputs are monotonic; a separately injected startup wall time supplies the sequence floor and future-sequence estimate.
Versions
A pairing version is a two-element array [n, origin]: Lamport counter
n (int 1 through 2^63−1) and a syntactically valid origin node id. Total order: compare n,
then origin lexicographically. An intent author sets
n = latest_known_n_for_that_consumer + 1 (from announce cache; 1 if
unknown).
Every new client operation MUST choose a fresh unpredictable version author id (valid node-id syntax), distinct from prior operations, even when the envelope origin is stable. Retries of that one operation reuse its complete version and intent. This prevents a delayed ACK for an older operation from being mistaken for a new request derived from a stale announce. The version author may differ from the envelope origin; it is an operation identifier, not an independently authenticated principal in site-PSK YWP/1.
Frame bodies
ann — announce
Sent every ANN_INTERVAL (2 s) with ±25 % deterministic jitter, on
startup (after state restore), and immediately after any state change.
{ "role": "producer" | "consumer",
"kind": "hdmi" | "usb",
"name": "<human label, ≤ 64 chars>",
"state": "idle" | "active" | "pending" | "error",
"err": "<detail>" | null,
"pairing": { "producer": "<id>" | null, "version": [n, "<id>"] } | null,
"media": { ... } | null }
- Consumers MUST include
pairing(null producer = unpaired; version[0, ""]when never paired). Producers setpairing: null. - Producers MUST include
media, an opaque driver-defined object that the paired consumer's driver of the samekindknows how to consume; the control plane never interprets it (ADR 0002). It MUST be a JSON object or null, and MUST be ≤ the frame budget. The reference drivers use: software HDMI (igmp){"group": "<ipv4>", "port": <int>}; Just Add Power (jap){"channel": <int>, "group": "<ipv4>"}; USB (usbip){"host": "<ipv4>", "port": <int>, "busids": ["<busid>", ...]}. A consumer whose driver rejects the media shape surfacesdriver-errorand retries — the media contract is enforced by the drivers, not this envelope. Consumers setmedia: null.
int — pairing intent
{ "op": "pair" | "unpair",
"consumer": "<id>",
"producer": "<id>", # required for pair; absent for unpair
"version": [n, "<id>"] }
Only the named consumer applies it. All agents cache the highest-version intent per consumer (see Anti-entropy).
ack
Multicast by the consumer after processing an intent addressed to it:
{ "version": [n, "<id>"],
"applied": true | false,
"reason": "applied" | "superseded" | "unknown-producer" | "driver-error"
| "kind-mismatch" | "self-pair" }
applied: false, reason: "superseded" means a newer version already
governs. unknown-producer is NOT a rejection: the pairing is applied
with state pending (media activates when the producer announces); the
ack still says applied: true with reason applied in that case —
unknown-producer is reserved for malformed producer ids. kind-mismatch
(hdmi consumer told to pair with usb producer) and self-pair are
rejections and MUST NOT change state.
An applied ACK confirms the saved pairing, not successful media activation or completed backend release after unpair. Clients distinguish that result from a subsequent matching consumer announce reporting active media. Open-PSK lab operation is explicitly identified by tools; it is not authenticated deployment.
evt — event
{ "event": "<slug>", "detail": { ... } }
Normative event slugs: pairing-applied, media-up, media-pending,
media-down, driver-error, driver-retry, peer-lost,
peer-returned, rx-drop, started, stopping. rx-drop detail
carries the drop counters {malformed, version, auth, stale, oversize}
and is emitted at most once per ANN_INTERVAL when counters changed; an
auth count rising is how an authentication failure surfaces (there is
no separate auth-fail event). driver-error detail is
{"detail": "<str>"}. A rejected intent surfaces only as its ack
(see below), not as an event.
bye
Graceful shutdown; body {}. Peers mark the node departed immediately
(no peer-lost event storm at the timeout).
Consumer state machine
States: idle (unpaired and backend release confirmed), pending (producer
parameters unknown, attachment not ready or teardown awaiting confirmation),
active (the backend confirms requested
attachment readiness), error (driver failed; retrying). Active does not
prove payload flow, application behavior or a physical target's response.
Driver evidence (group membership, process running, selected channel or
owned USB import) is described in bounded driver_status.detail announce
metadata and media-up event detail when available.
A changed non-null producer description invalidates prior attachment
readiness: clear old status evidence and announce pending before starting
the replacement attach. Only current-generation backend success restores
active readiness.
On int addressed to self:
1. If version <= current version ⇒ ack(applied=false, superseded).
(Equal versions: first application wins; re-delivery of the identical
intent that is already applied ⇒ ack(applied=true, "applied") — acks
are idempotent and re-sent so a retrying CLI always terminates.)
2. Validate (kind, self-pair, id syntax) ⇒ on failure ack(false, …).
3. Stage, but do not apply in memory, {pairing, version}. Emit a
Persist(state, request_id) effect. The shell performs the durable save
and feeds on_persist_result(request_id, success, now) back to the core.
Only matching successful feedback commits the pairing and allows the
remaining effects. Failure retains the previous committed pairing and
MUST NOT produce an applied acknowledgment, including on duplicate
delivery of the failed intent. Unknown/duplicate feedback is ignored.
4. Command driver: pair ⇒ attach to producer's media (from announce
cache; else pending until its ann arrives). unpair ⇒ detach.
5. ack, emit evt, re-ann immediately.
Driver transitions pending → active on success, → error on failure
with retry after RETRY_BACKOFF (1 s doubling, cap 30 s), emitting
events on every transition.
Repeated error or pending polls MUST NOT postpone an already scheduled
retry. Retry deadlines advance only after an attempted reconciliation;
successful attachment resets backoff. A backend reporting pending drift
from a live configured producer schedules bounded reattachment as well.
Unpaired configuration does not prove that the driver has released its old resources. After explicit unpair, or an unknown-producer replacement, track teardown until the backend confirms idle. Failed teardown remains visible as error and retries detach independently of producer liveness; no successful media-up is inferred from a stale active report. On restart a consumer with any nonzero saved version requests idempotent owned-resource teardown before reattachment, so a crash between a durable replacement/unpair and driver cleanup is recoverable. Pending/uncertain resources reported by an unpaired backend also trigger this reconciliation.
Startup uses the same persistence barrier. While a save is pending the shell serializes further core inputs; the core also refuses to start an overlapping save. An explicit replacement first detaches the old route, including when the new producer is unknown. Driver completion is tagged by the shell's route generation; stale completions cannot activate a newer route. A control-plane peer timeout alone does not unpair or require tearing down a healthy underlying media session. Orderly agent shutdown quiesces its local driver but retains the saved pairing for restart. The shell bounds queued driver work to the actual in-flight call and the latest desired route, retaining a detach barrier across coalesced changes. Superseded pending routes do not accumulate tasks or run during shutdown.
Detach is idempotent but teardown uncertainty is a driver error: retain owned resource bookkeeping until release is confirmed. Replacement cannot begin before previous teardown completes. IGMP membership, stream process liveness and an appliance channel selection are attachment evidence only.
The Linux USB/IP reference driver reconciles exact host, advertised TCP
port and busids. It detaches only owned imports, cleans newly created ports
after partial failure and never preempts unrelated importers. Ownership
journaling is separate from pairing configuration so restart can reconcile
surviving imports. Producer readiness requires configured devices actually
bound to usbip-host and visible through local usbipd, not merely present
in the physical inventory. No physical OTG presentation is implied.
Ownership intent alone cannot identify an import whose upstream connection
record was never written: ports lacking corroborating tuple metadata remain
untouched and report manual recovery required, without blocking unrelated
routes. Reject targets that cannot fit the upstream connection-record limit
before side effects. Producer unbind releases only bindings this driver
established; an already-bound export is not automatically owned.
Producer polls may reconcile startup bind failure or a returned configured
device; explicit unbind disables automatic repair.
Announcements must remain encodable during error and readiness transitions. Optional evidence is omitted first, then human error text is shortened to the available wire budget. Opaque media parameters are never truncated. Reject local descriptions whose mandatory fields cannot fit in the worst state/envelope size before publishing them.
The state file has the existing shape
{"seq": int, "pairing": {"producer": str|null, "version": [int,str]}}.
Integers are non-bool, bounded by 2^63−1; node ids are validated. The zero
version is exactly [0, ""] and implies an unpaired consumer. Saves use an
exclusive unique temporary file in the destination directory, complete
writes, file synchronization, atomic replacement and parent-directory
synchronization, with private permissions. Symbolic links and nonregular
state files are refused. Corrupt state never becomes fresh state. A missing
file on a commissioned installation must require explicit recovery; fresh
installation permission belongs to the provisioning shell (yoke agent
--commission). State input is limited to 4096 bytes, finite unique-key UTF-8
JSON with bounded nesting; invalid input is a recovery error.
A POSIX exclusive nonblocking advisory lease on the stable private
<state>.lock file is held for the agent lifetime. A second writer using
that state path is refused; the lock file is never unlinked on release.
It is not a pairing lease and its lifetime never changes saved pairing.
Duplicate node ids configured with different state files remain an operator
error that this local lock cannot prevent.
Producer state machine
Producers are passive: announce, transmit media to their fixed group
(HDMI) or accept sessions (USB), report driver health. They apply no
intents. USB single-attachment is enforced by the media plane (usbipd
refuses a second session), not by the control plane; if that refusal
reaches the producer's driver it surfaces generically as
evt driver-error {"detail": "<str>"}. Two consumers pairing to one USB
producer is therefore resolved at the media layer, and yoke models each
consumer's pairing independently regardless (a losing consumer sits in
error/pending with its driver retrying). HDMI producers are
inherently shareable — multiple decoders may join one encoder's group —
so nothing enforces exclusivity there.
Peer tracking
A node is live if an ann was received within PEER_TIMEOUT (3 ×
ANN_INTERVAL + 500 ms = 6.5 s). On expiry: peer-lost event; a
consumer paired to a lost producer transitions media to pending
(pairing retained). On the producer's return: peer-returned, media
re-attached.
Anti-entropy
All agents cache the highest-version normalized int per consumer. Retain
only op, consumer, version, and producer for pair operations. Unknown
extension fields are ignored, not copied into re-issued frames; malformed
producer fields are not cached. This guarantees relays can encode the known
semantic intent despite tolerated inbound float extensions or padding that
nearly filled the original sender's frame budget. On
receiving ann from consumer C whose pairing.version < cached
version for C: schedule a re-issue of the cached intent after a
deterministic jitter in [0.2 s, 1.2 s] (seeded per node); cancel if
any int for C with a version >= the cached one is heard first
(suppression). A re-issue is a fresh frame under the relayer's own
origin and seq carrying those normalized fields unchanged — the version
inside the body preserves authorship and total order. Re-issuing
verbatim copies is forbidden: they livelock against replay defense
whenever the original author has since sent higher-seq traffic
(ADR 0009). This converges consumers that were offline when an intent
was sent, without any coordinator. Cache entries expire after
INTENT_CACHE_TTL (24 h).
Agents observe ack frames: an ack with applied: false and a
rejection reason (self-pair, kind-mismatch, unknown-producer) for
version V of consumer C evicts a cached intent of exactly version V for
C everywhere and marks V as rejected there, so a doomed highest-version
intent is not re-offered forever. superseded acks evict nothing.
Timing constants
| Name | Value |
|---|---|
ANN_INTERVAL |
2.0 s (±25 % jitter) |
PEER_TIMEOUT |
6.5 s |
RETRY_BACKOFF |
1 s × 2ⁿ, cap 30 s |
CLI_RETRY |
5 sends, 0.5 s × 2ⁿ spacing; deadline 10 s |
INTENT_CACHE_TTL |
24 h |
SUPPRESSION_WINDOW |
0.2–1.2 s |
All timers derive from the injected clock; nothing reads wall time
directly in core.
Snooper
The snooper joins the control group, verifies signatures when it has the PSK (reporting, never enforcing), maintains the same announce cache and drop counters as an agent, and renders frames + inferred topology. It MUST NOT transmit. It can check protocol conformance and reconstruct recently observed control state from the multicast stream, within the retention and loss limits below. In media-multicast deployments the same host can additionally subscribe to a producer's media group to measure stream presence/bitrate without interpreting payload.
Observer caches are bounded by MAX_TRACKED, PEER_FORGET and
INTENT_CACHE_TTL, as applicable to their record type. A bounded early-ACK
cache permits an ACK arriving before its intent to be associated later.
Bad-signature frames may be displayed for diagnosis but MUST NOT update
trusted topology, replay or operation caches. Retention limits mean a
late-starting or lossy passive observer cannot prove absent history.
Callers computing current observer liveness supply their current monotonic
time; the observer reads no clock and packet silence never refreshes a
peer's last announcement time.
Text renderers escape terminal control characters (ASCII/C1 and Unicode
format/separator controls); JSON diagnostic output retains the original data.
Observers apply the same future-sequence admission bound as nodes, using
injected monotonic/startup-wall clocks before updating replay high-water.
If distinct normalized intents reuse one consumer/version, a version-only
ACK cannot establish which body it acknowledges: report ambiguity rather
than attributing success to one body. A fresh operation version resolves
that ambiguity; observing a corrected legacy retry is not proof of intent
identity.
Optional telemetry
Read-only telemetry observers may derive metrics from accepted existing frames. They MUST NOT send control frames or make pairing depend on a collector, scrape, dashboard or management heartbeat. The optional HTTP metrics/JSON interface in ADR 0020 is separate from YWP/1 and adds no envelope or body fields. Observed event counts are observation-relative, not guaranteed device lifetime totals.