Skip to content

IEEE register generated

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 of value) and EVENT (the fields of body);
  • 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 = 0 and no store_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): a chunks array 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 empty chunks array is a degenerate repair request or a disguised full request. A catalog-namespace (ns = 0) request carrying store_id or slot is 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 and chunks is MALFORMED" — that named an illegal state with no independent encoding: "full" is defined as the absence of chunks, 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 as blob_keys (namespace, store, slot, generation, chunk_index, chunk_count, total_bytes), followed by up to catalog_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 after frag_reassembly_timeout_ms (5 s) total — then either retries from scratch or falls back to the static profile (§8.5). total_bytes lets 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: GOODBYE BLOB_REFUSED (§4.5), never a half-session idling toward READY_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_ms between chunks and frag_reassembly_timeout_ms overall, 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_MISMATCH exists 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_WITHDRAWN carrying channel_id per withdrawn grant (silence is not an option, §4.5); answers later INTENTs on a removed id UNKNOWN_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.store op select each carry the entry-level store_id (§8.1) naming the STORE entry's store_id. store_id is 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_id selecting the store and slot the item. A store item is a CBOR map keyed by blob_keys (RFC-073; CDDL store-item), wherever it appears as a self-contained document (the bytes a BLOB_CHUNK stream for ns = 1 reassembles into, and a save import carrying a full item): slot (3), name (5), kind (6) and payload (7) REQUIRED, digest (11) OPTIONAL. digest is SHA-256 (32 B) over payload alone; present, it is what the receiver checks to choose BLOB_DONE status 0 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. name MUST fit the store's name_max and payload its per_item_max.
  • kind is <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, and kind is a label, never a schema selector the protocol interprets.
  • payload is 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.store whose options are index-aligned with the registered store_ops table (RFC-067; index 0 is op-select filler, §8.9): save 1 (the hub captures current live state by default; a client MAY supply a payload, which is an import), load 2 (the hub applies; the resulting truth arrives via the normal STATE broadcasts — ground truth, no special echo), delete 3, rename 4. An action.store field MUST NOT declare options beyond the registered ops; a device-specific store verb rides a separate action.* field. The hub validates kind and size on import and NACKs INVALID_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_max defaulting to preset_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 from action.*, which marks verbs: tagging a move's position field as an action would tell every generic client to draw a button where a positional control belongs. A command role generally pairs with a telemetry.* counterpart.
  • telemetry.target is the position the machine is currently commanded to, as opposed to telemetry.position, where it measurably is. Lag is deliberately not a role: it is target − 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 of pattern.*. plan.flags (RFC-100) is a bitfield8 (registry plan_flags: bit0 shaped, the planner shortened the commanded stroke or flattened its shape to hold the deadline; bit1 stretched, the segment runs past the commanded deadline; bit2 fallback, the planner substituted its fallback method; bit3 clamped, 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; no plan.feasible role 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 optionally mod.shape (a select; absent = the cycling trapezoid). A modulator is exactly one entry, attached by its entry-level mod_target to 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:

  1. build tabs from the spec categories present, in registry order, then device categories by id using their category_label;
  2. build cards from group strings in authoring order; ungrouped fields go to a default card;
  3. 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, no setting_key→read-only display with unit;
  4. 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;
  5. render a disabled field gray, never hidden;
  6. surface desc through a help affordance appropriate to the form factor;
  7. show writes as pending until ECHO, and display applied values only (§1.2-1 restated for settings);
  8. 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_ranges 0x8000-0xBFFF (user) holds channels that arrive at runtime and is never allocated by hub firmware. It is divided into 512 slices of accessory_slice_ids (0x20) ids; slice k has base 0x8000 + 0x20 · k. An accessory declares relative ids: relative id r is absolute id base + r in the host's catalog. r = 0x00 is never a channel (on the spoke it is the session-scoped channel BEACON, ACKMASK and GOODBYE ride); r = 0x01 is the registered accessory-status STATE (§17.1); r = 0x02-0x1F are 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 the safe annotation (§8.8) on every value-bearing field of every INTENT schema and every c2h STREAM layout; a field whose role is action.<name> is exempt, because entering the safe state abandons a verb in progress. A declaration SHOULD carry category on its entries (auxiliary, 7, is the natural home for a secondary actuator). The host does not rewrite its group strings.
  • Validation (host MUST; on failure JOIN_REPLY declaration_invalid and nothing is stored): every id in 0x01-0x1F; the r = 0x01 entry matches the registered accessory-status layout exactly; every STATE layout fits min_transport_payload (§9.1); every INTENT and EVENT schema's worst-case encoded frame fits 250 bytes (§13.3.1); every setting_channel names an id inside the declaration; every entry fits catalog_max_entry_bytes; the whole declaration fits accessory_declaration_max_bytes; every required safe is 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 with capacity. 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 NACK ACCESSORY_OFFLINE.
  • Accessory identity. accessory_id is a u64 the accessory generates randomly at first boot and persists, unchanged by reboots and firmware updates: the accessory twin of hub_instance_id (§6.1). The radio address is a transport address, not identity: the host keys records on accessory_id and updates the stored address on rejoin. An accessory whose storage is wiped is a new accessory and pairs again. The accessory persists its host's hub_instance_id and 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 grammar accessory_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 the accessories store; capacity is the host's accessory capacity. accessory-admin (0x0012, INTENT, configure): one op select with role action.accessory over accessory_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-roster tail (§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_REPLY capacity (a JOIN_REPLY with declaration_needed is provisional; the final one follows validation and the capacity check), and the refusal is also emitted on pairing-events (0x000B) as accessory_refused {accessory_id, result} so the operator who opened the window learns why nothing appeared. window_open is answered NACK ACCESSORY_CAPACITY when 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 GOODBYE NORMAL_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 answered not_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 refused INVALID_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 declared min/max. Let L(in) = out_min + (clamp(in, in_min, in_max) − in_min) · (out_max − out_min) / (in_max − in_min), with in_min ≠ in_max. Parameters beyond the four bounds ride the item's params, 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_min maps to out_max).
  • threshold_hysteresis: params on_above, off_below (source units, off_below ≤ on_above). The output is out_max once the source rises to on_above and out_min once it falls to off_below, unchanged in between; it starts at out_min when armed.
  • slew_limit: L(in) with the output's rate of change bounded by params rise_per_s and fall_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 param tau_s (seconds).
  • gate: out = L(in) while in_min ≤ in ≤ in_max, else the target's safe value.
  • piecewise_table: params are up to 8 (in, out) points, flattened, in strictly 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's max_rate_hz and, for an INTENT target, within intent_ingress_default_per_s. A map with internal state (slew_limit, lowpass) is also evaluated at the target's max_rate_hz while 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 enabled and a volatile armed, 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 refused INVALID_VALUE.
  • Degradation. Source or target removed (§8.6): the relationship is disabled and its target, where it still exists, set to its safe value. 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 grammar relationship_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 roster relationships-roster (0x0014, STATE, watch): {generation u16, count u8, capacity u8, armed 2 × bitfield8, faulted 2 × bitfield8}, bit i for rel_id i, capacity at most relationships_max (16). Relationships are authored through the §8.7 store verbs: relationships-write (0x0015, INTENT, configure) carries an action.store op select; all three entries carry store_id (§8.1, RFC-070). save creates or replaces an item (enabling or disabling is a save with enabled changed), delete removes one, rename renames it. There is no relationship-admin channel and no arm op: arming is §11.6's resume. The host only stores and evaluates; names and relationships are authored from a client.