Skip to content

fix(core): record a resolved transaction bucket key, so a stray of a spent group lands (C21) - #372

Open
vieiralucas wants to merge 9 commits into
mainfrom
core/c21-tx-bucket-rewrite
Open

fix(core): record a resolved transaction bucket key, so a stray of a spent group lands (C21)#372
vieiralucas wants to merge 9 commits into
mainfrom
core/c21-tx-bucket-rewrite

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes KANBAN C21 — a convergence-law violation: two replicas that folded an identical op set read different state.

The defect. A group's bucket key is (author, TxId). Committing a bucket removed its members from the buffer and kept no record, so a later member of that key started a fresh bucket at an arrival count the group had already met and no arrival could meet again. Which members committed and which were left holding was the arrival order's. Three rewrites reach it, none malformed on any member's own terms:

  • every member of a three-group rewritten to declare two — x,y,z commits {x,y} and holds z; z,y,x commits {y,z} and holds x
  • an unrelated op of the same author re-tagged with a live group's id — m1,m2,m4 commits {m1,m2}, m1,m4,m2 commits {m1,m4}
  • one op id delivered under two envelopes, since apply dedups on op id before reading the tag

The fix. Document::resolved_tx: HashSet<(ClientId, TxId)>; a member arriving under a resolved key is untagged and merges standalone. A key is spent at each of the three points a bucket can be:

  • a bucket commits (drain_buffer)
  • the author mints the group (tag_atomic) — the author applies its own edits as it makes them and buckets nothing, so without this it holds a stray every receiver merges
  • a member arrives naming a group other than the one the buffer holds that same id under — spends both, since only one can ever hold the id

Rides encode_state (STATE_VERSION 12 → 13). Both projections drop it whole: a key names an author and a group, never a partition.

Deliberately not taken. The record does not fire on an id merely applied. A resend is ordinary traffic on any transport that retries, so spending a key there makes state a function of how often an op arrived — the same law broken in the other dimension — and a fabricated group id on a duplicate would grow the persisted record without bound. is_admissible is judged before the dedup, so an envelope no replica may hold decides nothing about the groups this one holds.

Residue, pinned as its own test and filed as C27. Two envelopes of one id naming different groups, where the forged one completed another group's bucket and applied the id before the honest copy arrived. Order-dependent on main already; the record narrows it from a split in what a replica reads to a split in which keys it has spent. Members converge on eviction.

ARCHITECTURE.md §Opt-In: Atomic amended (the paragraph that named this shape open); DECISIONS.md logged.


Summary by cubic

Records resolved (author, TxId) bucket keys and untag late members so every arrival order lands the same ops (C21). Keys are spent deterministically on commit, the author’s mint, eviction, and when a buffered id shows up under a different group (spend both), and the record is key-ascending and persisted in STATE_VERSION 13.

  • Bug Fixes

    • Added Document::resolved_tx; members under spent keys are untagged and merge standalone; decode_state untags-before-drain.
    • Spend keys on: bucket commit; author mint (now drains after spending); eviction; and when a buffered id arrives under a different group (spend both). Do not spend on applied duplicates; only search the buffer when the id is actually buffered; run is_admissible before dedup.
    • Deterministic, key-ascending record encoding (client + tx); project_read_paths and project_zones drop the record to avoid leaking withheld partitions’ group counts. Clarified scope: the closed duplicate shape is a second envelope that disagrees with the copy the buffer holds (group id or declared size); identical envelopes spend nothing; destranded/tagless copies remain a residue; and a destranded recipient never earns the key a whole-delivery one spends (filed as C46). Residues tracked as C44, C45, C46.
  • Migration

    • Snapshot format 1213; older snapshots are rejected. No API changes.

Written for commit 9d1361c. Summary will update on new commits.

Review in cubic

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas force-pushed the core/c21-tx-bucket-rewrite branch 2 times, most recently from 68b99e7 to bedb84b Compare July 29, 2026 03:54
…spent group lands (C21)

A group's bucket key is `(author, TxId)` and committing it consumed the key
with no record kept, so a later member of that key started a fresh bucket at
an arrival count the group had already met and nothing could meet again.
Which members committed and which were left holding was the arrival order's,
so one op set folded to two states: three rewrites reach it — every member of
a three-group declaring two, an unrelated op of the same author re-tagged with
a live group's id, and one op id delivered under two envelopes, where `apply`
dedups before reading the tag.

`Document` now keeps a per-`(ClientId, TxId)` resolved-key set, written when a
bucket commits and when a tagged arrival's id is already held under a
different envelope, and read to untag a later arrival for that key so it
merges standalone. It rides `encode_state` (STATE_VERSION 13) so a restart
does not re-open the hole, and both projections drop it whole — a key names an
author and a group, never a partition.
… the mint

Three defects in the first pass, from a cold review:

- the dedup branch read "not in the buffer" as "a different envelope", so
  every resend of an already-applied tagged op spent its key. A duplicate is
  ordinary traffic on a transport that retries, so state became a function of
  how often an op arrived — and a fabricated group id on such a duplicate grew
  the persisted record without bound. It now fires only where the buffer is
  holding that id under a *different* group, which is the only evidence a
  bucket can offer, and spends both keys rather than the arriving one.
- the tag was read before `is_admissible`, so an envelope no replica may hold
  decided which groups this one released. Admissibility is judged first.
- the author never buckets its own group, so it recorded nothing and held a
  stray every receiver merged. `tag_atomic` spends the key at the mint.

Pins the fourth shape the record does not reach — two envelopes of one id
naming different groups, order-dependent on main already — as its own test.
Second cold review, on the head of the first pass:

- eviction untagged its buckets without spending their keys, so a member
  arriving after one waited on a group the replica had already released. Two
  replicas running one eviction policy then disagreed over nothing but which
  had ticked when the last member landed — a regression against main, where
  "both evicted everything" was a convergence point. Eviction is the fourth
  point a bucket resolves and now spends the key like the other three.
- the dedup branch built its buffer lookup eagerly, so every resend paid a
  linear scan of an uncapped buffer (44x on a 2000-op buffer). It searches
  only for an id the buffer can hold.
- the mint spent its key without draining, leaving a released member in the
  buffer until some unrelated arrival ran one.

Four new lines had no test killing them: the record's sort (load-bearing —
two replicas encoded different bytes without it), both projection scrubs, and
the untag on decode. Each has one now.

Files C28: unanimity is judged over the members that have *arrived*, so a
unanimous subset can reach its count before the dissenter lands — a minority
count rewrite folds one op set to two states. Pre-existing on main; the
ARCHITECTURE sentence that implied otherwise is corrected rather than left.
…'s order

Third cold review: HIGH nothing, and two of the previous pass's four "now
covered" lines were still undefended — the second commit's own claim was
wrong, because a slice-based edit had swallowed one of the tests it added.

- `project_zones`' record scrub had no test; `project_read_paths`' did. A
  zone-scoped snapshot now has to bucket a stray of a withheld zone's group,
  and to stay the same size whether that zone resolved one group or nine.
- `tag_atomic`'s drain had none. Restored and verified by deleting the line.
- the record's ordering rested on two hash sets happening to disagree; it now
  folds the same groups in every rotation.

Also corrects ARCHITECTURE and KANBAN C20, which still required an age input
or an evict-only-what-was-waiting contract before a periodic tick could be
safe. Eviction spending its key removed that: a replica that ticked between
two members of an honest group and one that did not now read the same state
and hold the same record. What remains is stated instead — a replica that
never evicts still does not converge with one that does, and the buffer of
untagged not-yet-ready members still differs by tick placement, so it is
state and record a bare period preserves, not snapshot bytes.
…ecord's order

Fourth cold review, plus the direction-blindness lesson from C24: a rule that
is consistently wrong satisfies "two orders agree" perfectly, so every
comparison the fix introduces was mutated to see whether a *named* test
objects.

