8. Catalog (normative)#
The catalog is the hub's machine-readable self-description. It is the load-bearing artifact of the whole protocol: everything a generic client knows about a hub it learns here, and every "how does a client discover X" question in this document resolves to "the catalog says".
8.1 The channel entry#
The catalog is an array of channel entries. The normative encoding is CDDL-defined in schema/catalog.cddl (Appendix C); this section is its prose companion and the CDDL wins on any disagreement.
An entry carries: id (u16), name, class (STATE/STREAM/INTENT/EVENT/STORE), dir (h2c/c2h), access (the floor tier required to subscribe, or for INTENT to send), max_rate_hz (f32; 0.0 = on-change only), default_priority, and exactly one of:
layout— an ordered array of packed fields, for STATE and STREAM;schema— a map of integer key → field, for INTENT (the fields ofvalue) and EVENT (the fields ofbody);store— a store descriptor, for STORE (§8.7).
Plus these optional entry-level keys:
| Key | Meaning |
|---|---|
category |
ui_categories value (RFC-047/048, Phase C2); 1–15 registered (15 setup, RFC-079; 5 and 9 retired by RFC-094, never emitted), 0x40–0x7E vendor/device-defined. Was setting_categories pre-Phase-C2 — same wire key, new vocabulary (pre-tag restructuring; see the registry tombstone). |
category_label |
REQUIRED iff category is in the vendor range (0x40–0x7E) |
replay_depth |
entries the hub MAY replay on grant — presence is the exception to §9.4's no-replay rule |
setting_channel |
the u16 INTENT channel that writes this entry's setting-annotated fields; REQUIRED iff any field carries setting_key |
stream_kind |
stream_kinds value; STREAM class only; absent means samples (0) |
store_id |
the store_id (store-descriptor key 1) of the STORE entry this entry belongs to (RFC-070): on the store's roster STATE (its §8.7 dynamic half) and on the INTENT entry carrying its action.store op select, so one key joins store, roster and writer. Optional; a roster or writer without it renders unlinked |
role |
a channel_roles string (≤ 24 B) naming the whole entry's purpose (RFC-065; §9.4). The field_roles doctrine applies: unregistered values are legal, recognition is an opportunity, generic fallback is mandatory. Unlike a field role, a channel role MAY appear on more than one entry; a client renders each, ascending by id |
mod_target |
[channel id, field] on a modulator's entry (RFC-066): the field it rides, named as a layout index on a STATE/STREAM target or a schema key on an INTENT target (the same addressing as relationship targets). See the mod.* roles below |
event_kinds |
EVENT class only: a map uint → tstr (label ≤ 24 B) labeling this channel's event_kind values (RFC-065; §9.4). MUST be absent on spec-core channels, whose kinds are registry tables |
Encoding structure rule. The catalog on the wire is its outer array header followed by each entry encoded as an independent, self-delimiting document. Every entry document individually satisfies the §5.3 depth-4 cap; decoders MAY — and depth-4 decoders MUST — process entries one at a time with per-entry decoder state. The etag (§8.3) is computed over exactly these concatenated bytes.
Entry size bound. A single encoded entry MUST NOT exceed catalog_max_entry_bytes (4096). A fully-annotated 50-field entry can encode to 8–10 KB, which would violate §5.8's no-unbounded-allocation rule for a per-entry decode buffer. Oversize is a catalog-authoring error caught by conformance tooling, not a runtime surprise: the author splits the entry across channels or trims descriptions. Total entries are bounded by catalog_max_entries (256), a conformance floor rather than a wire cap.
Depth budget, stated as a design constraint. Counting from an entry map: entry → layout array → field map → options array = 4, at the cap; likewise entry → layout → field → bits and entry → schema → field → options. The leaves of those containers are scalars by construction. Any future annotation that wants a map or array inside a field map is therefore blocked and must ride the entry level instead. This is a real constraint, not a formality.
8.2 Schema language scope#
The layout/schema vocabulary is deliberately small: fixed-width numeric types and fixed-width strings (packed_field_types), a scale factor (wire = physical × scale), short unit strings (mm, mm/s, mA, degC, %, count, flag, ""), min/max, and the §8.8 annotation block. It describes values and their meaning, not behavior and not appearance. Nesting, variable-length fields, and conditionals are out of scope by design; a channel that seems to need them is two channels.
8.3 Etag computation#
catalog_etag = the first etag_bytes (8) of SHA-256 over the catalog encoded in the §5.3 deterministic profile, entries sorted ascending by id. Deterministic encoding makes the hash reproducible from the catalog content alone — any implementation, any language, same bytes, same etag.
The etag covers everything in §8.1: ids, names, classes, directions, access, rates, priorities, layouts, schemas, store descriptors, and every annotation. It does not cover retained values — that is cfg_gen's and seq's job.
Because the etag covers annotations, adding a tooltip changes it. That is correct and intended: a client caching by etag would otherwise render a stale label forever.
8.4 Transfer: the catalog is blob namespace 0#
Chunked transfer is one verb for the whole protocol (§8.7). The catalog is simply blob namespace 0.
- BLOB_REQ (
0x1A, c2h, CBOR):blob(38) selects what — for the catalog,ns = 0and nostore_id/slot. An empty selection means "send everything";chunks(27) at the top level makes it a selective repair request listing missing indices. Grammar-level rejection (RFC-049e, stated precisely so a decoder has an actual rule to enforce rather than a state with no wire representation): achunksarray present but empty is MALFORMED and MUST be rejected — an empty selection already means "send everything," so a decoder must never have to guess whether an emptychunksarray is a degenerate repair request or a disguised full request. A catalog-namespace (ns = 0) request carryingstore_idorslotis likewise MALFORMED, because the catalog is a singleton with no store or slot to select. (These replace an earlier, imprecise framing — "carrying both a full request andchunksis MALFORMED" — that named an illegal state with no independent encoding: "full" is defined as the absence ofchunks, so there was never a distinct wire shape to reject. See §18-9.) - BLOB_CHUNK (
0x1B, h2c, raw): a fixed header naming the same identity fields asblob_keys(namespace, store, slot, generation,chunk_index,chunk_count,total_bytes), followed by up tocatalog_chunk_payload(192) bytes of the deterministic encoding. 192 fits every binding unfragmented; WS MAY carry multiple chunks back-to-back. - The receiver reassembles by index, requests missing indices after
catalog_chunk_gap_timeout_ms(500 ms, SHOULD), and abandons afterfrag_reassembly_timeout_ms(5 s) total — then either retries from scratch or falls back to the static profile (§8.5).total_byteslets a receiver size or refuse a transfer before assembling it (§5.8-1). A receiver that refuses — declared size over its reassembly budget — MUST say so: GOODBYEBLOB_REFUSED(§4.5), never a half-session idling towardREADY_TIMEOUT; hubs SHOULD log the declared size alongside it. - A hub MAY pace chunk emission, and MUST respect transport backpressure while doing so. §13.1 defines a transport refusal as "not accepted right now; the caller decides retry vs drop" — for BLOB_CHUNK the hub MUST retry, resuming at the refused index, and MUST NOT treat the refusal as an error (no NACK, no teardown). A hub that instead emits every chunk in one synchronous burst and discards refusals silently truncates any blob longer than the binding's egress queue; that is non-conformant, and it fails invisibly because the sender sees a completed loop. Correspondingly, a receiver MUST NOT assume a transfer arrives in one delivery: it is bounded by
catalog_chunk_gap_timeout_msbetween chunks andfrag_reassembly_timeout_msoverall, and by nothing else. The backpressure decision is table-driven (RFC-050, reapplying §10.4's pattern to this gap): pacing granularity itself stays a hub policy and is not on the wire, but the response to a binding's own §13.1 congestion signal is normative, not a hub-invented policy:
| # | Binding congestion signal (§10.3) | Decision |
|---|---|---|
| 1 | clear, or in-flight chunks < blob_chunks_in_flight |
Send the next chunk |
| 2 | congested, in-flight chunks = blob_chunks_in_flight |
Hold emission at the current index |
| 3 | congested → recovered (§10.3 thresholds) before the row-4 abort fires | Resume emission from the held index |
| 4 | congested, sustained > 5 s (§10.3's own sustained-congestion window) | Abort the transfer: one NACK BUSY carrying retry_after_ms, per the existing "one NACK answers one BLOB_REQ" rule below — never a NACK per chunk |
blob_chunks_in_flight (registry limits, default 4) is the concrete, binding-independent number the panel's "what IS the signal" finding asked for: a hub MAY advertise less, MUST NOT advertise more, and a chunk the receiver has not yet acknowledged by reassembly progress counts against it. This closes the gap between an advisory "MAY pace" and an implementer actually knowing what to code against.
flowchart TD
Start([next chunk to emit]):::start
Start --> Check{binding congestion\nsignal, [§10.3](qos.md#s10-3)}
Check -->|"clear, or in-flight <\nblob_chunks_in_flight"| Send[Send the chunk]
Check -->|"congested,\nin-flight = limit"| Hold[Hold at current index]
Send -->|"more chunks remain"| Check
Hold -->|"congestion clears\nbefore 5 s"| Resume[Resume from held index]
Resume --> Check
Hold -->|"sustained > 5 s\n([§10.3](qos.md#s10-3) window)"| Abort["Abort: one NACK BUSY\n+ retry_after_ms"]
classDef start fill:#2b6cb0,stroke:#1a365d,color:#fff,stroke-width:2px
The Send ⟲ Check loop is the ordinary case — most transfers never touch
Hold. Abort fires once per stalled transfer, never once per chunk.
- BLOB_DONE (0x20) is the transfer's positive completion signal, generalizing CATALOG_READY's pattern (§6.4) rather than adding a second concept: the receiver of a transfer — the client for the common hub→client case, the hub for a client→hub STORE import (§8.7) — MUST send BLOB_DONE once reassembly concludes, carrying the same identity fields as blob_keys (namespace, store_id, slot, generation) plus status (0 verified-complete after a local hash check succeeds, 1 hash-mismatch, 2 aborted — e.g. by row 4 above, or by the receiver's own frag_reassembly_timeout_ms giving up). It is idempotent, exactly like CATALOG_READY: safe to re-send on a duplicate delivery or a retried reassembly. The sender treats a nonzero status per its own retry policy — BLOB_DONE reports an outcome, it does not itself request a retry; a sender wanting one re-issues the transfer as a fresh BLOB_REQ. A receiver that never verifies (a static client, or one that trusts transport-level integrity) MAY omit BLOB_DONE; nothing upstream of the catalog namespace blocks on it, so its absence degrades observability, not correctness.
- A hub bounds concurrent transfers by its RAM; beyond that, BLOB_REQ gets NACK BUSY. A blob.ns value outside the registered blob_namespaces table (and outside the device-defined 128–255 range) MUST be rejected with NACK INVALID_NAMESPACE (RFC-049e) — a namespace that does not exist at all is a different failure from a store or slot that does not exist within a namespace that does, and a client needs to tell them apart to know whether retrying with a different store_id/slot could ever succeed. A request naming a store or slot that does not exist within a valid namespace gets NACK CHUNK_UNAVAILABLE, unchanged. One NACK answers one BLOB_REQ, whether the request was refused up front, a resumed transfer became unservable partway (the addressed item was deleted, resized, or its generation moved), or the sender aborted it per row 4 above — never one per bad index and never one per chunk.
- The hub MUST gate BLOB_REQ on the declaring entry's access exactly as it gates SUBSCRIBE.
Only the catalog namespace has a readiness concept (§6.4). You cannot decode STATE without the catalog; nothing gates on a preset.
8.5 The static-client profile (etag-pinned)#
A constrained client MAY ship with a compiled-in catalog and pre-encoded CBOR templates instead of a CBOR stack. Requirements:
- It sends its compiled-in etag in HELLO. If the hub's etag matches: full speed ahead, ready immediately.
- On mismatch it MUST choose a declared behavior: (a) proceed degraded — the §5.4 append-only rule guarantees its known prefix of every layout still parses; it MUST suppress any control function whose schema it cannot re-verify; or (b) refuse with a user-visible "update me" indication. Silent full operation on a mismatched etag is non-conformant.
- A degraded client sends CATALOG_READY with its stale etag (§6.4).
- The hub treats static clients identically to dynamic ones; the profile is client-internal except for the etag check. NACK
ETAG_MISMATCHexists for hubs configured to refuse degraded operation outright — a hub policy, not the default.
8.6 Catalog invariance and mid-session change#
The catalog is client-invariant: every session sees the same entries and the same etag. Access control acts at SUBSCRIBE/PUBLISH/INTENT/BLOB_REQ time (NACK ACCESS_DENIED), never by filtering the catalog. Per-client catalogs would fracture etag caching and static profiles, and would make a generic renderer's "gray, never hide" rule (§8.9) impossible to honor.
Settings are fixed per firmware. Entries in the core and device ranges are fixed per firmware boot. Only the user space (§8.10) changes at runtime (RFC-077), and only by an accessory join, a declaration replacement, or a forget; an accessory going offline changes nothing, so a flapping link never churns the catalog. Every core and device entry a session decoded under the old etag is byte-identical under the new one. A change is a catalog change, which changes the etag, announced on the catalog channel's STATE update (§4.2-3), to which every client MUST subscribe. A change outside the user space (a simulator or host hub changing without reboot) sends clients back to SYNCING; a user-space change does not revoke readiness (§6.4), and SUBSCRIBE, PUBLISH and INTENT are validated against the hub's current catalog, as always. A client that cannot accept the grown catalog (its total_bytes exceeds the client's reassembly budget) MAY stay LIVE on its old etag, degraded per §8.5(a), instead of GOODBYE BLOB_REFUSED: the byte-identical rule makes its old knowledge exactly correct for everything it had. There is no second "base" etag: degraded mode is the answer for a pinned client. The etag covers the whole catalog, user entries included (§8.3), and a reconnecting client with a stale etag runs §6.8 normally.
- Channels that vanish. On removal the hub, for every session, drops every subscription and publication grant on the removed ids and discards their retained values; sends one unsolicited NACK
CHANNEL_WITHDRAWNcarryingchannel_idper withdrawn grant (silence is not an option, §4.5); answers later INTENTs on a removed idUNKNOWN_CHANNEL; and drops and counts later bundles on one (§9.2). Removed accessory channels are never motion sources, so no ownership moves; relationships targeting them are disabled. - Channels that appear. Nothing is delivered until a session subscribes (§10.2): no implicit grant, even for a HELLO wish-list that named the id (wishes are evaluated once, at HELLO).
- Transfers in flight. A catalog transfer running when the etag changes is aborted with the one NACK §8.4 specifies for an item whose generation moved (
CHUNK_UNAVAILABLE); the client restarts against the new etag. - Renderers. A client SHOULD re-render in place, MUST keep user layouts keyed on stable ids, and MUST show an offline accessory's channels stale, never remove them (RENDERING.md §13 laws 8 and 10).
8.7 STORE channels and the blob verb#
A STORE-class catalog entry declares a collection of slot-addressed items: {store_id, kind, capacity, per_item_max, name_max}. kind is a namespaced string (e.g. "pattern.frayd", "trust.ledger"). Presets, saved positions, limit profiles, recordings and the trust ledger are all the same machinery.
- Why an ordinary catalog entry: a parallel top-level array would break the catalog root shape, the id sort, the etag computation and the per-entry depth rules — all four.
- The dynamic half is a separate tiny STATE channel carrying
{generation, count, capacity}, on-change and retained. A generation bump means "re-enumerate". This keeps the catalog invariant per firmware (§8.6) while the roster changes freely. Every store in the protocol is this pair of entries. - One key joins store, roster and writer (RFC-070). The roster STATE entry and the INTENT entry carrying the store's
action.storeop select each carry the entry-levelstore_id(§8.1) naming the STORE entry'sstore_id.store_idis unique per hub, so the match is exact; a client never pairs them by id adjacency or name. The key is optional: a roster-shaped STATE or a store writer without it, or naming no STORE entry, renders as a plain, unlinked list or action, never guessed into a store. Conformance tooling SHOULD warn when a roster-shaped STATE entry lacks it. - Items move over BLOB_REQ/BLOB_CHUNK with
ns = 1 (store),store_idselecting the store andslotthe item. A store item is a CBOR map keyed byblob_keys(RFC-073; CDDLstore-item), wherever it appears as a self-contained document (the bytes a BLOB_CHUNK stream forns = 1reassembles into, and asaveimport carrying a full item):slot(3),name(5),kind(6) andpayload(7) REQUIRED,digest(11) OPTIONAL.digestis SHA-256 (32 B) overpayloadalone; present, it is what the receiver checks to choose BLOB_DONEstatus0 or 1 for a store transfer (§8.4), the role the catalog's own SHA-256 plays for namespace 0. A hub that never computes one omits it.nameMUST fit the store'sname_maxandpayloaditsper_item_max. kindis<domain>.<variant>(RFC-073): a recognized<domain>(pattern,trust, any future spec-named category) and a device- or format-chosen<variant>. Domains are open:<domain>is advisory grouping a generic client may use for grouping or icons, never validated against a list, andkindis a label, never a schema selector the protocol interprets.payloadis OPAQUE. The protocol layer never decodes it, and §5.8's depth and allocation budget explicitly does not extend inside it. A client that decodes a preset has stepped above the protocol boundary.- CRUD rides INTENT on a device-declared channel, as one op select carrying role
action.storewhoseoptionsare index-aligned with the registeredstore_opstable (RFC-067; index 0 is op-select filler, §8.9):save1 (the hub captures current live state by default; a client MAY supply apayload, which is an import),load2 (the hub applies; the resulting truth arrives via the normal STATE broadcasts — ground truth, no special echo),delete3,rename4. Anaction.storefield MUST NOT declare options beyond the registered ops; a device-specific store verb rides a separateaction.*field. The hub validateskindand size on import and NACKsINVALID_VALUE; it never inspects the payload. - Caps are hub-declared with generous spec floors:
capacity ≥ preset_capacity_min(32) as a conformance floor,per_item_maxdefaulting topreset_item_max_bytes(4096). Small hubs declare less and the catalog says so.
The one carve-out. The store whose kind is "trust.ledger" has a registered item grammar (trust_ledger_keys, §12.6). Presets are device content and are genuinely opaque; the trust ledger is protocol content whose fields this document names, which every configure client must render and act on, and where "revoke device 3" has to mean the same thing on every hub. The store machinery is reused verbatim — chunking, repair, generation, caps, all free — and only this one store's payload grammar is agreed centrally. Opacity is the default and stays the default.
8.8 The settings metamodel (the annotation block)#
Every layout and schema field MAY carry an annotation block. All of it is optional and all of it is ignorable: a client that reads none of it behaves exactly as a v1-draft client did. A client that reads it can build its entire settings and control surface from the hub — a control added in firmware populates on every client's next connect, with its label, grouping, units, constraints and explanation coming from the machine rather than from each client developer's guesswork.
| Annotation | Applies to | Meaning |
|---|---|---|
setting_key |
layout fields | the CBOR key in the entry's setting_channel that writes this field. Present = this field is a setting (stored config): adopt it into a control. Absent = read-only (effective state or telemetry): display it, and never write it back into a setting's shadow |
default |
both | the factory value, same type as the field |
options |
both | labels for a single-select; the wire value is the array index |
group |
both | a free-form card heading within the category tab |
desc |
both | user-facing description, ≤ desc_max_bytes (128). Flash-resident on the hub, travels once, etag-cached |
role |
both | a field_roles string — see below |
step |
both | range granularity hint |
flags |
both | setting_flags bitmask: advanced, restart_required, secret, destructive |
access |
schema fields only | per-op minimum tier; overrides the entry's access floor upward or downward |
option_access |
schema fields only | per-option minimum tier, index-aligned with options |
safe |
both | the value the field takes in an accessory's safe state (§13.3.1, RFC-076): same type as the field, within its min/max. Usable in any catalog; REQUIRED in an accessory declaration on every value-bearing field of every INTENT schema and c2h STREAM layout (§8.10) |
destructive_options |
schema fields only | uint bitmask: bit i marks option i destructive (RFC-063). Options past bit 63 cannot be marked; an op table that needs more splits across fields |
setting_key presence is the stored-vs-effective distinction, and it needs no separate flag. A machine's stroke window may lawfully report a stored configured value on the write plane and a different effective value on the state plane — on an unhomed machine, for example, stored [5, 495] and effective [0, max_rail] are both true. A client that adopts the effective value into the stored control stomps operator input; the presence test is what tells it not to.
access and option_access are schema-field annotations only. A layout field is the read side — a STATE snapshot value — and all write authorization flows through the paired INTENT channel named by setting_channel + setting_key. A client needing per-option gating resolves that join (which it must do anyway to encode a write) and reads option_access on the schema field there. This also keeps the field map inside the depth-4 cap, which is already at its limit.
option_access exists because an op-style INTENT carries its verb as one enum-valued field — the safety-intents channel does exactly this — and per-field access cannot vary across the values of one field. Without it, the role-exempt safety ops (§11.2) would force their whole channel down to watch access, and a generic renderer would then offer hold/pause/takeover to every watcher, discovering otherwise only by NACK. That violates gray-never-hide.
Destructive invocations (RFC-063). The destructive flag on a schema field carrying an action.* role means invoking that verb loses state the operator cannot restore from the client: configuration, stored items, counters, sessions, uptime. On a writable layout field (setting_key present) it means writing that setting has that effect. An op select marks individual ops with destructive_options, a scalar so the field map stays inside the §8.1 depth budget. An invocation is destructive iff the field carries the destructive flag, or the invoked option's bit is set in destructive_options, or the field's role is action.reboot or action.reset (both tags already require confirmation, so a hub need not restate it), or the field's role is action.store and the invoked op is delete (RFC-067). A client MUST confirm-gate every destructive invocation on every renderer class (RENDERING.md §8.4 trigger) and MUST NOT infer destructiveness from labels, names or desc. The flag is rendering metadata: a hub MUST NOT change wire behavior on it and MUST NOT assume a confirm happened; the confirm protects an operator from a mis-tap, not a hub from a client. Entry-level destructive does not exist: per field keeps one home. source.background_run's false-to-true confirm (RENDERING.md §10.1) is bound by role, not by this flag, so no hub can forget it.
Field roles are the semantic vocabulary that lets a client find a thing on any hub without hardcoding a channel number: limit.jog.speed, limit.input.jerk, window.min, telemetry.position, identity.name, meta.enabled_mask, meta.reset_gen, and so on (registry field_roles). Two conventions rather than entries: <role>.peak is the peak companion of any telemetry role, and action.<name> marks a schema field as a verb rather than a value (§9.3).
role is a string, not a number, because action.<name> carries a device-chosen suffix no integer enum could express. Unregistered roles are legal. Nothing is hardcoded as a requirement; roles are hardcoded as opportunities. A client that recognizes a registered role MAY upgrade to a bespoke widget; a client that does not MUST fall back to generic rendering. Fallback is mandatory, upgrades are optional, an unknown role is never an error.
Three role families were registered by the first generic clients, which found the holes by refusing to guess:
command.<quantity>names a value-bearing INTENT field whose value is the setpoint;command.position(a commanded absolute target position, in the channel's own unit) is the first member. It is deliberately distinct fromaction.*, which marks verbs: tagging a move'spositionfield as an action would tell every generic client to draw a button where a positional control belongs. A command role generally pairs with atelemetry.*counterpart.telemetry.targetis the position the machine is currently commanded to, as opposed totelemetry.position, where it measurably is. Lag is deliberately not a role: it istarget − position, computed client-side — registering a third field for a subtraction would invite two sources of truth for one number.plan.*names motion-plan telemetry, the segment in flight:plan.start,plan.end,plan.current,plan.velocity,plan.elapsed,plan.duration,plan.style,plan.flags. The inclusion test is "would a different machine's motion planner have this concept?" — the same test that keeps device internals out ofpattern.*.plan.flags(RFC-100) is abitfield8(registryplan_flags: bit0shaped, the planner shortened the commanded stroke or flattened its shape to hold the deadline; bit1stretched, the segment runs past the commanded deadline; bit2fallback, the planner substituted its fallback method; bit3clamped, a ceiling or the travel window changed the command; bits 4-7 zero), set when a segment plans, from that plan, and cleared when the next segment plans clean; zero while no plan is in flight. A hub that plans a segment ahead of its start (§5.4) sets the byte when it plans, so it may lead the segment it describes by up to one schedule horizon. The plan is infeasible when any of bits 0-3 is set; noplan.feasiblerole exists, because a derived bool beside the byte would be a second source of truth for it, the reason lag is not a role.mod.*names the fields of a modulator (RFC-066):mod.amount(0 = no modulation),mod.rise,mod.hold,mod.fall,mod.rest,mod.phase, and optionallymod.shape(a select; absent = the cycling trapezoid). A modulator is exactly one entry, attached by its entry-levelmod_targetto the field it rides; its roles bind within that entry, so a client never resolves them catalog-wide. Time-like fields carry their clock in the field's unit (strokes or seconds).
Role cardinality. A registered role SHOULD appear on at most one field per catalog. A client that meets duplicates binds the first in catalog order — a deterministic, conformant tiebreak rather than a client-local guess. The SHOULD governs catalog-wide discovery, never the binding path of a role found through its own entry: roles whose entry carries a target key (mod_target, RFC-066) are expected to repeat, one set per entry, and are told apart by that key, so neither the SHOULD nor the tiebreak applies to them. The per-group families color.* and datetime.* (RFC-083) repeat by construction and are exempt: each MUST appear at most once per group, and the first-in-order tiebreak applies within a group.
Categories organize the surface. Entry-level category carries a ui_categories id (RFC-047/048, Phase C2; the navigation vocabulary of RENDERING.md §3, replacing the retired setting_categories): ids 1–15 are spec-registered (5 and 9 retired by RFC-094) with a canonical registry order and a navigation tier each (RENDERING.md §3) so placement, iconography and translation are consistent across every hub a client ever meets; 0x40–0x7E are device-defined, MUST carry category_label, and render as additional tabs after the spec set. A category spans channels — two channels in the same category merge into one tab, which is the answer to a category outgrowing one 242-byte snapshot (about 58 f32 or 115 u16 fields, inclusive of mask bytes).
Dynamic enablement. "Grayed out right now" depends on live machine state and therefore cannot live in static metadata at all. A settings STATE channel carries one or more bitfield8 fields tagged meta.enabled_mask; bit i gates the i-th setting-annotated field of that layout. On-change, retained, conflated — every client grays from the same ground truth.
Trial marks (RFC-099). A settings STATE channel of a hub that accepts trial writes (§9.3) carries one or more bitfield8 fields tagged meta.trial_pending, indexed exactly as meta.enabled_mask: bit i set means the i-th setting-annotated field holds a trial value, any session's, which reverts when that trial ends. On-change, retained. A client MAY mark those values; it MUST NOT treat a marked value as stored.
Secrets (normative). A secret-flagged field's value NEVER appears in STATE. The snapshot carries only a set/unset presence bit. Writes ride the paired INTENT normally, and ECHO confirms application without echoing the value: the ECHO carries the applied key with the CBOR value true in place of its value, so §9.3's key-completeness holds, and a client decoding applied against the schema MUST accept true for a secret-flagged key whatever the field's type (RFC-069). A WiFi password must never ride a retained snapshot that open-access watch sessions receive. A secret string SHOULD be str16 or write-only with a presence bit: a secret str32 burns 13 % of a snapshot to communicate one bit.
Validation is hub-side. min/max/step/width are UI hints; the hub is the referee and NACKs INVALID_VALUE. There is no regex requirement on clients — an optional pattern hint MAY be included and MAY be ignored. A constrained client must never need a regex engine to render a settings page.
Applied values stay inside advertised ranges. An ECHO applied value, and the value of any setting_key-bearing field, MUST lie within that field's declared min/max. A hub whose internal clamp can exceed its advertised range MUST widen the advertised range, not lie past it — generic renderers depend on it. Read-only effective fields lawfully exceed a paired setting's range and declare their own display bounds; that is not a violation, it is the stored-vs-effective distinction doing its job.
8.9 Normative rendering checklist (what a compliant client library means)#
This is spec text, not wire. A client claiming generic-settings support MUST:
- build tabs from the spec categories present, in registry order, then device categories by id using their
category_label; - build cards from
groupstrings in authoring order; ungrouped fields go to a default card; - choose the widget from type + constraints, never from a hint — there is no widget field, deliberately:
bool/u8→toggle, u8 +options→select,bitfield8→checkbox group, numeric + min/max→slider or numeric entry,str<N>→text, nosetting_key→read-only display with unit; - order presentation as authoring order: tabs in registry order then device ids; within a tab, channels ascending by id and fields in layout order. No ordering metadata is carried on the wire;
- render a disabled field gray, never hidden;
- surface
descthrough a help affordance appropriate to the form factor; - show writes as pending until ECHO, and display applied values only (§1.2-1 restated for settings);
- render an unknown role, flag, category, or annotation key generically — fallback is mandatory.
A phone renders a range as a slider, a remote as a click-wheel value, a plugin side panel as a numeric box. Same bytes, three honest UIs.
Index 0 of an op table is never an operation. For a schema select field carrying an action.* role, wire value 0 is NOT an operation unless the governing op table registers an op at 0 — registry op tables number from 1, so index 0 exists only to keep options array-index-aligned and carries a filler label. Clients MUST NOT render index 0 as an actionable choice. Gating index 0 with option_access at a high tier remains RECOMMENDED defense-in-depth, but cannot be the whole answer: AccessLevel tops out at configure, which real admin sessions actually hold, so there is no "level nobody holds" to gate with — the rendering rule is what closes it.
8.10 Accessory channels: the user space and the declaration (§8.10)#
An accessory (§13.3.1) is self-describing: its channels, types, ranges and deadman are fixed at flash time and declared to its host on join; only hub-side things (names, relationships) are authored from a client.
- The user channel space.
channel_id_ranges0x8000-0xBFFF(user) holds channels that arrive at runtime and is never allocated by hub firmware. It is divided into 512 slices ofaccessory_slice_ids(0x20) ids; slice k has base0x8000 + 0x20 · k. An accessory declares relative ids: relative id r is absolute idbase + rin the host's catalog.r = 0x00is never a channel (on the spoke it is the session-scoped channel BEACON, ACKMASK and GOODBYE ride);r = 0x01is the registered accessory-status STATE (§17.1);r = 0x02-0x1Fare the accessory's own, 30 at most. On the spoke the header carries the relative id; everywhere else, the absolute one. Slice membership (id & 0xFFE0) is how a client groups one accessory's channels: a structural rule, never name matching. The slice width is an address space, not a promise; how many ids a hub can carry is its advertised capacity (§8.6). - The declaration IS the accessory's catalog. Same CDDL (Appendix C), same deterministic encoding, same etag (§8.3), relative ids. It moves over the blob verb as the accessory's own namespace 0, verified by SHA-256 against the JOIN_REQ
declaration_etag(§13.3.2). No second grammar exists. A declaration REQUIRES thesafeannotation (§8.8) on every value-bearing field of every INTENT schema and every c2h STREAM layout; a field whose role isaction.<name>is exempt, because entering the safe state abandons a verb in progress. A declaration SHOULD carrycategoryon its entries (auxiliary, 7, is the natural home for a secondary actuator). The host does not rewrite itsgroupstrings. - Validation (host MUST; on failure JOIN_REPLY
declaration_invalidand nothing is stored): every id in0x01-0x1F; ther = 0x01entry matches the registered accessory-status layout exactly; every STATE layout fitsmin_transport_payload(§9.1); every INTENT and EVENT schema's worst-case encoded frame fits 250 bytes (§13.3.1); everysetting_channelnames an id inside the declaration; every entry fitscatalog_max_entry_bytes; the whole declaration fitsaccessory_declaration_max_bytes; every requiredsafeis present and in range; no STORE entries. - Persistence and sticky slices. Per accessory the host persists
accessory_id, peer address, slice index, declaration bytes and etag,deadman_ms, the hub-authored name, and the relationships that reference it. A slice is sticky: the accessory keeps it across reboots of either side and across declaration replacement until it is forgotten, so an absolute id a client layout or a relationship stored stays valid (RENDERING.md §13 law 10). A forgotten slice is never reassigned: 512 slices outlast the life of a hub, and a host whose every slice has been used refuses further joins withcapacity. At boot the host rebuilds its catalog from persisted declarations before admitting sessions, so the etag is stable across host reboots for an unchanged accessory set. A paired accessory that is absent keeps its channels in the catalog: it is offline, not gone; the host stops pushing its STATE (clients show it stale) and answers writes to it with NACKACCESSORY_OFFLINE. - Accessory identity.
accessory_idis a u64 the accessory generates randomly at first boot and persists, unchanged by reboots and firmware updates: the accessory twin ofhub_instance_id(§6.1). The radio address is a transport address, not identity: the host keys records onaccessory_idand updates the stored address on rejoin. An accessory whose storage is wiped is a new accessory and pairs again. The accessory persists its host'shub_instance_idand address; it needs no slice number, because it speaks relative ids. - Core surfaces for generic clients.
accessories(0x0010, STORE,kind"accessory.record",watch) with the registered item grammaraccessory_record_keys(accessory_id,slice,name,product,fw_version,declaration_etag): §8.7's carve-out applies for the trust ledger's reason, because this is protocol content every client must read the same way.accessories-roster(0x0011, STATE,watch):{generation u16, count u8, capacity u8, online 4 × bitfield8, safe 4 × bitfield8, unconfirmed_estop 4 × bitfield8}= 16 bytes, bit i of each mask being the accessory in slot i of theaccessoriesstore;capacityis the host's accessory capacity.accessory-admin(0x0012, INTENT,configure): one op select with roleaction.accessoryoveraccessory_admin_ops:window_open(the in-band twin of the pairing button),forget {accessory_id},rename {accessory_id, name}. - Capacity (RFC-077). A host declares its budgets and refuses honestly past them: a per-accessory budget (the most entries, layout fields and schema fields one declaration may use; at most 31 entries) and a user-space budget (the total entries, layout fields, schema fields and encoded catalog bytes it can add beyond its own catalog). The
accessories-rostertail (§5.4) advertises what remains:per_accessory_entries u8, free_entries u16, free_layout_fields u16, free_schema_fields u16, free_catalog_bytes u16, on change. A host MUST NOT advertise room it lacks. A declaration that would exceed either budget is refused with JOIN_REPLYcapacity(a JOIN_REPLY withdeclaration_neededis provisional; the final one follows validation and the capacity check), and the refusal is also emitted onpairing-events(0x000B) asaccessory_refused {accessory_id, result}so the operator who opened the window learns why nothing appeared.window_openis answered NACKACCESSORY_CAPACITYwhen the host has no free slice, no free peer entry, or less budget than the smallest legal declaration (the status entry plus one channel). The user space is accessories only. - Forgetting is host-side only.
forget: the host sends the accessory GOODBYENORMAL_CLOSURE, deletes its record and every relationship targeting it, retires the slice, and removes its channels (§8.6). A later JOIN_REQ from it is answerednot_paired; the accessory MAY then clear its stored hub. An accessory's own GOODBYE, or its local unpair gesture, marks it offline and deletes nothing: spoke frames are unauthenticated (H13), so an accessory-originated delete would let a forged frame wipe a record.
8.11 The relationship engine (RFC-078)#
A relationship maps one source field to one accessory target field, and it is hub policy: evaluated on the accessory host, surviving every client closing, so no client reimplements the mapping (§9.6's write-once argument).
- Source: a numeric field of an h2c STATE or STREAM layout anywhere in the host's catalog (core, device, or another accessory's user space), named by
(channel id, layout index). Target: a value-bearing field of an accessory's INTENT schema or c2h STREAM layout, named by(absolute channel id, schema key or layout index). Chains are allowed (an accessory field may source another accessory's target); a save that would close a feedback loop (a cycle through source and target fields) is refusedINVALID_VALUE. - Maps (registry
relationship_maps). Every bound and parameter is in each field's physical units (post-scale), and every map's output is finally clamped into the target's declaredmin/max. LetL(in) = out_min + (clamp(in, in_min, in_max) − in_min) · (out_max − out_min) / (in_max − in_min), within_min ≠ in_max. Parameters beyond the four bounds ride the item'sparams, an array of up to 16 numbers in the order listed: linear_clamp:out = L(in).invert:out = out_max + out_min − L(in)(in_minmaps toout_max).threshold_hysteresis: paramson_above,off_below(source units,off_below ≤ on_above). The output isout_maxonce the source rises toon_aboveandout_minonce it falls tooff_below, unchanged in between; it starts atout_minwhen armed.slew_limit:L(in)with the output's rate of change bounded by paramsrise_per_sandfall_per_s(target units per second; one value serves both when the second is absent).lowpass:L(in)through a first-order low-pass with paramtau_s(seconds).gate:out = L(in)whilein_min ≤ in ≤ in_max, else the target'ssafevalue.piecewise_table: params are up to 8(in, out)points, flattened,instrictly ascending; linear between points, held at the end values outside them; the four bounds are unused.- Evaluation. On every source update, writing the target only when the mapped output moved by at least the target field's
step(any change if none is declared), no faster than the target channel'smax_rate_hzand, for an INTENT target, withinintent_ingress_default_per_s. A map with internal state (slew_limit,lowpass) is also evaluated at the target'smax_rate_hzwhile its output is still converging. Writes take the accessory-host proxy path (§17.1.1), so the accessory's ECHO is the truth the relationship reports. - Persistence and independence. Relationships live in the host's non-volatile storage and survive every session ending and every reboot; no session owns one, and evaluation never depends on any session existing. Each carries a persisted
enabledand a volatilearmed, false at every boot: a reboot re-arms nothing (§11.6). - Ownership. An enabled, armed relationship owns its target field: a client write to that field gets NACK
SOURCE_CONFLICT(§11.4's code) until the relationship is disarmed or disabled. One target field has at most one enabled relationship; a second is refusedINVALID_VALUE. - Degradation. Source or target removed (§8.6): the relationship is disabled and its target, where it still exists, set to its
safevalue. Target accessory offline: the relationship idles; on rejoin, if still armed, the host sends the current mapped value. - Storage and authoring. The core STORE
relationships(0x0013,kind"relationship.map",watch) holds one item per relationship with the registered grammarrelationship_keys(rel_id,name,source_channel,source_field,target_channel,target_field,map,in_min,in_max,out_min,out_max,params,enabled); §8.7's carve-out applies because the hub interprets the item. Its rosterrelationships-roster(0x0014, STATE,watch):{generation u16, count u8, capacity u8, armed 2 × bitfield8, faulted 2 × bitfield8}, bit i forrel_idi,capacityat mostrelationships_max(16). Relationships are authored through the §8.7 store verbs:relationships-write(0x0015, INTENT,configure) carries anaction.storeop select; all three entries carrystore_id(§8.1, RFC-070).savecreates or replaces an item (enabling or disabling is asavewithenabledchanged),deleteremoves one,renamerenames it. There is no relationship-admin channel and no arm op: arming is §11.6'sresume. The host only stores and evaluates; names and relationships are authored from a client.