RFC 0008 — Vertex operations: assign and propagate; structural selective propagation; value-agnostic per-vertex delivery_mode¶
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 |
0008 |
Title |
Vertex operations: |
Status |
accepted (2026-07-06, maintainer-ratified design discussion; amended 2026-07-06b, and 2026-08-22 — Amendment 2, below; §B corrected 2026-09-30 by erratum, below) |
Author(s) |
AvatarSD (maintainer) |
Created |
2026-07-06 |
Comment window |
waived by the maintainer (solo-maintainer project, GOVERNANCE.md window dead ceremony) |
Tracking issue |
|
Target spec version |
v1 (draft refinement — no released v1 yet, so no v2 needed) |
Amendment 2026-07-06b (maintainer, live design discussion). The first draft of this RFC (merged in #236) said the
delivery_modefield was removed entirely. That is corrected here. Removing the value-based filter (ON_CHANGEbyte-diff) and the throttle (THROTTLED/min_interval_ns) stands. Butdelivery_modeitself is not deleted — it is redefined as a value-agnostic, per-vertex policy governing whether an ancestor’spropagatesweep includes a vertex, with three modes:UNCONDITIONAL,IF_NEWER(default), andEXPLICIT. The defaultIF_NEWERis the structural dirty-flush described in §B; the other two are per-vertex overrides. The wire change is unchanged from the first draft (a pure removal fromSUBSCRIBER.qos_settings); the new policy is a per-vertex host attribute, and configuring it over the wire reuses the vertex:settingsmechanism (deferred, §C). §§Summary, B, C, F, Files, and Alternatives are revised accordingly.
Summary¶
The conflated write operation (store the value and notify subscribers, in one
call) splits into the two irreducible operations it was always hiding:
assign— the vertex-local state transition: replace the vertex’s value. Reads no edge and sends nothing. (In graph terms, relabel a vertex; in C++ terms, theoperator=— and with rope-valued vertices (ADR-0053 §6) it is literally an atomic last-known-value swap.) It also advances the vertex’s write sequence (§B) and marks it pending.propagate— the edge transition: deliver a vertex’s value along its subscription edges to other vertices (and remote subscribers). Sends; does not mutate.
The runtime stops inspecting vertex values to decide delivery. The value-based
delivery filter (delivery_mode == ON_CHANGE, “deliver only when the value bytes
differ from last delivered”) and the throttle (THROTTLED / min_interval_ns) are
removed — including from the SUBSCRIBER.qos_settings wire encoding. In their
place, selective propagation is structural: assign advances a vertex’s write
sequence; a propagate(root) sweep flushes only the descendants actually assigned
since the sweep last covered them — no value comparison. What “changed” means is
decided by which operations the caller performed, never by comparing bytes.
delivery_mode survives this, redefined and relocated: from a per-subscriber,
value-based filter to a per-vertex, value-agnostic policy — UNCONDITIONAL /
IF_NEWER (default) / EXPLICIT — that governs how a vertex participates in an
ancestor’s sweep (§C). It never reads a byte; it selects, by ordering and intent,
which vertices an ancestor sweep pulls in.
This resolves #235. It
supersedes the value-based delivery_mode / ON_CHANGE clauses of accepted
RFC-0004 §E and
RFC-0005 §A and reference/05 — a reversal the
maintainer has authorized. No new wire verbs, no new type codes; the one wire change
is a removal (the delivery_mode / min_interval_ns / keepalive_ns QoS keys),
with the new per-vertex policy carried as host state (wire config deferred, §C).
Motivation¶
ON_CHANGE — “deliver only when the value bytes differ from the last delivered” —
asks the protocol runtime to read application data and make an application decision.
That is the wrong layer: a vertex stores bytes it never parses (ADR-0053 §1),
there is no meaningful operator!= for an opaque value, and whether an update is
worth delivering is the application’s judgement, not the graph’s. Byte-diff
suppression is also a performance trap at the exact scale it is meant to help:
comparing an N-byte value costs the same O(N) memory traffic the delivery would.
The genuine requirement ON_CHANGE was trying to serve is different and better
served structurally. A producer updates some vertices of a subtree, then — on a
timer, at a rate it chooses — calls one propagate at a parent covering that
subtree, and wants only the vertices it actually touched delivered, not the whole
subtree re-sent. That is coalescing keyed on which vertices were operated on, and
the signal for it is the vertex’s write sequence advancing under assign — a fact
the runtime owns, recorded at the moment of the operation — not a value comparison.
Separating assign from propagate makes this expressible and makes every delivery
an explicit act: the graph never fans out behind the caller’s back. And where a
producer wants a vertex always re-sent by the sweep (a slow keepalive), or never
swept up by an ancestor (deliver it only by hand), that too is an ordering/intent
choice — the three delivery_mode values (§C) — never a value one.
Proposed change¶
A. The two operations¶
A vertex sits in two orthogonal planes. The state plane holds its value; the propagation plane is its outgoing subscription edges. Exactly two primitives act on a vertex, and they touch disjoint planes:
assign(v, value)— replacev’s value. Effects, all vertex-local: swap the last-known-value (atomic), incrementv’s write sequence (§B), append to the stream ring ifvis a stream, and wake anyawaitwaiter onv. No fan-out. Idempotent in stored state (assigning the same value twice leaves the same value) but it always advances the write sequence — the record that an operation happened.propagate(v)— deliver, along subscription edges, the value ofvitself (always — §C) and of the qualifying descendants in the subtree rooted atv(§B/§C). No value argument: the last-known-value is the single source of truth, so there is no ambiguity about which value is sent.propagatedoes not get to decide whether to deliver what it selected — only to deliver it.
read and await are unchanged and both live in the state plane: await
observes assigns at its own vertex (the readiness plane of a single identity,
RFC-0005 §A), independent of propagation.
Amended 2026-08-22 (Amendment 2, below). Two clauses attach here.
awaitserves its woken value through the same role dispatch areadof that vertex serves — the sentence above always said so, and the reference implementation did not. And theassign/propagatepair requires retention: a vertex whose role retains no last-known-value refuses both verbs by value (SCHEMA_NOT_FOUND) rather than storing nothing and sweeping silence.
write is removed. The one call that did both is gone; a caller performs the two
operations explicitly. The wire mapping is unchanged (§D): a FWD{WRITE} arriving at
a terminus is an assign followed by a propagate of that vertex.
B. Structural selective propagation: the write sequence¶
Corrected 2026-09-30 by the write-sequence erratum (#1683) — see §Erratum at the end of this document. This section first defined pending as
write_seq > swept_seqover a monotonic sequence. Noswept_seqexists and nothing orders two sequence values: pending is a mark that anassignsets and a covering sweep drains, and the write sequence is an equality-only change counter. Rules 1 and 2 below stand verbatim.
Every vertex carries a write sequence write_seq: a change counter (never the value’s
bytes), advanced by every assign and compared for equality only — no reader orders two
of its values, and its width is an implementation choice (it MAY wrap). An assign also
marks the vertex pending — it was assigned since a sweep last covered it. This is the
structural, value-agnostic replacement for a “dirty bit”.
propagate(root) sweeps the subtree rooted at root and delivers each vertex it
selects to that vertex’s observers, then drains the vertex’s pending mark. Selection is governed per vertex by its delivery_mode (§C); in
the default mode (IF_NEWER) a descendant is selected exactly when it is pending —
so a vertex not assigned since the last covering sweep is skipped, not because its
bytes match anything, but because the runtime holds no record that it was operated on.
Two rules keep this minimal and consistent:
Per-vertex sequence (one small counter per vertex), delivery to the full observer set. When a sweep selects
u,uis delivered to every subscriber that observes it —u’s own edges and every ancestor carrying a subtree subscription (the RFC-0005 §A fan-out + vertical bubbling), including ancestors aboveroot. Therootargument selects which vertices flush; it does not cap who receives them. Consequence: ifuis observed from two different ancestors, the first sweep that coversudelivers it to both (bubbling reaches both) and drains its pending mark, so a second overlapping sweep correctly findsuno longer pending — no double-send, no missed observer, one counter of state. (Capping delivery atrootwas considered and rejected; it would force per-(vertex, root)bookkeeping — see Alternatives.)Coalescing is free.
assignoverwrites the last-known-value (last-writer-wins) and advances the sequence; it does not enqueue — a second mark on a marked vertex is the same mark. So k assigns to the same vertex between two sweeps flush once, with the latest value. A producer mayassignat any rate andpropagateon a timer at a lower rate; the timer rate is the delivery rate, and only touched vertices ride it.
Efficiency. A default (IF_NEWER) sweep costs O(pending-in-subtree), not
O(subtree-size): pending vertices are held in an ordered set of canonical PATH
keys, and a subtree is a contiguous prefix range of that order (a parent’s key is
a byte-prefix of every descendant’s, the property key_view_t::is_ancestor_of and the
bubbling walk already rely on). propagate(root) iterates the range [key(root), …)
while the prefix holds; assign inserts the key (unless the vertex is EXPLICIT —
§C — which never rides an ancestor sweep). A large, mostly-clean subtree with three
pending leaves flushes those three and touches nothing else. UNCONDITIONAL vertices
(which must be swept even when not pending) are held in a second ordered key set the
sweep also iterates by prefix range — so the cost is O((pending + unconditional)-in-
subtree), still independent of the clean-and-quiet remainder. (As an idle
optimization, assign MAY skip inserting a vertex that has no subtree subscriber above
it, reusing RFC-0005’s listeners_above_ counter — a sweep would deliver it nowhere.
This optimizes the mechanism, not the semantics: with no observer, pending-or-not is
unobservable.)
Branch assign composes. A POINT tree assigned to v
(RFC-0005 §B branch write) decomposes exactly as
before, except the store half is now assign at each value-carrying descendant
(advancing each write sequence) and there is no implicit notify. The producer then
calls one propagate(v) to flush the whole touched set selectively — which is precisely
the “update part of a subtree, then propagate the parent” workflow this RFC exists to
support.
C. delivery_mode: a value-agnostic, per-vertex propagation policy¶
delivery_mode is not a per-subscriber value filter (that is deleted). It is a
per-vertex attribute — a property of the vertex’s storage, like its role — that
governs whether an ancestor’s propagate sweep includes this vertex. Three modes:
Mode |
An ancestor sweep includes this vertex… |
|---|---|
|
always — deliver its current value on every covering sweep (a sweep-driven keepalive; the producer’s timer sets the rate). |
|
only if pending (marked since the last covering sweep, §B) — the structural coalescing flush. |
|
never — an ancestor sweep skips it entirely; it is deliverable only by a direct |
Two invariants make the modes coherent with §A:
assignis never gated. Whatever the mode,assignswaps the value and advances the write sequence. The mode governs propagation, not storage. (Amendment 2 adds the one thing that is not a mode question: a vertex whose ROLE retains nothing has no storage forassignto swap, and refuses the verb outright.)A direct
propagate(v)always deliversv. The argument of apropagatecall is its explicit target and is delivered regardless of its mode — the mode governs only the descendants a sweep pulls in. This is what makesEXPLICITreachable (“deliver it only by callingpropagateon it by hand”) and matches the intuition that asking to propagatevpropagatesv. In particular, updating a vertex (assign) and notifying its own subscribers (a directpropagateon it) are both unaffected bydelivery_mode; the mode changes only how the vertex behaves when an ancestor sweeps.
delivery_mode is therefore held as host state on the vertex, defaulting to
IF_NEWER. On the wire it leaves SUBSCRIBER.qos_settings (a subscription no
longer carries it — the source vertex owns the policy, not the observer). Configuring a
vertex’s mode from a remote peer reuses the ordinary vertex-:settings write path (a
delivery_mode NAME/VALUE under the vertex’s own SETTINGS, mirroring how
:subscribers / :acl hang off a vertex); this wire configuration is deferred and
optional — the core semantics need only the host attribute and its IF_NEWER default.
The value-based ON_CHANGE and the min_interval_ns / keepalive_ns throttles are
gone for good; the sole “re-send even when unchanged” need is served by UNCONDITIONAL
plus the producer’s own propagate cadence. delivery_compact
(RFC-0004 §E.1, label compaction) is a
separate, orthogonal subscriber hint and is retained — it concerns how a
route is encoded, not whether a value is delivered.
D. Wire mapping: a delivery is still a write¶
The wire is unchanged except for the §C qos_settings removal. “A delivery is a
write” (RFC-0004 §D) still holds: a FWD{WRITE}
carrying a VALUE, arriving at its terminus, means assign the addressed vertex,
then propagate it — the two host primitives in sequence, delivering to that
vertex’s own subscribers (local and remote) and bubbling to ancestor subtree
subscriptions. Because the terminus vertex is the argument of that propagate, it
is delivered regardless of its own delivery_mode (§C) — a directed write always lands.
A producer’s selective subtree flush therefore reaches a remote subtree subscriber
as one FWD{WRITE} per selected vertex (driven by the single host propagate), exactly
as a local subscriber receives one callback per selected vertex. No wire batch primitive
is introduced; propagate simply drives the sends it selects.
E. Stream vertices are a queue, not a coalesce¶
The write-sequence coalescing of §B is the stored-value semantic (last-writer-wins:
flush the latest once). A stream vertex (bounded history ring) is a queue — its
contract is “observe every buffered entry,” not “the latest.” Its propagation is a
drain of the entries buffered since the last flush, in order, not a coalesce. The
two roles keep their existing distinction; propagate dispatches on the vertex role,
and a stream’s flush delivers each ring entry appended since the previous flush.
F. Determinism properties (the contract)¶
No value inspection. No operation reads a stored value’s bytes to decide anything about delivery.
delivery_modedecides by ordering and intent (the write sequence, and the three per-vertex modes), never by comparing bytes. Removing value inspection is the point; the modes preserve it.Explicit composition, caller-ordered.
assignandpropagateare sequenced by the caller; nothing fans out implicitly.assign(A); assign(B); propagate(v)deterministically propagatesB(last-writer-wins).One effect each.
assigntouches only state (value + write sequence);propagatetouches only edges (delivery + draining the pending mark). Neither leaks into the other’s plane.Suppression is the application’s, by construction. To not deliver, do not
propagate— or set the vertexEXPLICIT, orread, compare in application terms, and decide. The graph never substitutes its own value comparison for that judgement.
Files this RFC edits¶
docs/reference/05-protocol-tlvs.md— removedelivery_mode,min_interval_ns, andkeepalive_nsfromSUBSCRIBER.qos_settings; notedelivery_compact(and the reserveddelivery_scope) are retained; notedelivery_modeis now a value-agnostic per-vertex attribute (defaultIF_NEWER), wire-configured via vertex:settings(deferred).docs/reference/12-deployment-profiles.md— the dispatcher’s delivery policy is now per-vertex (value-agnosticdelivery_mode), not per-subscriber; the throttle knob is gone.RFC-0004 §E — supersede the value-based
delivery_modereference (the QoS-hint list losesdelivery_mode/min_interval_ns;delivery_compactstays; the value-agnostic per-vertexdelivery_modeis the new home).RFC-0005 §A — supersede the “per-subscriber delivery policy (
delivery_mode, ON_CHANGE byte-diff, …)” clause; restate bubbling in terms ofpropagateover the write-sequence selection rather than an implicit per-write fan-out.Reference-implementation changes (
graph_tAPI:assign/propagate, the write- sequence selection sets, the redefineddelivery_mode_t, removal ofON_CHANGE/min_interval_ns/keepalive_ns) are recorded incore/CHANGELOG.mdand are not normative; they follow this RFC.
Compatibility¶
Breaking, and deliberately so — pre-1.0. No v1 is released, so no v2 is needed
(the RFC-0005 precedent). The host API break (write → assign/propagate,
delivery_mode redefined per-vertex and value-agnostic) is a mechanical caller
migration. The wire break is a pure removal of the value-based/throttle
qos_settings keys; a stale peer’s leftover delivery_mode under qos_settings is
ignored under the unknown-key rule, so there is no framing incompatibility — only the
(correct) loss of the value-based suppression it requested. The new per-vertex
delivery_mode adds no required wire field (host default IF_NEWER; wire config
deferred).
Alternatives considered¶
Keep
ON_CHANGE(value byte-diff). Rejected — the motivation. Wrong layer (runtime inspecting application data), no meaningfuloperator!=on opaque bytes, and O(N) comparison at the scale it is meant to relieve.Remove
delivery_modeentirely (this RFC’s own first draft). Rejected on amendment — deleting the field throws out a genuine, value-agnostic need: a producer wanting a vertex always re-sent by the sweep (keepalive) or never swept up by an ancestor (hand-delivered only). Those are ordering/intent choices the runtime can honor without reading a byte;UNCONDITIONAL/IF_NEWER/EXPLICITexpress them.Keep
writecoupled, drop onlyON_CHANGE. Rejected — it removes the symptom but keeps the disease (implicit fan-out on every store), and does not give the caller the two operations the selective-subtree-propagation workflow needs.delivery_modeper-subscriber (its old home). Rejected — inclusion in a sweep is a property of the source vertex (does this vertex ride its ancestor’s sweep), uniform across every ancestor that observes it, so it belongs on the vertex, not on each observing edge. Per-subscriber would also re-introduce the “who receives” coupling §B.1 deliberately avoids.Cap
propagatedelivery at itsroot. Rejected — it makes the selection state per-(vertex, root)(or per-subscriber) instead of one counter per vertex, to avoid a subscriber aboverootbeing starved. The “root filters which vertices flush; delivery follows the subscription graph” rule (§B.1) needs one counter and is consistent under overlapping roots.A per-vertex flag + full subtree walk on
propagate. Rejected in favor of the ordered key sets — a walk is O(subtree-size) even when three leaves are pending; the prefix-range drain is O((pending + unconditional)-in-subtree).
Discussion¶
The split reframes what a graph vertex is: not a mailbox you write to (store and
send fused), but a value cell you assign and a fan-out point you
propagate — two operations the caller composes. ON_CHANGE was the runtime
guessing the caller’s intent from the data; the write sequence and the three
delivery_mode modes are the runtime recording the caller’s intent from the
operations and the vertex’s declared policy. The first is value-coupled and
nondeterministic at the layer boundary; the second is structural, value-agnostic, and
exactly the coalescing — with keepalive and hand-delivery as the two deliberate
exceptions — that a timer-driven producer wants.
Amendment 2 (2026-08-22): §A/§C — a vertex that RETAINS NOTHING¶
Records the maintainer ruling made in the 2026-08-22 LKV-retention review and tracked in #1506 (decision table on #1504; the retention benchmark that sits beside it is #1505). The 14-day comment window is waived by default while the project is solo-maintained (GOVERNANCE.md §Roles) and was not invoked. (The 2026-07-06b amendment in the front matter above is Amendment 1 — it predates the numbering.)
Status: accepted. This is an amendment and not an erratum, on the strength of Clause 1 alone: it changes what a conforming implementation puts on the wire. Clause 2 is presented inside it as a correction to the §A/§C pair, and is deliberately not split into a second instrument — the two clauses are the two halves of one fact.
The fact both clauses follow from¶
role_t has exactly one non-retaining role. A STORED_VALUE and a STREAM vertex publish
a last-known-value on every store; a HANDLER vertex hands the value to its on_write seam and
keeps nothing, and serves a read by composing a value from its on_read seam
(core/src/graph.cpp, graph_t::store_value’s “handler consumed it — nothing stored”
sentinel). §A’s two primitives were written against the retaining case and left the
non-retaining one to fall out. It fell out twice, in opposite directions: await read the
storage that is never there, and assign/propagate wrote to it and swept the silence.
Clause 1 — await dispatches the same read contract read serves¶
§A already says what the answer must be: “read and await … both live in the state
plane”, await being the readiness form of the same data READ (the reference states it as
“read and await live wholly in the state plane”,
reference/02 §Assign, propagate, and the coalescing
sweep). The reference implementation did not. After a successful wake it read the
last-known-value slot directly, so at a HANDLER vertex read served the on_read seam
while await answered NOT_FOUND — after the awaited write had already reached on_write
and woken the waiter. Two doors, one contract, two answers.
§A gains:
After a wake,
awaitMUST serve its value through the same role dispatch areadof the same vertex serves at that instant. A vertex whose role composes its read value (aHANDLER, viaon_read) therefore answers anawaitwith that composed value.
Three consequences, all of them boundaries:
The degradation is the READ contract’s, not
await’s. AHANDLERexposing noon_readstill answersNOT_FOUND— the same answerreadgives it. This clause makes the two doors agree; it does not makeawaitsucceed wherereadwould not.The BRANCH fork is deliberately not mirrored. A
readof a vertex with ≥ 1 registered child serves the composed subtree fold (RFC-0016).awaitobservesassigns at its own vertex (§A, the readiness plane of a single identity), so a branch vertex’sawaithands back that vertex’s own last-known-value, exactly as it always has. Only the ROLE dispatch is shared, not the branch/leaf fork.The retaining path is untouched. A
STORED_VALUE/STREAMawaitkeeps the lock-free published-value fast path and pays no handler-dispatch cost. This is a correctness fix on a cold arm, not a re-plumbing of the hot one.
Wire visibility. The FWD{AWAIT} terminus resolves through this same host await, so it
inherits the correction with no resolver change at all: a FWD{AWAIT} addressed to a
HANDLER vertex that used to answer kind=ERROR + STATUS=ERROR(tr::path::not_found) now
answers kind=RESULT + the composed VALUE.
RFC-0004 §D’s op table already describes the AWAIT row
as “kind=RESULT + the next write’s TLV”; that row is unchanged in wording and now true of
one more role — read it as “the value a read would serve at the instant of the wake”, which
at a retaining vertex is the next write’s TLV. No grammar changes, no new status, no new
frame.
Clause 2 — assign / propagate at a non-retaining vertex refuse BY VALUE¶
§C’s design turns on one dependency: propagate takes no value argument — “the
last-known-value is the single source of truth” (§A). The accumulate-then-flush pair therefore
requires the state plane to hold something between the two calls. At a vertex that retains
nothing there is nothing to hold: assign handed the value to on_write, stored nothing and
marked the vertex, and the next covering sweep — reading a slot that is permanently empty —
delivered nothing, silently. That is the shape the counting doctrine already forbids: a
bound that silently discards work is indistinguishable from one that is never reached. And it
is not even pressure; it is a contract mismatch between the verb and the vertex.
§A and §C gain:
The
assign/propagatepair requires retention. A vertex whose role retains no last-known-value MUST refuse both verbs at the call site, by value, before any effect:assignMUST NOT enter the vertex’s write seam, andpropagateMUST NOT sweep.
The status is
SCHEMA_NOT_FOUND, matching the taxonomy’s existing contract-mismatch answer — the same status ahistoryordrain_unflushedof a non-STREAMvertex gives, and the same one anAWAITcarrying a:fieldselector gives (RFC-0010 §C). It is emphatically notBACKPRESSURE: nothing is under pressure, no queue is full, and a retry will never succeed. The refusal is permanent and structural.assignrefuses AFTER the WRITE gate, so the vertex’s role is disclosed only to a caller the ACL already admitted to write it.Only the sweep ROOT is judged.
propagate(root)refuses whenrootitself retains nothing; a sweep rooted at a retaining ancestor still walks a subtree containing non-retaining vertices exactly as before — they simply carry no mark, becauseassignno longer admits one. Both emission modes (RFC-0025 §4.1.2 clause 5) answer alike, because the refusal belongs to the verb and not to the framing.writeis unaffected and remains the right call. §D’s eager composition dispatches theon_writeseam and delivers in one step, which is exactly what a non-retaining vertex can do. Clause 2 removes a silent failure; it removes no capability.No new counter. The refusal is at the verb, so nothing downstream is discarded and there is nothing to attribute — the caller holds the error.
Conformance / behavioural vectors¶
# |
Vector |
Expected |
|---|---|---|
1 |
|
the |
2 |
|
|
3 |
retaining vertex (leaf and branch): AWAIT |
unchanged — the vertex’s own published value |
4 |
|
|
5 |
|
|
Vectors 1–5 live in core/tests/nonretaining_contract_test.cpp, each paired with a positive
control; the wire face of clause 1 is core/tests/op_resolve_test.cpp
(test_await_at_a_handler_replies_with_the_composed_value), controlled against the FWD{READ}
of the same vertex.
Not in scope¶
Three stances ruled alongside this one are documentation, not normative change, and land in
the backpressure/sizing guide rather than here: a composed branch read stays landed-LKVs-only
and never invokes descendant handlers mid-walk (RFC-0016 stands
— unbounded user code under a subtree walk is the anti-feature); and the ADR-0049 durability
latch stays LKV-only, with no on_read synthesis at subscribe time, “null ⇒ no latch”
being the specified degradation.
Erratum (2026-09-30) — §B’s write sequence is an equality-only change counter; “pending” is a mark, not a write_seq > swept_seq comparison (#1683)¶
What the text said. §B defined a monotonic per-vertex write_seq, a per-vertex swept_seq
recorded at each sweep inclusion, and pending as exactly write_seq > swept_seq. §A’s
summary, §C’s IF_NEWER row and §F’s “one effect each” bullet restated that comparison, and §B
justified a counter over a bit by overlapping sweeps and “future per-observer sequencing”.
What was wrong. The shipped reference implementation has never had a swept_seq, and no
code orders two write-sequence values:
Pending is a mark.
assignat anIF_NEWERvertex that someone observes inserts the vertex into the graph’s ordered pending set (canonical PATH keys, §B’s prefix-range sweep). A covering sweep drains the vertex’s mark as it delivers it; an eagerwritethat already delivered it clears the mark, so a later covering sweep does not re-deliver. Membership in that set is “assigned since a sweep last covered it” — nothing compares sequences to decide it.The sequence is a change counter, compared for equality only. Every
assignadvances it. Its one consumer is the localawaitpredicate, which asks whether the current value differs from the value sampled when the wait began (current != seq0). No reader asks whether one value is greater than another. Because the only test is inequality, the counter is free to wrap at its storage width: a wrap is still a change, and the only alias — exactly 2^width advances inside one await window — is bounded by that await’s timeout (a spurious timeout, never a lost value).
The rules §B states on top of the comparison all stand unchanged, because the mark delivers
them exactly as the comparison was meant to: the first covering sweep delivers u to its full
observer set and drains the mark, so an overlapping second sweep finds u not pending (rule 1);
k assigns between sweeps leave one mark and flush once with the latest value (rule 2); a
default sweep costs O(pending-in-subtree).
The correction. §B’s first paragraph now reads:
Every vertex carries a write sequence: a change counter (never the value’s bytes), advanced by every
assignand compared for equality only — no reader orders two of its values (the bump’sseq_cstmemory ordering is a separate, unchanged property), and its width is an implementation choice (it MAY wrap). Anassignalso marks the vertex pending — it was assigned since a sweep last covered it. A sweep that includes a vertex drains its pending mark.
and the other sites (§Summary, §B rule 1, §C, §F) were rewritten inline to match: “pending (write_seq > swept_seq)” means pending (marked
since the last covering sweep); “advances the vertex’s swept_seq to its current write_seq” /
“advancing swept_seq” means drains the vertex’s pending mark; “monotonic write sequence”
means write sequence (an equality-only change counter). The counter-over-a-bit rationale is
withdrawn: overlapping sweeps are made consistent by the drained mark, and no per-observer
sequencing is committed to.
Instrument: erratum, not amendment (GOVERNANCE.md). No
wire surface moves. The write sequence was never wire-observable — it feeds only the local
await predicate — and no frame, type code, error identity or conformance vector changes. The
wording is width-agnostic on purpose, so it holds for the 64-bit counter shipped at the time of
this erratum and for the 32-bit one #1682
proposes.