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: assign and propagate; structural selective propagation; value-agnostic per-vertex delivery_mode

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

#235

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_mode field was removed entirely. That is corrected here. Removing the value-based filter (ON_CHANGE byte-diff) and the throttle (THROTTLED / min_interval_ns) stands. But delivery_mode itself is not deleted — it is redefined as a value-agnostic, per-vertex policy governing whether an ancestor’s propagate sweep includes a vertex, with three modes: UNCONDITIONAL, IF_NEWER (default), and EXPLICIT. The default IF_NEWER is 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 from SUBSCRIBER.qos_settings); the new policy is a per-vertex host attribute, and configuring it over the wire reuses the vertex :settings mechanism (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, the operator= — 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) — replace v’s value. Effects, all vertex-local: swap the last-known-value (atomic), increment v’s write sequence (§B), append to the stream ring if v is a stream, and wake any await waiter on v. 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 of v itself (always — §C) and of the qualifying descendants in the subtree rooted at v (§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. propagate does 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. await serves its woken value through the same role dispatch a read of that vertex serves — the sentence above always said so, and the reference implementation did not. And the assign / propagate pair 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_seq over a monotonic sequence. No swept_seq exists and nothing orders two sequence values: pending is a mark that an assign sets 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:

  1. Per-vertex sequence (one small counter per vertex), delivery to the full observer set. When a sweep selects u, u is 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 above root. The root argument selects which vertices flush; it does not cap who receives them. Consequence: if u is observed from two different ancestors, the first sweep that covers u delivers it to both (bubbling reaches both) and drains its pending mark, so a second overlapping sweep correctly finds u no longer pending — no double-send, no missed observer, one counter of state. (Capping delivery at root was considered and rejected; it would force per-(vertex, root) bookkeeping — see Alternatives.)

  2. Coalescing is free. assign overwrites 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 may assign at any rate and propagate on 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…

UNCONDITIONAL

always — deliver its current value on every covering sweep (a sweep-driven keepalive; the producer’s timer sets the rate).

IF_NEWER (default)

only if pending (marked since the last covering sweep, §B) — the structural coalescing flush.

EXPLICIT

never — an ancestor sweep skips it entirely; it is deliverable only by a direct propagate on the vertex itself.

Two invariants make the modes coherent with §A:

  • assign is never gated. Whatever the mode, assign swaps 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 for assign to swap, and refuses the verb outright.)

  • A direct propagate(v) always delivers v. The argument of a propagate call is its explicit target and is delivered regardless of its mode — the mode governs only the descendants a sweep pulls in. This is what makes EXPLICIT reachable (“deliver it only by calling propagate on it by hand”) and matches the intuition that asking to propagate v propagates v. In particular, updating a vertex (assign) and notifying its own subscribers (a direct propagate on it) are both unaffected by delivery_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_mode decides 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. assign and propagate are sequenced by the caller; nothing fans out implicitly. assign(A); assign(B); propagate(v) deterministically propagates B (last-writer-wins).

  • One effect each. assign touches only state (value + write sequence); propagate touches 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 vertex EXPLICIT, or read, 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 — remove delivery_mode, min_interval_ns, and keepalive_ns from SUBSCRIBER.qos_settings; note delivery_compact (and the reserved delivery_scope) are retained; note delivery_mode is now a value-agnostic per-vertex attribute (default IF_NEWER), wire-configured via vertex :settings (deferred).

  • docs/reference/12-deployment-profiles.md — the dispatcher’s delivery policy is now per-vertex (value-agnostic delivery_mode), not per-subscriber; the throttle knob is gone.

  • RFC-0004 §E — supersede the value-based delivery_mode reference (the QoS-hint list loses delivery_mode/min_interval_ns; delivery_compact stays; the value-agnostic per-vertex delivery_mode is the new home).

  • RFC-0005 §A — supersede the “per-subscriber delivery policy (delivery_mode, ON_CHANGE byte-diff, …)” clause; restate bubbling in terms of propagate over the write-sequence selection rather than an implicit per-write fan-out.

  • Reference-implementation changes (graph_t API: assign/propagate, the write- sequence selection sets, the redefined delivery_mode_t, removal of ON_CHANGE / min_interval_ns / keepalive_ns) are recorded in core/CHANGELOG.md and 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 meaningful operator!= on opaque bytes, and O(N) comparison at the scale it is meant to relieve.

  • Remove delivery_mode entirely (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 / EXPLICIT express them.

  • Keep write coupled, drop only ON_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_mode per-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 propagate delivery at its root. Rejected — it makes the selection state per-(vertex, root) (or per-subscriber) instead of one counter per vertex, to avoid a subscriber above root being 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, await MUST serve its value through the same role dispatch a read of the same vertex serves at that instant. A vertex whose role composes its read value (a HANDLER, via on_read) therefore answers an await with that composed value.

Three consequences, all of them boundaries:

  1. The degradation is the READ contract’s, not await’s. A HANDLER exposing no on_read still answers NOT_FOUND — the same answer read gives it. This clause makes the two doors agree; it does not make await succeed where read would not.

  2. The BRANCH fork is deliberately not mirrored. A read of a vertex with ≥ 1 registered child serves the composed subtree fold (RFC-0016). await observes assigns at its own vertex (§A, the readiness plane of a single identity), so a branch vertex’s await hands back that vertex’s own last-known-value, exactly as it always has. Only the ROLE dispatch is shared, not the branch/leaf fork.

  3. The retaining path is untouched. A STORED_VALUE / STREAM await keeps 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 / propagate pair requires retention. A vertex whose role retains no last-known-value MUST refuse both verbs at the call site, by value, before any effect: assign MUST NOT enter the vertex’s write seam, and propagate MUST NOT sweep.

  • The status is SCHEMA_NOT_FOUND, matching the taxonomy’s existing contract-mismatch answer — the same status a history or drain_unflushed of a non-STREAM vertex gives, and the same one an AWAIT carrying a :field selector gives (RFC-0010 §C). It is emphatically not BACKPRESSURE: nothing is under pressure, no queue is full, and a retry will never succeed. The refusal is permanent and structural.

  • assign refuses 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 when root itself 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, because assign no 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.

  • write is unaffected and remains the right call. §D’s eager composition dispatches the on_write seam 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

HANDLER with on_read: WRITE then AWAIT

the on_read-composed VALUE, not an error

2

HANDLER with no on_read: AWAIT

NOT_FOUND (the read contract’s degradation)

3

retaining vertex (leaf and branch): AWAIT

unchanged — the vertex’s own published value

4

assign at a HANDLER

SCHEMA_NOT_FOUND; on_write not entered, nothing marked, the covering sweep delivers nothing

5

propagate at a HANDLER (both emission modes)

SCHEMA_NOT_FOUND; nothing delivered

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. assign at an IF_NEWER vertex 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 eager write that 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 assign advances it. Its one consumer is the local await predicate, 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 assign and compared for equality only — no reader orders two of its values (the bump’s seq_cst memory ordering is a separate, unchanged property), 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. 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.