RFC 0010 — Owner-writable application property fields: the field descriptor table, the reserved settings.app namespace, and owner-defined :schema¶
Note
Status: accepted. This page is an accepted change proposal, kept as the record of why the specification reads as it does. RFCs are proposals and history, not the standard. The normative specification is Protocol v1 and the annexes its §3 incorporates; where an RFC and the specification differ, the specification wins. All RFCs, with their status, are listed in the ADR and RFC index.
Field |
Value |
|---|---|
RFC |
0010 |
Title |
Owner-writable application property fields: the field descriptor table, the reserved |
Status |
accepted (2026-07-19 — maintainer ruling, window waived; in-comment from 2026-07-17, draft since 2026-07-09) |
Author(s) |
origin-firmware integration (drafted for maintainer review) |
Created |
2026-07-09 |
Comment window closes |
2026-07-31 (≥ 14 days per GOVERNANCE.md §Spec changes) |
Tracking issue |
|
Amendments |
1 (2026-08-22, |
Target spec version |
v1 (draft refinement — no released v1 yet, so no v2 needed) |
Roadmap item |
this RFC is the specification of the “field descriptor table” item of the 2026-07-08 architecture-review backlog (the vertex-verbs / lazy-validation / field-descriptor-table cluster; item 2 of that backlog — the |
Summary¶
The : field plane opens to the vertex owner. Today the writable field set
is closed: the reference field_write admits :subscribers[], :acl,
:children[], and seven fixed QoS knobs under :settings.*; every other field
name is SCHEMA_NOT_FOUND on write and read, and :schema reads are
synthesized from two of those knobs. Consequence: an application cannot attach
any metadata or configuration to its own vertex on the property plane —
ADR-0021’s promise of device-private fields (“like driver-private ioctls”)
has no substrate. This RFC provides it, in four coupled pieces:
A. Owner-declared application fields. The vertex owner — through the local host API, mirroring the owner-initiated doctrine RFC-0009 §A.1 establishes for removal — declares named fields under one reserved application namespace inside the settings container:
:settings.app.*. Declared fields are readable and (where declared writable) writable through the ordinary:fieldgrammar, locally and remotely; undeclared names remainSCHEMA_NOT_FOUND— theENOTTYdefault survives, opened only where the owner named a hole.B. The field descriptor table. Declaration, validation, and self-description are one structure: the owner installs a per-vertex descriptor table (name, writability, and an opaque-to-the-runtime descriptor record — dtype, unit, range, …).
field_writeconsults it after the protocol branches;read_schemaserves its projection merged alongside the synthesized protocol part, with defined precedence — making reference/02’s “a vertex exposes a schema describing every writable field” implementable for application fields.C. Change notification — restated, not changed. Field writes do not wake
awaitand do not propagate. A property change is followed by an ordinary announce write at the vertex (the consumer convention already ratified in the 2026-07-08 backlog ruling and fw ADR-0076); this RFC states it normatively so consumers have one documented contract.D. Storage: field bytes ride the vertex. A declared field’s value is a bare TLV stored inside the vertex — no subscriber list, no edge machinery, no per-field vertex; cost = its bytes plus one table slot. On the reference implementation it is one more verb pair on the
vertex_tseam (#338), exactly theset_acl/acl_bytesstore-verbatim pattern.
No new wire verbs, no new type codes, no new error identities. The wire-visible
change is that field paths which previously always errored become serviceable
when — and only when — the owner declared them, plus one reserved key (app)
inside the vertex SETTINGS namespace.
Motivation¶
The measured gap (F2 slice 1 of the originating production firmware — an ESP32-C6 smart-agriculture node; pin
94fc98d, 2026-07-09).core/src/graph.cppfield_writeis a closed set:subscribers[]/acl/children[]plus exactly sevensettings.*QoS knobs (reliability,durability,priority,history_keep_last,queue_max_bytes,deadline_ns,store_ref_min_bytes); every other field name returnsSCHEMA_NOT_FOUNDon write and on read.:schemareads are synthesized (the vertex name plus two QoS knobs) — there is nothing an owner could put there. And aPL-setPOINTwritten as a value is branch-write grammar (RFC-0005 §B — it decomposes), so a POINT-shaped descriptor record cannot even be parked in a data vertex as an opaque value. An application therefore has no way to attach metadata or configuration to its own vertex anywhere on the property plane.The doctrine already promises this. ADR-0021 rule 3: fields are “standard and device-private — like ioctls”; the device “owns the catalog of what each field accepts”. Reference/02 §Schema and field discipline: “a vertex exposes a schema describing every writable field”, and §The graph imposes no shape: a vertex’s writable fields are “whatever the schema says”. None of that is implementable today: there is no way to have a device-private field, and no way for the schema to say anything the runtime did not synthesize. This RFC is the missing mechanism — while keeping the collision-proofing that motivated the closed set (§A.1) and the closed-by-default
ENOTTYposture (§A.2).A real consumer is blocked on it, and its workaround is the exact cost this protocol exists to avoid. The origin firmware’s device-graph schema (its ADR-0076, device-graph taxonomy and path algebra) rests on two decisions with no upstream substrate: properties are bare fields (only subscribable things are vertices) and app configuration lives under one property container,
:settings.*, sibling to protocol-owned names. Its Gate-A parity checklist (fwdoc/gate-a-parity-checklist.md) collapses the majority of 64 protobuf handlers onto exactly these writes —:settings.persist,:settings.ha_exposed, per-parameterCTRL:settings.<param>writes described by:schema(dissolving an opaque params blob and itsSTALE_CLIENTfailure class),/hw/.../ :settings.*device config — and its HA/MQTT auto-discovery becomes a:schemawalker. With no writable substrate, fw’s interim stores a descriptor record in a<endpoint>/metachild vertex — at a measured ≈ 900 B of heap per vertex on the ESP32-C6 (fwdoc/libtracer-cutover-estimates.md§6 and §7, measured 2026-07-09: the full ~100-endpoint mirror exhausted the heap), i.e. the per-vertex cost the MCU vertex diet fights, spent on machinery (subscriber lists, ACL slots, LKV) that a static descriptor never uses. The fw estimates table lists “owner-writable app property fields + owner-defined:schema” as Gate-A blocking.The roadmap already names it. The 2026-07-08 architecture-review backlog carries a “field descriptor table” item in the vertex-verbs / lazy-validation / field-descriptor-table cluster. Item 2 of that backlog (the
vertex_tverb interface, #338) built the seam; this RFC specifies the table that was to slot behind it.
Proposed change¶
A. Owner-declared application fields¶
A.1 The namespace: one reserved subkey in the settings container — :settings.app.*¶
Application fields live under :settings.app.<name>… — a single reserved
subkey of the existing vertex settings container. app becomes a reserved
NAME inside vertex SETTINGS (reference/05 §0x0B): the protocol MUST
never mint a QoS or machinery knob named app, and implementations MUST NOT
accept settings.app as a protocol knob. Everything below settings.app. is
owner-defined: names, nesting (nested SETTINGS per §0x0B’s existing
module-namespacing shape), and value bytes are the application’s, opaque to
the runtime.
Why this spelling and not a sibling container (:app.*) or bare names
(:count):
Bare app names can never be collision-proofed. Any future protocol field name collides forever with somebody’s app field; tooling would need a registry to tell machinery from settings. (This is the same reasoning that led the driving consumer to a container — fw ADR-0076, “Considered options”.)
A flat share of
settings.*is collision-prone too. The protocol already owns names inside the settings container (the seven implemented knobs, and RFC-0008 §C planssettings.delivery_modewire config there). An app knob nameddelivery_mode— a perfectly plausible controller parameter — would collide. One reserved subkey ends the race: the protocol keeps minting flat knob names forever; applications only ever mint below.app.; one reservation, collision-proof both ways.It matches the namespacing precedent already in print. Reference/02 §Module-namespaced extension fields and reference/05 §
0x0Balready nest a module’s fields under the module’s NAME insideSETTINGS(NAME "transport_tcp" SETTINGS{…}). The application is one more namespace owner;appis its name. No new grammar.One config container keeps one traversal. A generic settings-panel renderer (the fw ADR-0076 use case; HA discovery) reads one property tree —
read :settings— and gets protocol knobs and app config in a single record, distinguished by the reserved subkey. A sibling:appcontainer would split configuration across two reads and two schema sections, and would spend a new top-level protocol bare name (the scarce resource) to do it.
The alternative (:app.* as its own container, giving apps a shorter
spelling and a container-level ACL story of its own) is recorded in
§Alternatives and flagged in §Discussion — including the consequence for the
driving consumer’s spelling (fw ADR-0076 wrote :settings.count; under this
RFC that is :settings.app.count — a one-token rename fw has ruled
acceptable at cutover time, before any name freezes).
A.2 Declaration is owner-initiated and local — the RFC-0009 §A.1 doctrine¶
Declaration of application fields is a local, owner-facing host API — the mirror of
register_vertex, exactly as RFC-0009 §A.1 makes removal owner-facing: the graph is a projection of device state, and what fields a vertex has is device state. There is no wire operation that declares a field; a remote peer can write declared fields (§A.3), never invent them.Reference shape (normative for the reference, informative for others): the owner installs, per vertex, a field descriptor table — an ordered set of entries
{ name (below settings.app.), access, descriptor-bytes, initial-value? }whereaccess ∈ { ro, rw, wo }is the owner-declared remote-writability and the descriptor bytes are the §B schema record for the field. Installation happens at any time the owner chooses (typically registration time); replacing the table is allowed and takes effect atomically with respect to concurrent field operations on that vertex.Undeclared stays
ENOTTY. A:fieldwrite or read naming anything undersettings.app.that the table does not declare returnsSCHEMA_NOT_FOUND— as does every non-protocol name outsidesettings.app.(unchanged). The closed set stays closed by default; the descriptor table is the owner naming the holes. This preserves ADR-0021’sENOTTYposture and keepslazy validationcheap: validation of an app-field write is one table lookup, not a schema parse.Erratum (2026-08-12), #435 — see §Erratum at the end of this document. This clause’s
SCHEMA_NOT_FOUNDanswers, for names undersettings.app., govern the owner and callers the vertex ACL admits. A caller the ACL denies the operation’s right receivesERROR{tr::access::denied}before any name undersettings.app.is resolved, so a protected vertex never discloses owner-field existence through the error channel.
A.3 Write semantics and gating¶
Local (owner) writes to a declared field always succeed regardless of the declared
access— the owner is updating its own projection (ro/woconstrain remote callers; the owner is not a caller).Remote writes (a
FWD{WRITE}with a:settings.app.…field tail arriving at the terminus, or any caller-attributed field write) are admitted iff both gates pass, in this order:the field is declared with
access = rworwo— otherwiseSCHEMA_NOT_FOUND(a field the schema does not declare writable has no write surface: theENOTTYof writing a read-only ioctl; deliberately caller-independent — see §Discussion 2);the caller holds the ordinary WRITE right on the vertex — otherwise
tr::access::denied, like any field write (control-plane writes are ACL-gated; nothing new here). Owners that want no remote app-field writes at all simply declare everythingro— remote writability is owner-declared, exactly as the mission of ADR-0021 rule 3 intends (“the device owns the catalog of what each field accepts”).
Erratum (2026-08-12), #435 — see §Erratum at the end of this document. The numbered order above is corrected: for a caller-attributed write the ordinary WRITE right (item 2) is evaluated first, and the declared-writability check (item 1) applies to callers that pass it. Both checks remain; only their order changes. As written, the pre-gate
SCHEMA_NOT_FOUNDdisclosed which owner names exist (undeclared vs declared-rovs gated) to callers the ACL denies.The written value is stored verbatim (bytes in, bytes out — §D). The runtime performs no dtype/range validation against the descriptor: the descriptor is self-description for consumers, opaque to the runtime (§B); semantic validation is the owner’s, in the §C apply step. (An implementation MAY offer opt-in shape checks as a host convenience; they are not conformance surface.)
Field writes to a nonexistent vertex do not create it — the existing rule (
:fieldcontrol writes keeptr::path::not_found; write-creates is a data-plane behavior) is unchanged and applies to app fields.Writes to a declared field notify the owner through a host seam (an optional per-vertex handler, the reference’s
handlers_tpattern): the owner learns “field F was written” and applies the configuration to the device — possibly restructuring children (the fw LED-stripcountexample), and then announcing (§C). A vertex with no handler simply stores the bytes (a passive metadata field — e.g. a label a UI writes and other UIs read).
A.4 Read semantics¶
read <v>:settings.app.<name>of a declared field serves the stored TLV bytes verbatim (refcount view, like:subscribers[N]), gated by the vertex READ right like every control-surface read. A declared-wofield (secrets: the fw HA password, WireGuard private key) returnsSCHEMA_NOT_FOUNDon read — write-only means no read surface, so a secret is never mirrored back.read <v>:settings.appserves the whole app container as oneSETTINGS-shaped record (declared, non-wofields with their current values).read <v>:settingsserves the full settings container: the protocol knobs and the nestedapprecord — the one-traversal property tree the generic renderer walks. (Bare:settingsand:settings.appcontainer reads are new read surfaces this RFC adds; the per-knob protocol reads they subsume are today unimplemented anyway.)A declared field that has never been written and carries no initial value reads as
NOT_FOUND(declared but empty — distinct fromSCHEMA_NOT_FOUND, undeclared), and is omitted from container reads.Amendment 4 (2026-10-06), #1878 — see §Amendment 4 at the end of this document. A named read serves the stored bytes or, when the owner answers the read itself, the owner’s current value; the container reads agree with the named read. No frame shape or status code changes.
B. Owner-defined :schema¶
B.1 The owner installs a schema record¶
Through the same local API (the descriptor table §A.2 — the schema is the
table’s projection), the owner provides, per vertex, a schema record
describing its application surface: one descriptor per declared field. The
descriptor is a structured TLV, opaque to the runtime (stored and served
verbatim, like :acl bytes); its vocabulary is a SHOULD-level convention so
generic consumers (settings renderers, HA discovery walkers) converge:
NAME <field-name> SETTINGS (PL=1) {
; NOTE: `access` is NOT here — the runtime projects it (see below).
NAME "dtype" VALUE <type tag> ; SHOULD — value shape (bool/i32/u32/f32/utf8/rgb/…)
NAME "unit" NAME <unit> ; MAY — display unit
NAME "min" VALUE … NAME "max" VALUE … ; MAY — advisory range
NAME "label" NAME <human label> ; MAY
… ; owner-defined extras — opaque
}
The owner MUST NOT supply an access member; the runtime projects it. The
served record leads with a runtime-projected access — read from the
descriptor table (§A.2), not from these bytes — and the owner’s descriptor
follows verbatim. access is the one member the runtime holds natively, which
is exactly why it owns the spelling: a projected member cannot contradict the
write gate, whereas an owner-supplied one could claim rw on a field the
table declares ro. An owner that supplies access anyway does not override
anything — it emits a duplicate member, and a consumer reading NAME-keyed
members would see two.
This is normative already: docs/reference/05
§0x07 fixes the record as NAME "access" VALUE <"ro"|"rw"|"wo"> — “runtime-projected
from the table — the one member the runtime owns (it cannot be lied about)” —
followed by <owner descriptor bytes, verbatim>, and read_schema emits exactly
that. An earlier draft of this section listed access inside the owner’s
descriptor with “MUST match the declared access (§A.2)”, contradicting both; the
projection is the design that survived contact with the compiler, and this RFC
now says so.
The runtime enforces none of the rest: everything but access is
self-description. This is the “lazy validation” of the backlog cluster: the
runtime validates addressing (declared or not) eagerly and cheaply, and
leaves shape validation to the consumers and the owner, who own the
vocabulary.
B.2 read :schema — merge shape and precedence¶
read <v>:schema serves one POINT containing both parts:
POINT (PL=1) {
NAME <vertex name> ; as today
SETTINGS <protocol part> ; synthesized by the runtime — the protocol
; fields/knobs this vertex actually serves
NAME "app" SETTINGS (PL=1) { ; owner part — present iff a descriptor
<field descriptors, §B.1> ; table is installed; served VERBATIM
}
}
The protocol part is synthesized and authoritative for protocol fields: the runtime, not the owner, says which machinery fields exist (
:subscribers[],:acl,:children[], the implementedsettings.*knobs). The owner part MUST NOT be consulted for them; an owner cannot lie about — or hide — protocol machinery.Erratum (2026-08-01), RFC-0022 §3.B/§4: “the implemented
settings.*knobs” now enumerates nothing. RFC-0022 deletedsettings_toutright:reliability,priorityanddurabilitymoved to the subscription’s packed delivery policy (they describe one producer→subscriber relationship, not a vertex);deadline_nsandqueue_max_byteswere deleted as inert and without a coherent per-vertex meaning; andhistory_keep_lastandstore_ref_min_bytesbecame owner-side declarations with no wire surface at all (they are construction parameters, not QoS). §Motivation item 1 below still lists the pre-RFC-0022 set of seven; read it as the historical record it is.The clause above is unchanged in force — the runtime is still authoritative for the protocol part — and its enumeration is now empty and therefore complete, which is the condition #706 was filed about and RFC-0022 dissolved rather than fixed. §A.4’s
:settingscontainer is likewise unchanged in shape and emptied of knobs: it readsSETTINGS{ [NAME "app" SETTINGS{…}] }, and an emptySETTINGS{}when the vertex declares no app fields.The owner part is authoritative for
settings.app.*and is served verbatim (no runtime merge into it — precedence by position, zero merge logic on an MCU: the read is two stored/synthesized byte runs concatenated inside one POINT).A name collision cannot occur by construction: the two parts describe disjoint namespaces (flat protocol knobs vs the reserved
appsubtree) — which is the quiet payoff of the §A.1 namespace decision.This makes reference/02’s discipline — “a vertex exposes a schema describing every writable field”, “module fields MUST appear in the vertex’s
:schemaoutput” — implementable for the application namespace: the fw HA walker reads:schema, finds the app part, and builds discovery from it; the fw constructor reads a controller’s:schemaand renders its parameter panel with no compiled-in layout knowledge (killing theSTALE_CLIENTclass — fw checklist row 25).
C. Field-change notification — the announce-write convention (restated)¶
Unchanged and now normative in one place (this restates the 2026-07-08 backlog ruling / fw ADR-0076 decision 1; it changes nothing):
A field write — protocol or app, local or remote — does NOT wake
awaiton the vertex, does not advance the vertex’s write sequence, and does not propagate along subscription edges. The property plane is silent by design;awaiton a single field is deliberately unsupported.A property change that consumers should notice is followed by an ordinary announce write at the vertex — the owner performs a data-plane
assign+propagate(RFC-0008) of the vertex once the change is applied. Subscribers (which observe the vertex, and via RFC-0005 bubbling its ancestors’ subscribers too) receive one ordinary delivery and re-read the small property tree if they care which knob moved. Notification is at vertex granularity, by the owner’s act, on the owner’s cadence.For a remote app-field write, the announce is the owner’s responsibility, in its §A.3 apply handler — after the device state actually changed (which may include restructuring children; the structural change is announced the same way). The graph never announces on the writer’s behalf: an announce is a statement that the device applied the change, and only the owner can truthfully make it.
Consumers MUST NOT poll fields for change detection (fw cutover risk register: “UI must not poll”) and MUST NOT expect per-field wakeups. A config datum that genuinely needs independent subscribers is promoted to a child vertex — the ratified field-promotion doctrine (CONTEXT.md §Field promotion), which this RFC leaves exactly as is.
D. Storage: field bytes ride the vertex¶
The memory model, stated normatively so the MCU cost is explicit:
A declared field’s value is a bare TLV stored inside the vertex: no subscriber list, no ACL slot of its own, no LKV/refcount machinery, no vertex-map entry, no path-key allocation. Cost = the value’s bytes + one descriptor-table slot (name + access + descriptor bytes). The descriptor table itself is per-vertex bytes, installable from ROM/flash-resident statics on constrained profiles (an implementation SHOULD allow the descriptor bytes to be non-owning/static, since they are typically compile-time constants).
Contrast, measured: the consumer’s interim — a
/metachild vertex per endpoint carrying the descriptor record — costs ≈ 900 B of heap per vertex on the ESP32-C6 (fw estimates §7, 2026-07-09: the ~100-endpoint mirror exhausted the heap and the boot died; a 20-vertex subset boots with 75 KB free). A descriptor that would ride the vertex as ~40–120 bytes of TLV pays ~10× that as a vertex, all of it machinery the descriptor never uses. The property plane exists precisely so that only subscribable things are vertices (fw ADR-0076 decision 1); this RFC is what lets a consumer actually obey that rule.Reference-implementation seam (informative): app-field storage and the descriptor table slot behind the
vertex_tverb interface (#338) as one more verb pair — the exactset_acl(raw, parsed)/acl_bytes()pattern: bytes stored under the vertex mutex, served back verbatim, in-flight reads keep refcounted views.graph_t::field_write’s closed-set tail (return SCHEMA_NOT_FOUND) gains one descriptor-table lookup for thesettings.app.prefix;read_schemagains the verbatim owner-part append. No cross-vertex orchestration is touched — which is precisely what the #338 seam was cut for.Persistence is out of scope: fields persist exactly as the owner persists them (the fw TLV-replay journal is a consumer choice). The graph holds bytes; it does not own durability.
Files this RFC edits (on acceptance)¶
docs/reference/02-graph-model.md— §Schema and field discipline: the core writable-field table gains the:settings.app.*row (owner-declared, descriptor-gated); §Module-namespaced extension fields gains theappreservation; the announce-write convention is recorded beside the stale/invalid discipline; §The graph imposes no shape cross-references the descriptor table.docs/reference/05-protocol-tlvs.md— §0x0BSETTINGS:appbecomes a reserved key (owner-defined subtree, opaque values); §0x07POINT /:schema: the two-part read shape (§B.2).CONTEXT.md— glossary entries: application field / field descriptor table (owner-declared, local-API-only declaration,ENOTTYdefault), announce write (the notification convention, promoted from consumer lore to spec vocabulary); §Field promotion gains the “app field vs promoted vertex” boundary sentence.core/reference implementation +core/CHANGELOG.md(the descriptor-table verbs onvertex_t, thefield_writetail lookup,read_schemaowner-part append, container reads for:settings/:settings.app) — follow this RFC, not normative.
Conformance-vector sketches (what would prove it)¶
New vectors under tests/conformance/vectors/v1/ (additive), plus host-API
tests where the surface is local:
app-field-declare-read-write— owner declaressettings.app.kp(rw, f32 descriptor) with an initial value: localread :settings.app.kpserves it; a remoteFWD{READ}of the same field serves identical bytes; a remoteFWD{WRITE}(caller holding WRITE) stores; both sides read back the new bytes verbatim.app-field-undeclared-enotty— on the same vertex: write and read ofsettings.app.undeclared, of a bare unknown field (:frobnicate), and of an unknown protocol knob (settings.nope) all returnSCHEMA_NOT_FOUND— before and after the descriptor table is installed (the table opens only its own names).app-field-gating— declaredrofield: remote write ⇒SCHEMA_NOT_FOUND; local owner write succeeds. Declaredrwfield, caller without WRITE ⇒tr::access::denied. Declaredwofield: remote write (with WRITE) succeeds; read ⇒SCHEMA_NOT_FOUND(the secret never mirrors back).app-schema-merge— vertex with an installed table:read :schemareturns one POINT holding the synthesized protocol SETTINGS andNAME "app" SETTINGS{…}with the owner descriptors byte-verbatim; without a table, the POINT has noappmember (today’s shape — no existing vector changes).app-field-announce-flow— subtree subscriber above the vertex; remote app-field write: no delivery occurs and noawaitwakes from the field write itself; the owner’s apply handler fires, owner performs the announce write; the subscriber receives exactly ONE ordinary delivery (the announce), after which a re-read of:settings.appshows the new value.Host: storage cost — declaring N fields on a vertex adds no vertices to the map, no subscriber slots, no edges (
ancestor_walks()/ edge-count observables flat); memory delta ≈ descriptor + value bytes (informative assertion, the §D contrast with the ≈ 900 B/vertex child workaround).Host: container reads —
read :settingsreturns protocol knobs + nestedapprecord in one SETTINGS;read :settings.appreturns the app record alone; a never-written declared field is omitted and readsNOT_FOUNDindividually.
Compatibility¶
No existing conformance vector changes. Every behavior this RFC defines occupies previously-erroring space (
SCHEMA_NOT_FOUNDon all of it); a vertex whose owner installs no descriptor table is byte-for-byte today’s vertex, including its synthesized:schemashape. No new wire verbs, type codes, or error identities.Wire: the
appkey reservation insideSETTINGSconstrains future protocol minting, not any existing frame; the two-part:schemaPOINT is additive (consumers ignoring unknown members per the unknown-key rule are unaffected).Host API: additive (the descriptor-table install verbs, the container reads). No existing caller changes.
The driving consumer migrates by deleting: the
/metachild-vertex workaround and its walker filtering go away; fw ADR-0076’s:settings.*spellings gain the.appsegment (:settings.count→:settings.app.count) — a rename fw takes at cutover, before any name freezes (fw checklist §naming already batches cheap renames there).
Alternatives considered¶
Bare app field names (
:count) on the property plane. Rejected — never collision-proofable against future protocol field names; the consumer’s own ADR rejected it for the same reason (fw ADR-0076, considered options).Flat sharing of
settings.*(the fw ADR-0076 spelling as written). Rejected — the protocol already owns flat names inside the container and plans more (RFC-0008’ssettings.delivery_modewire config); every future knob is a potential app collision. One reserved subkey (app) is the same decision, collision-proofed; the cost is one extra path token.A sibling top-level container (
:app.*). Workable and cleanly separated, but it spends a new top-level protocol bare name, splits the config surface across two container reads and two schema homes (a generic renderer walks both), and duplicates the gating storysettingsalready has. Kept as the recorded alternative (§Discussion 1) — it is the right answer only if the maintainer wants app config visually segregated from QoS at the path level.A wire-level field-declaration operation (remote peers defining fields). Rejected — the RFC-0009 §A.1 doctrine: the graph is a projection of device state, and the field catalog is device state; a peer manipulating the projection must go through the device’s own logic. Remote peers write declared fields; only the owner declares.
Store descriptor records in child vertices (the fw interim, as the permanent model). Rejected — measured ≈ 900 B/vertex on the target MCU for bytes-worth of static description; pays subscriber/ACL/LKV machinery a descriptor never uses; and doubles the enumeration surface every walker must filter. It is a fine consumer-side interim precisely because it erases cleanly when this lands (same pattern as RFC-0009’s tombstone interim).
Runtime-enforced dtype/range validation of app-field writes. Rejected — the runtime would be parsing application vocabulary it does not own (the ADR-0053/RFC-0008 layer argument: the runtime never interprets application bytes); the owner’s apply step is the semantic gate. Addressing validation (declared / undeclared, writability) stays in the runtime — one table lookup, the cheap “lazy validation” split.
An open container (any name under
settings.app.writable without declaration). Rejected as the default — it surrenders theENOTTYposture, turns typos into silent state, and leaves:schemaunable to describe the surface (the descriptor table IS the schema). An owner that wants open-world behavior can declare a single structured field and version its own bytes. (Flagged in §Discussion 3 in case a consumer surfaces a genuine need.)Per-field
await/subscription. Rejected — re-litigates the settled vertex-granularity decision (fw ADR-0076 decision 1; upstream 2026-07-08 ruling: field-notification RFC dissolved). Field promotion covers the rare datum that truly needs its own subscribers.
Discussion¶
Genuinely contentious points, flagged for the maintainer:
settings.app.*vs a sibling:app.*container (§A.1). The RFC picks the shared container with one reserved subkey — namespacing precedent, one-traversal renderer, no new top-level name. The sibling container is defensible on separation grounds (app config never visually mingles with QoS; container-level ACL knobs could differ). Note the consumer-spelling consequence either way: fw ADR-0076’s flat:settings.countbecomes:settings.app.count(chosen form) or:app.count(alternative) — fw has said renames are cheap until cutover freezes names, so this decision should not outlive that window.The read-only-write error identity (§A.3): a remote write to a declared-
rofield returnsSCHEMA_NOT_FOUND(no write surface exists — caller-independent, likeENOTTY), nottr::access::denied(which would suggest a different caller might succeed). The opposite reading — “the field exists, you may not write it” — is also coherent; whichever is chosen should also cover thewo-read case symmetrically.Strictness of declaration: is the declared-fields-only rule (§A.2) ever too rigid — e.g. a gateway materializing fields learned from a downstream device at runtime? The owner can re-install the table dynamically (declaration is not one-shot), which seems sufficient, but if a consumer surfaces a real open-container need, the
accessvocabulary could grow a container-level entry rather than abandoningENOTTY.How much descriptor vocabulary to freeze (§B.1): the RFC makes only
accessnormative and the rest (dtype/unit/min/max/label) SHOULD-level convention. Freezing more buys cross-vendor renderer portability at the cost of the runtime owning application vocabulary; the HA-walker and generic-renderer consumers should weigh in before v1 freezes.The unimplemented core-table fields. reference/02’s table also lists
:descriptionand:liveness.*, which the reference implementation does not serve either. This RFC deliberately does not adopt them —:descriptionin particular could become a conventional app field (settings.app.label) instead of core surface; the 02 table should be reconciled (trimmed or implemented) when this RFC’s edits land, so the spec stops listing fields nothing serves.Audited 2026-07-17 — the debt is exactly four fields, all phantom:
reference/02rowimplementation
:liveness.heartbeat_hznone
:liveness.last_seen_nsnone
:liveness.missed_deadlinesnone
:descriptionnone
Nothing anywhere serves a
:livenessfield; the onlylivenessincore/is transport-internal (the QUIC idle timeout, the ADR-0044 CAN peer TTL) and is unrelated to this surface. The whole:liveness.*family is a design that was never built, not a partial one — there is no heartbeat plane to report on. Notedeadline_nsexists as a knob and its 02 row says “max time between writes before liveness fault”, but no code raises that fault either, so the knob is stored and served and otherwise inert.Because reference/02 is normative by incorporation (ADR-0007), trimming these rows is a spec change and cannot be a drive-by docs fix — which is precisely why it belongs to this RFC’s edit list rather than to a bug.
Recommendation: trim all four, adopt none.
:liveness.*presupposes a runtime that polices timing, which is the posture RFC-0007/ADR-0051 deliberately removed (“analyzers police designs, not the runtime”); building it now would re-mint the thing that was just deleted.:descriptionis pure self-description with no runtime role, which is exactly whatsettings.app.is for — this RFC’s own substrate — so it costs nothing to drop and can be spelledsettings.app.labeltoday by any owner who wants it. If a consumer later needs a liveness plane, it should arrive as its own RFC with a design, not be inherited from a table row.The maintainer rules; this is not the author’s to decide — trimming normative rows is a spec change with a live comment window.
Per GOVERNANCE.md, the tracking issue stays open at least 14 days for implementer feedback before this document is merged (unless the standing solo-maintainer waiver is applied, as on RFC-0002/0005/0008). Sustained objections and their resolution to be recorded here.
Erratum (2026-08-12) — §A’s SCHEMA_NOT_FOUND answers govern callers the ACL admits; a denied caller learns nothing about owner names (#435)¶
What the text said. §A.2’s “undeclared names remain SCHEMA_NOT_FOUND” (restated
caller-unqualified in reference/05 §0x0B:
“undeclared names return ERROR{tr::schema::not_found} on read and write”), and §A.3’s
numbered gate order — declared-writability (SCHEMA_NOT_FOUND) before the caller’s
WRITE right (tr::access::denied).
What was wrong. Read literally, the error channel disclosed the owner’s field-name
set to callers the vertex ACL denies. The reference write door answered a denied caller
SCHEMA_NOT_FOUND for an undeclared or declared-ro settings.app. name before any
ACL evaluation, so probing spellings distinguished undeclared / ro / writable — while
the read door’s READ gate sat above resolution and answered the same caller
PERMISSION_DENIED for the same spelling: one name, two answers, split by path and by
caller. The write-side pre-gate resolution arrived with the
#430 hoist, whose justification —
“knob names are a fixed, published constant of the protocol, not a per-node secret” —
is exactly the property owner-defined names do not have. And
reference/05 §Gating :identity already names the
caller-dependent error split as the failure mode to avoid: “a denied caller gets
PERMISSION_DENIED where an allowed caller gets SCHEMA_NOT_FOUND — which leaks the
caller’s authorization state through an error code.”
The correction — namespace-governed disclosure, applied identically on read and write. Whether a name’s existence may be answered before the ACL gate is governed by whether the name set is a published protocol constant or an owner secret; the gate always protects the value:
namespace |
name set |
order (name-validity), read and write |
denied caller, nonexistent name |
denied caller, existent facet |
|---|---|---|---|---|
protocol-owned (the |
published by spec, identical on every node |
resolve-before-gate |
|
|
owner-defined ( |
owner-declared, per-node |
gate-before-resolve |
|
|
Concretely:
§A.2’s and §A.3’s
SCHEMA_NOT_FOUNDanswers forsettings.app.names (undeclared; declared-rowrite; §A.4’sworead) are answers to the owner and to callers the ACL admits the operation’s right — among those they remain exactly as specified, and caller-independent. A caller the ACL denies receivesERROR{tr::access::denied}before anysettings.app.name is resolved: for a denied caller the answer is uniform over declared, undeclared,roandwo, so the error channel discloses neither the owner’s names nor which spellings exist.§A.3’s numbered order is corrected accordingly (see the inline erratum note there): the WRITE-right gate is evaluated first for caller-attributed writes; both checks remain.
The protocol-owned namespace resolves name-validity above the gate on both doors — the write door always did; the read door is aligned by this erratum (a denied caller’s read of
:status, of bare:subscribers, or of a withdrawn:settings.<knob>answersERROR{tr::schema::not_found}, as its write already did). This is name-validity only, never the value: a denied caller against any existent facet (:schema,:settings,:acl, a declared app field, …) still receivesERROR{tr::access::denied}, the pinned selector-shape divergences (#869) are untouched, and:identityremains the sole facet that resolves fully above the gate (RFC-0011 §C).
Instrument: erratum, not amendment (GOVERNANCE.md).
The immutable spec pins none of these codes: which of the two errors a denied caller
sees lives in this RFC and reference/05, not in spec/v1.md. No grammar, frame shape,
type code or error identity changes — both codes are long in the RFC-0002 registry — and
the correction aligns the text (and the two reference-core doors) with the disclosure
rule the spec already commits to at :identity and with #430’s own scoping. Maintainer
ruling in #435 (grilled
2026-08-06; instrument confirmed 2026-08-12).
Record. The governing principle is to be recorded as an ADR whose number settles
after the #894/#897
reclamation ADR is written; until then this erratum and its conformance vector are the
record. Conformance: tests/conformance/vectors/v1/acl/denied-caller-undeclared-app-field
(the denied-caller reply, byte-exact), bound behaviourally by
core/tests/acl_test.cpp — test_denied_caller_disclosure_parity (both doors, both
namespaces, plus the existent-surface controls). Text corrected alongside:
reference/05 §0x0B (the caller qualifier) and
reference/02 §Owner-declared application fields
(the gate order).
Amendment 1 (2026-08-22) — the node-scoped seam census: a reserved :stats field namespace (#1503 step 5)¶
Status: accepted (maintainer ruling 2026-08-22; the 14-day window is waived per GOVERNANCE.md, as on RFC-0002/0005/0008/0011).
Why here. This RFC owns the reserved field namespaces — :settings.app.* and the app
subkey (§A.1), and the disclosure rule for the protocol-owned name set (§Erratum). A new
reserved field name is its business. RFC-0011 §C.1 is cited
as shape precedent only — the node-scoped field that “takes no vertex, and every vertex
answers identically” — and is not otherwise amended.
Motivation. After RFC-0004 Amendment 2 a
bulk-ingest producer may write with an empty src and thereby give up refusal-by-value
feedback. The sanctioned way such a producer observes downstream pressure is to poll
ordinary READs of introspection counters — which requires the
#1503 seam census to be
wire-readable rather than a C++ accessor. Steps 1–4 of that issue built the counters; this
amendment gives them their read surface.
D.1 The spelling — a node-scoped :stats field, in the :identity mould¶
A reserved field name stats joins the protocol-owned field namespace, which becomes
{subscribers, acl, children, settings, schema, identity, stats}. One seam is addressed as
<any-vertex>:stats.<seam-class>.<seam-name>
The field is node-scoped: it takes no vertex, every vertex of a node MUST answer it identically, and the content describes the node, never the addressed vertex. That is RFC-0011 §C.1’s property, applied to a second field.
Nothing else moves. There are no new vertices, no new type codes, no new error
identities, and no grammar change: :stats is a NAME in the existing :field.sub
tail, so reference/03 §Reserved characters is
untouched — a leading : remains illegal inside a NAME segment, and the
path/path-reserved-brackets conformance vector stands.
This clause SUBSTITUTES clause 2 of the 2026-08-22 scope-addition ruling (“introspection fields are ordinary vertices with their own (tiny) bounded lists”), which is withdrawn: it is not expressible in the shipped path grammar without changing §Reserved characters, and the property it was written to secure is delivered here by construction instead. The normative isolation sentence is:
The read targets whatever vertex the peer already addressed, and is bounded by that vertex’s existing list.
A monitoring READ therefore contends for exactly the resources of the vertex the monitor chose, and a flooded STREAM vertex cannot make a read of some quiet vertex queue behind it — per-vertex designated pressure, unchanged, with no new namespace to explode and no control-plane reserve clause.
The honest caveat, unchanged from the ruling: a physically saturated shared link (#1494 TX-pool territory) delays every frame including a monitoring read. That is precisely when read-timeout-as-hard- congestion-signal semantics apply, and the link-seam counters are how the two cases are distinguished afterwards.
D.2 NAME validity resolves ABOVE the READ gate¶
The stats namespace is published spec text, identical on every node — the
protocol-owned row of this RFC’s §Erratum. Accordingly, and on both doors:
An unrecognised
:statsspelling — bare:stats, a bare:stats.<class>, an unknown class or seam name, any[N]/[]/[*]selector at any level, or any tail deeper than<class>.<name>— MUST answerERROR{tr::schema::not_found}(0x0031) caller-independently. No:statsspelling may have two answers split by who asked.A recognised seam’s VALUE keeps its gate (§D.5).
Bare :stats and :stats.<class> name nothing by design: a census block is served
whole, and aggregating seams into a container is exactly what
ADR-0079 forbids.
D.3 One READ = one seam = the whole counter block in ONE TLV¶
A recognised seam read serves a single structured SETTINGS TLV whose members are the
seam’s counters as NAME / VALUE pairs, sampled in one call:
SETTINGS (PL=1) {
NAME "<noun>" VALUE <u64> ; one pair per counter, fixed-width u64 little-endian
... ; readers MUST ignore unknown NAMEs
}
Normative rules:
One call, one block. The counters MUST be sampled in the single call that answers the read. Snapshot coherence (
core/STYLE.md§Introspection — monotonic since construction, sampled unsynchronized, read as the DIFFERENCE between two snapshots) is unachievable across six separate field reads, which is why per-counter fields are a named door and not this surface.The vocabulary is
core/STYLE.md§Introspection’s —capacity/in_use/peak/refused/dropped/largest_refused, used-polarity always. A seam names only the nouns it has.Fixed-width
u64little-endian values, per the reference/05 integer conventions. A reader MUST ignore unknownNAMEs and MUST NOT depend on the member count or on a seam’s member set staying fixed: a seam may grow a noun.Members appear in the seam’s declared order, so a block is a stable byte shape for one seam at one version; that order is not a wire guarantee across versions, and a reader keys on the
NAME.
D.4 The seams this field answers — and the boundary, stated honestly¶
The field is node-scoped, and the entity that answers it is the graph (tr::graph, L4).
It therefore serves exactly the seams reachable from the node’s own graph:
spelling |
the seam |
nouns |
|---|---|---|
|
the injected failable block source ( |
|
|
the graph-level default receiver-ring source ( |
the same five |
|
the graph’s own delivery-drop door ( |
|
The boundary, and why it is where it is. The router seams (fwd_router_t::drop_stats
and its four injected sources) and the label-table and per-link seams
(route_handle_t::labels_used / labels_exhausted, transport_t::drop_stats) — all shipped
by #1503 steps 3 and 4 — are NOT served by :stats in this amendment. Those counters are
per-fwd_router_t, per-route_handle_t and per-link, not per-graph: a node may run more
than one router, a router may be shared, and the graph is L4 while the router is the net
plane. Dependencies point up the layers only, so the graph cannot reach down to sample
them, and inventing a registration channel so it could is a larger surface than this
amendment was scoped to carry.
Concretely: the entity that answers :stats is the graph, and it answers for the graph.
Router and link seams stay C++ accessors on the entities that own them, exactly as
reference/22 §6 Recipe A’s in-process arm
uses them. Extending the census to the net plane — the router registering its own seam
samplers with the graph at configure time, the way it already installs its sinks — is a
named door, not built here, and is recorded as a residual on
#1503. A future amendment that opens
it adds <seam-class> values (router, labels, link) and changes nothing above.
D.5 The VALUE resolves BELOW the READ gate — the gating is INVERTED against :identity¶
A recognised seam’s value MUST be gated on READ against the addressed vertex’s effective
ACL. A denied caller receives ERROR{tr::access::denied} (0x0050).
This is the opposite of RFC-0011 §C.2’s exemption, deliberately. :identity is pre-auth
because a public key is precisely what an unauthenticated peer must obtain in order to
authenticate at all; a node’s memory census is not first-contact material, and nothing
deadlocks by gating it. The narrow, named pre-auth exemption therefore continues to apply to
:identity alone, and :stats MUST NOT be added to it.
A denied caller’s PERMISSION_DENIED discloses that the seam exists. That is intended.
The seam namespace is published spec (this section’s table), identical on every node — the
same disclosure settings, children and schema already make — so the answer reveals
nothing a reader of this document does not have. What it must not do is reveal authorization
state through a code split on one spelling, and §D.2 is what prevents that: unrecognised
spellings are caller-independent, so the only caller-dependent answer is on a name whose
existence was never secret.
D.6 Readable; never writable; never awaitable¶
Never writable.
:statshas no write door. A write of any:statsspelling answersERROR{tr::schema::not_found}(0x0031), caller-independently — the ENOTTY of a facet with no write surface, exactly as:identity’s write already answers.Never awaitable, and this mints nothing. Counters are updated off the failure path and do not bump the vertex’s write sequence; making them real writes would put a publish on the hot path, which the
core/STYLE.md§Introspection counting doctrine forbids. Nothing could ever fire a wait on this surface. No new status and no new guard are needed: every field-tailed AWAIT already answersERROR{tr::schema::not_found}unconditionally — the #585 rule this RFC states in §C (”awaiton a single field is deliberately unsupported”), which holds for every selector including ones that read and write fine. This amendment cites that rule; it does not restate or narrow it.Threshold-crossing notification stays a named door, not built, exactly as ruled.
Per-counter fields stay a named door, for MCU clients that cannot parse a block.
Compatibility¶
Additive, and it occupies previously-erroring space only: before this amendment every
:stats spelling answered SCHEMA_NOT_FOUND on both doors, and after it every spelling
except the three recognised seams still does. No frame shape, type code, grammar rule or
error identity changes; both status codes it uses are long in the
RFC-0002 registry. An implementation that does not serve the
census keeps answering SCHEMA_NOT_FOUND and remains conformant for every other surface; a
monitor MUST therefore treat SCHEMA_NOT_FOUND on a seam as “this node does not publish that
seam”, never as an error.
Files this amendment edits (on acceptance)¶
reference/05 — the field-namespace set (twice), the new §
0x0Bcensus-record subsection, and a failure-mode entry beside the:identityone.reference/22 §6 Recipe A — the deferred wire arm, completed with the ruled spelling (its gate note is removed).
Reference core: the
:statsread arm ingraph_t’s field-read door.
Conformance¶
tests/conformance/vectors/v1/settings/stats-seam-block — the graph-door block, byte-exact,
with the other three answers named against the byte-identical vectors already held
(acl/denied-caller-undeclared-app-field for the denied caller;
settings/removed-knob for an unrecognised spelling and for the AWAIT refusal). Bound
behaviourally by core/tests/stats_field_test.cpp (shape, node scoping, the gate inversion
with :identity as its control, every unrecognised spelling at both an admitted and a denied
caller, the read-only half) and by core/tests/op_resolve_test.cpp —
test_await_field_selector_is_enotty, which now drives a :stats selector beside its
field-less control.
Amendment 2 (2026-08-23) — the net-plane seam sampler: router, labels and link join the census (#1503 residual)¶
Status: accepted (maintainer ruling 2026-08-22; the 14-day window is waived per GOVERNANCE.md, as on Amendment 1).
Scope. This amendment extends §D.4 only. §D.1, §D.2, §D.3, §D.5 and §D.6 are
untouched and govern the new classes verbatim: the field stays node-scoped (every vertex of
a node answers identically), NAME validity still resolves ABOVE the READ gate and the VALUE
below it, one READ is still one seam is one whole counter block in ONE SETTINGS TLV, and
the surface is still readable, never writable, never awaitable. No frame shape, type code,
grammar rule or error identity changes.
What it opens. Amendment 1 §D.4 named the door and declined to build it:
Extending the census to the net plane — the router registering its own seam samplers with the graph at configure time, the way it already installs its sinks — is a named door, not built here … A future amendment that opens it adds
<seam-class>values (router,labels,link) and changes nothing above.
This is that amendment, and it opens exactly that door in exactly that way.
D.4 (extended) — the net-plane seams¶
The boundary Amendment 1 drew was a dependency boundary: the graph is L4, the router is
the transport plane above it, dependencies point up the layers only, and so L4 cannot reach
DOWN to sample a router or a link. That argument is unchanged and is why the seams could not
simply be added to the graph’s switch.
What changes is the direction of the wiring, not the direction of the dependency. The
router already knows the graph — its constructor installs five {fn, ctx} pairs on it and on
its resolver (configure_remote_delivery_sink, on_reverse_ref, on_peer_subject,
on_link_id, configure_wire_target_resolver; ADR-0047’s captureless-thunk shape, published
through tr::sink_slot_t). The census sampler is the sixth instance of that same pattern:
the router registers a sampler UP into the graph at construction, the graph calls back
into it when a :stats read names a net-plane class, and L4 still names nothing below itself
— it holds one more {fn, ctx} word pair whose type mentions no transport concept at all.
The reserved seam classes and the nouns each publishes:
spelling |
the seam |
nouns |
|---|---|---|
|
this node’s |
|
|
the RFC-0027 label plane — the mint table’s refusals ( |
|
|
ONE registered transport child, by its |
|
<child> is an ordinary NAME step, so the whole spelling stays inside §D.1’s :field.sub
tail and nothing about the path grammar moves. A bare :stats.router, :stats.labels or
:stats.link names nothing, exactly as §D.2 already requires of every bare class.
Two nouns that are NOT here, and why. A link’s rx_capacity / tx_capacity ceilings are
per-kind and in per-kind units — bytes of buffer on the ESP WebSocket client link, TX-pool
slots on a CAN link — so transport_t has no unit-safe ceiling to publish at the interface,
and a single capacity noun carrying two units would be worse than none (core/STYLE.md
§Introspection reads capacity against in_use in the resource’s own unit). They stay
per-kind accessors (esp_ws_client_link_t::rx_capacity() / tx_capacity()), and §D.3’s “a
seam may grow a noun” is what admits them later without another amendment, once there is a
unit-safe interface seam to publish them through. labels_used IS published, because its unit
is labels on every kind alike.
D.4.1 — an unpublished seam answers SCHEMA_NOT_FOUND¶
A seam whose sampler is not registered — a node that constructed no router — and a
linksub-key that names no registered child, or one whose child has been removed, MUST answerERROR{tr::schema::not_found}(0x0031), caller-independently.
This mints nothing: §Compatibility of Amendment 1 already anticipated exactly this answer
(“:867–876” — “An implementation that does not serve the census keeps answering
SCHEMA_NOT_FOUND … a monitor MUST therefore treat SCHEMA_NOT_FOUND on a seam as ‘this
node does not publish that seam’, never as an error.”). The sentence above states which
node conditions produce it, so a monitor is not left to infer them.
Because the SAMPLER, not the spec text, decides whether a NAME within a net class is served,
that decision MUST be taken above the READ gate — §D.2’s caller-independence rule is
otherwise breakable in the new classes alone: an unserved :stats.router.nope would answer
SCHEMA_NOT_FOUND to an admitted caller and PERMISSION_DENIED to a denied one, one
spelling with two answers split by who asked. The reference implementation settles it with a
recognition probe — the same sampler call with no output buffer, which samples nothing
and is never handed the caller — and a conformant implementation MUST resolve net-plane NAME
validity caller-independently by whatever means.
D.4.2 — ownership and lifetime (restated, not minted)¶
Nothing new is required of an embedder. The two rules that govern this seam are the two that already govern the other five:
The router MUST outlive every dispatch the graph can still make (
graph.hpp:130) — the lifetime the heldgraph_t&reference and the remote-delivery sink already demand. The sampler is read only inside a:statsfield read, so the window is no wider than the existing sinks’, but it is not narrower either.Re-binding a graph to a second router is UNSUPPORTED (
fwd_router.hpp:266-267): “constructing a router against a graph that is ALREADY serving frames is unsupported, which is whatconfigure_in the verb’s name says.” The sampler slot is asink_slot_tlike the others, so a violation is DEFINED (a skipped dispatch, i.e.SCHEMA_NOT_FOUND) rather than undefined — but it is still a violation.
D.4.3 — the cost, stated¶
Sampling happens only inside the reference implementation’s already
[[gnu::noinline, gnu::cold]] stats_seam / read_stats path, which is reached only after
a field read has spelled stats. No hot path gains an instruction. The static cost is one
more tr::sink_slot_t on graph_t — three words (a u32 generation counter, the
function pointer, the context pointer) — carried by every node whether or not it has a
transport plane, which is the RAM-census delta this amendment spends.
Compatibility¶
Additive, and it occupies previously-erroring space only: before this amendment every
router / labels / link spelling answered SCHEMA_NOT_FOUND, and after it every one of
them still does on a node with no router. A node that publishes the seams is a superset; a
monitor written against Amendment 1 is unaffected, and one written against this amendment
degrades by construction, because §Compatibility already told it to read SCHEMA_NOT_FOUND
as “not published here”.
Files this amendment edits (on acceptance)¶
reference/05 §The seam census record — the seam table gains the three net-plane classes and the unpublished-seam rule.
reference/22 §6 Recipe A — the “what the wire arm does NOT reach” paragraph, which this amendment retires.
Reference core:
graph_t::configure_stats_sampler/graph_t::sample_statsand thestats_block_tcarrier;fwd_router_t::sample_statsand its constructor’s sixth install.
Conformance¶
tests/conformance/vectors/v1/settings/stats-seam-net-router — the router seam’s block,
byte-exact, with the unpublished-seam and denied-caller answers named against the
byte-identical vectors already held. Bound behaviourally by
core/tests/stats_net_seam_test.cpp: live sampling, the per-link identity of
:stats.link.<child>, the unregistered and removed sub-keys, the no-router positive
control, node scoping across the new classes, caller-independence of an unserved net NAME
with a served one as its control, and the read-only half.
Erratum (2026-08-23) — :stats IS in the field namespace; three reference pages still said it never would be (#1503)¶
Amendment 1 added stats to the protocol-owned field namespace, but three pages predating it
still carry the pre-amendment sentence — reference/19
(twice), reference/14 (the namespace set, the
“intended, not implemented” note and its failure-mode row) and
ADR-0027’s 2026-07-30 erratum
banner. Each is corrected to state what is true after Amendments 1 and 2: the node-scoped
census :stats.<class>.<name> is implemented and readable, while the per-connection
:stats facet ADR-0027’s worked example drew — a facet of the addressed vertex, whose
content differs per connection — remains not implemented and is what those pages were right
to warn against. Text-only, no wire surface moves, so this is an erratum and not a third
amendment.
Amendment 3 (2026-10-03) — three :stats.mem seam names for the graph’s sub-pools¶
Status: accepted (maintainer ruling 2026-10-03, Q17 of the one-allocation-seam grilling; the 14-day window is waived per GOVERNANCE.md, as on Amendments 1 and 2). The design it serves is ADR-0083.
Scope. This amendment extends §D.4’s table only, in the way Amendment 2 did. §D.1, §D.2, §D.3, §D.5 and §D.6 are untouched and govern the new names verbatim. The field stays node-scoped, NAME validity still resolves above the READ gate and the VALUE below it, one READ is still one seam is one whole counter block, and the surface is readable, never writable, never awaitable. No frame shape, type code, grammar rule or error identity changes. This is introspection naming only.
Why. Under ADR-0083 a graph takes one injected root source and derives per-purpose sub-pools from it for accounting and caps. Each sub-pool’s counters need a name a monitor can poll.
D.4 (extended) — the sub-pool seams¶
spelling |
the seam |
nouns |
|---|---|---|
|
the value sub-pool derived from the graph’s root (published values) |
|
|
the table sub-pool derived from the graph’s root (registration, child and label tables, core containers) |
the same five |
|
the net sub-pool derived from the graph’s root, the default for router and transport sources when none is injected |
the same five |
Erratum (2026-10-06), #1782 — see §Erratum (2026-10-06) at the end of this document. The
:stats.mem.valuesrow said “published values and LKV nodes”; “and LKV nodes” is struck.
:stats.mem.control and :stats.mem.ring keep their Amendment 1 meaning. A node that does
not derive a given sub-pool answers that name with SCHEMA_NOT_FOUND, and a monitor reads
that as “this node does not publish that seam”, exactly as Amendment 1 §Compatibility already
requires. Per-size-class detail is not in this surface.
Amendment 4 (2026-10-06) — a named app-field read serves the owner’s current value when the owner answers it (#1878)¶
Status: accepted (maintainer ruling 2026-10-06; the 14-day window is waived per GOVERNANCE.md, the solo-maintainer default, as on Amendments 1–3).
Scope. This amendment changes §A.4’s first and second bullets only. §A.1–§A.3, the §Erratum’s gate order, §B and §C are untouched. No frame shape, type code, grammar rule or error identity changes: a read answers the same TLV shape with the same status codes, and a node whose owner never answers a read behaves byte-for-byte as before.
Why. §A.4 said a named read “serves the stored TLV bytes verbatim”. An owner that keeps a field’s state itself — restored at startup, changed by another subsystem, or a client write it received and did not adopt — then had reads report the last stored write rather than the state the field actually holds. The owner is the authority for its own fields (§A.3); the read surface should be able to say what the owner says.
A.4 (amended) — read semantics¶
read <v>:settings.app.<name>of a declared, non-wofield serves either the stored TLV bytes verbatim or, when the owner has installed a local read hook and that hook answers, the owner’s current value as one TLV of the same shape a field write stores. The owner’s answer is not stored. A hook that declines leaves the read to the stored bytes, and toNOT_FOUNDwhen none are stored. Installing the hook is a local host-API act, like declaring the table (§A.2); no wire operation installs, removes or reveals it.The gates and their order are unchanged. The READ gate and the §Erratum’s order run first; an undeclared or
wofield answersSCHEMA_NOT_FOUNDwithout the owner’s hook being consulted, so awosecret is still never mirrored back and the hook is never an existence oracle.read <v>:settings.appandread <v>:settingslist, for each declared non-wofield, the same TLV the named read would serve at that moment: a field the owner answers is listed with the owner’s value, and a field neither stored nor answered is omitted, as before. The containers and the named read never disagree.An owner that cannot produce an answer for want of resources answers the read
BACKPRESSURE, the same transient status any read that cannot allocate gives.
Compatibility. Additive on the wire: no new type, field name, opt bit or status. A reader
cannot tell an owner-answered value from a stored one, and need not — both are the field’s
value as the owner holds it. Conformance vector §1 (app-field-declare-read-write) is
unchanged for a node whose owner installs no read hook; a node that installs one serves the
owner’s value on both the local and the remote read, which §1’s “identical bytes” property
still holds for.
Erratum (2026-10-06) — :stats.mem.values does not count LKV nodes (#1782)¶
Amendment 3’s §D.4 row for :stats.mem.values described the seam as “published values and LKV
nodes”. The LKV nodes it meant are hazard_slot_t’s indirection nodes, and the implementation
that moved them off the global heap (#1782)
draws them from a process-wide node class, not from the value sub-pool derived from a
graph’s root: the hazard domain they belong to is process-wide, and a node moves between the
threads (and so the graphs) that publish through it. No :stats.mem name reports that class.
The row is corrected to “published values” (see the inline note there). A dedicated counter for
the node class would be a new seam name, and so a future amendment, not part of this one.
Instrument: erratum, not amendment (GOVERNANCE.md). The text described a count the shipped behaviour never made; the seam’s name, its nouns and every value a node answers are unchanged. No frame shape, type code, grammar rule, error identity or seam name moves.