Skip to content

Scope the PRISM append-invalidation epoch per anchor (#126) - #151

Open
kiwidream wants to merge 7 commits into
2.x.xfrom
scope-append-epoch-per-anchor
Open

Scope the PRISM append-invalidation epoch per anchor (#126)#151
kiwidream wants to merge 7 commits into
2.x.xfrom
scope-append-epoch-per-anchor

Conversation

@kiwidream

@kiwidream kiwidream commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the #126 over-abandon: a valid found block terminally abandoned as
append_epoch_stale without ever reaching qbitd, forfeiting the reward.

The defect

The append-invalidation epoch is a single global counter. An append whose
share stamp is later than this candidate's declared anchor — but early enough
to predate some newer exposed anchor — bumps the counter, and the landing's
pre-offer fence reads effective_append_epoch != live_append_epoch and
abandons. The counter cannot distinguish an append that invalidates this
candidate's window from one that does not. On the fallback submit path the
block is abandoned before any node offer.

The fix

Scope the epoch comparison per anchor:

  • Each bump now records its causing row's stamp —
    max(job_issued_at_ms, accepted_at_ms), exactly the quantity the existing
    predates-anchor comparison turns on — in a bounded map (256 entries; ample
    against concurrent landings while failing closed once pruned) with a
    retained-floor watermark.
  • Both landing fences (the advisory pre-offer read and the authoritative
    fence-held read under _payout_append_landing_fence_lock) now ask an
    anchor-scoped predicate: invalidated iff some bump in (baseline, live]
    was caused by a row predating this candidate's declared anchor.
  • Fail-closed everywhere it cannot prove otherwise: epochs equal → not
    invalidated; unknown anchor, baseline older than the retained history,
    backwards epoch, or any gap in the retained window → invalidated.
  • The post-offer branch is untouched (it already logs rather than abandons —
    a candidate that reached qbitd paid its as-issued window when it landed),
    as is the collection_only exemption.
  • The declared anchor is read from found_block["anchor_job_issued_at_ms"],
    the same reader _expose_inflight_scan_anchor uses, so the fences and the
    exposure cannot drift apart. Declared on BlockFinalizationPort, forwarded
    from PrismCoordinator next to the neighbouring epoch helpers.

The ordering argument that makes this sound was verified on this tree, not
assumed: _land_candidate exposes the declared anchor and drains unfenced
in-flight appends before its fences arm, so any append that could predate
the anchor has already bumped — or takes the landing fence and bumps behind
it. The stamp is written in the same locked region as the increment it
belongs to, so no bump can skip the history a landing later reads.

Closing the disarm gap

A deep review of the above found the ordering argument had a hole, and the
second commit closes it. The anchor-scoped predicate reads recorded bumps,
so it is only as sound as the guarantee that every append predating a
landable window's anchor actually bumps. That guarantee did not hold: a
seeded window's anchor is exposed to the append-side predates() checks only
by the armed payout-ledger artifact, and the bump that consults that anchor
also disarms the artifact. The live anchor set could then go empty while jobs
from earlier windows were still landable, and a replay row genuinely
predating such a candidate's declared anchor committed silently — no
bump, no stamp — so the fences read only harmless history and submitted an
invalidated window. On that interleaving the anchor-scoped tree submitted
where the pre-fix tree had failed closed.

The fix makes the anchor watermark universal. Every write to the armed
artifact slot now routes through _replace_payout_ledger_artifact_locked,
which folds the outgoing artifact's snapshot_anchor_ms into the
monotone, never-retired _payout_published_job_window_anchor_ms before the
slot moves. Folding the outgoing rather than the incoming anchor is what
keeps the anchor-set maximum monotone even when a lower-anchored artifact
replaces a higher one. The fold runs under _job_cache_lock — the same lock
every predates() read takes — so no append can observe the anchor gone with
the watermark not yet raised.

The invariant that now actually holds, and that the fence comments now state:
the anchor-set maximum never decreases while any job is landable, so
every append predating a live or seeded anchor bumps and leaves a stamp for
the fences to read. The watermark's permanence is what the per-anchor
predicate makes affordable — the extra bumps a retired anchor forces come
only from replay-shaped rows (the pending-commit floor keeps ordinary share
commits above every job anchor) and are forgiven per anchor rather than
over-abandoning.

The same commit rewords the authoritative-fence comment in
block_finalization.py, which claimed the recorded stamps were "the whole
history the block's window can still see" — true for bumps, but not for
predating appends until the fold above made every predating append bump.

Regression coverage

tests/test_prism_landing_append_epoch.py, 10 scenarios on the
LandingHarness (from the stacked harness PR), all proved order-stable with
assert_deterministic(..., harness_factory=LandingHarness):

  1. The over-abandon, gone — the reproduction now lands: the block reaches
    qbitd, confirms, and is not abandoned. Fails before the fix with
    submitted == [] (verified by reverting block_finalization.py alone;
    the stamp recording is inert without the fence change).
  2. Fail-closed preserved at and inside the boundary — a share stamped at, or
    before, the candidate's declared anchor still abandons with nothing
    submitted.
  3. The authoritative fence — a bump placed between the advisory pre-offer
    read and the fence-held read is terminal when it predates the candidate's
    anchor, harmless when it does not.
  4. Pruned history — a baseline older than the retained window abandons.
  5. No declared anchor — abandons.
  6. The disarm gap — a bump that disarms the artifact must not blind the
    next append. Fails before the fold by submitting: the second append
    returns None (no live anchor covers it), the fences see only the first
    bump's harmless stamp, and the block is submitted and confirmed with an
    envelope written — a coinbase whose window omitted a durable share. After
    the fold the second append bumps and the landing abandons.
  7. A straddling row lands — issued at or before the candidate's anchor,
    accepted after. It does not predate the candidate's window, so the block
    must still land.
  8. A distinct-stamp row inside the window abandons — the companion case,
    both stamps at or before the anchor.

Scenarios 7 and 8 pin the recorded stamp's max(job_issued_at_ms, accepted_at_ms) choice, which every equal-stamp scenario above left
indistinguishable from min() or either stamp alone. Under a min() mutant
scenario 7 fails by abandoning a valid block — #126's over-abandon back
again. The harness's append_late_visible_share gained an additive
accepted_at_ms kwarg to express the straddle.

Testing

  • GIT_CONFIG_GLOBAL=/dev/null python3 -m unittest tests.test_prism_landing_append_epoch tests.test_prism_block_candidates tests.test_prism_block_finalization tests.test_prism_payout_state tests.test_prism_landing_interleavings tests.test_prism_landing_budget_watchdog tests.test_prism_landing_harness_model — 318 tests, OK.
  • Full tests/test_prism_* sweep on Linux — 1812 tests, OK. (On macOS the same
    sweep shows the five known os.splice failures in test_prism_job_builder,
    which are platform-local and pass on Linux.)
  • Docker-based python -m compileall on the CI Python — clean; bash -n over
    all tracked shell scripts — clean.

The harness PR (#148) has merged, so this now targets 2.x.x directly.

Fixes #126.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

kiwidream and others added 4 commits August 19, 2026 11:53
Issue #128 ranks block candidate landing first by incident history, and
the harness landed by #139 covered only the writer-lease lifecycle. This
teaches it the landing state machine, which is the one owner where two
threads of the same process reach the same durable rows.

The PostgreSQL model gains the rows landing turns on: qbit_pool_blocks,
qbit_block_candidate_outbox, the qbit_confirm_pool_block publication
ordinal allocator and the durable floor read. The allocator is modelled
on the shipped PL/pgSQL rather than on an approximation of it: the
ordinal comes from a sequence, so an exact replay of an already-confirmed
row burns nothing, a terminally disposed row reports the superseded
disposition, and an aborted confirmation leaves a gap the MAX-based floor
tolerates. That distinction is the whole of the two-tail defect -- a
later ordinal can publish first and leave an earlier one behind the
floor. Landing writes are staged per transaction for the same reason
lease writes already were: a deadline-scoped statement sends COMMIT as a
separate message, and the interesting window is inside it.

Statement classification is anchored on fragments lifted verbatim from
the production SQL, as the lease signatures already are, so a change to
that SQL breaks classification loudly instead of quietly changing what
the fake models. Where a modelled statement is a large CTE, the model
reproduces the effects a landing reads back and says so.

Three new pieces support the topology. Scheduler-aware locks, conditions,
semaphores and events make a contended process-local lock an interleaving
point rather than a wedged baton -- the lease scenarios never needed
them, because they interleave two processes around one row, while the two
landing tails share the payout-balance serializer, the publication order
guard and the ledger's admission gates. Phase breakpoints let a scenario
stop a tail at a boundary the landing already names for its own watchdog,
because landing's interesting windows are the gaps between statements and
no statement runs at the instant that matters. And HarnessBase now holds
what every state machine's harness owns, so run_scenario and
assert_deterministic are generic over it rather than typed to one
harness; the two owners still owed to #128 plug in without touching the
determinism check again.

The acceptance criterion is #133's two-distinct-hash interleaving, driven
end to end: block A confirms on the synchronous client-thread tail, block
B lands on the accounting actor inside A's confirm-to-publish window and
publishes the next ordinal, and A then publishes behind the raised floor.
It is already fixed here, so the scenario passes; the evidence is that
the interleaving can be expressed at all, which nothing could do before.
The suite also pins the load-bearing claim underneath it -- the per-hash
disposition lease does not serialise distinct hashes -- and the fresh
lease proof that gates the superseded-envelope restore.

Every scenario is proved order-stable over 25 runs of the full checkpoint
trace.

Refs #128, #133.
Issue #125: a landing-class ledger step is two waits, not one. The caller
queues for the ledger writer lock -- local, unbounded by any statement
deadline -- and only then runs the statement it queued for, under its own
full budget. Both are spent on the block-work thread the watchdog monitors,
and before 49f04c3 both were heartbeat-silent, so an escalated landing at
the reviewed 120s cap could hold the heartbeat still for ~240s against a
120s tolerance. The watchdog hard-exited mid-landing and the in-memory
escalation counter died with the process, so the restart came back at the
30s base to repeat the cycle.

Add landing-harness scenarios for both halves of the fix:

- a real accounting tail queued behind a held writer lock stamps
  wait-ledger-admission:landing every slice instead of going quiet, and an
  A/B against the same gate at the same budget with no progress hook shows
  slicing changes nothing about when the wait ends;
- the escalation ladder is climbed by genuinely timed-out landings and
  every rung's admission-plus-statement pair fits the watchdog tolerance,
  across a clamped cap, a clamped base and a configuration needing no
  clamp at all, with the clamp reported exactly once;
- both composed: a landing that queues for all but one slice of its
  escalated budget still lands, inside the tolerance, with no silence
  longer than one slice.

Every scenario is proved order-stable with assert_deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lease half of the deterministic harness is defended by
tests/test_prism_concurrency_harness.py; the landing half added in
cefdbfa was not. A fake that answers an unrecognised statement
plausibly drifts away from production silently, and every scenario
built on it inherits the drift, so a landing scenario that failed
could not distinguish "the code under test is wrong" from "the model
is wrong".

tests/test_prism_landing_harness_model.py pins the model's own
contract in four parts:

- The publication-ordinal allocator against qbit_confirm_pool_block:
  a fresh confirmation takes the next sequence value, an exact replay
  returns 1 without burning one (proved by the next block's ordinal),
  every terminal disposition reports -1 rather than 0, a height
  mismatch and a missing row both report 0, and the durable floor —
  a MAX over the table — stays put across the gap an aborted
  confirmation leaves behind.

- Transaction scope: a deadline-scoped landing write is invisible to
  another session until COMMIT and visible to its own before it, and
  a rolled-back landing leaves no row while keeping its ordinal burned.

- Classification: every LandingOp is driven through the shipped
  PsqlShareLedger method that emits it, a landing table mention that
  matches no signature raises naming _LANDING_SIGNATURES, the deferred
  markers still name their follow-up state machine, and a deposed
  writer fails the lease CTE in the two shapes the SQL dictates — the
  outbox statements report it, the PL/pgSQL functions raise.

- The synchronisation primitives: mutual exclusion and FIFO grants,
  non-blocking and virtual-clock-timed acquires, the non-reentrant
  re-acquire that a real mutex would deadlock on, RLock depth, the
  condition's release/re-acquire and restored depth, semaphore
  exhaustion and over-release, and the event's parked and timed waits.

One landing scenario is proved order-stable with assert_deterministic
against LandingHarness. Nothing sleeps in wall-clock time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The append-invalidation epoch is one global counter, so a landing that
compares its own baseline against the live value learns only that some
payout window was invalidated. A share stamped later than this candidate's
declared anchor -- but early enough to predate a newer exposed window --
bumped that counter without touching this candidate's window, and the
pre-offer fence read the inequality and terminally abandoned the candidate
as append_epoch_stale. On the fallback submit lane that happens before
qbitd ever sees the block, so a valid block's reward is forfeited.

Record, with each bump, the stamp of the row that caused it
(max(job_issued_at_ms, accepted_at_ms) -- exactly what
_pending_share_predates_anchor compares), keep the most recent 256 in a
bounded map alongside the oldest epoch still retained, and let a landing
ask whether any bump in (baseline, live] was caused by a row that predates
its own declared anchor. Both landing fences ask that instead of comparing
counters. The predicate fails closed on everything it cannot prove: an
unknown declared anchor, a baseline older than the retained history, or a
gap in the scanned range. The post-offer branch is untouched -- it already
logs rather than abandons -- and collection_only candidates stay exempt.

The relaxation rests on the existing ordering: a landing exposes its
declared anchor and drains unfenced in-flight appends before its fences
arm, so every append that could predate the anchor has already bumped or
bumps behind the fence.

tests/test_prism_landing_append_epoch.py drives all of it through the
landing harness: the over-abandon (now lands, confirms, publishes), the
fail-closed pairs at and inside the anchor, both fences (the bump placed
between the advisory read and the fence-held one via phase:tip-height-rpc),
a pruned history, and a candidate with no declared anchor. Every scenario
is proved order-stable with assert_deterministic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

kiwidream and others added 3 commits August 19, 2026 13:18
The retention test captured time.monotonic() and asserted that a silent
reconnect cycle at exactly +TTL still resumes, relying on the store's
strictly-greater expiry comparison. That boundary arithmetic is not
exact for an arbitrary float base: (start + 900.0) - start rounds above
900.0 for roughly one in five thousand captured clock values, which
flips the boundary cycle to expired and fails the test. CI hit this on
the push run for the map commit while the pull_request run of the same
commit passed.

Every record and lookup in the test runs under frozen_clock, so the
base never needs to be a real monotonic reading. Pin it to an
exactly-representable constant, making the boundary arithmetic exact.

Refs #145.
The anchor-scoped predicate reads recorded bumps, so it is only as sound
as the guarantee that every append predating a landable window's anchor
actually bumps. That guarantee had a hole: a seeded window's anchor is
exposed to the append-side predates() checks only by the armed
payout-ledger artifact, and the bump that consults that anchor also
disarms the artifact. The live anchor set could then go empty while jobs
from earlier windows were still landable, and a replay row genuinely
predating such a candidate's declared anchor committed silently --
_record_late_visible_payout_append returned None, no bump, no stamp --
so the landing's fences read only harmless history and submitted an
invalidated window. Demonstrated with a deterministic interleaving: the
pre-#126-fix tree failed closed on it; the anchor-scoped tree submitted.

Route every write to the armed-artifact slot through
_replace_payout_ledger_artifact_locked, which folds the outgoing
artifact's snapshot anchor into the monotone, never-retired
published-job-window watermark before the slot moves. The fold runs
under _job_cache_lock -- the same lock every predates() read takes -- so
no append can observe the anchor gone with the watermark not yet raised.
The invariant that now actually holds, and that the fence comments now
state: the anchor-set maximum never decreases while any job is landable,
so every append that predates a live or seeded anchor bumps and leaves a
stamp for the fences to read. The watermark's permanence is what the
per-anchor predicate makes affordable: the extra bumps a retired anchor
forces come only from replay-shaped rows (the pending-commit floor keeps
ordinary commits above every job anchor) and are forgiven per anchor
rather than over-abandoning.

Reword the authoritative-fence comment in block_finalization.py: it
claimed the recorded stamps were "the whole history the block's window
can still see", which held for bumps but not for predating appends
until the fold above made every predating append bump.

Also pin the recorded stamp's max(job_issued_at_ms, accepted_at_ms)
choice, which every equal-stamp scenario left indistinguishable from
min() or either stamp alone: a straddling row -- issued at or before the
candidate's anchor, accepted after -- does not predate the window, and
recording anything but the max would abandon a valid block, resurrecting
the #126 over-abandon. tests/test_prism_landing_append_epoch.py gains
the disarm-gap scenario (premise: the bump disarmed the artifact;
verdict: nothing submitted) and the straddling pair (lands, and abandons
once both stamps predate), all proved order-stable with
assert_deterministic; the landing harness's append_late_visible_share
learns an additive accepted_at_ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The harness PR (#148) was squash-merged, so 2.x.x carries
tests/prism_landing_harness.py under a commit unrelated to this branch's
history. The merge base therefore predates the file on both sides and git
reports an add/add conflict for it.

Resolved in favour of this branch: 2.x.x's copy is byte-identical to this
branch's pre-change version of the file, and this branch's copy is that
same file plus the additive accepted_at_ms kwarg the straddling-row tests
need. Nothing from 2.x.x is dropped.

Everything else auto-merged; the regions are disjoint. Verified both sides
survived: this branch's anchor-set comment and the per-anchor fences in
block_finalization.py, and 2.x.x's idempotent-confirm-replay handling from
#146.
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.

1 participant