- the record's sort direction was the one that did not. Reversing it passed
  the whole suite, because the only assertion was that rotations agree — and
  they agree in any order. The encoded section is now read back and asserted
  key-ascending. (The outcome-deciding comparisons were already pinned: the
  conflict guard by 3 tests, the stray untag by several, take_complete_tx's
  lowest-position rule by 1.)
- four sites claimed the third closed shape covered "a destranded copy racing
  the tagged one". It does not: the record spends a key on evidence the buffer
  is *holding*, and a destranded copy carries no tag to disagree with, so an
  honest three-member group plus a bare copy of one member folds to 2
  observable states over its 24 orders — no forgery, and identical on main.
  The closed shape is two envelopes of the *same* group; C27 is widened to own
  both ways a second envelope can go uncontradicted, and the (b) half is
  reachable on honest traffic.
- the growth bound overstated itself in both clauses: one key per op is equal
  to, not smaller than, the dedup set, and a peer sending conflicting tags
  charges two per id. Restated, and pointed at C20's missing buffer cap.
… correctly

Fifth cold review: HIGH nothing, three MEDIUMs.

- judging `is_admissible` before the dedup is load-bearing and nothing pinned
  it — the previous commit claimed every introduced comparison was mutated,
  and this one was not. With the check moved back, a forged `count: 0` on a
  *held* member's id spends both keys and releases the honest group waiting
  under one of them: an envelope every replica refuses deciding what this one
  shows. Now killed by name.
- the previous commit over-corrected the third shape to "two envelopes of the
  same group". Wrong in both directions: identical envelopes spend nothing,
  and the branch's own passing test closes a different-groups case. What the
  record closes is a second envelope that disagrees with the copy the buffer
  is *holding* — in group id or in declared size; what C27 owns is a second
  envelope with nothing tagged to disagree with.
- "the record narrows C27 from a split in what a replica reads to a split in
  which keys it has spent" was false. On the orders that buffer the
  disagreeing copy the conflict rule fires and everything lands, so a pool
  that read two ways without the record can read three with it. Measured over
  392 forged pools: better on 156, unchanged on 232, worse on 4; eviction
  still collapses every one. Stated as measured.

Also: the record's sort ignored its client component (two clients collide on a
TxId readily, since it hashes member seqs alone) — the test now folds two
authors; both projection tests now complete the bucket with a member on its
own key, so the reading tells "held" from "refused"; and the eviction tick
claim is scoped to a caller that keeps ticking.
@vieiralucas
vieiralucas force-pushed the core/c21-tx-bucket-rewrite branch from b7e20a0 to 52586ec Compare July 29, 2026 06:13
Sixth cold review: HIGH nothing, one measured regression against main.

The record is per-replica *evidence*, and ARCHITECTURE says every filtering
seam destrands the survivors of a group it cuts — so the recipient that most
needs the key is the one with no evidence to earn it. The author spends the
key at the mint and a whole-delivery recipient spends it on commit, but a
zone- or read-scoped recipient served the same group untagged never buckets it
and never spends it. A forged stray under that group's id then merges at the
first two and is held at the third, where on main all three held it alike.

Attacker-triggered (it needs the forged stray this unit exists for) and closed
by eviction, which is another reason C20 is a correctness policy. Filed as C46
rather than fixed here: the other way is for the destranding seams to spend the
keys they cut, and a snapshot projection would first have to answer what it may
reveal about a group straddling its cut — the naive `extend(split)` re-adds
groups that fell wholly inside the withheld partition, which is exactly what
the record's clear exists to hide.

Also corrected: the growth bound named the buffer cap, but eviction empties the
buffer and keeps the key — the bound is the dedup set; the conflict rule's
credit went to the `buffered` short-circuit rather than to `buffered_tx`, which
is the gate that decides; and a TxId collision within one author now costs the
later group its atomic view rather than merely sharing a bucket.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants