EntitlementLedger
The replicated, conflict-free tally of who was granted what fairness entitlement, who passed it down, and who spent it.
It is the one genuinely-new CRDT of the fair-share layer: a BoundedCounter grown a path dimension. Where a bounded counter escrows one shared budget across replicas, this escrows a whole tree of budgets — indexed by the edge each grant crossed — and every peer can merge its copy with any other and always agree, with no clock and no central referee.
What you can do with it
Construct and merge ledger states (via ZERO and bootstrap, or the internal test factory) — the merge (piece) is a provable join-semilattice — and drive the economics on top of it: holdings (derived spendable authority), the conserving mutators mint / delegate / release / transfer / spend (each returning a Patch or null when holdings are insufficient), and validate (the integrity report).
Safety vs. diagnostics
Safety is the local holdings check each mutator runs on the actor's own complete state before it emits a patch — a peer never spends beyond holdings(P, self), with zero coordination, because every term of that check reads a slot only that peer writes. validate is a diagnostic, not a safety gate. It is eventually consistent: on a fully-delivered state its report is exact, but under partial delivery of a multi-hop transfer-funded charge it may transiently list a false LedgerConflict.PerEdgeSafety / LedgerConflict.PersistentNegativeHoldings that a later anti-entropy round dissolves. Each feasibility-consuming mutator carries a witness that keeps the direct and single-hop-transfer cases from false-firing; deeper transfer chains are the accepted transient. Consumers must not hard-gate on validate().isEmpty() while rebalancing is in flight — gate on the mutator's null.
One root per ledger (a standing invariant)
A ledger describes one tree, with exactly one root: the group bootstrap was called with. holdings credits a group's creditIn from the minted supply whenever that group has no inbound edge, and MintRecord carries only a holder and an amount — it is not bound to a root. So if two independently-bootstrapped ledgers are pieced together, the merged state has two rootless groups and each of them is credited the full mintedTotal, double-counting every mint in the Σ-holdings conservation identity.
Nothing in the representation prevents this, so it is a caller invariant: never merge ledgers from different bootstraps. Binding a MintRecord to its root would make it structural, but that is a wire-format change and is deliberately not taken here.
Delta-state idiom: two patches from one base lose the first
Every mutator reads the receiver and returns a patch carrying absolute slot values, and the join is max. Two patches computed from the same base therefore do not compose — the merge keeps the larger and silently drops the other:
val a = ledger.delegate(r, e, 10)!! // issued(e)[r] = 10
val b = ledger.delegate(r, e, 5)!! // issued(e)[r] = 5 ← also read from `ledger`
ledger.piece(a).piece(b) // issued(e)[r] = 10, NOT 15That is the price of absolute-value deltas, and it is what buys duplicate-delivery idempotence — but it means a caller must thread the state: call each mutator on a ledger that has already absorbed the previous patch, never fan several out from one snapshot. HeddleNode does this by running each op inside its Quilter.mutate block, so the op always sees fresh state.
Lifecycle (H2)
Each edge carries a Lifecycle in a per-edge max-register (PREPARED < ACTIVE < CLOSING < RETIRED, join = max). The transitions prepare / activate / close / retire climb that chain under strict generation-and-drain discipline (design §5.3): close admits no new delegation, retire finalizes only a fully drained edge (outstanding == 0). An edge present without an explicit register entry defaults to Lifecycle.ACTIVE — the H1b "present edge is ACTIVE" assumption, now made explicit rather than assumed. Delegation is gated on Lifecycle.ACTIVE, and two Lifecycle.ACTIVE inbound generations for one child surface as LedgerConflict.DualActiveInbound with the contested lineage quarantined (§5.2, §10.11).
The representation
Fourteen components, each already a join-semilattice, so piece is just their componentwise join (the product-of-lattices idiom):
records — the immutable topology (parent/child/weight per edge) as a grow-only set of records per edge id. A healthy id carries a singleton set; two divergent records under one id are both retained (never collapsed by a last-writer-wins on a parent pointer, which §5.2 forbids) so a later phase's
validatecan report the divergence.minted — root supply, keyed by a unique MintId so mints union rather than collide.
issued/returned/leafSpent/rollupSpent— per-edge monotone GCounters. Every(edge, replica)slot is written exclusively by that replica, so the merge is per-slot max and no honest concurrency can race.transfers— peer-to-peer hand-offs at a path, a per-donor-row matrix keyed by PathKey; the row for a donor is written only by that donor.the relocation counters —
issuedRelocIn,leafRelocIn/leafRelocOut,rollupRelocIn/rollupRelocOut, described next.gauges— the per-edge virtual-time seat register (Gauge), joined componentwise max. This is the only multi-writer per-edge component: unlike a counter slot, which exactly one replica writes, any peer may assert a floor for any edge. The pairing of each floor with the issuance its writer observed is what makes that safe under an order-free join — see Gauge and grossVirtualService.
The spent split (leafSpent vs rollupSpent) is the load-bearing choice: a completed charge on a leaf path charges the leaf's own final edge in leafSpent and every strict-prefix edge in rollupSpent, which keeps the conservation identity topology-independent even when a former leaf later gains a child.
All three lattice laws (idempotent, commutative, associative) hold by construction, and duplicate or reordered delivery of any patch is absorbed idempotently by the counters' max — convergence comes from the lattice, not from event ids.
Relocation counters — a net decrease without a decrement (#1665 slice 1)
A generation is sometimes retired with entitlement still riding on it (the advisory-retire race), and making the child whole means moving an already-recorded quantity from the dead edge onto the live one. A grow-only GCounter cannot be decreased, so the move rides a second monotone counter that cancels the first — the PNCounter idiom, applied per-edge-per-slot:
effIssued(e)[r] = issued(e)[r] + issuedRelocIn(e)[r]
effLeafSpent(e)[r] = leafSpent(e)[r] + leafRelocIn(e)[r] − leafRelocOut(e)[r]
effRollupSpent(e)[r] = rollupSpent(e)[r] + rollupRelocIn(e)[r] − rollupRelocOut(e)[r]Every stored component still only grows; the effective value is derived and may fall, exactly as outstanding/holdings already do. There is no issuedRelocOut — issuance is never net-decreased. Because the five new families are ordinary GCounter maps joined componentwise, piece stays idempotent/commutative/associative by the same product-of-lattices argument that already covers the other eight; adding them makes the CRDT strictly larger, not structurally different.
Slot ownership is what makes this sound. The base counters on a live edge belong to the data plane — replica r writes its own slot, and only ever a value it derived locally. The relocation counters belong to the control plane exclusively (log apply); the data plane never touches them. So a re-home adds its credit to issuedRelocIn(t)[r] rather than fabricating an absolute on the contended base issued(t)[r] that r's own delegate writes concurrently — two writers on one max-joined slot would silently erase one side, with conservation and per-edge safety blind to the loss.
Spend relocation rides the quiesce fence (#1693). Moving an already-charged spend drains the dead edge to zero headroom, so one straggler charge afterwards would leave a permanently unclearable per-edge-safety violation. What makes it safe is that the move's magnitude is derived from log-recorded per-peer promises rather than any peer's gossip view — see relocationPatch and ControlCommand.Quiesce.
Samples
val root = GroupId("root")
val alice = ReplicaId("alice")
val bob = ReplicaId("bob")
// The same mint act (same nonce) observed independently on two peers.
val onAlice = EntitlementLedger.bootstrap(root, mapOf(alice to 100L, bob to 100L), nonce = "genesis")
val onBob = EntitlementLedger.bootstrap(root, mapOf(alice to 100L, bob to 100L), nonce = "genesis")
// Merging is idempotent, commutative, and associative — either order agrees.
check(onAlice.piece(onBob) == onBob.piece(onAlice))
// Reading one edge's summary is a pure projection (null here — no edges minted yet).
check(onAlice.edge(AttachmentId("acme")) == null)val root = GroupId("root")
val leaf = GroupId("leaf")
val alice = ReplicaId("alice")
val e = AttachmentId("root→leaf")
var ledger = EntitlementLedger.bootstrap(root, mapOf(alice to 100L), nonce = "genesis")
// prepare then activate the edge, then delegate 10 units down it.
ledger = ledger.piece(checkNotNull(ledger.prepare(AttachmentRecord(e, root, leaf, Weight.ONE))))
ledger = ledger.piece(checkNotNull(ledger.activate(e)))
ledger = ledger.piece(checkNotNull(ledger.delegate(alice, e, 10L)))
// Close the edge, then try to retire it while entitlement is still outstanding — refused.
ledger = ledger.piece(checkNotNull(ledger.close(e)))
check(ledger.retire(e) == null) // 10 units still outstanding
// Drain it (return the grant), then retire succeeds; the register now reads RETIRED.
ledger = ledger.piece(checkNotNull(ledger.release(alice, e, 10L)))
ledger = ledger.piece(checkNotNull(ledger.retire(e)))
check(ledger.lifecycle(e) == Lifecycle.RETIRED)Functions
Promote edge to Lifecycle.ACTIVE — delegation across it is now admitted (design §5.3). null if edge is unknown/divergent, or already Lifecycle.CLOSING/Lifecycle.RETIRED (a closing edge cannot be resurrected — closure dominance, §10.10). Idempotent from prepared/active.
Summaries of every Lifecycle.ACTIVE edge whose parent is parent, in a deterministic order (by AttachmentId). Non-active generations (prepared, closing, retired) are excluded — this is the parent-facing scheduling view, and only active edges are candidates. An edge counts if any record under its id names parent and its lifecycle is active — divergent records are retained, not collapsed, and their reconciliation is a later concern.
id's base issuance — Σ_r issued(id)[r], excluding relocation credit. This is the Gauge's fold axis, and it is published because a consumer holding a gauge cannot interpret the register without it: the read is floor + (baseIssuance − folded) / w, and edge's EdgeSummary.issued is the effective value, which deliberately is not this.
Promote edge to Lifecycle.CLOSING — no new delegation is admitted, but spend and release still drain it (design §5.3). null if edge is unknown/divergent, or already Lifecycle.RETIRED. Idempotent from closing.
The parent-facing EdgeSummary for id, or null if the edge is entirely unknown to this ledger. Reported at effective values (base ± relocation, see the class KDoc), so an edge that received a re-homed generation reads the credit it now carries and a drained one reads zero outstanding. spent is the total charged through the edge — effective leafSpent + rollupSpent.
id's stored virtual-time Gauge, or null when the edge carries none — either it has never been seated, or this view is simply missing the patch that seated it. The two are indistinguishable here on purpose: absence is the seat-bump predicate (see Gauge), and it is a local read, so a peer that has not seen the seat may re-seat from its own front. That is safe precisely because its bump folds its own observed issuance.
The live entitlement path from the root down to group's inbound edge, in root→group order (empty when group is the root), or null if the lineage is quarantined (a divergent record, two live inbound edges, no live path to the root, or a cycle — see holdings/validate). A caller that must charge service against a captured path (design §4.4) reads this at reservation time, while the topology is valid, and later hands the captured list to spendCaptured.
The join: the componentwise least-upper-bound of this and other.
Introduce a new attachment generation: record record and mark its edge Lifecycle.PREPARED (design §5.3 create). null if the edge id is already known to this ledger — each generation is prepared exactly once; changing a weight or parent mints a new id, never re-preparing an old one. No entitlement may cross a prepared edge until activate.
The single AttachmentRecord for id — its parent, child and weight — or null if id is unknown or divergent (two conflicting records under one id, which a healthy ledger never has; see validate). The parent-facing read a scheduler pairs with edge's EdgeSummary to build a policy input.
Finalize a drained edge: promote edge to Lifecycle.RETIRED (design §5.3). null unless edge is currently Lifecycle.CLOSING and fully drained (EdgeSummary.outstanding == 0, i.e. every delegated unit has been returned or spent). This is the drain gate: a retire is refused while entitlement is still outstanding across the edge. Once retired, nothing crosses again and its history stays queryable via edge forever.
Charge amount of completed service by r at leaf group. requires group is a leaf; null if r's holdings there are insufficient. Charges leafSpent(inbound(group))[r] and rollupSpent(e)[r] for every strict-prefix edge e — one atomic patch keeping per-edge outstanding correct at every level (design fix 1). amount 0 is a no-op cancel.
Charge amount of completed service by r against a path captured earlier (design §4.4 / §10.4: "charge every edge of the path captured at reservation; history never moves to a newer generation"). Unlike spend, this does not recompute the lineage or re-check isLeaf/holdings from the current topology — it charges the exact capturedPath edges directly: leafSpent on the captured final edge and rollupSpent on every captured strict-prefix edge.
The integrity faults derivable from this merged state, in one canonical order (identical on every replica). This is an eventually-consistent diagnostic, not a safety gate — safety is the local holdings check in the mutators. On a fully-delivered state the report is exact; the per-patch witness keeps the direct and single-hop-transfer cases honest under partial delivery, but a partially- delivered multi-hop transfer-funded charge may transiently list a false conflict that self-heals on anti-entropy. The checks: