Skip to content

v3.1.0 chore: consolidate release for main - #783

Merged
kiwidream merged 351 commits into
mainfrom
RobertClarke/main-v3.1.0
Oct 27, 2026
Merged

kiwidream merged 351 commits into
mainfrom
RobertClarke/main-v3.1.0

Conversation

@RobertClarke

@RobertClarke RobertClarke commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Promotes the released v3.1.0 to main, following docs/releasing.md and #778, which did the same for v3.0.0. The head merges main (b9579c67, #781) with the v3.1.0 tag (59194d03, #782) and takes the tag's tree. The repository allows only squash and rebase merges, so this lands as one squashed commit. v3.1.0 is already tagged and published on 3.x.x, so merging this PR adds no tag.

What main becomes

The v3.1.0 tag's tree plus what main has that 3.x.x doesn't. Every file that differs from the tag:

git diff v3.1.0 HEAD --stat
 README.md                                          |   6 +-
 .../src/coordinator/miner_submit.rs                |  38 ++-
 .../src/coordinator/miner_submit_acquire_tests.rs  |  18 +-
 doc/release-notes-3.0.0.md                         | 284 ++++++++++++++++++++-
 doc/release-notes-3.1.0.md                         |   6 +-
 doc/release-notes-3.x.x-development-line.md        |  15 +-
 docs/prism-ledger-ops.md                           |   2 +-
 docs/prism-pool-acquire-metrics.md                 |  22 +-
 docs/prism-rust-migration.md                       |   2 +-
 docs/releasing.md                                  | 111 ++++++++
 scripts/prism_pool_acquires.json                   |   2 +-
 11 files changed, 454 insertions(+), 52 deletions(-)

Why main keeps each one:

main's own content is all kept

git diff v3.0.0 origin/main --stat lists exactly 8 files:

No main commit since #778 touches anything else. main's workflows equal v3.0.0's, so taking v3.1.0's CI and load-harness workflows loses nothing.

Against main

git diff origin/main HEAD: 202 files, +54,745 / −777, 66 of them added. It is 3.1.0 over 3.0.0:

Checked locally

  • python3 scripts/check_version_skew.py: VERSION 3.1.0 matches every workspace crate.
  • python3 scripts/check_prism_settings.py: PRISM setting inventories and operator guidance agree.
  • python3 scripts/check_toolchain_pin.py: Rust 1.98.1 is pinned consistently ….
  • python3 scripts/check_prism_pool_acquires.py passes.
  • cargo metadata --locked passes for the root and fuzz workspaces.
  • cargo deny --locked check: advisories ok, bans ok, licenses ok, sources ok.
  • cargo check --locked --workspace --all-targets, cargo clippy --locked --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass.
  • Every relative link and anchor in the changed Markdown files resolves.
  • Local /code-review medium rounds of git diff v3.1.0 HEAD ran before pushing, and their findings were fixed. These were all wording in docs/releasing.md and the development-line note, where "this branch" and "the branch head" now name 3.x.x. One finding was left as it is: main: update tokio for RUSTSEC-2026-0386, and keep the block-proof acknowledgement bound from dropping a disposition poll's connection #781's mapping of a panicked poll task to a retried poll-error. That is main's merged code, carried unchanged.

L3

As on #778 and #782, the 400k two-frontend presets can fail on #716's commit-gate refusal. That is #779, and it is handled separately.

Follow-ups

  • Codex thread r4381993943 (P1) is deferred to 3.x.x. The peer sync's apply_prepared takes no cluster-row fence against blob pruning, so a pulled job can commit while the pruner deletes an existing blob it reuses. The code is v3.1.0's, unchanged here; this PR carries the tag's tree and fixes nothing in it.

After merge

  1. Open sync PRs that merge the released main into 3.x.x and into 3.0.x (docs/releasing.md, "Reconcile the development branch"). They bring main: update tokio for RUSTSEC-2026-0386, and keep the block-proof acknowledgement bound from dropping a disposition poll's connection #781's part 2 and the released 3.0.0 notes back to 3.x.x. Don't reset or force-push either branch.
  2. main needs a second human approval; no admin bypass.

🤖 Generated with Claude Code

kiwidream and others added 30 commits September 11, 2026 14:58
* docs: design record for WindowRef and Ledger::read_window (#264)

Add docs/prism-coordinator-refactor/window-ref.md, the design record that
#265 (migration 007) and #273 (migration 008) implement against, and link
it from the directory README.

The record specifies:
- WindowRef { anchor_ms, prior_balances_digest, shares: Option<ShareRange> }
- the six window_* columns and their three-state CHECK
- Ledger::read_window: one REPEATABLE READ READ ONLY transaction,
  mandatory ascending keyset paging, a streaming digest, typed errors and
  never a partial window
- per-caller revision fences that keep today's observe_candidate rule for
  claims
- the non-window bundle inputs a rebuild stores instead of reading config
- the upgrade procedure, retention floor and expected cost at 400k shares
- the borrowed qbit-prism parts API merged in #296

It went through two adversarial reviews.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: hex-serialize WindowRef digests and clamp the resume timeout (#264)

Two fixes to the WindowRef design record after review:

- A derived Serialize writes a [u8; 32] as a JSON array of 32 integers, not
  the lowercase 64-hex string the payload and column contract requires. Both
  digest fields now carry #[serde(with = "hex32")], a small adapter in
  ledger/window.rs that writes lowercase hex and reads exactly 64 lowercase
  hex characters. The server's hex dependency has no serde feature.
- The proposed resume timeout, initial_job_timeout_seconds minus 5 s, is
  zero or negative for any setting at or below 5 s, which validation allows.
  It is now outer - min(5 s, outer / 2), computed from the Duration the
  caller already builds: 25 s at the 30 s default, always positive, strictly
  shorter than the outer timeout, and free of Duration underflow.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: bootstrap resumes use stored policy inputs (#264)

An empty-window resume rebuilt each miner's bootstrap bundle through
Coordinator::build_bundle. That function reads config.ctv_enabled,
payout_policy, ctv_direct_floor and ctv_config, so after a config or
fingerprint reset a resumed job could get a different signed bundle under
the same job ID. That is the drift the stored-inputs invariant forbids.

The record now has the per-miner bootstrap rebuild call the borrowing
builders directly. It takes its own build_slots permit after the
single-flight entry's permit is released. Its inputs are the synthetic
share build_bundle fabricates today, the template, anchor, payout_policy
and ctv stored in StoredPrepared, and the balances and revision read_window
returned. #273's must-not row now forbids build_bundle for any resume
rebuild.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: readable imported audits and a race-free upgrade order (#264)

Two fixes to the WindowRef design record after review.

Imported legacy audits: #265 stores canonical_audit_bytes and no inline
body. Only audit_canonical_bytes reads those bytes today. Ledger::audit_bundle
returns just the JSON column, so backfill-ctv would stop on imported rows,
and the bundle endpoint's fallback reads only body_uri. The record now
requires the same PR to make both readers decode the stored canonical
bytes first, digest-checked and under spawn_blocking. Leaving an imported
audit readable only through body_uri is now forbidden.

Upgrade order: a zero pending count taken while the pre-007 frontends still
run proves nothing, because a block can be found before they stop. The
count is now taken after every frontend is stopped. A non-zero count means
restarting the pre-007 frontends to drain and repeating, and 007's refusal
remains the backstop.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: one owned window per Prepared and the established lock order (#264)

Two fixes to the WindowRef design record after review.

Window ownership: Prepared and JobContext would have held an owned
AuditBundle beside the Snapshot. into_bundle takes the share Vec by value,
and two Arcs don't share storage, so every resume would keep a second
window: about 0.75 GB per 400k rebuild instead of 0.5 GB. Both structs now
hold Arc<Snapshot>, the only owned window, next to Arc<AuditBundleBody>.
Canonical bytes and verification come from the parts functions, and an
owned bundle is assembled only at claim.

Lock order: the D6 prune now takes SETTLEMENT_LOCK and then ORDER_LOCK,
the order every existing dual-lock site uses, so it can't form an
advisory-lock cycle with a refresh snapshot or a candidate finalization.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: version the audit builder behind durable references (#264)

Stored inputs reproduce a bundle only under the builder that first built
it. If a release changes builder output, a pending candidate that survived
the upgrade would rebuild into a different bundle than its coinbase commits
to, and fail on every retry or land changed audit data.

qbit-prism gains AUDIT_BUILDER_VERSION, which both the 007 candidate and
008's StoredPrepared store. A rebuild checks it before read_window: a
mismatched candidate is retried with an alert and never rebuilt with the
current builder, and a mismatched job is a cache miss. A release that
bumps the constant ships a migration step that refuses while pending
outbox rows carry another version; they drain through the 007 procedure.
Keeping frozen old builders is recorded as the rejected alternative.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: pin the signing keys behind durable references (#264)

The bundle embeds both public keys and their signatures, so a rebuild
reproduces the original bytes only under the original keys. Rotating a
key takes an operator resetting the cluster fingerprint, and a candidate
still pending across that reset would be re-signed into bytes its
coinbase does not commit to.

Both documents now store signer_keys, the two public keys the building
frontend signed with. A rebuild checks them next to the builder version,
before read_window, and never re-signs a mismatch with the local keys.
#265 extends configure to refuse a new fingerprint while a pending outbox
row stores other keys, so the operator drains with the old keys first.
Keeping retired seeds online to reproduce old signatures is recorded as
the rejected alternative.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: reuse the refresh-time WindowRef at submit (#264)

Submit built the candidate's reference from the prepared Snapshot, which
re-hashes and re-serializes the whole 233 to 260 MB window whenever a
block is found. That puts 0.7 to 1.4 s of digest CPU on the
latency-critical share path, on top of the once-per-refresh cost the
cost table budgets. refresh_once already computes the identical
reference, so Prepared now keeps it as window: WindowRef. Submit clones
that small value into the candidate, a resume takes it from
StoredPrepared, and re-digesting at submit is a must-not for #265.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: sort rebuilt prior balances canonically (#264)

prior_balances_digest sorts internally, but AuditBundle.prior_balances
serializes in vector order. read_prior_balances has no outer ORDER BY,
and SQL doesn't guarantee the function's internal order through a table
expression. Balances returned in a different order at claim or resume
would pass the digest check and still change the canonical bundle.

read_prior_balances now sorts with the digest's comparator (order_key,
recipient_id, p2mr_program_hex, byte order). Refresh and every rebuild
share that function, so the builder gets the same vector. A SQL ORDER BY
alone would follow the database collation, not the digest's byte order.
A #265 unit test requires permutations to give identical bytes.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: finish recovered claims from landed digest columns (#264)

A claim that finds its block's audit already landed finished through
materialize_audit_row. That function reads the whole range unpaged,
serializes and clones it on the async runtime, and builds an owned
AuditBundle: two share arrays at 400k on the submit loop, which the
cooperative 60 s deadline cannot interrupt.

The recovery branch now checks the landed row's columns alone, as
today's landing does for an existing audit. share_snapshot_sha256 must
equal window_snapshot_sha256, found_block_bits must match the header,
and the claim then finishes. materialize_audit_row on the submit loop is
a #265 must-not. If that path ever needs the body, #267's paged,
parts-based reader becomes a prerequisite.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: re-pin the WindowRef record to 3.x.x after #283 (#264)

#283 landed through #300: the 2.x.x tip merge and the split of ledger.rs
into candidates, jobs, window and connect submodules. The record's code
citations were pinned to 1398bbc, and many pointed into the old ledger.rs.
Every citation now resolves at 3.x.x 41a2afd: 76 of 252 changed, and all
247 cited line numbers were checked against that tree.

"Where the code lives" now describes what #300 landed. ledger/window.rs
exists under workstream B and already holds share_from_row and
read_prior_balances, and SELECT_SHARE stays in the module root. The only
question left for Anatolie is whether read_range moves out of A-owned
ledger/audit.rs.

Two review fixes in the landed-audit recovery path. A retried claim
authenticates the landed audit against the block it found: the landed
coinbase must match block_hex, and the root of the landed commitment
leaves must equal the coinbase's witness reserved value. For an empty
window, the snapshot comparison uses the digest of [bootstrap_share],
because window_snapshot_sha256 is NULL there.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep pool connections free during window rebuilds (#264)

build_slots bounds concurrent rebuilds at build_workers, but that doesn't
protect the database pool. PRISM_DATABASE_MAX_CONNECTIONS may be as low as
4, build_workers may exceed it, and share appends and candidate-lease
heartbeats share the pool with a 15 s acquire timeout. Rebuilds of
distinct jobs could hold every connection for their whole multi-page
reads, and a heartbeat that fails to get a connection drops recoverable
claim work.

read_window now also takes a permit from a window_reads semaphore sized
clamp(database_max_connections - 2, 1, build_workers). The caller takes it
after build_slots and releases it before the rebuild, so two connections
always stay free. Calling read_window without that permit is a must-not
for #265 and #273. A dedicated pool was rejected because it would exceed
the configured connection budget.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: continue to submission after recognizing a landed audit (#264)

The recovery path finished a claim as soon as it authenticated a landed
audit. But an inactive block lands before the lease renewal and
submitblock, so an earlier claim can land and lose its lease before it
submits. Finishing then would abandon a valid found block that was never
submitted.

A recognized landed audit now means landing is done. The claim skips
read_window, the rebuild and land_candidate, and continues through
today's observe, renew and submitblock flow, finishing only where today's
code does: an active block, or an inactive one whose revision or parent
moved. Finishing a claim only because its audit landed is a #265
must-not.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: reserve in-flight windows and scope the claim deadline (#264)

Two review fixes.

The retention floor covered written references only. Ledger::snapshot
releases its locks when its first transaction commits, and the scan, the
bundle and save_job follow with no row referencing the window yet. A prune
in that gap, after a fall in difficulty, could delete rows of the window
being built. D6 now adds a reservation. The snapshot's locked first
transaction inserts it, the refresh fills in the window start after the
scan, and save_job deletes it (publishing nothing if it expired or the
rows are gone). The prune skips while any reservation is unresolved and
otherwise keeps every reserved window. An age-based horizon was rejected
because a window's reach in time depends on hashrate.

The claim's 60 s timeout covered the whole call, including submitblock,
whose RPC timeout may be up to 86,400 s. It now covers read_window plus
the rebuild only, and the later steps keep their existing deadlines.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: answer djh58's four B contract points (#264)

djh58's review on #273 (comment 5624891010) raised four points against
the resume contract. The record now:

- lets B's published-identity and lease check decide resume eligibility
  instead of revision equality, and rebuilds eligible work with its
  as-issued balances from an immutable qbit_prism_balance_snapshots row
  keyed by prior_balances_digest;
- keeps reconstruction failures (Incomplete, digest mismatch, decode,
  database, expiry) distinct from missing work, under one end-to-end
  deadline, the caller's existing initial_job_timeout_seconds, with no
  inner timeout;
- names both share digests, the native snapshot_sha256 and #274's
  canonical_digest_hex, amends #274's reuse instruction, and adds a cost
  model for a sliding window;
- moves the template out of the prepared payload into an immutable
  qbit_prism_templates row, keeping #273's 1 MB criterion whole-value, and
  moves #265's block_hex into a bytea column.

It also records the permit-ownership and coalesced-waiter rules from the
same review.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: add the balance source to read_window and authenticate block bytes (#264)

The resume contract needs as-issued balances, but read_window's declared
API had no way to ask for them, and the read algorithm always used the
current set. Implemented literally, it would have returned
PriorBalancesChanged for the published work it was meant to rebuild.
read_window now takes a BalanceSource. Current reads the current view as
before. AsIssued reads the qbit_prism_balance_snapshots row keyed by the
reference's digest, sorts it with the digest's comparator and verifies it.
A missing row is the new BalanceSnapshotMissing, and a mismatching one is
Decode. Claims pass Current, and resumes of published work pass AsIssued.

Moving block_hex to a bytea column took it out of candidate_sha256. The
candidate JSON now keeps block_sha256. Claim decode checks it and that the
block header hashes to block_hash before any witness derivation, landing
or submission.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: prune externalized templates and balance snapshots with their jobs (#264)

Moving each full template into an immutable qbit_prism_templates row left
no cleanup for those rows. Templates change on every new transaction set
and at every reanchor, so each is a new, possibly multi-megabyte row, and
prune_expired_jobs deletes only job rows. The balance snapshots had the
same gap.

prune_expired_jobs becomes one transaction under SETTLEMENT_LOCK, the lock
save_job holds while it inserts the template, the balance snapshot and the
job row. It deletes the expired batch, then the template and balance rows
that batch referenced and no remaining job row references, in two
statements, because a data-modifying CTE's NOT EXISTS would still see the
deleted jobs. The job row gains an indexed template_sha256. Balance
snapshots follow job rows only, since claims read the current balances.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep the issued revision on resume and ratchet D6's horizon (#264)

Two review fixes.

A resumed Snapshot now keeps StoredPrepared.payout_revision, the revision
the job was issued with. read_window's current revision is only B's
eligibility input and the claim's probe hint. The job wire and any
candidate take their revision from the snapshot, so a block found on R0
work carries R0, and observe_candidate takes the superseded path instead
of retrying PriorBalancesChanged forever.

A D6 horizon computed from today's difficulty fails when difficulty rises
after a prune: Ledger::snapshot stops when rows run out, so it would
publish an underweight window. D6 now needs a ratcheted horizon kept at a
durable maximum difficulty in qbit_prism_cluster. It must also record the
share_seq it deleted below, so snapshot fails when its scan reaches that
floor before the requested weight.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop rebuilt windows from resumed jobs once the coinbase is built (#264)

build_workers bounds how many rebuilds run at once, not how many rebuilt
windows stay resident. Each resumed JobContext kept its Prepared, with
Arc<Snapshot> and Arc<AuditBundleBody>, and a session keeps up to 64 jobs
per connection. Sequential resumes of different jobs could therefore pin
about 0.5 GB each, and the stated k x 0.5 GB peak didn't hold.

Once the rebuild has produced the job's coinbase, a resumed Prepared keeps
only what submit reads: the WindowRef, the snapshot scalars, the template,
the fee and the body's non-window fields. Submit reads nothing else, and
the whole-bundle clone into the candidate goes with #265. Snapshot.shares
and reward_manifest.shares are dropped before the job reaches the session,
so windows exist only inside rebuilds.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: stop old signers before a reset and fence writes on the fingerprint (#264)

configure's one-time pending-row check could pass while an old-key
frontend was still running. That frontend could then find a block and
enqueue a candidate with the old signer_keys, which no new-key frontend
can rebuild, and enqueue is not fenced by the fingerprint today.

A rotation now follows the 007 order: drain, stop every old-key frontend,
then check that nothing is pending, reset and start the new frontends.
#265 and #273 also re-read config_fingerprint FOR SHARE inside every
enqueue and save_job transaction and refuse writes under a foreign
fingerprint. FOR SHARE conflicts with configure's FOR UPDATE, so a write
and a reset cannot interleave, and a procedure violation fails loudly
instead of leaving rows nobody can rebuild.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep every stored input in slim resumed jobs (#264)

The slim resumed Prepared kept the WindowRef, the snapshot scalars, the
template, the fee and the body's non-window fields, but not the stored
payout_policy and nested ctv. A candidate found on that job must persist
those exact inputs, and neither the fee nor the coinbase manifest can
recover them, so submit would have had to read local configuration.

The slim form now keeps the decoded StoredPrepared itself: payout_policy,
the whole ctv, audit_builder_version, signer_keys, coinbase_suffix,
template_sha256, the WindowRef and the snapshot scalars, all O(1). For an
empty window it also keeps the miner's own bootstrap_share. Only the
window is dropped. Dropping a stored input before enqueue is a #273
must-not.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: let the published-work lease reach submission (#264)

Keeping R0 on a resumed snapshot doesn't help if its found block never
reaches observe_candidate. Today submit rejects work whose frontend or job
revision differs from the ledger's, apart from parent-stale work inside
the stale grace. That grace work is credited as a share, but a candidate
is enqueued only when the work isn't stale, so its block is dropped.

B8's lease now decides those submit checks the way it decides resume
eligibility. Work the lease covers is not stale because the revision
changed, and a block found on it is enqueued as a candidate carrying R0,
with its as-issued window and inputs. observe_candidate then decides at
claim whether it lands. Work outside the lease keeps today's handling.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: submit leased candidates first and clear terminal block bytes (#264)

Two review fixes.

A candidate enqueued from leased R0 work still met the unchanged claim
fence, which would finish it as superseded before submitblock and discard
the block. The candidate now records a leased flag. A leased candidate
skips the pre-submit supersession checks and the pre-submit landing,
renews its lease, calls submitblock, and only then meets
observe_candidate. Whether an active leased block lands is B8's rule with
#289.

Moving block_hex into the block_bytes bytea column meant the terminal
UPDATE no longer removed the block. It now NULLs block_bytes too, so the
outbox doesn't keep every finished block forever.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct claim-flow line citations to 3.x.x 41a2afd (#264)

The landed-audit continuation and leased-candidate paragraphs cited
coordinator.rs claim-flow lines read from a later 3.x.x, where #308 had
shifted them by five. They now point at the record's pin, 41a2afd:
the post-rebuild observe at :1039, the active branch at :1040-1048, the
inactive supersession at :1050-1063, the landing at :1067-1069, the
post-land check at :1070-1080, the lease renewal and submitblock at
:1084-1092, and the post-submit observe at :1095.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: submit leased candidates before any rebuild (#264)

A leased candidate skipped the pre-submit fences, but the #265 claim
rebuild still called read_window with BalanceSource::Current before
them. For an R0 candidate that returns PriorBalancesChanged and retries
forever, so the block never reached submitblock.

A leased candidate now submits before any rebuild, because submitblock
needs only the stored block. If the block is active, its audit rebuild
uses BalanceSource::AsIssued. A non-terminal leased outbox row therefore
keeps its balance snapshot. The prune checks for such rows and takes
SETTLEMENT_LOCK then ORDER_LOCK, so it cannot interleave with a leased
enqueue.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: release rebuilt windows off the runtime; hold the lease to unchanged balances (#264)

Two review fixes.

Freeing a completed rebuild's last Arc<Snapshot> and Arc<AuditBundleBody>
drops about 0.5 GB on whichever Tokio worker releases it, which can stall
lease heartbeats and share processing. The rebuild's spawn_blocking closure
now builds the coinbase and the slim resumed form and drops the window
there. Every other release of a rebuilt window goes through
spawn_blocking(drop).

An active leased block rebuilt with R0's balances could not land, because
landing requires the current balances. Until B8 and #289 define how such a
block lands, the lease covers only revision changes that leave
prior_balances_digest unchanged, and a balance change ends it at once. An
active leased block then lands through today's path, and the open
question asks djh58 and #289 for the full rule.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: land first-time claims from parts; sweep orphaned balance snapshots (#264)

Two review fixes.

A first-time landing assembled the rebuilt window into an AuditBundle, then
cloned it, serialized it whole for the stored body and the canonical length,
and re-read the range unpaged, all on a Tokio worker. At 400k shares that is
several half-gigabyte representations on the runtime. CandidateClaim now
holds the parts. Landing verifies, serializes and digests them in one
spawn_blocking closure before its transaction opens. It keeps the range
fence, but as a paged re-read. #267 is not a prerequisite.

The balance-snapshot prune only considered digests from the expired batch,
so a snapshot kept alive by a leased candidate could never be reclaimed once
its job had gone. Snapshots now get their own statement over every row with
no remaining reference.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep the bootstrap share in local jobs; correct local window residency (#264)

Three review fixes.

An empty-window local job held its only full AcceptedShare in
JobContext.bundle. Once JobContext holds a body, that share would be gone
and submit could not fill Candidate.bootstrap_share. JobContext now keeps
the synthetic share beside the body.

The record claimed the local frontend keeps one full window. Retired local
jobs keep their generation's Prepared for Stratum's retention period, as they
do today. The record now says so. Bounding that is logged as a #273
acceptance note.

The landing bind still copies the stored body into SQLx's argument buffer on
the runtime. The record now names that copy. Measuring it is logged as a
#265 acceptance note.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep null-response leased candidates pending; carry empty-window balances (#264)

Two review fixes.

The record finished an inactive leased candidate as superseded after
submission. Today's wrapper treats a null submitblock result on an inactive
block as unresolved, because it can still be a known side-chain block, and
retries with the candidate intact. Leased candidates now keep that outcome,
so a block that becomes active later still has its audit inputs.

For an empty window, resume waiters build their own bootstrap bundles from
the as-issued balances. The single-flight entry now carries those balances
until every waiter has built, instead of only the rebuilding caller holding
them.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: land a submitted leased candidate before any terminal outcome (#264)

After a null submitblock result on an inactive leased block, the retry
resubmitted before landing. A node can then answer "duplicate", which the
string branch treats as terminal, clearing the unlanded audit inputs.
Today's flow avoids that by landing an inactive block before its RPC, so a
late acceptance stays reconcilable.

A leased candidate still submits first, so delivery never waits on a
rebuild. It then rebuilds with AsIssued and lands at the observed
revision before any terminal outcome, whether or not the block is active,
and only then takes today's three outcomes.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: re-establish candidate references inside the enqueue transaction (#264)

Taking locks in order stops a prune and an enqueue from interleaving, but
it doesn't decide which runs first. A submission that passed its expiry
check could lose to a prune that deleted the job row and its balance
snapshot, and then enqueue a leased candidate pointing at missing data.

Enqueue now re-inserts a leased candidate's balance snapshot from the
as-issued balances the job carries (the slim resumed form keeps them),
and probes the window's first share row, both inside its ORDER_LOCK
transaction. Shares can't be written back, so an expired job row now holds
the retention floor until the prune deletes it, and the prune waits a grace
past expiry. A failed probe still publishes the candidate, with an alert.

Part of #261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(prism): reserve session extranonces safely across sequence wrap

* fix(prism): fence stopped session owners by process incarnation

* fix(prism): drain session guards before shutdown cleanup

* fix(prism): scope migration readiness to mining startup

* test(prism): build complete legacy schema for read-only connect

* fix(tests): route integration databases through test gate

* test(prism): register all gated session coverage

* fix(tests): adapt fixtures to shared session backend
…320)

* docs(prism): generate metric inventory and migrate deployed alerts

* fix(prism): preserve the network-wide connected client alert floor

* fix(prism): evaluate standby lag from observed durable WAL progress

* fix(prism): delete provisioned native rules when gates disable them

* fix(prism): alert on invalid synchronous standby configuration

* docs(prism): record local review evidence and prepare PR draft

* fix(prism): reject stale alert and metric metadata

* fix(prism): alert when public scrape fails

* fix(prism): preserve native failure and paging alerts

* fix(prism): preserve native alert tuning overrides

* Revert "fix(prism): preserve native alert tuning overrides"

This reverts commit 31b2681.

* fix(prism): preserve configurable alert tuning

* fix(prism): render tuning dwells as JSON

* fix(prism): validate rendered alert dwell overrides
* docs(prism): pin intentional legacy health differences

* docs(prism): correct legacy max blocks semantics

* style(prism): format health snapshot assertions
* fix: preserve PRISM issued-work and stale-grace semantics

Separate observed and published tip authority, retain original issued
worker context through replacement and reconnects, and preserve bounded
delivery-based grace without renewing expiry after awaited work.

Exercise real Coordinator decisions and socket interleavings, including
constant node-RPC load across 1, 32 and 128 clients.

Refs #280

* docs: reference PRISM miner parity settings

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* fix(prism): bind share admission to one published payout view

* fix(prism): verify changed block notifications with fresh chain state

* docs(prism): record B8 review regressions and rebased qualification

* test(prism): adapt merged admission fixture to stale grace contract

* fix(prism): classify retained capacity by current published tip

* fix(prism): retain prepared dependencies for issued work

* test(prism): verify issued job dependency persistence

* fix(prism): keep sessions alive while retained work is usable

* docs(prism): record timer coverage and final qualification procedure

* docs: point D2 credit explanation to miner submit module

* test: align candidate lease tip fixture with coordinator state

* fix: integrate miner parity with native metrics registry

* fix: integrate miner parity with metrics registry API

* ci: run issued job dependency acceptance explicitly

* test: keep miner parity fixture publication reachable

* test: complete JSONB gate fixture config

* docs: specify prepared window reference integration plan

* test: restore D2 coordinator coverage

* feat(prism): add window reference schema and authenticated reader

Add the independently approved B273 schema and reader foundation on PR313, with typed reconstruction errors, immutable blob tables, per-version migrations, and PostgreSQL qualification. Runtime prepared/resume, candidate, atomic repair and GC integration remain gated on A265 and shared owner agreement.

* feat(prism): add coherent payout state for lease eligibility

* Keep cancelled window cleanup off runtime workers and qualify current base

* Qualify landed 008 and 009 migration orders on current base

* Preserve legacy balance order and reject fatal window reads

* Prove legacy balance order preservation in PostgreSQL

* Run all gated suites before manifest proof

* Revert "Run all gated suites before manifest proof"

This reverts commit b11cbbb.

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
…m on read (#325)

* 3.x.x: store imported legacy audits as canonical bytes and decode them on read

The legacy-audit import wrote the parsed bundle back into the
audit_bundle JSONB column. That value holds two copies of the payout
window (shares and reward_manifest.shares), so at 400k shares it
crosses PostgreSQL's JSONB container limit. The import now stores
only canonical_audit_bytes and the non-share metadata. It keeps its
idempotency and optimistic guards, verifies off the runtime, and drops
the parsed window before the write transaction opens. Rows that
already hold an inline body keep it unchanged, because the
body-present CHECK needs one of audit_bundle and body_uri.
found_block_bits stays as stored: no audit body carries the compact
bits.

A shared decode_canonical_audit_body helper checks the stored bytes
against audit_bundle_sha256 and parses them with
parse_audit_bundle_value. It returns the logical body the import used
to store inline, and all of that work runs under spawn_blocking.
Ledger::audit_bundle serves native snapshot rows first, then imported
canonical bytes, then inline bodies, so backfill-ctv works on imported
rows. The bundle endpoint serves imported bytes before any body_uri
fallback, on the same public pool. A digest mismatch or undecodable
bytes refuses the row, even when the legacy file still exists. The
settlement-artifacts audit link now also accepts stored canonical
bytes, so it does not decode a whole body just to show a link.

The JSONB ceiling gate drops its import-phase known violation, and the
measurement and capacity-readiness docs now count three
window-carrying writes.

Part of #265.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 3.x.x: load only the served audit body and bound imported-audit decodes

The bundle queries now project no inline audit_bundle for a row whose
canonical bytes supersede it, the same rule Ledger::audit_bundle uses,
and report whether canonical bytes exist so bundle() skips the bytes
query for rows without them. The flag is removed before responding.

Imported-audit decodes run after their read connection is released, so
ApiState now carries a semaphore sized from the same read concurrency as
the public read pool. bundle() takes an owned permit inside the
request's existing deadline and moves it into the blocking decode, so a
dropped request cannot free it before the decode ends. Ledger::audit_bundle
takes no permit and releases its fetched row before decoding.

The decode refuses body-ref and v2 envelope schemas, since canonical
bytes are always a flat bundle. The storage-sizing doc describes the
canonical-bytes import, and the measurement doc gains a post-#265 note.

Tests pin the pre-change row shape (inline body, canonical bytes and
body_uri, no snapshot), bound the decode across a dropped request, and
refuse a digest-valid body-ref envelope.

Part of #265.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: add deterministic HA readiness probe harness

* test: qualify readiness probe timing

* test: preserve probe state in jsonl harness

* test: harden readiness probe input boundaries

* test: reject overlapping readiness probes

* docs: use generic HA readiness contract wording

* test: classify malformed probe durations as failures
#333)

* ledger: let append callers veto COMMIT at the last moment

append_at_revision_gated takes a non-optional pre-commit hook. It runs at
most once, after every statement including persist_candidate has
succeeded, while ORDER_LOCK is held and immediately before COMMIT. A false
return rolls the transaction back explicitly and fails with the typed
CommitGateClosed error, so COMMIT is never sent. append and
append_at_revision keep their signatures and pass no hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* metrics: count late share confirmations and unknown outcomes

A share whose commit is confirmed after the acknowledgement deadline is
still accepted, and a commit whose outcome cannot be established is no
longer reported as a clean rejection. Both need their own signal.

Add the late_confirmed_shares_total family, recorded through
record_late_confirmation and zero-registered like the other counters, and
the ledger-outcome-unknown reject reason. Without the new reason, an
unknown outcome would fold into internal-error.

PrismShareAppendFailures paged on any ledger-confirmation-failed, so it
now matches the unknown reason too and keeps its low-volume total-write-
failure coverage. The deployment patch is regenerated from the reviewed
snapshot, and a scenario covers the new reason alone.

Document the narrowed meaning of ledger-confirmation-failed, the new
reason, the statement-timeout guard, and the block-only acknowledgement
bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* coordinator: reconcile share-commit deadlines instead of dropping the append (#324)

submit wrapped the whole persistence future in a timeout and dropped it
when the deadline fired, answering ledger-confirmation-failed. If COMMIT
had already been flushed, sqlx drained the reply and reused the
connection, so the share was committed and credited without an
acknowledgement. A load harness reproduced that: 164 such rejections, two
of them committed rows. The block-only path committed its candidate
first, so its timeout always led to a later credit with no
acknowledgement.

Run the append in a task behind a one-shot commit gate. At the deadline
the acknowledgement path either closes the gate, which proves COMMIT was
never sent and aborts the doomed append, or finds the commit already in
flight and awaits its reply for share_commit_grace. Only a server error
of severity ERROR is a definite rejection; every other error, and a
commit that outlasts the grace, is answered ledger-outcome-unknown and
logged with its eventual outcome. A confirmation whose commit took at
least the effective statement_timeout is also unknown, because a
cancelled synchronous-replication wait commits only locally.

An append carrying a found-block candidate is never refused, since that
would discard the block; it waits for block_only_ack_timeout instead.
Block-only proofs always finish their enqueue and then follow the
candidate's disposition within the same bound, and poll errors after the
enqueue never become a rejection.

The new bounds are Config fields rather than environment variables, and
statement_timeout is read from the session the ledger pool actually uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* coordinator: bound the block-only enqueue wait and time COMMIT in the task

Review feedback on PR #333.

enqueue_candidate_once was awaited directly, outside the bound that covers
every other step of a block-only submission, so a degraded database could
hold the acknowledgement open well past block_only_ack_timeout. Wait for it
in a task until the bound instead. The enqueue still runs to completion,
because cutting it off could strand a COMMIT that was already sent, but the
miner is answered ledger-outcome-unknown on time and the enqueue's eventual
outcome is logged.

The synchronous-replication guard compared statement_timeout against a
COMMIT duration measured when the coordinator classified the finished task.
Scheduler delay after a successful COMMIT therefore counted as database
time and could turn a durable accepted share into an unknown one. Measure
the duration inside the append task, where COMMIT actually finishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* metrics: count a late confirmation from when the append finished

Review feedback on PR #333.

late_confirmed_shares_total was decided by reading the clock while the
acknowledgement was being processed, which can be arbitrarily later than the
append itself. A coordinator task scheduled late therefore counted an
on-time confirmation as a late one. Decide it from the instant the append
finished, which the task already reports, and record it where that instant
is known.

The help text also claimed every counted share was confirmed within the
grace period. That was never true of a share-pass submission carrying a
found-block candidate: those are deliberately never refused, so they can be
confirmed later still, up to the block-only bound, and they are counted.
Say what the counter measures instead, and spell the found-block case out
in the metrics documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(prism): add bounded PostgreSQL WAL measurement helper

* fix(prism): harden WAL qualification failure semantics

* fix(prism): harden WAL helper validation and outcomes

* fix(prism): sanitize WAL helper connection inputs

* test(prism): cover WAL helper outcomes and scope

* fix(prism): preserve WAL operation outcome uncertainty

* fix(prism): bound WAL measurement subprocesses

* fix(prism): complete WAL helper diagnostics and test coverage

* test(prism): repair WAL helper test layout

* test(prism): stabilize WAL diagnostic mocks
…323)

* 3.x.x: measure the share-append throughput floor against PostgreSQL

test/test-prism-postgres-throughput.sh measured `cargo run -- benchmark`, a
CPU-only run of the audit builder with no database at all, and the 2.x.x
report it replaced measured one bulk INSERT ... SELECT ... FROM
generate_series. Neither says anything about the path a share actually takes.
Every native share append is one transaction that takes the cluster-wide
ORDER_LOCK advisory lock, checks writability, bumps the single-row ledger
clock and writes two rows, so share appends are serialized cluster-wide, and
nobody had measured that with more than one writer.

tests/throughput_floor.rs seeds a payout window with the #264 fixture
generator and then appends through Ledger::append at 1, 2 and 4 concurrent
appenders, each appender holding its own Ledger with its own pool and its own
instance_id, as separate production frontends do. A connection outside every
appender pool samples pg_locks for backends queued behind ORDER_LOCK while a
level runs, and the report says how many waiters it saw and for how long --
labelled throughout as sampled estimates, with the episode count a lower
bound, because a wait shorter than the 10 ms interval can be missed entirely.
A sampler that fails reports null and the error, never zero.

The report is qbit.prism.postgres-throughput.v2, a deliberately new schema
name: its numbers must never be read against v1's bulk-insert rate. It is
written before the floor is asserted, so a run that fails the floor still
leaves its evidence behind. Every QBIT_PRISM_* input is validated at entry,
before a connection is opened: nan, inf, a negative or an empty floor are
configuration errors rather than extreme floors, and a window size that does
not divide the fixture's window weight would not produce the window it claims
to.

CI_MIN_SHARES_PER_SEC is provisional -- 25% of the slowest level rate measured
in a debug build on the writer's host. Debug is the right base because CI runs
this test in debug, and the wide margin is honest about the fact that nothing
has calibrated it on the 2-vCPU runner yet.

Part of #271

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

* 3.x.x: point the throughput script and target at the share-append floor

test-prism-postgres-throughput ran `cargo run -- benchmark`, which measures
the audit builder's CPU cost and never opens a database, so the target named
after PostgreSQL throughput was not measuring PostgreSQL at all. It now runs
the full-size share-append floor: the ignored throughput_floor test, in
release, against PRISM_TEST_DATABASE_URL when one is set and otherwise through
prism-native-tests.sh, which initdb's a disposable cluster on a random
loopback port and removes it on exit.

The builder benchmark has not gone anywhere and is still worth running; the
script says so, and gives the exact command, because it answers a different
question rather than a worse version of this one.

`set -e` is suspended around the test so the report path is printed on a red
run too -- the test writes the report before it asserts the floor, so a run
that fails the floor is exactly the run whose report someone wants to read --
and the test's exit status is re-raised immediately afterwards. Every
QBIT_PRISM_* variable reaches the test process unchanged; the script reads
none of them except to say where the report landed.

Part of #271

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

* 3.x.x: resolve pg_stat_statements by schema, not by search path

The pg_stat_statements probe was `to_regclass('pg_stat_statements')`, and the
read and the reset named the view unqualified. Every connection in this run
carries `options=-csearch_path=<run schema>`, which replaces the search path
rather than prepending to it, so `public` is not on it. On a server with the
extension installed in `public` the probe therefore returned NULL and the
report said "unavailable" about a view that was sitting right there -- an
EP-OBSERVABILITY failure of exactly the kind the rest of this file is careful
about, because it reads as "this server cannot tell us" when the truth was
"this query looked in the wrong place".

The extension's schema is now resolved from pg_extension and the server's own
quote_ident, and both the reset and the read qualify with it. Verified against
PostgreSQL 16.15 with `shared_preload_libraries=pg_stat_statements` and the
extension created in public: the block now reports 2000 calls per level, and
its total_exec_time corroborates the sampler closely -- 4254 ms against a
sampled 3.97-4.31 waiter-seconds at two appenders, and 17387 ms against a
sampled 16.9-17.3 at four. Two independent methods agreeing to within a few
percent is worth more than either alone, which is the whole reason the block
is there.

Part of #271

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

* 3.x.x: attribute ORDER_LOCK waits to the run that caused them

The adversarial review found that the per-run schema isolates the tables but
not the lock. ORDER_LOCK is a PostgreSQL advisory lock and an advisory lock is
scoped to the database, so any other backend appending shares into the same
database serialises against this run's appenders, and the sampler -- filtered
only by database -- counted those foreign waits as this level's. Measured on a
quiet server against the same server with one other appending process, the
rates fell 40-57% and nothing in the report said why. A run like that either
fails a calibrated floor for a reason that is not the append path, or, once the
margin narrows, passes on a number describing two workloads.

Every connection this run opens now carries a per-run application_name,
`throughput-<uuid>`, pushed through the URL -- which is the only thing
Ledger::connect is handed, so the seeding ledger, every appender ledger and the
sampler all announce themselves without any production code knowing this test
exists. sqlx 0.8 parses the parameter out of the URL and sends it in the
startup packet, but the run does not take that on trust: it reads
current_setting('application_name') back on its first connection and refuses to
measure anything if the server disagrees, because a silently ignored parameter
would make every foreign backend look like ours and every contaminated run look
clean.

The sampler then reports this run's waiters as before, and foreign_waiters_max
and foreign_backends_seen beside them. A foreign backend counts whether it was
waiting on the lock or holding it: either way it is serialising against these
appends. A backend whose pg_stat_activity row this role may not read counts as
foreign, which is the safe direction. A level during which any foreign backend
was seen is marked contaminated, in the level, at the top of the report and in
the floor's failure message; it is still measured and still gated, because the
rate is what the appends really achieved, but the report now says plainly that
the number describes a shared database and must not be carried forward.

contaminated is null, not false, when the sampler could not look. "Nobody
checked" is not "nothing was there", and the same reasoning fixes the smaller
version of that bug next door: a level that ended before the sampler completed
a poll used to emit mean_waiters 0.0, max_waiters 0 and
estimated_waiter_seconds 0.0, which reads as "nobody ever waited". Those are
null now, samples stays 0, and the error says the level ended first.

The sampler's shutdown and its interval are fixed together. It tested the stop
flag only at the top of its loop and then blocked in sleep(interval), so every
level burned up to a whole interval of dead time after its appends had
finished -- 65 seconds of wall clock for 0.43 seconds of measurement at a
20-second interval. The flag is now paired with a Notify signalled alongside
it, so the sleep is interruptible and a level ends when the appends end;
notify_one stores the permit, so the signal cannot be lost in the window
between a poll and a sleep. The interval itself is bounded to 1..=1000 ms at
entry, before any connection, with a message saying that an interval coarser
than a second cannot resolve a lock wait that lasts milliseconds.

Two other gaps the review left open. A run that dies connecting, seeding or
appending now writes a report anyway -- identity, configuration, the full error
chain, passed_minimum false and an empty results array -- so the newest file on
disk belongs to this run rather than to some earlier one; a failure to write it
is attached to the original error as context and never replaces it. And the
default report path is per test, target/prism-postgres-throughput-<test>.json,
with a "test" field in the JSON to match, because the two floor tests run in
parallel threads under --include-ignored and the survivor of a shared path was
whichever finished last, unlabelled. QBIT_PRISM_THROUGHPUT_REPORT is still the
explicit override.

Cancellation is documented rather than fixed: SIGINT kills the process before
the schema drop runs, and handling it properly needs a signal handler, which
means a dependency this test may not add. The module docs now say so and give
the query that finds and drops the leftovers, next to the standing requirement
that this run wants a database nothing else is appending to.

Part of #271

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

* 3.x.x: make the throughput script point at a report that exists

Two things the script got wrong, both of which send a reader looking for
evidence that is not there.

It printed the report path unconditionally, after the status had been
captured. On the fallback path with PRISM_TEST_DATABASE_URL unset and no local
server binaries, the run dies before the test starts and writes nothing, yet
the script still announced a file. It now prints the path only when the file is
actually on disk, and otherwise says that no report was written and where it
would have been. The exit status is unchanged either way.

It also pointed at the old shared default path. The test writes
target/prism-postgres-throughput-<test name>.json now, one per test, so the
script derives its path from the test it runs rather than from a constant that
has moved.

The header gains the two warnings the test itself carries. ORDER_LOCK is
database-wide, not schema-wide, so this must run against a database nothing
else is appending to; a run that shares one is marked contaminated and its
number describes two workloads. And a cancelled run leaves its
prism_throughput_<uuid> schema behind, so the header gives the query that finds
the leftovers and the statement that drops them -- unnecessary on a disposable
cluster, which is thrown away whole, and that is the reason to prefer one.

Part of #271

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

* 3.x.x: print the floor's per-level rates where CI can see them

The floor test prints its rates with println!, and libtest throws away the
captured output of a passing test. CI keeps no report artifact either, so a
green prism-native-postgres run recorded nothing about how far above the floor
it was, and the provisional CI_MIN_SHARES_PER_SEC could not be calibrated from
CI at all.

After the report is written, the test now emits one summary line through
std::io::stderr() directly, which the test harness does not capture: the test
name, build profile, window, shares per level, the rate at each appender
count, the floor, the verdict and the contamination flag. Every CI log then
carries the margin the floor is set from. A failed write is ignored, because
the report file already holds everything the line summarises.

Part of #271

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 3.x.x: move the throughput floor onto the shared integration gate

#322 replaced every test's private copy of the database guard with one
shared gate, qbit-prism-test-gate, and made the prism-native-postgres job
prove that each gated test executed against test/prism-gated-tests.txt.
The floor still carried its own copy of the old guard, which the new
check_gate_env_reads lint rejects, and it was not on the list, so the proof
step would have failed the moment the test ran.

The floor now asks the gate for its database URL, so it follows the same
decision table as every other gated test and records its decision in the
execution manifest. share_append_throughput_floor joins the list; the
full-size test is not selected in CI and stays off it.

Part of #271

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 3.x.x: set the CI throughput floor from the CI runner's own numbers

CI_MIN_SHARES_PER_SEC was a provisional 71 shares/s, a quarter of what an
8-vCPU devbox managed, because nothing had measured the runner that enforces
it. Four runs of prism-native-postgres on blacksmith-2vcpu-ubuntu-2404, read
from the summary line the test now writes, put the slowest level of each run
at 1,290, 847, 1,242 and 1,200 shares/s (debug build, the job's PostgreSQL 16
service, a 20k-share window and 2,000 appends per level).

The floor is now 250, about 30% of the slowest level seen. Runs on that
runner differ by about a third, so the margin keeps a noisy run green, while a
3-4x collapse of the append path still fails the build. The report's source
label no longer calls the constant provisional.

Part of #271

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 3.x.x: run the floor the disposable-cluster path was skipping

Four findings from the review of this PR, all in the paths that are hardest to exercise by hand.

The wrapper script passed cargo_args beginning with 'test' to prism-native-tests.sh, which runs 'cargo test "$@"' itself, so the second word became a test-name filter. With --exact alongside it the disposable-cluster path selected nothing, printed nothing and exited 0: the script that exists to measure the floor measured nothing whenever PRISM_TEST_DATABASE_URL was unset. The array now carries only the arguments, and the direct call adds 'test' itself.

The full-size test is selected by name, so a missing database must fail it rather than skip it; it now takes the gate's required entry point, and the CI-sized test keeps the skipping one. Resetting pg_stat_statements cleared the whole server's counters, including other databases; each level now reports the difference of two reads instead, and says so where the numbers are. The level clock started after the barrier opened, so an appender the runtime resumed first could work outside the measured window and inflate the rate; it starts before.

Part of #271

* 3.x.x: scope the lock statistics to this database and stop announcing stale reports

Two findings from review, both about a number that looks right and is not.

`pg_stat_statements` is server-wide. The level's advisory-lock figures
were the difference of two reads filtered only by query text, so an
advisory lock taken in any other database on the same server landed in
the level's count. A lock in another database cannot contend with this
run -- an advisory lock is scoped to its database -- so the level was
correctly marked uncontaminated while its lock figures described someone
else's work. Filter on `dbid`, and say so in the report's caveat.

Measured on a PostgreSQL 16 with `pg_stat_statements` loaded, over a
level during which another database took 3,000 advisory locks: the old
query attributed 3,000 calls to the level, the filtered one attributes 0.
With the fix and a concurrent foreign load of about 24,000 locks, each
level reports exactly 300 calls for 300 appended shares -- one lock per
share, which is what the append path takes.

The wrapper announced any file at the report path as this run's evidence,
so a run that died during compilation or cluster setup pointed the reader
at a previous run's report while returning a failure. The earlier file is
now moved aside before the run, which makes the report's presence
afterwards proof that this run wrote it, and it is put back if this run
wrote nothing. Deleting it instead would have destroyed the previous
run's data on a run that produced none of its own.

Timestamps were tried first and rejected: a warm build and a small window
can finish inside one filesystem timestamp tick, and a fresh report then
compares as no newer than the moment the run started, which reports a
good run as stale.

Verified end to end through the wrapper against a real PostgreSQL 16, and
for all three announcement cases: a failed run with an earlier report
present, a successful run that writes one, and no report at all.

Part of #271

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
)

`3.x.x` has been red since #344 landed. Two pull requests collided
semantically while each was green on its own:

- #333 (e13051c) added `ledger-outcome-unknown` to `RejectReason`, so
  `/metrics` now emits one more `qbit_prism_rejections_total` series;
- #344 (037b04e) added `event_exposition_matches_the_pre_allocation_change_byte_for_byte`,
  whose fixture was captured at 2914a62, before that reason existed.

Neither branch could see the problem, because it only appears in the
merged tree. The whole difference is one line, so the fixture gains it.

The comment above the assertion is refreshed at the same time: it claimed
provenance the fixture no longer had, and it now says what the pin
implies -- adding a value to a closed label set has to be recorded here.

Verified on this branch: the four metrics_allocation tests pass, and the
failure reproduces on a6fe3fd without this change.

Part of #338
* fix: record cancelled collector pool acquisitions

* docs: align collector cancellation timing contract

* docs: qualify cancelled collector waits in alert contracts

* test(metrics): include approved unknown ledger outcome in fixture

PR #333 intentionally added the ledger-outcome-unknown rejection label and a producer contract test. Add exactly that sample to the older exposition fixture from #344, retaining the byte-for-byte equality assertion and leaving runtime behavior unchanged.

(cherry picked from commit 4e5b4c4)
rustls 0.23.44 accepted TLS 1.3 handshake messages sent at the wrong
encryption level when they followed a key-changing message in the same
record, instead of terminating the connection as RFC 8446 section 5.1
requires. The transcript stays authenticated, so a network-position
attacker cannot alter or complete a handshake; the effect is that a peer
can send handshake messages in plaintext that should have been encrypted
without rustls rejecting the connection. It is the same bug as Go's
CVE-2025-61730.

The advisory names 0.23.45 as the fix. This is a lockfile-only change:
`cargo update -p rustls`, no manifest or source change.

The advisory database picked this up today, so the dependency audit now
fails on 3.x.x and on every open pull request.
…ndow (#353)

* 3.x.x: add PrismRewardManifestHeader and a checked reward-manifest rebuild (#267)

A native audit row is about to stop storing `reward_manifest.shares`. The
counted window is a pure fold over `(shares, found_block)`, so the row only
needs the manifest header; `restore_reward_manifest` reruns the fold and
returns its window only after proving the rebuilt header is identical to the
stored one, failing as the existing `reward_manifest` audit mismatch
otherwise. There is no unchecked variant.

`PrismRewardManifestHeader` denies unknown fields, so a legacy manifest that
still carries `shares` can never be read as a header; and the full manifest
still requires `shares`, so an older reader meeting a normalized row fails
explicitly instead of serving an empty window.

Tests prove the rebuilt bundle is byte-identical to the original (both
schemas, credit_policy included), that one changed field in a rebuilt share
moves the canonical sha256, that a tampered header is refused by name, and
that neither shape can be mistaken for the other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXvnfUC8CvPxskAXLgd1xx

* 3.x.x: store native audit bodies without reward_manifest.shares and verify the range before the settlement lock (#267)

The landed native audit body kept `reward_manifest.shares`, one record per
counted share, so it grew with the payout window: about 235 MB in one JSONB
value at the production window, 88% of PostgreSQL's container limit. The
landing write now removes that copy alongside the top-level `shares`; the
stored body is O(distinct miners and payout accounts). Nothing else is added
or removed, `audit_body_byte_len` stays the full canonical artifact length,
and the GIN-indexed leaf arrays stay in the body.

Reads branch on the stored shape. A body whose `reward_manifest` has no
`shares` key is decoded as `PrismRewardManifestHeader` and its window rebuilt
through `restore_reward_manifest`, which refuses a header the fold does not
reproduce; a body that still has the key (landed before this change) is
served exactly as before. Both arms then run the unchanged canonical digest
check, so a rebuilt body is proven byte-identical, not merely equivalent.
Imported rows never enter this path. The snapshot digest, the rebuild and the
canonical hash run on one blocking thread, and the public API hands that job
the same permit imported decodes hold, so a dropped request cannot free the
limit before the rebuild ends.

The durable-range proof no longer runs inside the settlement transaction.
`verify_durable_range` runs before the lock, page by page (4096 rows, the
window reader's page), decoding and comparing each page on a blocking thread
against the same positions of the candidate window: same predicate, same
ordering, same full equality, same bootstrap exemption, same error text, and
the first mismatched page fails the landing. An already-landed block skips it
and takes the existing-row digest path as before. Under the lock the snapshot
now only counts the same anchored predicate and fails the landing if the count
differs from the window.

The JSONB ceiling gate's landing entry is deleted: that write no longer
crosses the threshold. Gated tests prove the stored shape and identical served
bytes, that a tampered stored header is refused by name, that pre-change
native rows still serve identical bytes, and that imported rows serve from
their canonical bytes even with a poisoned inline body. An ignored harness
measures the stored body, the settlement-lock hold and the landing peak RSS
at a configurable window size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXvnfUC8CvPxskAXLgd1xx

* 3.x.x: document the measured native audit body before and after normalization (#267)

State the measured stored bytes per block at 20,000 and 100,000 shares before
and after `reward_manifest.shares` left the stored body, say that the residual
is O(distinct miners and payout accounts) rather than a fixed bound, and
separate `audit_body_byte_len` (the full canonical artifact) from
`sum(pg_column_size(audit_bundle))` (the stored, compressed non-share body).
Also record the measured cost of rebuilding a native audit on read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXvnfUC8CvPxskAXLgd1xx

* 3.x.x: correct three statements the audit-body normalization made untrue (#267)

The JSONB ceiling gate's module doc and its landing phase note both still
described the landing write as embedding the payout window, and the gate
printed that note on every run. The payout-artifact measurement guide counted
three native window-carrying writes. After normalization there are two, and
landing is flat in the window.

`d2_bootstrap_tests` cited `persist_audit_snapshot` re-reading the share range
inside the transaction as one of the three closed exits for an empty payout
window beside a non-zero carry. The exit is still closed, but by
`verify_durable_range` page by page before the settlement lock plus the
in-transaction count of the same anchored range, so the citation now names
both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 3.x.x: give the paged durable-range proof negative multi-page coverage (#267)

`verify_durable_range` had no negative coverage: its body could be replaced
with `Ok(())`, together with the in-transaction count guard, and every
reachable test still passed. The gated tests only ever drove it with a
one-share window, so no test had executed a second page of the loop.

A new gated test loads a 10,000-share window, three pages of the proof's
4096 rows, and lands four candidates that disagree with the ledger from the
claim side, since the share ledger is immutable by trigger. Each window is
built and signed as it is, so the audit signature check that precedes the
proof accepts it and the proof is what refuses it, asserted by name: one
`ntime` differs on the first share of the second page, one on the last share
of the final partial page, one share is missing from the final page so the
durable page outruns the window slice, and one share the ledger accepted
after the anchor is claimed, so every page matches and the final count
refuses it. A refused landing writes nothing. The unaltered window then
lands and serves identical bytes, so the same fixture proves the loop accepts
a correct multi-page window.

The in-transaction count guard has no independent coverage. The anchored set
is frozen once the anchor is issued and the ledger is append-only, so a
window that passes the paged proof cannot count differently under the lock
without a writer that defeats the immutability trigger between the two
reads; it is defence in depth, and the test names the proof's message, not
the guard's.

`land_small_block` now appends a second share after landing, so present-day
ledger state differs from the block's anchored snapshot in every test that
uses it. A read that rebuilt the window from current ledger state instead of
the snapshot range passed all of them before; it now fails the snapshot
digest.

Two comments are corrected without a behaviour change: the negative block in
`reward_manifest_header.rs` now mutates the restored window it claimed to,
and the permit comment in `read_models.rs` says the permit also spans the
snapshot lookup and window read that precede the blocking job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P2UWk2rh2R1W8yqTdLZ4k

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(prism): record ledger advisory-lock and pool-acquire waits

The native registry declares two database wait families, but nothing in
the ledger observed either one: advisory-lock waits had no observations
at all, and pool acquisition counted only the metrics collector's own.
ORDER_LOCK contention, which serializes every share acknowledgement, was
therefore invisible in production and in qualification.

Give Ledger an optional metrics handle, supplied by the new
connect_with_metrics; connect keeps its signature and passes None, so the
tools and tests that connect without a registry are unchanged and record
nothing. The coordinator passes its own handle.

Route every advisory lock through the existing lock() helper, now timing
the pg_advisory_xact_lock statement alone, and every ledger transaction
through a new begin() helper that times pool acquisition alone. begin()
is Pool::begin split in two around the timer, down to its sqlx::Error
return type, so both helpers are drop-in replacements and every call
site's `?` converts exactly as before. Leaving the BEGIN round trip
outside the timer keeps the pool series meaning what the collector
already records. Errors, including a lock_timeout expiry, propagate
unchanged and record as failure.

A drop guard records a wait abandoned by cancellation as a failure with
its elapsed time, so a cancelled wait stays distinct from one that never
happened. Completing the wait takes the handle out of the guard, so a
wait is recorded exactly once. The metrics mutex is taken only after the
wait has ended, never across an await, so rendering cannot queue behind a
contended lock; a guard held across the await would also make the future
!Send and fail to compile in the spawned share path.

The CPFP funding lock has no LockKind value and stays unobserved; an
unmapped key trips a debug assertion rather than borrowing another lock's
label.

Refs #328.

* test(prism): cover ledger wait metrics by lock and outcome

Two gated PostgreSQL tests and one source invariant.

The first attaches a registry and drives each outcome against a real
database: the migration lock from schema creation, a share append taking
ORDER_LOCK once, a second session holding ORDER_LOCK past a 200 ms
lock_timeout so the append fails with SQLSTATE 55P03 and records one
failure of at least that duration, an append cancelled mid-wait
recording its partial wait and no more, the settlement lock through
save_job, five transactions each observing one pool acquisition plus the
collector's own unchanged, a pool acquisition cancelled while every
connection is held, and a closed pool recording an acquisition failure.

The second runs the same sequence on a ledger with no handle and asserts
that results and error codes, 55P03 included, are what they are at the
base. It deliberately makes no assertion about a registry: one no ledger
can reach has nothing to record into and could not fail the test.

The third walks the ledger sources recursively and fails if a
transaction begins outside the timed helper or a second
pg_advisory_xact_lock statement appears, so a new call site, including
one in a nested module directory, cannot silently stop being observed.

Refs #328.

* docs(prism): describe the recorded database wait metrics

The two database wait families now say what they measure and who records
them: pool acquisition excludes transaction BEGIN and the queries that
follow, and advisory-lock timing covers one pg_advisory_xact_lock round
trip for the migration, order and settlement locks. Both record a wait
abandoned by cancellation as a failure, except the collector's own
acquisitions, which keep reporting cancellation through collector status
rather than inventing a completed wait.

The follow-up ownership and alert-migration notes no longer describe
either family as unpopulated, and record that PrismDatabasePoolWaitHigh
now evaluates ledger acquisitions while its description still predates
this change. No alert rule or digest input changes.

Refs #328.

* docs(prism): record the pool alert's collector-availability gate

PrismDatabasePoolWaitHigh is gated on collector_available for the database
collector. That collector acquires from the same pool as the ledger, so
sustained pool exhaustion times out its own acquisition, zeroes the gauge and
suppresses the rule exactly while the ledger histogram is recording the waits
that should fire it.

The gate cost nothing before this branch, because the family carried no samples
during such an outage. It does now. The rule itself cannot change here: its file
feeds the alert specification digest embedded in the deployment patch, which can
only be regenerated from the frozen template. #336 tracks that work; this records
the interaction where operators read it.

Refs #328.
* docs: correct pool-acquire alert measurement boundaries

Describe metrics collection and instrumented ledger transaction pool acquisition, retaining query/advisory-lock/read-gate exclusions and the five no-replacement dispositions. Regenerate migration tables and deployment patch from the verified frozen snapshot; all alert behavior is unchanged.

* test(metrics): include approved unknown ledger outcome in fixture

PR #333 intentionally added the ledger-outcome-unknown rejection label and a producer contract test. Add exactly that sample to the older exposition fixture from #344, retaining the byte-for-byte equality assertion and leaving runtime behavior unchanged.

* feat(prism): record ledger advisory-lock and pool-acquire waits

The native registry declares two database wait families, but nothing in
the ledger observed either one: advisory-lock waits had no observations
at all, and pool acquisition counted only the metrics collector's own.
ORDER_LOCK contention, which serializes every share acknowledgement, was
therefore invisible in production and in qualification.

Give Ledger an optional metrics handle, supplied by the new
connect_with_metrics; connect keeps its signature and passes None, so the
tools and tests that connect without a registry are unchanged and record
nothing. The coordinator passes its own handle.

Route every advisory lock through the existing lock() helper, now timing
the pg_advisory_xact_lock statement alone, and every ledger transaction
through a new begin() helper that times pool acquisition alone. begin()
is Pool::begin split in two around the timer, down to its sqlx::Error
return type, so both helpers are drop-in replacements and every call
site's `?` converts exactly as before. Leaving the BEGIN round trip
outside the timer keeps the pool series meaning what the collector
already records. Errors, including a lock_timeout expiry, propagate
unchanged and record as failure.

A drop guard records a wait abandoned by cancellation as a failure with
its elapsed time, so a cancelled wait stays distinct from one that never
happened. Completing the wait takes the handle out of the guard, so a
wait is recorded exactly once. The metrics mutex is taken only after the
wait has ended, never across an await, so rendering cannot queue behind a
contended lock; a guard held across the await would also make the future
!Send and fail to compile in the spawned share path.

The CPFP funding lock has no LockKind value and stays unobserved; an
unmapped key trips a debug assertion rather than borrowing another lock's
label.

Refs #328.

* test(prism): cover ledger wait metrics by lock and outcome

Two gated PostgreSQL tests and one source invariant.

The first attaches a registry and drives each outcome against a real
database: the migration lock from schema creation, a share append taking
ORDER_LOCK once, a second session holding ORDER_LOCK past a 200 ms
lock_timeout so the append fails with SQLSTATE 55P03 and records one
failure of at least that duration, an append cancelled mid-wait
recording its partial wait and no more, the settlement lock through
save_job, five transactions each observing one pool acquisition plus the
collector's own unchanged, a pool acquisition cancelled while every
connection is held, and a closed pool recording an acquisition failure.

The second runs the same sequence on a ledger with no handle and asserts
that results and error codes, 55P03 included, are what they are at the
base. It deliberately makes no assertion about a registry: one no ledger
can reach has nothing to record into and could not fail the test.

The third walks the ledger sources recursively and fails if a
transaction begins outside the timed helper or a second
pg_advisory_xact_lock statement appears, so a new call site, including
one in a nested module directory, cannot silently stop being observed.

Refs #328.

* docs(prism): describe the recorded database wait metrics

The two database wait families now say what they measure and who records
them: pool acquisition excludes transaction BEGIN and the queries that
follow, and advisory-lock timing covers one pg_advisory_xact_lock round
trip for the migration, order and settlement locks. Both record a wait
abandoned by cancellation as a failure, except the collector's own
acquisitions, which keep reporting cancellation through collector status
rather than inventing a completed wait.

The follow-up ownership and alert-migration notes no longer describe
either family as unpopulated, and record that PrismDatabasePoolWaitHigh
now evaluates ledger acquisitions while its description still predates
this change. No alert rule or digest input changes.

Refs #328.

* docs(prism): record the pool alert's collector-availability gate

PrismDatabasePoolWaitHigh is gated on collector_available for the database
collector. That collector acquires from the same pool as the ledger, so
sustained pool exhaustion times out its own acquisition, zeroes the gauge and
suppresses the rule exactly while the ledger histogram is recording the waits
that should fire it.

The gate cost nothing before this branch, because the family carried no samples
during such an outage. It does now. The rule itself cannot change here: its file
feeds the alert specification digest embedded in the deployment patch, which can
only be regenerated from the frozen template. #336 tracks that work; this records
the interaction where operators read it.

Refs #328.

* feat(prism): record ledger advisory-lock and pool-acquire waits

The native registry declares two database wait families, but nothing in
the ledger observed either one: advisory-lock waits had no observations
at all, and pool acquisition counted only the metrics collector's own.
ORDER_LOCK contention, which serializes every share acknowledgement, was
therefore invisible in production and in qualification.

Give Ledger an optional metrics handle, supplied by the new
connect_with_metrics; connect keeps its signature and passes None, so the
tools and tests that connect without a registry are unchanged and record
nothing. The coordinator passes its own handle.

Route every advisory lock through the existing lock() helper, now timing
the pg_advisory_xact_lock statement alone, and every ledger transaction
through a new begin() helper that times pool acquisition alone. begin()
is Pool::begin split in two around the timer, down to its sqlx::Error
return type, so both helpers are drop-in replacements and every call
site's `?` converts exactly as before. Leaving the BEGIN round trip
outside the timer keeps the pool series meaning what the collector
already records. Errors, including a lock_timeout expiry, propagate
unchanged and record as failure.

A drop guard records a wait abandoned by cancellation as a failure with
its elapsed time, so a cancelled wait stays distinct from one that never
happened. Completing the wait takes the handle out of the guard, so a
wait is recorded exactly once. The metrics mutex is taken only after the
wait has ended, never across an await, so rendering cannot queue behind a
contended lock; a guard held across the await would also make the future
!Send and fail to compile in the spawned share path.

The CPFP funding lock has no LockKind value and stays unobserved; an
unmapped key trips a debug assertion rather than borrowing another lock's
label.

Refs #328.

* test(prism): cover ledger wait metrics by lock and outcome

Two gated PostgreSQL tests and one source invariant.

The first attaches a registry and drives each outcome against a real
database: the migration lock from schema creation, a share append taking
ORDER_LOCK once, a second session holding ORDER_LOCK past a 200 ms
lock_timeout so the append fails with SQLSTATE 55P03 and records one
failure of at least that duration, an append cancelled mid-wait
recording its partial wait and no more, the settlement lock through
save_job, five transactions each observing one pool acquisition plus the
collector's own unchanged, a pool acquisition cancelled while every
connection is held, and a closed pool recording an acquisition failure.

The second runs the same sequence on a ledger with no handle and asserts
that results and error codes, 55P03 included, are what they are at the
base. It deliberately makes no assertion about a registry: one no ledger
can reach has nothing to record into and could not fail the test.

The third walks the ledger sources recursively and fails if a
transaction begins outside the timed helper or a second
pg_advisory_xact_lock statement appears, so a new call site, including
one in a nested module directory, cannot silently stop being observed.

Refs #328.

* docs(prism): describe the recorded database wait metrics

The two database wait families now say what they measure and who records
them: pool acquisition excludes transaction BEGIN and the queries that
follow, and advisory-lock timing covers one pg_advisory_xact_lock round
trip for the migration, order and settlement locks. Both record a wait
abandoned by cancellation as a failure, except the collector's own
acquisitions, which keep reporting cancellation through collector status
rather than inventing a completed wait.

The follow-up ownership and alert-migration notes no longer describe
either family as unpopulated, and record that PrismDatabasePoolWaitHigh
now evaluates ledger acquisitions while its description still predates
this change. No alert rule or digest input changes.

Refs #328.

* docs(prism): record the pool alert's collector-availability gate

PrismDatabasePoolWaitHigh is gated on collector_available for the database
collector. That collector acquires from the same pool as the ledger, so
sustained pool exhaustion times out its own acquisition, zeroes the gauge and
suppresses the rule exactly while the ledger histogram is recording the waits
that should fire it.

The gate cost nothing before this branch, because the family carried no samples
during such an outage. It does now. The rule itself cannot change here: its file
feeds the alert specification digest embedded in the deployment patch, which can
only be regenerated from the frozen template. #336 tracks that work; this records
the interaction where operators read it.

Refs #328.

---------

Co-authored-by: Alex <alex@swaplabs.xyz>
* test: cover native ledger deadlines and execution safety

Add 14 PostgreSQL regressions for late issued-job expiry, atomic publication, migration restart, incomplete snapshots, and claim-fenced execution.

Use a test-only protocol observer to detect sensitive write replay after timeouts or lost acknowledgements, and document the native retry and snapshot contracts.

* test: make candidate backoff eligibility deterministic

Verify retry scheduling before controlling eligibility through fixture timestamps.
Use future and past infinity to exercise claim fencing without racing the
one-second retry deadline or waiting for it to elapse.

Addresses the timing-race review comment on #355.

* test: remove fanout backoff timing race and clarify locks

Compare the fanout retry schedule with its durable write timestamp, then
control claim eligibility through fixture timestamps. Document settlement
and order locking and the scope of the sibling-row independence test.

Addresses the two follow-up review comments on #355.

* test: isolate ledger deadline and fanout eligibility checks

Freeze all three completed fanouts before the global pre-deadline claim
assertion. Use a dedicated fixture pool without competing server timeouts
for the issued-job absolute-deadline regression, retaining bounded barriers
and cleanup. Production behavior and other fixture defaults are unchanged.

Addresses the two follow-up review comments on #355.
* docs: correct pool-acquire alert measurement boundaries

Describe metrics collection and instrumented ledger transaction pool acquisition, retaining query/advisory-lock/read-gate exclusions and the five no-replacement dispositions. Regenerate migration tables and deployment patch from the verified frozen snapshot; all alert behavior is unchanged.

* test(metrics): include approved unknown ledger outcome in fixture

PR #333 intentionally added the ledger-outcome-unknown rejection label and a producer contract test. Add exactly that sample to the older exposition fixture from #344, retaining the byte-for-byte equality assertion and leaving runtime behavior unchanged.

* fix: keep pool-wait alert active when database collector fails

Remove only the collector-availability gate; preserve snapshot freshness, sample sufficiency, thresholds, duration and labels. Add bounded PromQL scenarios with per-instance assertions, recovery and the old-expression negative control.

Refs #336. Landing depends on #334 and reconciliation with #343 and #345.

* feat(prism): record ledger advisory-lock and pool-acquire waits

The native registry declares two database wait families, but nothing in
the ledger observed either one: advisory-lock waits had no observations
at all, and pool acquisition counted only the metrics collector's own.
ORDER_LOCK contention, which serializes every share acknowledgement, was
therefore invisible in production and in qualification.

Give Ledger an optional metrics handle, supplied by the new
connect_with_metrics; connect keeps its signature and passes None, so the
tools and tests that connect without a registry are unchanged and record
nothing. The coordinator passes its own handle.

Route every advisory lock through the existing lock() helper, now timing
the pg_advisory_xact_lock statement alone, and every ledger transaction
through a new begin() helper that times pool acquisition alone. begin()
is Pool::begin split in two around the timer, down to its sqlx::Error
return type, so both helpers are drop-in replacements and every call
site's `?` converts exactly as before. Leaving the BEGIN round trip
outside the timer keeps the pool series meaning what the collector
already records. Errors, including a lock_timeout expiry, propagate
unchanged and record as failure.

A drop guard records a wait abandoned by cancellation as a failure with
its elapsed time, so a cancelled wait stays distinct from one that never
happened. Completing the wait takes the handle out of the guard, so a
wait is recorded exactly once. The metrics mutex is taken only after the
wait has ended, never across an await, so rendering cannot queue behind a
contended lock; a guard held across the await would also make the future
!Send and fail to compile in the spawned share path.

The CPFP funding lock has no LockKind value and stays unobserved; an
unmapped key trips a debug assertion rather than borrowing another lock's
label.

Refs #328.

* test(prism): cover ledger wait metrics by lock and outcome

Two gated PostgreSQL tests and one source invariant.

The first attaches a registry and drives each outcome against a real
database: the migration lock from schema creation, a share append taking
ORDER_LOCK once, a second session holding ORDER_LOCK past a 200 ms
lock_timeout so the append fails with SQLSTATE 55P03 and records one
failure of at least that duration, an append cancelled mid-wait
recording its partial wait and no more, the settlement lock through
save_job, five transactions each observing one pool acquisition plus the
collector's own unchanged, a pool acquisition cancelled while every
connection is held, and a closed pool recording an acquisition failure.

The second runs the same sequence on a ledger with no handle and asserts
that results and error codes, 55P03 included, are what they are at the
base. It deliberately makes no assertion about a registry: one no ledger
can reach has nothing to record into and could not fail the test.

The third walks the ledger sources recursively and fails if a
transaction begins outside the timed helper or a second
pg_advisory_xact_lock statement appears, so a new call site, including
one in a nested module directory, cannot silently stop being observed.

Refs #328.

* docs(prism): describe the recorded database wait metrics

The two database wait families now say what they measure and who records
them: pool acquisition excludes transaction BEGIN and the queries that
follow, and advisory-lock timing covers one pg_advisory_xact_lock round
trip for the migration, order and settlement locks. Both record a wait
abandoned by cancellation as a failure, except the collector's own
acquisitions, which keep reporting cancellation through collector status
rather than inventing a completed wait.

The follow-up ownership and alert-migration notes no longer describe
either family as unpopulated, and record that PrismDatabasePoolWaitHigh
now evaluates ledger acquisitions while its description still predates
this change. No alert rule or digest input changes.

Refs #328.

* docs(prism): record the pool alert's collector-availability gate

PrismDatabasePoolWaitHigh is gated on collector_available for the database
collector. That collector acquires from the same pool as the ledger, so
sustained pool exhaustion times out its own acquisition, zeroes the gauge and
suppresses the rule exactly while the ledger histogram is recording the waits
that should fire it.

The gate cost nothing before this branch, because the family carried no samples
during such an outage. It does now. The rule itself cannot change here: its file
feeds the alert specification digest embedded in the deployment patch, which can
only be regenerated from the frozen template. #336 tracks that work; this records
the interaction where operators read it.

Refs #328.

* docs: distinguish pool-wait guard from database gauge guards

* feat(prism): record ledger advisory-lock and pool-acquire waits

The native registry declares two database wait families, but nothing in
the ledger observed either one: advisory-lock waits had no observations
at all, and pool acquisition counted only the metrics collector's own.
ORDER_LOCK contention, which serializes every share acknowledgement, was
therefore invisible in production and in qualification.

Give Ledger an optional metrics handle, supplied by the new
connect_with_metrics; connect keeps its signature and passes None, so the
tools and tests that connect without a registry are unchanged and record
nothing. The coordinator passes its own handle.

Route every advisory lock through the existing lock() helper, now timing
the pg_advisory_xact_lock statement alone, and every ledger transaction
through a new begin() helper that times pool acquisition alone. begin()
is Pool::begin split in two around the timer, down to its sqlx::Error
return type, so both helpers are drop-in replacements and every call
site's `?` converts exactly as before. Leaving the BEGIN round trip
outside the timer keeps the pool series meaning what the collector
already records. Errors, including a lock_timeout expiry, propagate
unchanged and record as failure.

A drop guard records a wait abandoned by cancellation as a failure with
its elapsed time, so a cancelled wait stays distinct from one that never
happened. Completing the wait takes the handle out of the guard, so a
wait is recorded exactly once. The metrics mutex is taken only after the
wait has ended, never across an await, so rendering cannot queue behind a
contended lock; a guard held across the await would also make the future
!Send and fail to compile in the spawned share path.

The CPFP funding lock has no LockKind value and stays unobserved; an
unmapped key trips a debug assertion rather than borrowing another lock's
label.

Refs #328.

* test(prism): cover ledger wait metrics by lock and outcome

Two gated PostgreSQL tests and one source invariant.

The first attaches a registry and drives each outcome against a real
database: the migration lock from schema creation, a share append taking
ORDER_LOCK once, a second session holding ORDER_LOCK past a 200 ms
lock_timeout so the append fails with SQLSTATE 55P03 and records one
failure of at least that duration, an append cancelled mid-wait
recording its partial wait and no more, the settlement lock through
save_job, five transactions each observing one pool acquisition plus the
collector's own unchanged, a pool acquisition cancelled while every
connection is held, and a closed pool recording an acquisition failure.

The second runs the same sequence on a ledger with no handle and asserts
that results and error codes, 55P03 included, are what they are at the
base. It deliberately makes no assertion about a registry: one no ledger
can reach has nothing to record into and could not fail the test.

The third walks the ledger sources recursively and fails if a
transaction begins outside the timed helper or a second
pg_advisory_xact_lock statement appears, so a new call site, including
one in a nested module directory, cannot silently stop being observed.

Refs #328.

* docs(prism): describe the recorded database wait metrics

The two database wait families now say what they measure and who records
them: pool acquisition excludes transaction BEGIN and the queries that
follow, and advisory-lock timing covers one pg_advisory_xact_lock round
trip for the migration, order and settlement locks. Both record a wait
abandoned by cancellation as a failure, except the collector's own
acquisitions, which keep reporting cancellation through collector status
rather than inventing a completed wait.

The follow-up ownership and alert-migration notes no longer describe
either family as unpopulated, and record that PrismDatabasePoolWaitHigh
now evaluates ledger acquisitions while its description still predates
this change. No alert rule or digest input changes.

Refs #328.

* docs(prism): record the pool alert's collector-availability gate

PrismDatabasePoolWaitHigh is gated on collector_available for the database
collector. That collector acquires from the same pool as the ledger, so
sustained pool exhaustion times out its own acquisition, zeroes the gauge and
suppresses the rule exactly while the ledger histogram is recording the waits
that should fire it.

The gate cost nothing before this branch, because the family carried no samples
during such an outage. It does now. The rule itself cannot change here: its file
feeds the alert specification digest embedded in the deployment patch, which can
only be regenerated from the frozen template. #336 tracks that work; this records
the interaction where operators read it.

Refs #328.

* Expose pool acquisition waits independently of health publication

---------

Co-authored-by: Alex <alex@swaplabs.xyz>
* docs: add PRISM fatal-state recovery runbook (#290)

Document the operator procedure for a shared fatal state: evidence
first, graceful stop of every frontend with stale-versus-stopped
instance guidance, separately reviewed reconciliation before clearing,
the audited fatal-state clear command, and restart verification.
Includes read-only, bounded SELECT examples only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RXEBLAaU5LEGU9Px59q19

* Add transactional PRISM fatal-state recovery and regression tests (#290)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* v3.0.0 fix: harden native PRISM configuration and containers

* fix: align heartbeat freshness with health cadence

* fix: decouple job pruning from health publication cadence
…ns, gate startup (#321)

* 3.x.x: pin the migration source schema and gate startup on it

The migrator accepted any database that had a share ledger, tested the
outbox with a JSONB predicate that a #258 v2 row slips past, never read
qbit_prism_schema_capabilities, and skipped the schema version entirely
when PRISM_POSTGRES_INIT_SCHEMA=0, the native default. Its "frozen 2.x.x
schema" in the upgrade tests was include_str! of the live 001, which #244
already edits.

Freeze the 2.x.x release SQL as byte-exact fixtures taken from the
release commits (001 from v2.0.2, identical at v2.0.1 and v2.0.0; 002
from v2.0.2), pin their digests, pin the live 001's digest, and compare
the live 001 to the release statement for statement, since #244 changed
only its comments. Editing either live file fails the test with the
instruction the editor needs.

Migration 006 pins the accepted source states as data: fresh, pre-#258
(001 only), #258 applied (candidate_storage_version = 2 and every 002
object), partial 002 (refused, naming the missing object) and newer
(refused before any DDL). The inventory comes from the catalog, so the
drain predicate is built only from outbox columns that exist: the
storage_version and body_id clauses on a #258 source, the v1 form on a
pre-#258 one, where a fixed predicate errored instead of refusing. The
check reads rows, never the capability row, which only proves 002 ran.
The refusal names the blocking rows and the 2.x.x drain: the pinned
coordinator's submitter, or lab.prism.recover_pending_blocks from the
2.x.x image.

Every start now reads qbit_prism_schema_migrations and the capability
rows, with or without initialize, and refuses a schema other than
version 6 or a capability the binary does not understand, naming the
required version. A successful migration records the accepted source
state, its 2.x.x release and commit, the declared capability value and
the prior schema version in qbit_prism_migration_source; migrate prints
it, every start logs it, and a repeated migrate never rewrites it. 006
also declares candidate_storage_version = 1 on sources that had no
capability row and adds the outbox storage_version column so one claim
statement serves every source; the chunk tables are not carried.

The API database test hand-applies migrations up to 003 and then
connected without initialize, which the gate now refuses; it initializes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AauTYgQEqxzDE13xLjYTR9

* 3.x.x: park candidates the claim lane cannot decode

The claim lane returned the outbox row's candidate column alone. A #258
v2 row returns NULL with the claim token set, no CandidateClaim can be
built, the submit loop logs "candidate polling failed", and the row
re-enters the due lane at every lease expiry, forever.

Return storage_version and an Option<Value> candidate. A version 1 row
with a body is decoded as before. Any other row, a v2 body this server
does not import or a version it does not know, is parked inside the
claim transaction: the claim is released, last_error says why, and
next_attempt_at becomes infinity, so no lease expiry offers it again and
ordinary v1 work keeps flowing. The row stays pending with its body
untouched; resetting next_attempt_at re-offers it, which is how an
operator hands it back after draining it with the 2.x.x image. The edit
is deliberately small, since #265 reshapes candidate storage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AauTYgQEqxzDE13xLjYTR9

* Document the supported 2.x.x source schemas and the drain requirement

The migration guide names the minimum supported 2.x.x release (v2.0.1,
whose offline recovery command is part of the drain; v2.0.0 has the same
schema), the newest (v2.0.2), #258's rollback floor, the source-state
table the migrator pins, the drain requirement for v1 and v2 rows with
the 2.x.x commands, what qbit_prism_migration_source records, the
startup gate, and how a parked row is found and re-offered. The server
README points at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AauTYgQEqxzDE13xLjYTR9

* 3.x.x: accept a newer schema at start and let capabilities fence formats

The startup gate refused any schema version other than the one this
binary migrates to. That silently withdrew the rollout the guide
promises, where compatible Rust releases drain and replace one frontend
at a time, because a release that adds a migration would have kept every
frontend on the previous release from starting once its migrate ran.

Refuse only a schema below the required version, as the issue asks. A
newer schema is accepted with a warning that names both versions: native
migrations are additive, and a release whose format an older binary must
not touch declares a capability, which require_known_capabilities still
refuses exactly as before. The guide's rollout paragraph is restored with
the order that makes it hold: the new release's migrate runs first,
frontends on the previous release keep starting while its migrations are
additive and it declares no capability they do not understand, and a
release that declares a new capability needs every frontend stopped
first. The startup-gate test now asserts that a start on the next schema
version succeeds and rewrites nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AauTYgQEqxzDE13xLjYTR9

* Record the migrator and schema freeze in the 3.x.x development-line note

The note listed how 002_candidate_bodies.sql is handled as frozen by a
pending issue. It is now carried byte-identical to v2.0.2 and
digest-pinned, never applied, and a #258 source is accepted after the
drain check while a partial or newer source is refused before any DDL.
The matching "not done yet" bullet is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AauTYgQEqxzDE13xLjYTR9

* 3.x.x: verify the frozen 001 schema after it has run, refusing drift transactionally

A database with no 002 objects was accepted as the pre-#258 source on the
strength of qbit_share_ledger existing. 001 is idempotent, but its IF NOT
EXISTS leaves an existing table, column, index or named constraint as it
is, so an older, partially applied or locally modified schema passed the
pin, was recorded as v2.0.1, and its incompatible definitions survived a
successful migration.

The migrator now applies 001 to the source as before and then, before
002_multi_instance.sql or any later native DDL touches those tables,
checks that what 001 left alone is the release definition. The expected
definitions are not a stored fingerprint: under a savepoint in the
migration transaction it creates a scratch schema, applies the same 001
there (plus 002_candidate_bodies.sql for a #258 source), reads every
table, column, constraint, index, trigger and function that produced,
and rolls the savepoint back, so the comparison is exact for the running
PostgreSQL version and nothing from the scratch apply survives. Each
object then needs an equivalent in the schema the migrator runs in:
column type, NOT NULL, default, identity, generated status and
collation; constraints per table by definition; index definitions and
validity; trigger definitions and enabled state; function arguments,
result, language, body, volatility and settings. Schema qualification,
column order, comments, auto-generated constraint names and the NOT
VALID mark 001 itself leaves on an upgraded table are ignored; extra
objects are kept and logged; a missing or different one fails the
migration, which rolls back whole, naming each object and what differs.
A migrate role without CREATE on the database is refused with the grant
it needs. The source-state table gains the "drifted 001" row in
SOURCE_STATES, migration 006's comment and the migration guide; the
migration record is unchanged.

Tests: a pre-#258 source with a dropped foreign key, a dropped NOT NULL,
a changed column type and a same-named index with a different
definition is refused naming all four and left untouched; a #258 source
with a replaced 002 function body and a disabled 002 trigger is refused;
an extra table, column and index, a 001 column moved to a different
physical position with its data, and a NOT VALID credit-policy CHECK
still migrate; a role without CREATE on the database is told the grant.
Replays of the real 0.1.2 -> 1.x -> 2.x history (every 001 revision in
order, then the frozen v2.0.2 file, with and without 002) all migrate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVxRdWsnzBhrsgiEX2kvKR

* 3.x.x: register the source-schema gated tests in the gate manifest

The branch was rebased onto #322, which fails the prism-native-postgres
job for any gated test that executes without being listed in
test/prism-gated-tests.txt. None of the ten Postgres tests this PR adds
in tests/support/ledger_2x.rs were listed, so the job would have failed
on its manifest check. List them, sorted, and update the count the gate
guide quotes. The other #322 checks (formatting, clippy with -D warnings,
the gate env-read lint, the toolchain pin, version skew) already pass on
this branch and need no change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mWgeEnsyHLZSiGaAj8bZi

* 3.x.x: compare release sequences in the source-schema check

The post-001 fingerprint read tables, columns, constraints, indexes,
triggers and functions, but never the sequences behind 001's bigserial
columns or its explicit qbit_audit_publication_sequence_seq. CREATE TABLE
IF NOT EXISTS leaves an existing sequence exactly as it is, so a lowered
MAXVALUE or a changed INCREMENT on qbit_share_ledger_share_seq_seq
survived the idempotent apply, compared equal to the frozen release, and
migrated; the next share inserts would then fail or take unexpected
identifiers.

The fingerprint now records every sequence of the schema from
pg_sequence: data type, start, increment, minimum, maximum, cache and
cycle. The value a sequence has reached (last_value, is_called) is the
source's data and is not read, so an advanced ledger still migrates and
keeps its position. A release sequence that is missing or whose structure
differs is drift, named in the refusal the way columns and functions
are, for example

  sequence qbit_share_ledger_share_seq_seq differs: maximum expected
  9223372036854775807, found 1000000

and an extra sequence is logged with the other extras.

Tests: the pre-#258 drift test also lowers the share sequence's maximum
and doubles the payout entry sequence's increment, and the refusal names
both and rolls back; the tolerated-differences test advances the share
sequence with setval and the first native share takes the next value; a
unit test covers the comparison. The real 0.1.2 -> 1.x -> 2.x histories
(every 001 revision in order, then the frozen v2.0.2 001, with and
without 002) all still migrate, and each leaves the same sequence
structure as a fresh apply. The migration guide and the 006 comment list
what is compared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mWgeEnsyHLZSiGaAj8bZi

* 3.x.x: treat a database as fresh only when no release object exists

A database without qbit_share_ledger and without any 002 object was
classified as fresh on that evidence alone, and the post-001 comparison
was skipped for it. A selective restore or a hand-installed piece of the
schema, qbit_pool_blocks on its own for instance, therefore passed: 001's
CREATE ... IF NOT EXISTS kept whatever those objects held, the migration
recorded success, and the accounting code ran over a schema nobody had
checked.

The release definitions are now taken once, before any DDL touches the
source: the frozen release SQL (001, plus 002 for a #258 source) is
applied to a scratch schema under a savepoint and rolled back, and that
one fingerprint serves both checks. A database without qbit_share_ledger
is fresh only if it has no table, sequence, index, trigger or function
the release 001 creates. Otherwise it is the new source state
"partial 001", refused before any DDL and naming the objects present,
capped as the drift report is:

  refusing to migrate a partial 001 source before any DDL: the database
  has no qbit_share_ledger but holds 4 object(s) that the 2.x.x
  release's 001_share_ledger.sql creates (table qbit_pool_blocks; index
  ...), so it is neither an empty database nor a 2.x.x ledger, and
  001's IF NOT EXISTS would keep those objects whatever they hold.
  Nothing was changed. Restore the full pre-migration backup, or
  migrate into an empty database

Objects the release does not create, an operator's own table, do not
disqualify a fresh database and are logged as extras. The post-001
comparison now runs for every source state, fresh included, where it
passes trivially: 001 has just created everything.

SOURCE_STATES gains the partial 001 row after fresh. The rows are cited
by name through constants and looked up by name, never by position, and
a unit test checks every cited name is exactly one row, so reordering
the table cannot silently change a refusal again. The pinned-data test,
the 006 comment, the guide's table and the development-line note carry
the new row.

Tests (Postgres): a leftover 001 table, sequence or function alone,
each created from the frozen release definition, is refused as partial
001 naming it, with no native table, no scratch schema and the leftover
untouched; an empty database with only an operator's table still
migrates as fresh and keeps the table; a truly empty database migrates
as fresh. The real 0.1.2 -> 1.x -> 2.x history replays, with and without
002, still migrate. Both new gated tests are in the manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mWgeEnsyHLZSiGaAj8bZi

* 3.x.x: recheck the outbox before 006 on a native schema an earlier build migrated

A database an earlier 3.x.x build brought to native schema 3, 4 or 5 went
straight to 004, 005 and 006 without the drain check, and that build's
v1-only predicate never counted a v2 row (`candidate ?& ...` is NULL for a
NULL body). A pending v2 candidate could therefore be carried into version 6
and parked by the claim lane instead of refusing the cutover, leaving a
possibly valid found block unsubmitted.

`migrate_schema` now runs the column-aware inventory and
`refuse_undrained_outbox` on a recorded version 3, 4 or 5 before 004, 005
and 006 run, so a refusal on that path is before any DDL as well. Native
pending rows carry the native payout_revision, bundle and block_hash fields
and are not counted; only a v2 body, a body_id, a storage_version other than
1, or a v1 body without those fields is refused. The refusal has its own
wording: it names the native schema version, names the rows the same way as
the 2.x.x refusal, and gives the remedy the guide backs, restoring the
pre-migration 2.x.x backup and draining there with the pinned image. The
2.x.x release is not supported against a native schema (its revert script
refuses one), so the message says not to point it at the database.

Postgres tests build the earlier build's schema 5 faithfully: the frozen
001 (and 002), the current migration, then 006 undone, deleting the version
6 row and dropping qbit_prism_migration_source, and on a pre-#258 source
also the storage_version column and the capability table 006 created. A
pending v2 row on the #258 simulation is refused naming the row, with
max(version) still 5 and no 006 object; a native pending candidate on either
simulation migrates to 6 and is still claimable; an undrained 2.x.x v1 row on
the pre-#258 simulation exercises the v1-only form of the predicate. The
guide's drain text and the 006 comment describe the check; the gated-test
manifest and its documented count are updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuEYH8xHQRo7C4SnexM3cP

* 3.x.x: compare table and sequence persistence in the source-schema check

The release-schema fingerprint recorded a table by name and compared its
columns, constraints and indexes, so a release table an operator had
changed to UNLOGGED compared equal to the logged table the scratch apply
creates, and the migration accepted a ledger or outbox whose rows
PostgreSQL truncates after a crash.

The fingerprint now records pg_class.relpersistence for every table and
every sequence (PostgreSQL 15 and later have unlogged sequences, and a
sequence owned by an unlogged table is unlogged with it). A mismatch is
drift, named the way the other differences are: `table
qbit_share_ledger differs: expected logged, found UNLOGGED`, and for a
sequence the persistence joins its other properties. Extra objects are
unaffected.

A Postgres test makes qbit_payout_carry_forward UNLOGGED on a pre-#258
source: it holds the carried balances and has no foreign key in either
direction, so SET UNLOGGED is accepted as it is, and the sequence behind
its bigserial column follows the table. The migration is refused naming
the table and its sequence, rolls back with the relations still unlogged
and no native table, and accepts the same source once it is logged again.
(qbit_audit_publication_sequence_seq is not used for this: 001's own guard
already refuses that sequence when it is not logged.) A unit test covers
the comparison for tables, sequences and a temporary table. Replays of the
real 0.1.2 -> 1.x -> 2.x schema history, with and without 002, still
migrate: no revision creates an unlogged relation. The guide's
release-schema paragraph, its drifted 001 row and the comment in migration
006 name persistence; the gated-test manifest and its count are updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuEYH8xHQRo7C4SnexM3cP

* 3.x.x: compare constraint validation, exempting only the release's own NOT VALID check

The source-schema check stripped NOT VALID from every constraint definition
before comparing, so a foreign key or CHECK an operator had dropped and
re-added NOT VALID compared equal to the validated release constraint, and
a source whose rows were never checked against it, orphaned outbox rows or
invalid accounting rows for instance, still migrated.

The blanket strip is replaced by a pinned exemption list, NOT_VALID_EXEMPT:
the constraints the frozen release 001 itself adds NOT VALID on an upgraded
table and never validates. Today that is qbit_share_ledger_credit_policy_check
alone (001 adds it NOT VALID to a qbit_share_ledger upgraded from before the
column existed; a fresh apply creates it validated inside CREATE TABLE). An
exempt constraint is accepted in either state on either side. Every other
constraint is compared by pg_constraint.convalidated as well as by
definition: a release constraint that is NOT VALID in the source is drift,
`constraint qbit_block_candidate_outbox_share_id_fkey on
qbit_block_candidate_outbox is NOT VALID; the release validates it`. The
definition without its NOT VALID suffix is still the lookup key, so an
unvalidated constraint finds its release counterpart and is named.

A test parses the frozen 001 and 002 fixtures for constraints added NOT
VALID and not validated later in the same file (plain statements and the
format() form alike) and asserts the set equals NOT_VALID_EXEMPT, so the
list cannot drift from the release. A Postgres test drops and re-adds the
outbox share_id foreign key and a carry-forward CHECK NOT VALID: the
migration is refused naming both, rolls back with nothing validated, and
accepts the source once they are validated. The existing tolerated-
differences case, with the credit-policy check NOT VALID, still migrates.
Unit tests cover the comparison and the exemption. Replays of the real
0.1.2 -> 1.x -> 2.x schema history, with and without 002, still migrate:
the only constraint a genuine history leaves NOT VALID is the exempt one.
The guide's release-schema paragraph, its drifted 001 row and the comment
in migration 006 describe the rule; the gated-test manifest and its count
are updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuEYH8xHQRo7C4SnexM3cP

* 3.x.x: require every schema migration at start, 009 included

and may land after 009, and the rebase moved its 009 step into
migrate_schema with the rest of the chain. The startup gate still compared
max(version) with 6, which a database at 2, 3, 4, 5 and 9 passes without
006, and its "newer than required" warning would have fired on every start
once 009 was in the chain.

REQUIRED_SCHEMA_VERSIONS names 2, 3, 4, 5, 6 and 9. A start refuses a
database missing any of them, naming the gap, and warns only about
migrations this binary does not know. The pre-006 outbox refusal names the
applied set instead of a high-water mark; prior_schema_version in the
source record stays the highest migration recorded.

Tests: a database at 2, 3, 4, 5 and 9 applies 006 on the next migrate and
leaves 009 as it found it; a start without PRISM_POSTGRES_INIT_SCHEMA on
that database is refused naming 6; the newest-migration case now undoes
009. #319's read-only pre-009 connect test is removed, since the gate
refuses that database by design, and the gate manifest follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: refuse unknown capabilities before 004 to 006 run on a native schema

On a database an earlier 3.x.x build migrated to native schema 3, 4 or
5, migrate_schema ran the drain check and then applied 004, 005 and 006
without reading the capability rows, so a database a newer release wrote
(candidate_storage_version = 3, or a capability this binary does not
know) was altered and recorded at 6, and only Ledger::connect refused
it, after the commit. Every native path now takes the inventory first
and refuses those rows before any DDL, on a database already at 6 too,
before 009; the 006 record reuses the same rows instead of reading them
again, and the recorded versions are read in order so the refusal names
them deterministically. A pre-006 test on a #258 source covers both
refusals and the migration once the row is gone; the migration guide and
the 006 comment describe the order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: compare row-level security and policies in the source-schema check

The release-schema fingerprint read a table's name, persistence and columns
only, so a release table with row-level security enabled or forced, or a
policy on it, compared equal to the frozen release. For a migrate role that
does not bypass row-level security, a policy hiding pending rows (or forced
security with no policy) makes the drain check see an empty outbox, so the
migration commits with legacy candidates stranded where the native claim
lane never sees them. The fingerprint now reads relrowsecurity and
relforcerowsecurity from pg_class and every policy from pg_policy (command,
permissive or restrictive, roles, USING and WITH CHECK, rendered without
schema qualification), both for the scratch release apply and the source,
and the comparison refuses a release table whose flags differ or that
carries a policy the release does not have, naming the table, the flags
found and each policy. Policies on tables the release does not create are
not reported. A Postgres test migrates as a non-superuser owner role and
shows the row hidden, the refusal, the rollback, and the migration once
security is off and the row drained; the docs and the 006 header say what
is compared and why.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Quote the pre-006 outbox refusal as the migrator words it since 009 joined the chain

The guide's example still read "a native schema 5 database", the wording
from before the applied migrations were tracked as a set; the migrator
names the recorded set ("a native database at schema migrations 2, 3, 4,
5, 9") since the startup gate stopped comparing a high-water mark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: refuse a 2.x.x or empty source that already holds a native object or column

Codex P1 on #321: a source that already contains an object a native
migration creates (a qbit_prism_cluster(singleton boolean PRIMARY KEY),
for instance) passed the fresh and release checks as a harmless extra.
002's CREATE TABLE IF NOT EXISTS then kept it as it was, the migration
recorded its versions, and only after commit did writable() fail on the
missing fatal_error column, with every later migrate skipping the
recorded version. A column a native migration adds with ADD COLUMN IF
NOT EXISTS (002's claim columns on the outbox and the fanout artifacts,
003's canonical_audit_bytes and inactive_since, 006's storage_version on
a pre-#258 outbox) is the same bug: a source release table that already
has a column of that name keeps it whatever its type or default.

The scratch apply release_fingerprint runs now continues, after the
release SQL, with the native migrations in the order migrate_schema
applies them (NATIVE_MIGRATIONS, pinned to REQUIRED_SCHEMA_VERSIONS by a
unit test) and fingerprints the scratch schema again. The second reading
minus the first is the reserved set (ReservedObjects): every table,
sequence, index, trigger and function a native migration creates and the
release does not, and, per release table, every column the native chain
adds to it. qbit_prism_schema_migrations, the migrator's own, is never
in it, and an object or column in both readings (the capability table on
a #258 source, the outbox's storage_version there, which the release 002
added; on a pre-#258 source 006 adds it, so it is reserved) stays
governed by the release checks. Reserved columns are kept in their own
field: objects_present also serves the partial-001 check, where a
present table already refuses and listing its columns would be noise, so
only require_no_native_collision looks at them. On the 2.x.x path,
before any DDL, the source is fingerprinted once, require_fresh_source
(now synchronous, on that reading) runs on a fresh source, and a source
that already has a reserved object or column is refused as the new
"native collision" state, naming each (capped by named_objects; a column
as column <table>.<column>), saying nothing was changed, and telling the
operator to restore the backup or check and remove the objects; nothing
is dropped and no DROP is suggested. release_objects_present is renamed
objects_present.

The migrator applies each native migration from NATIVE_MIGRATIONS, so
the scratch apply and the real one cannot drift apart. SOURCE_STATES,
its pinned-table test, the comment in 006 and the guide's table carry
the new row; the guide shows the refusal and names the column case.

Tests, against PostgreSQL 16: an empty database with a stray
qbit_prism_cluster is refused naming it, with no migrator table and the
stray table unaltered, and migrates as fresh once the table is removed;
a pre-#258 and a #258 source with a stray native table, function and
sequence are refused naming all three, unchanged, and migrate once they
are removed; a pre-#258 and a #258 source with a stray claim_token
integer on the outbox are refused naming column
qbit_block_candidate_outbox.claim_token, unchanged (the column keeps its
type), and migrate once it is dropped, after which 002 adds the text
column it wanted. The unit test on the reserved set pins the column
derivation, including storage_version reserved on a pre-#258 outbox and
not on a #258 one. The api_database fixture, which hand-applies 001 to
003 and then lets connect bring the schema forward, now records versions
2 and 3 the way the migrator does: native objects with no record are the
collision this commit refuses.

The genuine 0.1.2 -> 1.x -> 2.x history replays (the 12 distinct 001
revisions reachable from the release tags, main, 2.x.x and 3.x.x,
applied in order into one database and each on its own, then the frozen
v2.0.2 001, with and without 002, then migrate: 26 runs) are all
accepted, so no real history contains a native object or column.

The new Postgres tests are registered in test/prism-gated-tests.txt, and
the "N today" count in docs/prism-integration-test-gate.md, which had
fallen behind the manifest at an earlier rebase, is set to the
manifest's id count, 114.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: fold migration 008 into the native chain after rebasing on 3.x.x

#327 added 008_prepared_window_reference.sql to the runner in connect.rs,
which this branch had moved into migrate_schema. Put 008 in
NATIVE_MIGRATIONS, so the scratch apply reserves what it creates, and in
REQUIRED_SCHEMA_VERSIONS, so the startup gate requires it; apply it after
006 and before 009. 007 is the only reserved number now. The pre-006
fixtures the tests undo from the current chain therefore model the database
today's 3.x.x leaves, at 2, 3, 4, 5, 8 and 9, and the messages and docs
name that set.

* 3.x.x: refuse a native record with 3 and not 2 before any DDL

Every native build has recorded 2 and 3 in the one transaction that
applied them, so a record with 3 and not 2 was edited or restored
selectively. migrate_schema's native path applied the rest of the chain
above it and only the startup gate then refused the gap, at every start,
and no later migrate could repair it: 002 is not re-run on a database at 3,
and recording it unseen would vouch for objects the run never checked.
Refuse the record before any DDL, naming the remedy; a start without
initialize keeps refusing the gap.

* 3.x.x: require the capability declaration once migration 006 is recorded

require_known_capabilities accepted a database at 6 whose capability table
or candidate_storage_version row was gone, as if absence were the legacy
state it was before 006; after a selective restore or a deletion the
database could no longer say which release wrote it and the connect gate
let it through. 006 created both and nothing native removes them, so
require them at connect once require_schema_version has established 6,
naming the remedy, and refuse the same database on the migrate path before
008 or 009 run above the missing declaration.

* 3.x.x: compare the enforcement triggers of release constraints

The trigger reading skipped every internal trigger and the constraint
comparison looked at the definition and convalidated only, so a release
foreign key whose referential triggers a superuser had disabled (ALTER
TABLE ... DISABLE TRIGGER ALL during a bulk load, say) compared equal: no
new row was checked against it, 001 never re-enables a trigger it did not
create, and the native ledger could go on accumulating orphaned rows. Read
the enabled state of each internal trigger that enforces a constraint into
its definition, on both tables of a foreign key, and refuse a release
constraint whose states differ from the scratch apply, naming them.

* 3.x.x: refuse inheritance involving a release table

The table reading never looked at pg_inherits, so a child created with
INHERITS (qbit_share_ledger) was an extra ordinary table, accepted with a
warning, while PostgreSQL includes its rows in every query of the ledger,
the unqualified share reads included, without the release constraints ever
checking them. Read each table's parents and children into its definition
and refuse a release table that has either, naming them; a release table
attached as a partition of or inheriting from another table is drift the
same way, and inheritance among the operator's own tables stays theirs.

* 3.x.x: count a reserved name held by a relation of any kind as a collision

The index reading left every index backing a primary, unique or exclusion
constraint to the constraint comparison, and the collision check looked up
reserved index names in that reading only, so an operator table whose
unique constraint was named qbit_prism_candidate_claim_idx owned that name
unseen: 002's CREATE INDEX IF NOT EXISTS would skip the outbox index it
meant to create, record the migration anyway, and leave candidate polling
to scan the outbox. IF NOT EXISTS looks at the name alone, for a view or a
composite type as much as for such an index, so read every relation of the
kinds no other map models into the fingerprint, reserve the native ones the
release lacks, and count a reserved table, sequence or index name held by
any of them, naming what holds it. An index backing a constraint of an
expected table goes with that table and is not named twice.

* 3.x.x: refuse extra user triggers on release tables

The release-schema check classified a user trigger the release does not
create on a release table as a tolerated extra, so require_release_schema
logged it and migrated over it. Such a trigger can be valid for the
legacy writer and incompatible with the native one: a guard that rejects
writer_epoch = 0 lets the 2.x.x writer's leased epochs through and fails
every native share insert, which writes epoch 0, and a trigger can just
as well rewrite what the migrator and the native writers put in the
accounting tables. Report a found user trigger on a release table whose
(table, name) the release lacks as drift, naming the trigger, its table
and its definition, and whether the release has no trigger on that table
or simply does not create this one, so the migration refuses
transactionally and the database is unchanged. Triggers on tables the
release does not create stay the operator's own; the expected-trigger
definition and enabled-state comparison and the enforcement-trigger
check of release constraints are unchanged.

Regression: a comparator unit test for both reasons, a missing release
trigger and an operator table's trigger; and a two_x PostgreSQL test on
the frozen pre-#258 and #258 sources that shows the guard accepting a
legacy epoch and refusing epoch 0, the refusal naming the trigger and
rolling back with the trigger, the legacy rows and no native table or
version in place, then the same source migrating once the operator
drops the guard, with the operator table's trigger kept and firing and
the native writers inserting their epoch-0 shares.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N2if7arQVKaZ1yMupJiprr

* fix: reject extra constraints on migration source tables

* fix: reject unsatisfied extra migration source columns

* fix: reject extra indexes that affect native writes

* fix: update rustls to address RUSTSEC-2026-0285

* fix: reject executable expressions on extra source columns

* fix: fingerprint table rewrite rules before migration

* fix: reject incoming foreign keys from extra source tables

* fix: validate storage version before native migration 006

* fix: reject domain-typed extra migration columns

* fix: reject migration sources resolved outside the current schema

* fix: reject all extra indexes on release tables

* fix: validate source metadata before later native migrations

* fix: reject preexisting source metadata before migration 006

* fix: reject row security on capability metadata

* fix: separate legacy source versions from native capabilities

* fix: reject outbox row security before native migration drain

* fix: validate migration history before trusting version records

* fix: require an ordinary table for capability declarations

* fix: bind the capability declaration to the current schema

The connect-time gate probed qbit_prism_schema_capabilities with an
unqualified to_regclass, which follows search_path. When the migrated
schema had lost its capability table, the probe could resolve a later
schema's table, and its version-1 row cleared a database whose own
declaration was gone, while require_migration_history had bound the
history to current_schema(). Migrate was already safe through
require_source_schema_resolution; connect was not.

read_capabilities now looks the visible relation up in the catalog and
refuses one outside current_schema(), naming both schemas, before any
row is read. Both callers share the path: the connect gate drops its
separate probe and the migrate inventory reads through the same
function. A new gated test drops the migrated schema's declaration,
declares version 1 in a later schema on search_path, and asserts that
connect and migrate both refuse it, that the loss is still named as a
loss without the later schema, and that the restored declaration is
read with the later schema still present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: bind migration provenance to the verified schema

* fix: reject object collisions in native migration gaps

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…write off the ordering lock (#350)

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: pass the signer keys #334's wait-metrics tests now need

`Ledger::configure` takes the frontend's signing keys since this slice, so the
fingerprint contention tests #334 added call it with a fixed pair. That suite
measures advisory-lock and pool-acquire waits, not signing, so the only thing
the keys have to be is the same on every call.

* 3.x.x: resume a job's bundle only while the current inputs still describe it

A resumed job reuses the bundle it was issued with, but captures its inputs
from this frontend's configuration now. Before this slice that was harmless:
the candidate carried the assembled bundle, and landing used it directly. Now
the candidate stores the inputs and the claim rebuilds the audit from them, so
an old bundle labelled with today's configuration rebuilds to a coinbase the
block does not commit to. The claim fails its comparison before `submitblock`
and retries until the job expires, which can lose a found block.

The stored bundle is now reused only when the captured inputs still describe
it, comparing what the bundle itself records about how it was built: the payout
policy, and the public keys that actually signed its coinbase manifest and its
ledger attestation. A difference is a cache miss, so the bundle is rebuilt
rather than mislabelled.

Both directions are covered, since over-tightening would silently defeat the
job cache and rebuild on every resume: unchanged inputs must still describe the
bundle, and a payout-policy change, a full signer rotation and a rotation of
either key alone must each refuse it.

Known residue, recorded in the doc comment: a bundle records neither the CTV
settlement inputs nor `audit_builder_version`, so a change to either between
issue and resume is still undetectable here. Closing that needs the issued job
to store its inputs, which is #273's format.

* 3.x.x: migrate #355's ledger tests to the slim candidate

#355 landed deadline and single-execution coverage against the candidate that
still carried an assembled bundle, and against `configure` before it took the
frontend's signing keys. Adapted, the same way #325's and #267's suites were:
a `TestCandidate` pairs the stored candidate with the bundle a landing still
needs, claims attach their parts through `with_bundle`, and the conflicting
claim that is never landed carries none.

`ledger_deadline_atomicity` builds its candidate from a bundle rather than a
snapshot, so its window reference is derived from that bundle: the anchor and
prior-balances digest it already records, and the range and digest of the shares
it carries. Its signer keys come from the public keys that actually signed it,
rather than from a local pair, so the stored inputs describe the bundle.

* 3.x.x: make the landed-audit forgery always alter the row, and migrate #360's fixture

The forged-coinbase case placed a literal '0' at a fixed offset. Whenever the
digit already held that value the forgery changed nothing, so the claim was
right to accept it and the case failed only by luck. The coinbase varies per
run, because the bundle's anchor comes from the ledger clock, so this passed
repeatedly and then failed once as "the forgery was accepted" — an alarming
message for a case that was in fact a no-op. A security check that can quietly
become vacuous is worse than one that is absent.

The digit is now flipped to a value it does not currently hold, and the row is
read back either side of the statement so a forgery that altered nothing fails
the case for that reason instead.

Also passes the signer keys #360's `tests/support/fatal_state.rs` now needs,
since `configure` takes them in this slice.

* 3.x.x: drop the pre-#362 path for the heartbeat SQL gated test

#362 moved `live_instance_tests` from `tools` to `ledger::instances`. Merging
the gated-test manifest during the rebase kept both paths, because a rename
upstream looks like one id removed and another added, and a union keeps the
removed one. The old `tools::` path then never executed and the gate refused the
run: "194 distinct gated tests executed, below the minimum of 195".

The manifest is a superset of the base's ids, and every id this branch adds over
the base resolves to a test function that exists in the tree.

* v3.0.0 feat: add compact prepared storage and immutable template blobs (#359)

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones,…
* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: pass the signer keys #334's wait-metrics tests now need

`Ledger::configure` takes the frontend's signing keys since this slice, so the
fingerprint contention tests #334 added call it with a fixed pair. That suite
measures advisory-lock and pool-acquire waits, not signing, so the only thing
the keys have to be is the same on every call.

* feat: add compact prepared storage and immutable template blobs

* Validate compact prepared endpoints and document caller contracts

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, …
* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: pass the signer keys #334's wait-metrics tests now need

`Ledger::configure` takes the frontend's signing keys since this slice, so the
fingerprint contention tests #334 added call it with a fixed pair. That suite
measures advisory-lock and pool-acquire waits, not signing, so the only thing
the keys have to be is the same on every call.

* 3.x.x: resume a job's bundle only while the current inputs still describe it

A resumed job reuses the bundle it was issued with, but captures its inputs
from this frontend's configuration now. Before this slice that was harmless:
the candidate carried the assembled bundle, and landing used it directly. Now
the candidate stores the inputs and the claim rebuilds the audit from them, so
an old bundle labelled with today's configuration rebuilds to a coinbase the
block does not commit to. The claim fails its comparison before `submitblock`
and retries until the job expires, which can lose a found block.

The stored bundle is now reused only when the captured inputs still describe
it, comparing what the bundle itself records about how it was built: the payout
policy, and the public keys that actually signed its coinbase manifest and its
ledger attestation. A difference is a cache miss, so the bundle is rebuilt
rather than mislabelled.

Both directions are covered, since over-tightening would silently defeat the
job cache and rebuild on every resume: unchanged inputs must still describe the
bundle, and a payout-policy change, a full signer rotation and a rotation of
either key alone must each refuse it.

Known residue, recorded in the doc comment: a bundle records neither the CTV
settlement inputs nor `audit_builder_version`, so a change to either between
issue and resume is still undetectable here. Closing that needs the issued job
to store its inputs, which is #273's format.

* 3.x.x: migrate #355's ledger tests to the slim candidate

#355 landed deadline and single-execution coverage against the candidate that
still carried an assembled bundle, and against `configure` before it took the
frontend's signing keys. Adapted, the same way #325's and #267's suites were:
a `TestCandidate` pairs the stored candidate with the bundle a landing still
needs, claims attach their parts through `with_bundle`, and the conflicting
claim that is never landed carries none.

`ledger_deadline_atomicity` builds its candidate from a bundle rather than a
snapshot, so its window reference is derived from that bundle: the anchor and
prior-balances digest it already records, and the range and digest of the shares
it carries. Its signer keys come from the public keys that actually signed it,
rather than from a local pair, so the stored inputs describe the bundle.

* 3.x.x: make the landed-audit forgery always alter the row, and migrate #360's fixture

The forged-coinbase case placed a literal '0' at a fixed offset. Whenever the
digit already held that value the forgery changed nothing, so the claim was
right to accept it and the case failed only by luck. The coinbase varies per
run, because the bundle's anchor comes from the ledger clock, so this passed
repeatedly and then failed once as "the forgery was accepted" — an alarming
message for a case that was in fact a no-op. A security check that can quietly
become vacuous is worse than one that is absent.

The digit is now flipped to a value it does not currently hold, and the row is
read back either side of the statement so a forgery that altered nothing fails
the case for that reason instead.

Also passes the signer keys #360's `tests/support/fatal_state.rs` now needs,
since `configure` takes them in this slice.

* 3.x.x: drop the pre-#362 path for the heartbeat SQL gated test

#362 moved `live_instance_tests` from `tools` to `ledger::instances`. Merging
the gated-test manifest during the rebase kept both paths, because a rename
upstream looks like one id removed and another added, and a union keeps the
removed one. The old `tools::` path then never executed and the gate refused the
run: "194 distinct gated tests executed, below the minimum of 195".

The manifest is a superset of the base's ids, and every id this branch adds over
the base resolves to a test function that exists in the tree.

* v3.0.0 feat: add compact prepared storage and immutable template blobs (#359)

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused configure.

from_snapshot's streamed digest equals sha256(serde_json::to_vec) and
the snapshot_sha256 landing writes for the same window. The balance
writer stores the canonical sort, is idempotent under any permutation,
errors rather than accepting a different encoding of the same digest,
and read_window(..., AsIssued) hands back the stored order. The probe
misses a missing endpoint, the paged reader compares 4,999 rows page by
page with a vector held inside the caller's transaction, and a row the
predicate skips inside a range is Incomplete with no partial window.

The permit case builds a runtime with one blocking thread, occupies it,
and cancels a reader parked mid-page on a database gate: no permit is
available while the cleanup is queued, and one is once it has run. A
failed blocking hand-off surfaces as TaskFailed, never as Decode.

Part of #265

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

* 3.x.x: carry the window reference on prepared work, behind a read permit

`Prepared` gains `window: WindowRef`, computed once per non-cached refresh
beside `build_bundle` under `tokio::join!`, so submit can clone it instead
of re-digesting the window on the share path when a block is found. An
empty snapshot gets the empty-window reference on the runtime thread; a
resume computes it inside the blocking decode it already runs.

`Coordinator` gains the `window_reads` semaphore, sized
`clamp(database_max_connections - 2, 1, build_workers)`, and the one entry
point callers use: it takes an owned permit after the caller's `build_slots`
permit and hands it to `Ledger::read_window_with_permit`, which holds it
across the whole read, including the off-runtime cleanup a cancellation
leaves behind, and releases it before the rebuild. `Ledger::read_window`
is awaited on the runtime, never wrapped in `spawn_blocking`; the test with
a single blocking thread pins that.

The four gated `window_ref_tests` are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: switch the candidate outbox to the window reference

`Candidate` drops the embedded `AuditBundle` for `window: WindowRef` and
every builder input except the window: `found_block`, `payout_policy`, the
nested `ctv` triple (whose presence selects the CTV builder), the stored
`bootstrap_share` of an empty window, `audit_builder_version`,
`signer_keys`, a `leased` flag and the now-required `coinbase_suffix_hex`.
The block leaves the document for the `block_bytes` column, authenticated
by `block_sha256`, which `candidate_sha256` covers.

The write serializes, digests and checks the candidate before the append
or enqueue transaction opens; under `ORDER_LOCK` the transaction re-reads
`config_fingerprint FOR SHARE` and refuses a value the writer did not pin,
re-inserts a `leased` candidate's as-issued balance snapshot through the
shared writer, probes the window's first row (an absent row alerts and
still publishes), and inserts only the prepared bytes with the six typed
columns. Submit builds the slim candidate from `Prepared.window`, the
`BundleInputs` captured where `build_bundle` read configuration, and the
job's own bootstrap share; it clones no bundle and reads no config.

The claim's `UPDATE … RETURNING` returns the columns and the block, and
the decode, off the runtime, checks the document against them, the digest,
`sha256(block_bytes)`, the header hash, the required fields and the two
reference invariants. A pending row with a NULL anchor is a hard error
naming the pre-007 frontend; an inline document is never decoded. Before
any read the claim retries, with an alert naming both values, a stored
builder version or signer pair that is not this binary's; a landed audit
is authenticated against the block (coinbase, audit root as the witness
reserved value, share snapshot, bits) and the claim then skips the rebuild
and landing and continues to observe, renew and submit. Otherwise it takes
`build_slots`, then `window_reads` inside one 60 s deadline around the
awaited `read_window(Current)` and the borrowing builder run directly in
`spawn_blocking`, and keeps `Arc<AuditBundleBody>` and the shares as
`ClaimParts`. `Incomplete`, `SnapshotDigestMismatch`, `Decode`,
`BalanceSnapshotMissing`, `TaskFailed` and the deadline retry with their
own alerts; `PriorBalancesChanged` finishes only a block `observe_candidate`
proves inactive and superseded; `Database` propagates. A `leased`
candidate submits from `block_bytes` first, then rebuilds `AsIssued` and
lands before any terminal outcome. Rebuilt parts are released off the
runtime on every path.

Landing takes the parts and does all whole-window work in one blocking
closure: `verify_audit_parts`, the raw body, the counted canonical bytes,
the leaves and accounts, and the share snapshot digest, which must equal
the reference's. The balance fence compares the current set and the parts'
through the semantic digest, so an as-issued set compares equal in any
order. `persist_audit_snapshot` re-reads the range with the paged reader
inside the landing transaction, page against slice on the blocking thread;
`read_range` stays for the API. The terminal UPDATE NULLs the six window
columns and `block_bytes` with `candidate`. `configure` refuses to pin a
fingerprint onto a reset one while a pending row stores other signer keys,
and the startup gate requires 007 with 009.

The JSONB gate drops its enqueue entry, and every helper that built a
candidate builds the slim one, handing the ledger-level landings the
bundle's parts through `CandidateClaim::with_bundle`.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: cover the candidate switch on PostgreSQL

Ledger-level, in `candidate_window_switch`: a two-page window enqueues a
document with no array and no share row, under 1 MB, whose six typed
columns equal its reference and whose block is stored as bytes; the
writer fence refuses a reset or different fingerprint in the same
transaction, share included; a missing window prefix row alerts and still
publishes, and its read then meets `Incomplete`; a `leased` enqueue
re-inserts the as-issued balance snapshot in the writer's canonical order,
writes none when not leased, and refuses balances that do not hash to the
reference; every preparation failure is refused without waiting for
`ORDER_LOCK`; every column, digest, block, header, NULL-anchor,
missing-field, invariant and inline-document disagreement is refused at
claim; landing refuses parts that are not the referenced window and a
claim without parts; the terminal UPDATE NULLs the six columns and the
block for submitted and abandoned rows; the landed-audit authentication
rejects a forged coinbase, root, snapshot or bits; and `configure`
refuses a rotation over a foreign-signed pending candidate.

Coordinator-level, in `window_switch_tests` behind a fake node: a stored
builder version or signer pair that is not this binary's retries, naming
both values, while the build slot is held; a landed audit is authenticated
and the claim continues to `submitblock` without a build slot, and a forged
landed coinbase or root is refused with the row left pending;
`Incomplete`, a digest mismatch, changed balances on a current block and
the rebuild deadline all retry with an alert and release their permits;
`classify_window_error` keeps `TaskFailed` apart from corruption and
propagates `Database`; the read, rebuild and landing complete while
`ORDER_LOCK` is held, with one build worker; a `leased` candidate submits
first, then lands from `AsIssued`, and finishes submitted, abandoned with
the node's reason, or pending when its balance snapshot is missing; and
`OffRuntime` releases on a blocking thread, on cancellation too.

The fifteen gated tests are listed in the manifest.

Part of #265

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 3.x.x: migrate the imported-audit tests to the slim candidate

Slice 1 (#325) merged after this stack branched, and its tests in
tests/support/ledger_2x.rs still built the pre-007 candidate: they read
block.bundle from a Candidate and called candidate_with_bundle without a
window reference or settlement inputs. The rebase merged them cleanly and
the tree then failed to compile.

The three helpers now take the TestCandidate the constructors return, so
they reach the bundle beside the slim candidate, and land_confirmed
attaches the claim's parts the way the other ledger tests do. The three
constructor calls pass the window reference the bundle was built on, and
the settlement inputs for the CTV case. No assertion changes.

Part of #265.

* 3.x.x: qualify lease renewal and the claim's canonical bytes at scale

Incident 1: a candidate whose window holds N shares is claimed and
processed under the short lease, and every renewal is read back from
claim_expires_at. The largest gap stays below interval + 2 x timeout,
the lease never lapses, and a successor ledger cannot take the claim.

The bundle a claim rebuilds through the window-read entry point and the
borrowing builder has the canonical bytes refresh built for the same
job, and the landed audit carries that bundle's digest.

Both run at PRISM_WINDOW_QUALIFY_SHARES (20,000 by default); the
400,000-share variants are ignored and run on a larger host.

Part of #265

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

* 3.x.x: measure ACK latency and ORDER_LOCK hold across window sizes

Incident 2: two frontends on one database submit ordinary shares at a
fixed rate while one of them solves a block on an N-share window and
its own submit loop claims, rebuilds, lands and submits it. The solving
share's ACK and the bystander's p99 stay within
max(2 x baseline p99 + 50 ms, 250 ms), every ACK is well under the
share-commit timeout, and the block is enqueued exactly once. Latency
is the in-process MiningBackend::submit wall clock; the socket-level
harness (#271) does not exist yet.

A pg_locks sampler on its own connection measures how long a solving
append holds ORDER_LOCK at two window sizes with no other traffic: the
larger size stays within a fixed slack of the smaller, and the
candidate document is the same size at both and under 1 MiB.

Part of #265

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

* 3.x.x: hold the JSONB gate's enqueue to a window-free row under 1 MiB

At every measured size the enqueue must be accepted and every JSONB
value it writes, the outbox candidate document included, must stay
under 1,048,576 bytes. At the reduced sizes the enqueue, claim and
landing phases must all run. The ratchet and its expected values are
unchanged.

Part of #265

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

* 3.x.x: make the window-read permit test actually wait before it looks

The delay that gives an unpermitted read time to finish was an empty
async block, so the check that the read had not finished ran at once
and passed whether or not the entry point took a permit. It now sleeps
500 ms inside a 5 s bound, and fails when the permit acquisition is
removed from the entry point.

Part of #265

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

* 3.x.x: rebuild a CTV candidate from its stored settlement inputs

No host-runnable test reached the CTV branch of the claim rebuild. A
CTV-enabled frontend now claims a candidate whose stored direct floor
differs from its configuration, and builds a different coinbase than
the configured floor would, and the claim must land that candidate's
own audit and submit it.

Part of #265

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

* 3.x.x: pin the SQL order of the current-balance reads

Nothing defended the ruling that the current-balance path keeps its SQL
order: every fixture's balances already arrived in byte-wise canonical
order. The carry table's text columns now take an ICU collation, under
which the SQL order and the canonical sort disagree, and the snapshot
and the current-balance window read must both return the SQL order.

Part of #265

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

* 3.x.x: record what incident 2 must also assert once #324 is in the base

After #324 a slow solve is acknowledged late rather than refused, so
acceptance proves nothing and the latency percentiles stay the
assertion. The doc comment and a TODO at the outcome checks name the
counter and outcome to assert at rebase.

Part of #265

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

* 3.x.x: assert incident 2's late-confirm counter now that #324 is in the base

W3c wrote the qualification tests against a base that predated #324 and left a
marked place for the assertions that only make sense once the commit gate is
there. The base now carries it, so apply them: incident 2 asserts
`qbit_prism_late_confirmed_shares_total` is zero on both frontends and that no
acknowledgement, the block-solving share included, is `ledger-outcome-unknown`.

Acceptance alone is no longer proof. #324 gives a share-pass append carrying a
candidate its own `block_only_ack_timeout`, so a slow solve is never refused at
the share deadline; it is answered late, or answered unknown. Either would have
satisfied the acceptance checks with the serialization stall still present.

Also set #324's two new `Config` fields in the qualification fixture.

* 3.x.x: pin a refused candidate as a definite rejection, not an unknown outcome

#324 decides what a miner is told when a block-only enqueue fails:
`enqueue_failed_before_commit` answers a provably uncommitted failure with
`ledger-confirmation-failed`, and anything else with `ledger-outcome-unknown`,
which asks an operator to go and follow the outbox row.

Slice 3 added the checks that guard the reference: the block's length, its
header hash, its digest against `block_sha256`, the coinbase suffix, the
reference invariants, and a leased candidate's balances. All of them are raised
by `prepare_candidate` before the enqueue takes a connection, so none carries an
`sqlx::Error` and all classify as definite today. That is a property of where
the checks sit, not something the classifier knows about them, so it is pinned
here rather than left to hold by accident: a corrupt candidate must never send a
miner chasing a row that was never written.

Verified by mutation: classifying a non-sqlx failure as unknown fails this test.

* 3.x.x: say why the full-size qualification variants cannot reach 400k yet

Their ignore reasons blamed RAM and the host. The real limit is #273: the job
payload still holds three copies of the window (2 x AcceptedShare + 1 x
CountedShare), so publishing a job for a 400,000-share window needs about
670 MiB and PostgreSQL refuses the write above its 256 MiB jsonb object limit.
Every full-size variant fails at fixture set-up, before it measures anything.

The count must also divide the window weight exactly, so the largest usable
size today is 125,000, where all four variants pass in release:

  incident 1   50 renewals, max gap 118.25 ms against a 700 ms bound
  incident 2   phase-A p99 2.60 ms, phase-B p99 95.79 ms, bound 250 ms
  lock hold    median 0.75 ms at 5,000 against 1.32 ms at 125,000
  canonical    refresh and claim rebuild both 139,040,610 B, same sha256

The candidate document stays flat at 1103 B and 1107 B, which is the property
this slice exists to establish.

* 3.x.x: migrate the audit-body normalization tests to the slim candidate

#267 landed its own test file against the candidate that still carried an
assembled bundle. This slice replaced that with a window reference, so the file
keeps a `TestCandidate` pairing the stored candidate with the bundle a test
still needs, as `tests/ledger_postgres.rs` does, and attaches the claim's parts
through `with_bundle`.

One case needed more than a shape change. The four negative cases hand the claim
a window the ledger does not hold and require the durable-range proof to be what
refuses it. A candidate now commits to a snapshot digest, so a reference taken
from the unaltered snapshot would be refused earlier, at the reference check,
and the proof under test would never run. `window_ref_for` therefore derives each
altered window's reference from that window, leaving the proof as the only thing
that can notice, which is what #267 wrote them to prove.

* 3.x.x: pin the durable-range proof outside the settlement lock

#267 moved the full-equality proof of a candidate's window out of the
settlement transaction so the lock is not held for a window-sized read. This
slice made that read paged, so it does not occupy a runtime thread either.
Reconciling the two is the step that can quietly undo the first: keep this
slice's `persist_audit_snapshot` through the rebase and the full read goes back
under the lock. The landing stays correct, every other test still passes, and
only the lock hold grows, so nothing in the suite can tell. Measured at 100,000
shares on #267's branch that was a 34% longer hold.

The guard is deterministic and needs no large window. A second connection holds
`SETTLEMENT_LOCK`, so the landing blocks on it once its proof has run. While it
waits, one share's `ntime` is altered: full equality sees that, a count over the
same anchored predicate cannot. The landing must then succeed, which it can only
do if it had already read and compared the shares before taking the lock.

Accepted history is immutable and a trigger enforces it, so the test lifts that
trigger for the single statement, inside its own disposable schema, and puts it
straight back.

Verified by mutation: restoring the in-transaction range read fails this test
with "the landing read share payloads under the settlement lock".

* 3.x.x: serialise the coordinator test modules that share the ledger's locks

The ledger's advisory locks are cluster-wide constants, not schema-scoped, so
two coordinator test modules running in parallel contend for ORDER_LOCK even
though each works in its own throwaway schema. Every module held a lock of its
own, which serialised it against itself and left cross-module waits to
`lock_timeout`, 5 s by default.

#324's reconciliation tests hold ORDER_LOCK for up to about 1.5 s, and this
slice adds three more modules that open a ledger. Once enough of them ran at
once those waits stacked past the timeout: a full `--lib` run failed with
"canceling statement due to lock timeout" in `candidate_lease_tests` and
`window_switch_tests`, and passed on the next run. A suite that fails for a
reason unrelated to what it tests is worse than a slow one.

All seven modules now take one shared lock. The run goes from about 35 s to
about 62 s, and three consecutive runs pass where the flake had reproduced.

* 3.x.x: add migration 007 and refuse pre-007 candidate outbox rows

Migration 007 mirrors 008 onto qbit_block_candidate_outbox: the same six
window-reference columns, and a three-state CHECK whose body is identical
to 008's apart from the table and constraint names. It also adds
block_bytes, the nullable bytea the block moves into so the candidate
document stays small, and a partial index on
window_prior_balances_sha256 for the balance-snapshot GC. Nothing writes
any of them yet; this is additive schema only.

The runner applies 007 by membership, in its own block between 5 and 8,
so it assumes neither 006 nor 008 has run. Before any DDL it refuses
while the outbox still holds a pending row in a pre-007 shape: a native
inline candidate, a NULL candidate, or storage_version other than 1 on
the 2.x.x schemas that have that column, which is checked for first so
the predicate does not error on a native schema. The refusal names every
offending block_hash and the drain procedure, and the whole migration
transaction rolls back, so a refused start leaves the schema untouched.

The applied set is now [2, 3, 4, 5, 7, 8, 9], and the four exact
migration assertions move with it rather than being weakened to a
superset.

Part of #265

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

* 3.x.x: retain the cluster fingerprint configure pinned or verified

The candidate enqueue and save_job must fence against a fingerprint
reset by re-reading qbit_prism_cluster.config_fingerprint FOR SHARE in
their own transaction and refusing a write that is not this frontend's
own. That needs the value configure settled at startup, so Ledger keeps
it in a shared OnceLock, set only once the pin or the match has
committed, and exposes it read-only through config_fingerprint().

configure keeps its signature and its mismatch refusal, and a refused
call retains nothing. The rotation refusal in configure, and the writer
fence itself, belong to the enqueue change and are not here.

Part of #265

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

* 3.x.x: share the window reference, balance writer and page primitives

WindowRef::from_snapshot is the synchronous reference builder both
writers need: anchor_ms copied, the semantic balances digest, and for a
non-empty snapshot the first and last share_seq, the length, and the
share array's SHA-256 streamed through the existing DigestWriter, so the
233 to 260 MB serialized window at 400,000 shares is never built and the
digest equals the one qbit_prism_audit_snapshots already stores. An
empty snapshot gives the empty-window reference. Callers run it inside
their own spawn_blocking.

put_balance_snapshot is the one canonical writer of
qbit_prism_balance_snapshots, on the caller's transaction: the sort by
(order_key, recipient_id, p2mr_program_hex), the semantic digest that
keys the row, and compact JSON of the sorted set, all off the runtime,
then INSERT ... ON CONFLICT DO NOTHING. On a conflict it reads the
stored bytes back and compares them with its own, because these rows are
immutable and can only disagree through corruption. The enqueue and
save_job and its repair all write through it. read_prior_balances and
the current-balance read still keep their SQL order; the writer's sort
defines the stored encoding, which is the order
read_window(..., AsIssued) returns.

probe_share_rows and read_range_paged are read_window's endpoint probe
and page loop, lifted onto a caller's connection so the enqueue probe
and the landing re-read share one implementation. The predicate, the
ascending keyset order, the 4096-row page bound and the per-page count
bound are unchanged, and read_window now calls them, with its signature
and behaviour unchanged. The paged reader takes the caller's state and a
per-page consumer, both of which travel to the blocking thread with the
rows, so the landing re-read can compare each page with its slice of a
vector it already holds without any whole-window work on the runtime; a
dropped read still releases the accumulated state on a blocking thread.

read_window_with_permit holds the caller's window_reads permit across
the whole read. The permit is moved into a BlockingDrop declared before
the read starts, so a cancellation releases it on a blocking thread
after the page state queued its own release there, and never with the
future; a return releases it inline, before the caller's rebuild.

WindowError::TaskFailed separates a cancelled or panicking blocking
hand-off from corruption. Every hand-off maps a JoinError to it instead
of to Decode, so a cancellation is a retryable failure with its own
alert and never takes the abandon path.

Part of #265

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

* 3.x.x: derive the witness merkle leaves from an assembled block

A claim rebuilds from stored inputs, and the witness leaves are the one
builder input that scales with the block, so they are re-derived rather
than stored: one 64-hex string per transaction beside the candidate
would grow the document with the block.

witness_merkle_leaves_from_block skips the 80-byte header, reads the
compact-size transaction count, walks past the coinbase and returns
double_sha256 of every remaining transaction's full serialized bytes.
That is sound because assemble_submission appends the template's
transactions verbatim after the coinbase, so the result equals
witness_merkle_leaves_hex(&transactions_from_template(template)) for the
block built from that template, with or without segwit transactions.

The segwit-aware walk is strip_witness_transaction's own, factored into
a helper that also reports where the transaction ends, which is what a
block walker needs; strip_witness_transaction keeps its trailing-byte
check and its output. A truncated block, trailing bytes, a zero count
and a count that disagrees with the transactions present are all errors,
never a partial list of leaves.

Part of #265

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

* 3.x.x: version the audit builders and key the CC5 goldens by it

A stored window reference reproduces its bundle only under the builder
that first built it. The schema strings do not capture that, because the
share shape chooses them rather than the builder logic, so qbit-prism
gains AUDIT_BUILDER_VERSION, starting at 1. A candidate and a prepared
job store it, and a rebuild refuses a reference at another version
instead of rebuilding it with the current builder.

The bump rule is on the constant: any change that alters
canonical_audit_bundle_bytes for the same inputs bumps it in the same
commit. The CC5 golden test now selects its expected digests from a
table keyed by the constant, and fails when no entry exists, so a new
output is recorded as a new entry beside the old one and review sees
both. Version 1 holds the existing literals, unchanged.

Part of #265

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

* 3.x.x: cover 007, the fingerprint and the window primitives on PostgreSQL

Ten gated database cases, all listed in test/prism-gated-tests.txt. They
go in a new file rather than the shared 2.x.x support module, which
another open change appends to.

Migration 007: a frozen 2.x.x schema carrying only terminal outbox rows
upgrades with every row and field intact and the seven new columns NULL;
each of the three pre-007 pending shapes is refused by name, with
nothing from 007 applied and no version 7 row, and the same binary
starts once the row is drained; a database at [2, 3, 4, 5, 8, 9] applies
007 and only 007, leaving every other version's applied_at untouched;
the CHECK accepts exactly the three states across all 64 null
combinations and rejects a short or uppercase digest, a zero or
out-of-range count and a zero sequence; and the retention index is the
partial one on window_prior_balances_sha256.

config_fingerprint() returns what configure pinned or verified, is
shared by clones, and stays empty after a refused conf…
* feat: extend pool acquisition timing to direct ledger queries

Reuse the existing cancellation-safe wait guard for a shared Ledger::acquire boundary and eight direct query paths. Preserve SQL and transaction behavior, and qualify elapsed timing, cancellation, and checkout release against PostgreSQL.

Refs #352.

* test: include publisher acquisitions in live pool observations

The HTTP exhaustion test observes the aggregate pool histogram, now including payout-revision and heartbeat waits. Require every explicit cancellation to remain visible while preserving exact per-attempt assertions in the isolated tests.

* docs: describe partial direct-query pool timing coverage

Refresh canonical metric and alert guidance and regenerate the inventory and review-only provisioning patch from the checksum-pinned source snapshot. Alert expressions, thresholds and role exports are unchanged.

* docs: complete direct-query timing coverage guidance

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* docs: align acquisition coverage in compatibility explanations

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
* refactor: share pool acquisition observation

Use one optional-metrics guard for ledger and collector checkout waits.
Choose Tokio monotonic time explicitly while retaining the existing
advisory-lock clock and all checkout, error, and cancellation boundaries.
Add first-poll, unattached cleanup, and SQLx BEGIN boundary regressions.

Refs #352

* refactor: place checkout timing beside metrics events

Rename the async timing boundary to time_pool_acquire and expose it through
metrics while leaving the recording hook name unchanged. Consolidate helper
tests under events, retaining unique result, clock, cancellation cleanup and
concurrency assertions, and remove the ledger free forwarding function.

Preserve advisory-lock timing and every PostgreSQL checkout/BEGIN regression.

Refs #352
RobertClarke and others added 17 commits October 8, 2026 07:04
…no plan walks the ledger (#754)

The pool hashrate series serves fully rolled buckets from the rollup
tables. It re-reads two kinds of rows from the ledger: the boundary
(the current bucket and the leading partial bucket) and the tail (the
shares past the rollup watermark). Neither read gave the planner an
index range it could use. The boundary bounded accepted_at with an OR
and a NULL guard on the range start. The tail bounded share_seq only
from below, with the same guard on time and an OR on the miner. On a
production-sized ledger (66M shares, rollups current), rc.5's default
pool request walked all of p0's seq_walk index for the boundary,
removing 66,090,385 rows to return 2,390, in 19-40 s. With the
boundary fixed, the generic plan's tail still walked p0's
accepted_recent_idx for no row, in 17-27 s. Pool hashrate-series
requests therefore ran past the public read deadline: PRISM answered
503 read_timeout, and the proxy in front of it 504. A custom plan also
walked a big miner's whole history. 2.x builds the same read with
bounded predicates, so this is a 3.x regression.

The boundary is now two disjoint slices, each a range on accepted_at
bounded at both ends:
- [GREATEST(current_bucket, started_at), ended_at];
- [started_at, LEAST(first_full_bucket, current_bucket)), which is
  empty without a range start.
The tail takes its shares from a subquery bounded by share_seq on both
sides, by the watermark and by max(share_seq), which is one backward
probe per partition. OFFSET 0 fences the subquery, so the time and
miner tests only filter its stream. The tail therefore reads only the
shares past the watermark: none while the rollups are current, and the
lag while they catch up. With no progress row it reads every share, as
rc.5 did.

On a production-sized ledger the fixed query took 9-23 ms for the
pool and 2-15 ms for miners. That held in 20 of 20 runs, across
generic plans, custom plans with the anchor pinned or NULL, and auto
plans after six executions. Its output equalled rc.5's in 7 of 7
anchored requests, for which rc.5 took 15.8-25.2 s. On a 6M-share copy
with a 600k-share miner, every request ran in 1-17 ms at
random_page_cost 4 and 1.1. There the output matched rc.5's in 104
cases per watermark state, 312 in all. The only exceptions were
unanchored requests whose range start slid while the old query ran,
and they differed only in the leading bucket.

Two tests in api_2x pin this.
- chart_rollups_match_raw_for_every_range_bucket_and_watermark
  requires the rollup series to equal the raw-ledger series in 78
  cases (every range and bucket pair, context, anchor and subject).
  It checks them before the rollups run, at a current watermark, and
  with shares past it.
- chart_rollups_read_only_the_partial_buckets_under_any_plan runs the
  chart's plans over 20,000 rolled-up shares, half of them one
  miner's. It covers the pool at three ranges, a small miner and the
  big miner, in custom and generic modes, at random_page_cost 4 and
  1.1, with the watermark current and again with 300 shares past it.
  Each plan may read at most 1,000 ledger rows plus the lag. Every
  ledger index scan outside a Limit probe must bound one column from
  both sides, and no sequential scan may read ledger rows. It fails
  against rc.5's query and against the boundary-only version.

The change is read-only SQL in the public read path: no migration, no
schema change, no write path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set VERSION and [workspace.package].version to 3.0.0-rc.6: rc.5 plus
#754 (the pool hashrate series bounds both of its ledger reads, the
boundary buckets and the tail past the rollup watermark, at both ends,
fixing a 3.x regression that timed it out on large ledgers), #750
(#748: a failed record of 2 is re-checked on a fresh connection when
its own one was lost, and each attempt is bounded on the client's
side), and #753, a test-only fix for #724. Both Cargo.lock files move
only their workspace-member entries.

doc/release-notes-3.0.0-rc.6.md covers:
- the changes, with #754's figures from its pull request (19 to 40 s
  and a 504 on a 66M-share ledger before, and 17 to 27 s with only the
  boundary fixed; 1 to 17 ms on a 6M-share copy after, under every plan
  mode and random_page_cost tried, with its output matching rc.5's; and
  on a production-sized ledger, 9 to 23 ms for the pool and 2 to 15 ms
  for miners in 20 of 20 runs, against rc.5's 15.8 to 25.2 s, with
  identical output),
  #750's lost connections, fresh read, client-side deadlines and two
  new stops, and #753's wait for current work;
- upgrading: rc.6 adds no migration, capability, setting or script, so
  an upgrade from rc.5 and a rollback to it replace the binary only, at
  fence 2 too, and a database that rc.5's migrate migrated is served by
  rc.6 unchanged, which a cutover that pinned rc.5 can rely on. An
  upgrade from rc.4 or earlier is as in rc.5's notes, which the section
  links: migrate for 026, fence 2 and the deferred backfill, #738's
  snapshot rule and fatal-state clear --timeout-seconds;
- the same evidence set as rc.5's, with the pair's serving evidence
  carried;
- the known issues: rc.5's, with #748 fixed and #724's drill race and
  new-tip shares fixed on the test side (#747, #753), plus #752 after
  the cutover, #738's fix now planned after the cutover, and #739 not in
  this candidate.

The development-line notes name 3.0.0-rc.6, and they and the 3.0.0
notes link all six candidates' notes.

docs/prism-rust-migration.md names rc.5 where it said "this release"
of serving at fence 2 and finishing the backfill. That is in three
places: that every build before rc.5 refuses fence 2, that the starts
of rc.5 and later accept it, and that only rc.5 and later finish a
pending backfill. In this candidate's tree "this release" would have
excluded rc.5, which does both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion after 20 s, below qbitd's 30 s idle close (#759) (#760)

* 3.x.x: drop an idle node connection after 20 s, below qbitd's 30 s idle close, and send each relay call on a new connection (#759)

The frontends' qbitd RPC client kept an idle keep-alive connection for 60
seconds, while qbitd closes one after its -rpcservertimeout, 30 seconds by
default. A request written into a connection just as the node closes it is
never read, and fails after the connection existed. That is the plain
"transport failed" error, which nothing retries. On submitblock it is an
unknown outcome, and the block is never offered again. A production
deployment logs that error from the CTV broadcaster on about one pass in
eight to fifteen, which fits this race: the broadcaster's 30 second interval
runs in step with the 2 second refresh interval.

The client now keeps an idle connection for at most 20 seconds
(POOL_IDLE_TIMEOUT), so it drops one before the node can close it. A
compile-time assert keeps the bound below QBITD_DEFAULT_RPCSERVERTIMEOUT.
node_client_builder() carries the bound, and the public API's node client
and the load harness's node clients are built from it.
Rpc::with_pool_idle_timeout sets another bound, for a node with a shorter
idle close and for tests.

Every RELAY_METHODS call now goes through a second client that keeps no
idle connection, so each relay opens a new one. A relay can then fail only
to connect, which is provably unsent, or on the connection it just opened,
which is a genuine unknown rather than this race. A node with a shorter
idle close than the bound, or a proxy that drops idle connections, can no
longer race a relay. A relay is rare, and the new connection costs one
connect.

tests/rpc_idle_close.rs runs the real client against a loopback node that
drops a request arriving after its idle limit, as the race does, and counts
the connections it accepts. With a shorter bound, the next call opens a new
connection and is answered. With the old long bound, it reuses the stale one
and fails with the production error, which is not RpcNotSentError. A relay
call after the pause is answered on a new connection even with the long
bound. The migration guide notes the node-side requirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: keep relay calls on the bounded pool, and build Rpc's client from node_client_builder (review on #760)

A new connection for each relay put its connect inside submitblock's 1
second deadline (PRISM_BLOCK_SUBMIT_RPC_TIMEOUT_SECONDS). A deadline that
passes while connecting is a timeout, not a connect error, so the block
would count as an unknown offer and never be offered again: a lost SYN or a
slow accept would cost a block that never reached the node. A warm pooled
connection within the 20 second bound is the safer path, so relays share it
again, as they did before this PR.

Rpc's client now starts from node_client_builder(), like the public API's
and the load harness's. The pool_idle_timeout accessor and its test only
restated the constant, so they are gone. The docs name a proxy's idle
timeout beside qbitd's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…t-audits (#765) (#770)

import-audits no longer holds SETTLEMENT_LOCK while it sends a legacy audit's UPDATE.

Each row's parameters are encoded off the runtime before any transaction opens. The UPDATE then runs on a connection of its own, under transaction-local write timeouts (PRISM_IMPORT_AUDITS_WRITE_TIMEOUT_MS), holding only that row's lock. After that the importer polls SETTLEMENT_LOCK with pg_try_advisory_xact_lock for up to PRISM_IMPORT_AUDITS_LOCK_TIMEOUT_MS, and only the write guard and the COMMIT run under it. A SETTLEMENT_LOCK holder waiting on the row preempts it, and a row that exceeds a bound is rolled back and named in the error, so a re-run resumes at it.

SIGTERM and SIGINT stop the command between rows, or roll back the row in progress. Each row logs its parameter sizes and phase times on stderr.

Fixes #765.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 3.1 dual-writer: node identity foundation (settings, migration 027, node-identity set)

The foundation the 3.1 dual-writer stack builds on (CONTRACT D1, D-4,
D-9, D-12, D-14, D-17). With PRISM_DUAL_WRITER off, the default, no new
setting is read and every statement PRISM runs is 3.0's.

Settings (config/dual_writer.rs): PRISM_DUAL_WRITER, PRISM_NODE_INDEX,
PRISM_CARRY_OWNER, PRISM_PEER_DATABASE_URL and its _FALLBACK,
PRISM_PEER_SYNC_INTERVAL_MS (250) and PRISM_PEER_SYNC_BATCH_ROWS (5000),
parsed into Config::dual_writer. Each error names its setting and never
echoes a value; a peer DSN must not name this node's own database.
PRISM_DUAL_WRITER_DOWNGRADE lets a single writer start on a database
that has run as a dual-writer node, which it otherwise refuses (D-12).

Types: NodeIndex and NodeIdentity (node_identity.rs), PeerSyncStatus
with its watch-channel publisher and the copied-table list
(peer_sync.rs). The Ledger carries the frontend's configured identity,
set once by Coordinator::new.

Migration 027, additive, with no capability and no shutdown proof:
origin_node smallint NOT NULL DEFAULT 0 on the thirteen copied tables, a
catalog-only change even on the partitioned share ledger (no CHECK,
which would scan every partition under the lock); sync_seq on
qbit_pool_blocks and qbit_prism_jobs, drawn after the inserting
transaction's xid so a puller can wait out every older transaction; the
append-only carry-owner journal qbit_prism_node_roles; the never-copied
identity singleton qbit_prism_node_identity; qbit_prism_node_lineage
(own-row floors and the system identifier and timeline of the last
own-log verification); and the peer sync's cursors, conflicts and safe
peer mark, qbit_prism_peer_share_mark().

qbit-prism-server node-identity set --index N personalises a database
once, in the bootstrap or cutover: every copied table's origin_node
default becomes N, share_seq, payout_entry_seq and carry_forward_seq
step by 2 with the node's parity above every value held (A even, B
odd), and the session sequence cycles within the node's half of the
extranonce1 space. A frontend never writes the identity: in dual mode
it refuses a database whose personalisation drifted, and reports one
with no identity or the other node's.

Gated tests cover the migration, personalisation of two databases
beside an untouched single writer, and the downgrade refusal. Every
schema version pin gains 27, and ledger_2x's historical fixture undoes
027.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: describe the peer_sync ORDER_LOCK holder in the metrics metadata

docs/prism-native-metrics.md is generated from the registry and
docs/prism-metric-metadata.json; the holder's description now lives in
the metadata, and the inventory is regenerated from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the peer sync engine and the own-log latch

With PRISM_DUAL_WRITER on, each node pulls the rows its peer originated
in the copied tables, by identity, and never a WAL position (CONTRACT
§3, D-1 to D-15). One supervised task per frontend runs a pass every
PRISM_PEER_SYNC_INTERVAL_MS, or at once while a batch was full, over
PRISM_PEER_DATABASE_URL, moving to the fallback when a path fails and
back to the first path every 30 s. Every statement against the peer is
bounded, so a black-holed path fails a pass instead of stalling it.

Each pass checks that this database is personalised as this node
(D-9), that the peer is the other node and runs this cluster's
fingerprint (D-6), and that both carry the same columns of every copied
table; otherwise it syncs nothing and alerts. Then, in order (D-5):
- shares and their header mappings, in share_seq order, scanned over
  the peer's whole ledger. Each node commits its own rows in share_seq
  order under ORDER_LOCK and raises its own sequence above every peer
  row, under ORDER_LOCK, before inserting it, so once a position is
  visible on the peer no row of the peer's below it can commit later.
  The cursor is therefore the safe peer mark,
  qbit_prism_peer_share_mark(), written with the rows it covers (D-14).
  Partitions are attached before a row beyond the lead is inserted. A
  row whose header this node credits to another share, or that it holds
  with other content, is refused whole as a conflict, and the stream
  moves past it;
- the carry-owner journal (D-4);
- landed blocks, each whole in one transaction, block row first, with
  only their immutable landing columns, derived ones at this node's
  defaults (prepared, immature, no publication sequence,
  awaiting_maturity), so they count only once this node's reconciler
  confirms them. A block this node already holds, its own landing or an
  adoption, is skipped whole and its facts compared (D-1, D-10, D-15);
- prepared jobs with their template and balance blobs, blobs first, in
  one transaction (D-3, D-11, D-19).
The two sync_seq streams stop at the mark read at the peer's sync
barrier: qbit_prism_next_sync_seq() now takes an advisory lock shared
until its transaction ends, and the puller reads the sequence while it
holds it exclusively, taken with pg_try_advisory_lock so no writer ever
queues behind it. Unlike a cluster-wide xid horizon, a long transaction
in another database never holds the pull back.

Rows held under an identity with other content are never written over:
each is recorded in qbit_prism_peer_sync_conflicts and alerted.

The own-log latch (D-8, D-17): until it is set, a pass pulls back this
node's own rows the peer holds and this database lacks (shares under
ORDER_LOCK in ascending batches, blocks, prepared jobs and journal
rows), raises the own sequences above everything the peer has seen of
this node and the ledger clock above the recovered rows, and records the
system identifier and WAL timeline it verified on. Without the peer it
is set when the database is still on that server, so a plain restart
serves; a new identifier or timeline, or no record, keeps it unset and
alerts. A later loss of the peer never clears it; a change of
identifier or timeline while the frontend runs does. Until it is set,
the Stratum listeners, the refresh and the submit loop wait, so a
restored node never reuses a key the peer holds.

PeerSyncStatus is published through a watch channel, which the
Coordinator holds for health (D-9), and as qbit_prism_peer_sync_*
gauges and counters, declared without samples on a single writer.

Gated tests on two databases: steady sync both ways, out-of-order
commits of shares and landings, a restored node's own-log recovery, the
latch with and without the peer, conflicts and whole-block skips,
peer rows beyond the attached partitions, the fallback path, and the
identity and fingerprint refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: wait for the peer to hold what adopting a found block needs (D-19)

A dual-writer node has no failover standby, so the pair leaves
PRISM_OFFER_STANDBY_APPLICATION_NAME and _FLUSH_WAIT_MS unset there and
nothing requires them. Its peer adopts a block it found if it dies (S8).
Before a found block's submitblock, the node now waits up to
PRISM_PEER_INGEST_WAIT_MS (default 250, 0 off, at most 10000) for the
peer to hold what adoption reads: this node's own shares through the
block's window, and the prepared record the block was built on, whose
template and balance blob the peer applies in the same transaction. Both
come from the peer's own cursors over this node's streams, read on a
small pool of the wait's own over the peer DSN and its fallback. The
wait never holds a block: at its bound, or with the peer unreachable,
the block is offered as before, and the outcome is counted in
qbit_prism_peer_sync_offer_waits_total{outcome}.

A gated test checks that the wait confirms once the cursors cover the
needs, times out within its bound when they do not, reports an
unreachable peer, and is off at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: rebuild a node from its peer's database (node-identity repersonalise, table inventory)

CONTRACT D-16 (S7). A node whose disk was replaced is rebuilt from a
promoted physical copy of its peer's database, which says it is the peer.
qbit-prism-server node-identity repersonalise --index N makes it node N's,
in one transaction under SETTLEMENT_LOCK and ORDER_LOCK.

The table inventory (ledger/table_inventory.rs) classifies every table of
the schema as copied (peer_sync::COPIED_TABLES, checked at compile time),
local (cleared, kept or rewritten) or pair-wide (kept), with the decision
beside each entry, and names the rows and columns of copied tables that
are a node's own. Re-personalising derives its reset from it:

- cleared, the peer's in-flight work, which this node must neither settle
  nor credit a second time: the candidate outbox and deferred shares,
  session reservations, instance rows, CPFP packages and retired funding,
  sync conflicts, and issued jobs (prepared records stay); the peer's
  fanout claims are handed back as a holder's release does;
- kept, what is derived from rows both nodes hold or is history this
  database carries: block, payout and carry states and publication
  ordinals, the balance summary, the cluster row (payout revision, chain
  epoch, ledger clock, chain view, fingerprint, any halt), divergences,
  broadcast history, rollups, vardiff hints, the submission hold and the
  operator journals, the partition catalog and the schema record;
- rewritten, the identity, the keys (set's personalisation, every key
  above every key held and every position the peer reached in this node's
  old streams), the floors with no own-log verification, and the cursors:
  shares from the peer's newest share in the copy (D-14 prefix), blocks
  and prepared records from the peer's floor, since those commit in no
  sync_seq order.

It refuses, changing nothing, unless the identity names the peer, while an
instance row neither drained nor stopped has a heartbeat younger than 60 s
(a copied row of the peer's frontends stops counting 60 s after the copy
stopped following the peer), on the server the peer last proved its own
log on (its own database, not a promoted copy), and while the schema
holds a table the inventory does not classify or lacks one it names.

Gated tests (tests/dual_writer_rebuild.rs) copy node A's database with
CREATE DATABASE ... TEMPLATE and check the result against A table by
table, every refusal, and that the inventory classifies every table of a
migrated schema. A frontend started on the other node's database now
names the command in its alert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: never carry an audit bundle's sealed bytes, and keep the sync's columns and the inventory in step

canonical_audit_bytes is stored when a database seals a share
partition, so it follows that database's partitions: the table
inventory keeps it local, and the peer sync no longer copies it. A
gated test checks that every column of each copied table is either
carried by the sync or named local by the inventory, never both and
never neither, but a prepared job's expires_at, copied as the peer
held it and never compared, so a column a later migration adds to a
copied table must be classified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: fix the engine review's findings (barrier hold, latch, snapshots, retries)

From the coordinator's review of cca56f14.

- The sync barrier is one statement, qbit_prism_sync_barrier() (027):
  it tries the barrier with pg_try_advisory_xact_lock and reads the
  sequence under it. The lock ends with that statement's own
  transaction, which a cancel or a vanished client also ends, so a
  frozen or partitioned puller never leaves the survivor's job saves
  and landings waiting. Before, the puller held a session lock across
  two round trips. The two fence comments that forbid advisory locks
  now say why this one cannot form a cycle.
- A peer that accepts the connection but cannot be read is unreachable
  for the latch (D-8): any error after connecting falls back to the
  local rollback evidence, unless own-log recovery found the own log
  diverged from the peer's copy.
- Every peer read is one autocommit statement: no REPEATABLE READ
  transaction, so no snapshot or xid horizon outlives a statement or a
  dead client (#738). The peer session runs with statement_timeout 8 s,
  under the client's 10 s bound, lock_timeout 2 s,
  idle_in_transaction_session_timeout 5 s, and server keepalives and a
  10 s tcp_user_timeout.
- A peer block is skipped only when a row of it is held here under the
  same identity with other content (a unique violation, or the D-15
  snapshot compare). Any other failure rolls back the block and its
  cursor and the next pass applies it again.
- Own-log recovery raises the own share sequence above the rows it
  pulls back before attaching partitions for them.
- A pulled share_seq more than 64 partitions above what this node can
  hold is refused as a conflict, never raised to.
- Origin reads take the shape only the (origin_node, share_seq) index
  can serve: origin_node BETWEEN $1 AND $1, ordered by origin_node and
  share_seq.
- The cached sync mark is dropped whenever the peer connection is
  recreated, so a mark read from a peer before its restore is never
  used after it.
- D-5 across passes: a peer block waits until the share mark covers its
  window's last share_seq.
- 027 alters the share ledger and its header mappings last, so a
  frontend still serving waits on them only from that step to the
  commit.

Gated tests: a stalled puller holds no barrier and leaves no lock; a
peer that answers but cannot be read leaves the latch to the evidence;
a block whose insert fails for a passing reason is retried, not
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: document the peer sync and restoring a node from its own backup

The ledger operations chapter gains how the peer sync runs: its paths and
pacing, the identity, fingerprint and column checks, the four streams in
D-5 order, the sync barrier, conflicts, the own-log latch and its D-17
evidence, the D-19 wait before a found block's offer, and the metrics to
watch. A restore of a node's own backup needs nothing extra, except a
restore that keeps the timeline while the peer is down, which must clear
the verification first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a peer row that fails to apply here fails only its stream, and a run of failures is alerted

The rest of the coordinator's review item 3. A block, share batch,
journal or prepared batch that fails to apply locally (a lock or statement
timeout, say) used to fail the whole pass: the prepared stream behind a
stuck block stopped too, the peer was reported unreachable and the sync
switched paths, and nothing alerted however long it lasted. Now only that
stream fails. Its cursor stays, the next pass applies the rows again, the
other streams go on, and its lag grows; after 20 failed passes in a row an
ALERT line names it, repeated every minute while it lasts. A failed peer
read still fails the pass and moves to the other path.

The retry test now also checks that the pass and the share and prepared
streams go on while the block waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: read the peer's fingerprint with only the sync role's column grant, and test the sync as that role

D6's first pair start found every pass failing with "permission denied for
table qbit_prism_cluster": the peer's fingerprint read filtered on
singleton, but the sync role may read config_fingerprint only. The read
now has no predicate; the table holds one row.

A new gated test, the_sync_needs_only_the_peer_roles_documented_grants,
creates a role with exactly the documented grants on both databases and
runs every read on the peer as it: each node's pull, its own-log check and
the found-block wait. It failed with the same error before this fix. The
ledger operations doc now lists the role's grants and settings, which the
test pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: stop the hashrate rollups and the share archive at the safe peer mark

The rollup watermark and the archive's rollup condition both assume no row
commits below one already folded. That holds for a node's own shares
(ORDER_LOCK) but not for the peer's, which the sync inserts later, below
shares this node already holds: a late peer share was never rolled up, and
a partition could leave while the peer's rows could still land in it,
after which the share stream could not insert them at all.

- On a dual-writer frontend the rollup sweep's batch also stops at
  qbit_prism_peer_share_mark(): no peer row arrives at or below it, and
  nothing rolls up before the first pull. A single writer runs 3.0's
  statement unchanged; a unit test pins that the dual statement is 3.0's
  text with one clause inserted after the batch bound.
- share-archive plan, on a personalised database, blocks the rollup
  condition of every partition the mark has not passed.
- For a long peer outage, when the peer will be rebuilt from this node, an
  operator declares its unpulled rows lost with one documented UPDATE of
  the new qbit_prism_node_lineage.peer_tail_lost_at (027, local state); both
  then go on without the mark. repersonalise clears it on the rebuilt node.

Gated tests: a late peer share below the local maximum is still rolled up,
with the declaration lifting the stop; a partition the mark has not passed
stays blocked with this node's sequence past its bound, clears when the
tail is declared lost, and clears for good once a peer share above its
bound moves the mark past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: pin the metric census with the peer sync's families

The cardinality test pins the families and series a frontend serves. The
eleven qbit_prism_peer_sync_* families are declared on every frontend
(80 to 91 families, at startup and populated), and the peer_sync holder of
the ORDER_LOCK hold histogram adds its 14 buckets, sum and count (965 to
981 populated series). A single writer publishes no peer sync sample, so
the startup series count is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: index the jobs by origin and expiry for the dual-mode expiry prune

D3's request. A dual writer keeps the peer's prepared records a day past
their expiry (D-3), and its expiry prune selects each node's expired rows
by that node's own (origin_node, expires_at) range. With only the expiry
index, every prune of one node's rows walked the other's retained rows.
027 now builds qbit_prism_jobs_origin_expiry_idx in its transaction: the
expiry prune keeps a single writer's table to its unexpired jobs, so the
build is short, and 027 holds the table's lock from its first ALTER of it
to the commit either way. undo_027 drops it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: fix the local review's findings (a recovery cut short, block pacing, the found-block wait)

From /code-review medium on the local commits.

- A recovery cut short no longer latches on stale evidence. Since the
  engine review, a peer that answers but fails leaves the latch to the
  D-17 evidence. After a restore that kept the timeline, that evidence
  still matches, so a recovery that failed partway set the latch with own
  rows missing, before the sequences were raised above the peer's
  positions. Now, once recovery finds an own row missing, it first forgets
  the stored verification; the latch then waits for the peer until
  recovery completes, across a crash too. The restore test locks the
  peer's journal to cut recovery short, and checks that the latch stays
  down and the verification is gone.
- Peer blocks waiting for the share mark ask for the next pass at once
  only while the mark moves. A failing share stream no longer spins the
  pass loop.
- The D-19 wait's bound covers this node's own read of what adoption
  needs, so a busy local pool cannot hold the offer past it. The wait
  keeps one warm connection per path, tries the path that answered last
  first, and gives each path an even share of the time left. A first path
  that accepts connections and never answers no longer uses up the
  fallback's time. A path that runs out of time is not counted as
  unreachable. New cases: a slow local read, and a silent first path.
- A unit test without a database checks that the pull's column lists and
  the table inventory agree on every copied column.
- Docs: the latch's forgotten verification, the wait's connections, how
  many connections the sync role needs, and the archive after a downgrade.

Kept as designed: a block that fails to apply is retried and alerted,
never skipped (the coordinator's review item 3). A pulled share_seq beyond
the pull's headroom is refused as a conflict (item 5); only a jump in the
peer's own sequence reaches it, never a backlog, as the constant's comment
now explains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual writer: migration 031, the share ledger's (origin_node, share_seq) index, built online

CONTRACT D-14: the peer sync's share pulls and the window cut read the
share ledger by node in share_seq order, and without an index on
(origin_node, share_seq) each is a walk of the primary key until it meets a
row of the node it wants; on a node that has written nothing lately the cut
walks the whole ledger under ORDER_LOCK. Migration 031 adds
qbit_share_ledger_origin_seq_idx. It is index-only and additive, with no
capability and no shutdown proof, like 024. Versions 28 to 30 stay
reserved for the sibling delegates; each version is checked on its own, so
31 is required without them.

031 is the first online index migration on the partitioned ledger, and
PostgreSQL builds no index of a partitioned table CONCURRENTLY. The online
runner (ledger/migration/online.rs) now builds such an index the way
PostgreSQL documents: one leaf per partition with CREATE INDEX
CONCURRENTLY, named <partition>_origin_seq_idx as
qbit_prism_share_partition_create and the share archive name every leaf,
while appends continue; then the index ON ONLY the parent (catalog work
under a SHARE lock on the ledger) and each leaf attached (catalog work under
an ACCESS EXCLUSIVE lock on that leaf index alone), both taken with 017's
2 s lock timeout and retries. PostgreSQL marks the index valid when the
last leaf is attached, and 31 is recorded then. A partition attached before
the parent exists gets its leaf in the next pass; one attached after it gets
its leaf from the parent's definition. A partition that left while the
parent waited for its leaf would leave it invalid for good, so the runner
holds the share archive's lifecycle lock for the whole build and refuses,
before any DDL, a partition still detaching, a leaf attached but invalid,
and anything else under a leaf's name. Interrupted, it resumes: a built
leaf is kept, an invalid one dropped and built again, the parent kept.
migrate --offline-indexes builds the leaves plainly in the transaction that
records 31. The scratch derivation declares an index of a partitioned table
by its parent alone, and refuses an online drop of one, which DROP INDEX
CONCURRENTLY cannot do. A fresh or empty source applies the file in the
migration transaction, which names the leaves the same way.

Registered in REQUIRED_SCHEMA_VERSIONS, NATIVE_MIGRATIONS,
ONLINE_MIGRATIONS and derive_online, and in the recovery evidence's
required versions. Gated tests: the build on a ledger at 27 with shares
waits for an open append on its first partition while later appends land,
a start refuses the database until 31 is recorded, once, and a second start
builds nothing; an interrupted run resumes (an invalid leaf rebuilt, a built
leaf, an attached leaf, the parent and a partition attached meanwhile kept)
after refusing a foreign index under a leaf's name before any DDL. The
offline tests assert 031's plain leaf builds. The undo helpers undo 031
before 017 and 027, every version pin gains 31, and the docs, the
--offline-indexes help and the cutover rehearsal's step names cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: refuse to sync without a valid 031 index, and guard the plans of the sync's origin reads

The coordinator's review item 6, and D2's request for 031.

- The sync refuses to run unless qbit_share_ledger_origin_seq_idx is a
  valid btree on both databases. Here it is checked with the node
  identity, every 30 s; on the peer, in the facts read every pass. The
  refusal is an ALERT and qbit_prism_peer_sync_refused{reason="schema"}.
  Without the index every share read by origin would walk the ledger.
- The sync's origin reads are built by functions the tests can plan:
  HIGHEST_SHARE, shares_scanned_sql(), shares_of_sql() and SHARES_BEYOND.
- New gated test the_origin_share_reads_never_walk_the_other_nodes_rows.
  It holds node A's rows under a long run of node B's, where a plain
  origin_node = $1 read walks the primary key backward. It plans each
  read prepared, at random_page_cost 4 and 1.1, custom and generic. It
  requires no sequential scan that reads rows (the empty lead partitions
  read none), the origin index for every origin read, and no more ledger
  rows visited than the read returns or scans by design, plus a probe per
  partition. With the index dropped it fails on every origin read.
  A one-off check on 3,000,000 rows across three partitions found the
  same shapes: 1 to 10 rows visited, under a millisecond.
- The guard also caught the header-mapping lookup of both share reads:
  `share_id IN (SELECT ... FROM picked)` let the planner hash the picked
  rows and scan the whole mapping table, a row per share in production.
  The lookup now takes `share_id = ANY(ARRAY(SELECT ...))`, the repo's
  idiom for this, and probes the share_id key. The guard bounds the
  mappings each read visits too.
- The refusal test drops the index on the peer, then here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual writer: account for 027 in the recovery evidence and the metrics census

Fallout of d8593724 in the gated suites.

Migration 027 gives qbit_share_ledger, qbit_payout_carry_forward and
qbit_pool_payout_entries an origin_node column, and the recovery evidence
export (scripts/prism-recovery-evidence.sql) records those three tables as
whole rows (to_jsonb). Every row of a migrated 2.x.x source therefore gained
"origin_node": 0, so its share, carry and payout digests no longer matched
the source's: the restore runbook's `cmp source.summary.json
migrated.summary.json` would fail after any 3.1 migrate, and so did
migration_rollback's evidence comparisons across a migrate
(frozen_2x_backup_restore_reconciles_before_ack_and_exposes_post_ack_loss,
the_evidence_export_defers_share_hashes_while_the_backfill_permits_serving,
and the cutover rehearsal's "recovery evidence unchanged" check).

origin_node is a row's provenance in a dual-writer pair, not accounting.
The export already leaves it out of every other table 027 gave it (blocks,
audit bundles, share hashes and audit snapshots are recorded by column
list), as the script's header says native metadata is never exported. The
three whole-row records now drop it (`to_jsonb(x) - 'origin_node'`), on a
2.x.x source a no-op, so a source and its migrated copy export the same
rows again. The parallel exporter's cut point follows the shares statement,
the test support's accounting_state drops it the same way, and the restore
runbook says so.

The observability census pins the populated series count: the new
peer_sync ORDER_LOCK holder adds one hold histogram, 16 series (13 buckets,
+Inf, sum and count), so 965 becomes 981.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: classify the 031 runner's and the index check's call sites in the checkout census

scripts/check_prism_pool_acquires.py found the sync's new index check
(origin_index_valid, a pooled read) and the 031 runner's describe() calls,
which only format refusal text, unclassified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: fix the second local review's findings (peer read sizes, a silent wait, repersonalise's read, the 031 wait)

From the second /code-review medium, on the integrated branch.

- The sync's reads fit the peer's 8 s statement timeout: the lag count
  stops at 100,000 rows (was 1,000,000), and PRISM_PEER_SYNC_BATCH_ROWS at
  most 20,000 (was 100,000), since a batch is one statement on the peer.
- A peer URL the PostgreSQL client cannot use fails the settings check by
  name, and the found-block wait's constructor returns the error instead
  of turning the D-19 wait off silently.
- repersonalise reads the peer's newest share in the shape 031's index
  serves (HIGHEST_SHARE), never a walk of this node's rows under the locks
  it holds.
- The 031 build logs every 30 s while a share-archive command holds the
  lifecycle lock it waits for.
- 027 says why the header mappings are altered before the ledger: an
  append reads the mapping first. The review proposed the other order,
  which would deadlock with an append waiting between the two.
- The rebuild runbook says repersonalise can tell the peer's own database
  from a promoted copy only while the peer has an own-log verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: stop the frontend when its database is restored under it, recheck columns every pass, never confirm an unread dependency

Codex's review of 193e7fd8 on #766, a local /code-review medium of the
first fix, and CI.

- P1: a database restored or promoted under the running frontend (its
  system identifier or WAL timeline changed since the latch set) now stops
  the frontend. The peer sync ends with OwnLogLostWhileRunning, which the
  server takes as a critical task exit. For this one it cancels every task
  at once instead of draining them, so no open transaction commits another
  own row. The process manager restarts it, and the restart's startup gates
  serve nothing until own-log recovery completes. Until it has exited, the
  ledger refuses every share append, a credited deferred share included
  (OwnLogLost). The check runs before the local refusals, so a restore
  that also trips one is still caught. Gating each writer instead (an
  earlier draft) left holes: a pass that recovers before the latch drop
  is seen, and appends already waiting for ORDER_LOCK.
- P2: both sides' copied columns are compared every pass, before any pull.
  This database's are read before the peer is, so a local failure is never
  taken for the peer's. A rolling migration that gives one node a copied
  column first stops the sync before a cursor passes rows whose new column
  this node would drop.
- P2: a failed local read of what adopting a found block needs ends the
  D-19 wait at once, unconfirmed (counted unreachable, the error in the
  warning), never confirmed, and never a wait for nothing.
- CI: the fixture copy (CREATE DATABASE ... TEMPLATE) retries for up to
  10 s while its source shows a session whose client has just closed:
  repersonalise's copy test met one on a busy runner.

Tests: the append refusal and its lifting in the latch test; a failed
local read in the offer-wait test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: stop the frontend when its database changes identity while it runs

Codex's review of 31bf958b on #766. Once the peer sync has seen the
database ready as this node, a later identity check that finds it
unidentified, the other node's, or drifted (an origin default or a key
sequence's parity changed) now stops the frontend, as a restore under it
already does: the sync ends with FrontendStop::IdentityChanged, the
server cancels every task at once, and the ledger refuses every share
until the process exits. The restart applies the startup checks: it
refuses a drifted database, and admits no miners on one that is not this
node's. Before, only the sync paused, and the frontend went on writing
rows under an identity that was no longer its own.

The stop error is now one type, FrontendStop, with a variant per cause
(OwnLogLost, IdentityChanged), and both causes take one path: it raises an
ALERT, refuses every share, drops the latch and publishes it, so health and
the gauges stop reporting the node ready while it exits. The identity
counts as seen ready on any ready check, whatever the 031 index's state.
Rows written between a drift and the next check, every 30 seconds, keep
what the database gave them; the restart refuses the drifted database
until node-identity set repairs it.

New gated test: a_frontend_whose_identity_changes_while_it_runs_stops
(a drifted origin default, and an identity row rewritten as the other
node's; the published status and the refused share).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: end the sessions left on a fixture's database before copying it

repersonalise's copy test failed twice in CI on "source database ... is
being accessed by other users". PostgreSQL's CREATE DATABASE ... TEMPLATE
already waits 5 s for other sessions, and the 10 s retry added in
31bf958b met the same session for 265 s: not a closing backend but a
session that stays. Locally none remains.

FixtureDatabase::copy now waits up to 10 s for every other session on
the template to end, then names what remains on stderr and ends it with
pg_terminate_backend. The caller closed its pools, so anything left is
background work it no longer owns. The retry around CREATE DATABASE is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the coordinator's review of 193e7fd8, Codex's third round, and zeroed counters

The coordinator's review (status/review-D1-193e7fd8.md):
- P2, rollups during a peer outage: the sweep's cap at the safe peer mark
  froze a survivor's own rollups while reporting them caught up. It is
  gone. A dual writer sweeps as a single writer does, after taking the
  progress row. A peer share the sync inserts below the watermark is
  folded into the rollup buckets by that pull, in its transaction, under
  the same row lock: the same grains and buckets as rollups.sql, which a
  unit test pins. The two serialise on that row, so a share is folded
  once, by whichever saw it. The archive gate still stops at the mark, and
  peer_tail_lost_at now lifts only that.
- P2, repersonalise on the peer's own database: a copy whose lineage
  carries no own-log verification is refused unless --unverified-copy
  says the operator checked it. When PRISM_PEER_DATABASE_URL is set, the
  command first reads that server's system identifier and timeline and
  refuses if they are this database's, or if it cannot be read, unless
  --unverified-copy.
- P3: the column test pins every copied table's full column list, so a new
  column needs a decision before it ships.
- P3: a pulled share whose share_id this node already credits at another
  share_seq is refused as a conflict (the ledger is unique on share_id per
  partition only).
- P3: own rows pulled back in own-log recovery are exempt from the pull
  ceiling; the sequence is raised to the highest of them. A backup node
  restored from an old backup no longer wedges on its own rows.

Codex on a732525e:
- P1: own-log recovery reads every own landed block and prepared job the
  peer holds from the lineage's sync_seq floor, not from the highest one
  held here: those streams commit out of sync_seq order, so a backup can
  hold a block whose earlier-numbered sibling was still open. Missing rows
  are counted before the verification is forgotten; held ones are
  compared. New gated test:
  recovery_finds_an_own_block_committed_after_a_later_numbered_one.
- P2: a found block whose prepared record is no longer held here (pruned
  while it waited) is offered unconfirmed, like a failed read.

D5's paging rules: a dual-writer frontend's peer sync counters (rows and
conflicts per table, failures per path, offer waits per outcome) start at
0, so the first increase pages. A unit test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: bound own-log recovery's reads, and the local review's other findings

The local review of 4786fb64:
- Own-log recovery read whole every own landed block the peer holds since
  the lineage's floor, with its audit bundle and share snapshot, at every
  start. It now reads their keys, a thousand at a time, and reads whole
  only the blocks missing here.
- It also read own prepared jobs this node had pruned at expiry while the
  peer kept its copy, as D-3's retention will. Each counted as a missing
  own row: it raised the rollback ALERT, forgot the verification and was
  put back. Recovery now reads only the unexpired ones. New gated test:
  recovery_leaves_own_prepared_jobs_pruned_at_expiry.
- Own rows read back in recovery had no ceiling, so one damaged row far up
  the peer's sequence would have raised this node's sequence to it and
  attached every partition on the way. The ceiling now counts from the
  peer's cursor over this node's shares, which every own row the peer
  holds came through, so a backup node restored from an old backup still
  takes its rows back. New gated test:
  recovery_refuses_an_own_row_beyond_the_peers_cursor.
- A pulled share that broke two refusal rules was recorded and counted as
  two conflicts. It is now one, with both reasons; the conflicts test
  covers it.
- The late-share fold runs only when the pull inserted rows.
- Every frontend on a personalised database takes the rollup progress row
  before its sweep, with PRISM_DUAL_WRITER on or off. So a single writer
  running beside a dual writer (a rollback in progress, or a
  misconfiguration) cannot pass a late peer share unfolded.
- node-identity repersonalise reads the peer's server within one 10-second
  bound, where only the connect was bounded before. It tries
  PRISM_PEER_DATABASE_URL_FALLBACK when the first path does not answer.

Kept as they are:
- A found block whose prepared record is gone is offered at once, counted
  unreachable, without waiting for its shares. The record goes only at
  expiry, so the block has waited at least a job's lifetime, and nothing
  can confirm the record.
- The missing-row checks before each apply stay. They forget the
  verification before any own row goes in, so a recovery cut short between
  pages never leaves the old evidence trusted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: compare held own blocks in recovery, and the second local review's other findings

The local review of a481852d:
- Reading only the missing own blocks dropped the comparison of those held
  here. The key read now carries a digest of each block's immutable facts
  and audit digest, which is everything apply_block compares. Recovery
  reads whole the blocks missing here and those held with another digest.
  A held one with other facts is a conflict, and the own log has diverged.
  New gated test: recovery_finds_an_own_block_held_with_other_facts.
- The own-row ceiling now counts from the higher of the peer's cursor over
  this node's shares and the peer's lineage floor. That covers own rows a
  peer held when it was personalised, as well as the ones it pulled.
- If the peer returns fewer of the listed blocks than were asked for,
  recovery fails and starts again instead of going on without them.
- repersonalise reports why each path to the peer's server failed, not
  only the last one.
- The docs say to stop every frontend on a database before personalising
  it. A frontend reads at start whether its database is personalised, and
  its rollup sweep depends on that.

Kept: the recovery's unexpired-only read of prepared jobs also leaves out
held ones that the peer counts as expired. A prepared record is immutable
apart from its expiry, so a difference there is damage, not a lost row,
and this node prunes such a record anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: forget the own-log verification when recovery finds the own log diverged

The local review of 1116c09a: a conflict among this node's own rows in
own-log recovery marked the own log diverged in memory only. The
verification stayed recorded, so a restart without the peer would latch on
it and serve a diverged own log. Any such conflict now forgets the
verification as well. The docs promise the latch stays unset with or
without the peer until an operator decides, and this makes that hold.
recovery_finds_an_own_block_held_with_other_facts now checks that the
verification is gone and that a peerless restart stays down.

The held-block comparison also probes each key once (a left join) instead
of twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a found block is confirmed only below rows the peer holds, not rows it refused

Codex on 9fa64d1a (P2): a share or prepared job the peer refused as a
conflict still moves the stream's scanned_through past it, as it must or
the stream would stall. The found-block wait read that cursor as proof
that the peer holds every row adoption needs, so it could confirm a block
whose window held a share the peer refused.

For shares and prepared jobs, the cursor's ingested_through now means
this: NULL while this node holds every peer row it scanned. A pull that
refuses one sets it just below the first refused row, where it stays
until an operator resets it. For a prepared batch with a conflict, that is
below the batch's lowest job. The wait reads the position through which
the peer holds this node's rows, so a block above a refused row is never
confirmed; it runs to its bound and counts timed_out. Blocks keep no stop,
since nothing waits on them. Repersonalise starts the rebuilt node's share
cursor with nothing refused.

The runbook's reset moves the stream's cursor back to the stop and clears
the stop, naming the stream. The next pull then reads the refused row
again, instead of the wait trusting a row that never came. 027's comment
on the column says what it now means (text only; no database change).

Tests: the conflicts test gains a prepared job B holds under A's id with
another parent. It checks each stream's stop, that a later clean pull
keeps the stop, and that the wait confirms needs below each stop and
times out above. The wait's unit test covers held positions below the
scanned ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: quarantine refused peer rows for good, and clear the own-log verification with an own-row conflict

The coordinator and D2 on f5597cb5's reset: a refused peer row must never
be inserted later, and the safe peer mark must never step back. A row that
landed below a window cut a window had already used would break the
landings and audits built on it (contract section 4.4).
- A refused share or prepared job is quarantined for good: recorded,
  never inserted, never read again. The pull moves past it and nothing
  moves back. The rewinding reset is gone; there is nothing to reset.
- For shares and prepared jobs, ingested_through now holds the highest
  refused key (GREATEST; NULL while none was). For a prepared batch with
  a conflict, that is the batch's highest job.
- The found-block wait confirms only a window whose first share lies above
  the highest refused share, and a prepared record above the highest
  refused one. Confirmations resume by themselves once windows move past
  a quarantined share. AdoptionNeeds gains first_share_seq, taken from the
  candidate's window.

Codex on f5597cb5 (P2): an own-row conflict in recovery was committed
before the verification was forgotten, so a crash between the two could
leave a restart without the peer trusting the old evidence. Recording a
conflict on a row this node originated now clears the verification in
the same transaction.

Tests:
- The conflicts test checks that later pulls never lower the safe peer
  mark, and never insert a peer share at or below a mark already passed.
- It also checks that the refused rows never arrive, that each stream's
  highest refused key holds, and that the wait confirms only above it.
- The divergence test applies a differing own block directly and checks
  the verification is gone with the conflict.
- The wait's unit test covers windows and records around refused keys.

The docs give the quarantine rules instead of the reset. 027's comment on
the column changes in text only; no database change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the found-block wait checks the prepared record the block's job names

Codex on 7658fa27 (P2): the wait found the block's prepared record by its
window's anchor and balances, taking the newest. A template update over an
unchanged window prepares a second record with the same two, so the wait
could check a newer record the peer holds instead of the one the block was
built on. That one might have expired, or been quarantined there.

The candidate keeps the issued job it was found on, and every issued job
names its prepared record (payload.prepared_key; full jobs are saved only
in tests). The wait now reads that exact record's sync_seq:
- If the job or the record is gone, the offer is unconfirmed.
- A record the peer holds anyway is no need. That is a record that is the
  peer's own, or one from before 027, which both databases of a pair
  started from.

The offer-wait test prepares a newer record over the same window. It
checks the lookup returns the one the job names, that a missing job fails
it, and that a peer-origin or pre-027 record is no need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the first rollup sweep and a pull serialise before any progress row exists

Codex on 7fed050c (P2): before the first sweep there is no progress row,
so the dual sweep's lock and a pull's fold both locked nothing. The first
sweep could pass a late peer share that a still-open pull had inserted,
and nothing would ever count it.

- The sweep on a dual-writer database creates the row first, at 0, which
  is what a sweep reads for a missing row and what its own statement would
  create.
- A pull whose fold finds no row locks the progress table (SHARE ROW
  EXCLUSIVE) until it commits, and folds again. So the first sweep waits
  for it, and the pull waits for a first sweep that created the row
  meanwhile and then folds against its watermark.
- A pull never writes the row, so share-archive plan's "the rollup sweep
  has never run" still holds whenever there is no row.
- The fold now also reports whether a row was there.

New gated tests:
- the_first_sweep_waits_for_a_pull_that_found_no_progress_row: without
  the sweep's creation step it counts 2 of 3 shares.
- a_pull_that_found_no_progress_row_folds_against_the_first_sweep: it
  drives the real pull. Without the table lock, the pull does not wait.

Codex's other P2 on 7fed050c, that the found-block wait does not verify
the database behind its path, is deferred as a misconfiguration case. The
rationale is on the thread and in the PR body's Follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: share archives carry each row's origin_node (format v2)

Codex on f59ac088 (P1): the share archive's rows, its read projection and
the restore's insert all left out origin_node (migration 027). A restored
partition would therefore attribute every row, the peer's included, to
the restoring database's own node.

- ArchiveRow gains origin_node, which is read with the row and restored
  explicitly. It is written last, and only when it is not 0, so a v1 line
  stays a valid line for a row of node A's.
- A new schema, qbit.prism.share-archive.v2, is written exactly when some
  row has another origin. Every archive of a database that never ran a
  dual writer stays v1, byte for byte, and a 3.0 binary still reads it.
  An archive that carries the peer's rows is refused by name by a binary
  that would restore them as its own.
- The reader accepts v1 and v2, and requires the declared schema to be the
  one its rows imply. Spelling out a 0 origin is not canonical.

Tests:
- New gated test share_archive::a_v2_archive_restores_each_rows_origin:
  archive, verify, seal, detach, drop, then restore a partition with 50
  peer rows. It checks v2 and every row's origin.
- The restore round-trip test now checks that an all-node-A partition
  stays v1.
- The row unit tests cover the v2 encoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: rustls 0.23.45 -> 0.23.46 for RUSTSEC-2026-0346 to -0349

The Dependency audit (cargo deny) fails on every PR from 2026-10-16, on
four rustls advisories published since the last green run, each fixed in
0.23.46. Nothing else in the lockfile moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: an attached archive import maps each header under its share's origin

The local review of eb12d75e:
- An attached import (restore --attach into a database with no catalog row)
  rebuilt the header mappings without origin_node. On a database
  personalised as the other node, they took its default. Each mapping now
  takes its share row's origin.
- An archive row's origin must be 0 or 1. Anything else is refused while
  streaming, on archive, verify and restore alike.
- a_v2_archive_restores_each_rows_origin also imports the archive,
  attached, into the database with node B's origin defaults. It checks
  every row's origin and that no mapping differs from its share's.
- The ledger ops chapter describes v2, and says to archive and restore a
  dual-writer database's partitions only with a 3.1 binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the origin index check requires the index's definition, not only its name

Codex on 1fc5d3c6 (P2): the peer sync accepted any valid btree named
qbit_share_ledger_origin_seq_idx, even one on another table or on other
columns, for example after a failed manual repair. It would then sync
without the (origin_node, share_seq) access path its origin reads need.
The check now also requires the index to be on qbit_share_ledger, on
exactly (origin_node, share_seq), with no predicate or expression, on
this node and the peer alike. the_sync_refuses_wrong_identities_and_fingerprints
replaces the index with one of that name on share_seq, which is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: refuse a pulled share whose ID is held or retained here as a rejected share

Codex on 1ed3365b (P2): a share both nodes rejected, each at its own
share_seq in another partition, passed the pull's checks. The ledger is
unique on share_id per partition only, and a rejected share has no header
mapping. The pull then inserted a second row under the same global ID, and
archiving the newer partition would later fail on the retained ID.

Such a row is now refused as a conflict when the ID is held here as a
rejected share at another share_seq, or retained in
qbit_prism_rejected_share_ids at another share_seq. A refused rejected
share is in no window, so it never raises the stream's quarantine key that
holds the found-block wait back. The conflicts test adds a share both
nodes rejected: it is refused once, and the key stays put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: record the own-log verification only on the server recovery began on

Codex on 75e24918 (P1): each step of own-log recovery takes its own
pooled connection. So a failover of the local database partway through
could leave the recovered rows and the raised sequences on the old
primary, while the verification and the latch landed on the promoted one,
which lacks them.

Recovery now captures the system identifier and WAL timeline when it
begins. record_own_log_verified applies only while the database still
shows them, checked in the same UPDATE. Otherwise it refuses, the latch
stays down, and recovery runs again on the new server. The latch test
checks that a verification under evidence the server no longer shows is
refused and leaves the recorded one alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* qbit-prism: a dual-writer window's per-node cut in the reward manifest

A new WindowCut type ({"0": share_seq|null, "1": share_seq|null}) names
the highest share_seq of each node's rows a dual-writer window may include.
The reward manifest and its stored header carry it as `cut`, absent when
None, so every existing bundle, fixture and vector keeps its bytes. The
coinbase commits the cut through the reward-manifest leaf.

The builders take the cut as an input, and the verifier rebuilds a manifest
under its own cut. Every window share must be at or below the cut's highest
entry (ShareOutsideWindowCut). Dropping, adding or altering a cut fails at
the commitment leaf. The build-audit-bundle binary accepts `window_cut`.

Adds a dual-writer fixture: both nodes' share_seq parities, pre-3.1
history, a gap, late node-1 rows outside the cut, and a partially counted
crossing share. Also adds the window_cut_bundles tests (library and binary
build, verify and canonicalize; tampering; parallel equals serial; a
randomized two-node reproduction model) and pinned golden digests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism: pin the dual-writer fixture's CTV build and refuse one share_id from both nodes

The CTV-settlement build of the dual-writer fixture is pinned beside the
coinbase-options build: the reward manifest, cut included, also feeds every
fan-out precommitment, and both builds commit to the same manifest. A
window holding one share_id from both nodes is refused by the fold in the
builder and every verifier, never credited twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: per-node window cuts for the dual writer, and migration 028

In dual-writer mode every payout window records a per-node cut: the
highest share_seq of each node's rows it may include. A row is eligible
when it is eligible as in 3.0 (accepted, and accepted and issued at or
before the anchor) and its share_seq is at most its node's entry.

This node's entry is its newest own row, read under ORDER_LOCK in the
cutoff's statement. The peer's entry is its newest row at or below the
peer sync's safe mark (qbit_prism_peer_share_mark(), D-14) that is stamped
at or before the anchor. A peer row that arrives late is above the peer's
entry, so it is outside every window built before it and joins later
ones.

The anchor rule stays on every row (D-13). Each node's stamps rise with
its share_seq, so the cut alone decides membership. Clock skew can only
delay a peer's newest shares, never make a window irreproducible or a
landing fail.

Every path that selects, re-reads or proves a window applies the cut: the
snapshot and full scan, read_window, verify_durable_range, the in-lock
count, audit persistence and reconstruction, the #619 holding probe and
the leaf witness. The incremental advance reads each node's new rows
between the old and fresh cuts and merges the peer's late rows in
share_seq order (advance_in_cut), proved by the existing leaf witness and
eligible-row count.

At a 200k-row window a merge of 1,000 late peer rows and 1,000 own appends
takes about 92 ms, against 1.5 to 1.6 s for the full scan.

The cut reads are shaped so only the (origin_node, share_seq) index can
serve them. A plain max() with an equality filter was measured walking the
primary key through the other node's whole run inside ORDER_LOCK. The
index ships as D1's migration 031; until a ledger has a valid one, a
dual-writer snapshot refuses before it takes any lock. The dual-writer
test fixtures create the same index meanwhile.

A cached window is not reused for a new template once the peer's mark has
moved, since the peer's newer rows can all lie below this node's cutoff.
The refresh probe reads the mark in dual-writer mode only.

WindowRef carries the cut, absent when None, inside the digest-checked
candidate document and prepared payload. Migration 028 adds cut_seq_0 and
cut_seq_1 to qbit_prism_audit_snapshots. Bundles commit the cut in
reward_manifest.cut, except a bootstrap window's. With dual-writer mode off
nothing has a cut, and every eligibility, snapshot, probe and dependency
statement is 3.0's.

Tests:
- a 512-case proptest of the merge against the full scan;
- gated tests with both nodes' rows in one ledger: the model, late rows
  with a no-cut negative control, clock skew, reproduction on the other
  node's database, a seeded advance walk, single-writer parity, the plan
  shape, the refusal without the index, and the peer mark;
- gated landings with late peer rows;
- a measurement harness.

Every version pin gains 28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: rebuild a dual-writer block's audit with its window's cut, so it lands

The audit rebuild that every post-offer landing and every reconciliation
retry runs (build_claim_parts) called the serial builders, which build
without a cut. The coinbase was built by bundle_build with the window's
cut. So every dual-writer block was accepted on chain, but its landing
failed with "audit parts' window cut None differs from the candidate's
window reference" and the row stayed in reconciliation. D6 found it in
its S1 and S5 scenarios.

The rebuild now applies the rule bundle_build applies. A dual-writer
window's audit carries the reference's cut, whether or not the peer's
entry is empty. A single writer's window, and the bootstrap window with
its one synthetic share, carry none, so those bytes are 3.0's.

A row the failed landing left in reconciliation lands on its next retry:
it keeps every input, and the retry rebuilds the audit from them.

Tests:
- three coordinator tests through process_candidate (offer, rebuild,
  landing): an idle peer with no mark, under direct settlement; both
  nodes' rows, under CTV settlement; and a block whose first landing
  failed, which lands on its reconciliation retry without a second offer;
- the ledger-level landing tests now land the rebuilt audit, check it is
  the one the coinbase committed to, and gain an idle-peer case.

Without the fix, the three coordinator tests and both non-bootstrap
ledger tests fail with D6's error. The window_switch fixture now builds
coinbases with the snapshot's cut. That cut is None for every
single-writer test, so their bytes are unchanged.

The test fixtures' stand-in index takes migration 031's name,
qbit_share_ledger_origin_seq_idx (CREATE INDEX IF NOT EXISTS). The
plan-shape guard pins its cut reads to that index or its partitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: alert once a minute on a dual-writer ledger without 031's index, with a gauge

Without migration 031's (origin_node, share_seq) index, a dual-writer
snapshot refuses before it takes any lock, and the refresh loop logged
that refusal on every tick and wake: 920 lines in three minutes per
frontend in D6's integration run.

The refusal is now typed (OriginIndexMissing). The refresh loop logs it
as an ALERT at most once a minute, and again at once after a successful
refresh. The snapshot sets a new gauge,
qbit_prism_dual_writer_origin_index_missing: 1 while it refuses, 0 once
it finds the index. A single writer declares the gauge without a sample.
The metric census pins move with it. They also take in the peer sync's
families, which the base had not counted.

The refusal test checks the type, the gauge in both states, and only its
own database's advisory locks; other tests share the cluster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: dual-writer review fixes for the own entry, the holding probe and bootstrap work

Fixes from the second local review and the coordinator's review of
a3e9854.

The cut entries:
- This node's entry is now its newest own row eligible at the anchor, so
  both entries are rows the window's predicate admits, and the higher one
  is the window's newest row. A rejected or future-stamped legacy row on
  top no longer stops the incremental advance.
- The own-cut read inside ORDER_LOCK first probes at or above the
  retained window's entry, which reads only the newest partitions, as
  3.0's cutoff does. The probe of every partition runs only for a first
  snapshot, or when that one finds nothing.
- The index check requires a btree whose two columns share one
  direction.

The #619 holding probe of a dual-writer window also checks this node's
entry row, and that the peer sync's mark covers the peer's entry. A
restore can lose each log's newest rows while the window's last row
survives.

Bootstrap work: empty-window work is replaced as soon as the peer mark
moves, as a first share replaces it. The refresh trigger label and the
work's equivalence check now see the cut.

The incremental advance:
- the delta bound counts the union of the nodes' ranges, not their sum;
- the margin walk and the witness-and-count proof are shared with 3.0's
  advance, and its statements keep 3.0's text.

Statements:
- The refresh probe reads the peer mark in its revision statement. A
  single writer's statement is 3.0's.
- compact_issued's two dependency statements are built at compile time
  from one text. A test pins the single writer's to 3.0's digest.

Tests:
- the holding probe under a rewound mark, a lost own entry and a pruned
  prefix;
- the bounded own-cut read across four partitions;
- the plan guards with the new parameters;
- a BRIN index refused;
- bootstrap work replaced when the mark moves, while full work keeps its
  cadence;
- the union bound;
- pinned statement texts.
audit_completeness's hand-made snapshot table gains 028's columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: final local review: the peer entry under both marks, one margin step, wording

Fixes from the last local review before the PR:

- The peer's entry is read in the second transaction, after ORDER_LOCK.
  It is now bounded by the mark read there as well as by the mark the
  cutoff's statement read, so a mark rewound in between never admits a
  peer row whose lower rows this database no longer holds. The cutoff
  still bounds every admitted row.
- The dual-writer cutoff statement is planned with its values, as every
  bounded ledger read here is, so the retained entry prunes partitions in
  every plan.
- Both merges carry one Margin, whose take step is the only copy of the
  saturating fold below the retained first row. The proptest uses it too.
- read_range_paged says it applies 3.0's predicate with no cut.
- The ops doc says a held window's last row matches the window's
  predicate, whichever node wrote it. It also says bootstrap work is
  replaced whenever the peer's mark moves until a window holds shares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: drop the dual-writer tests' stand-in for 031's index

The base now carries migration 031, which creates
qbit_share_ledger_origin_seq_idx on every fixture the tests migrate. The
fixtures' own CREATE INDEX IF NOT EXISTS is gone. The refusal test, which
drops the index, still restores it under 031's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: pre-PR review: the peer entry in one statement, the readiness probe, evidence

Fixes from the last local review before the PR:

- The peer's entry is bounded by the mark now inside the peer-cut
  statement itself, beside the mark the cutoff's statement read, so a
  rewind between statements is covered too, with one round trip fewer.
  The statement also returns the mark the entry used, and the snapshot
  records that one for the refresh to compare. A rewound mark that comes
  back then rebuilds the window.
- The dual-writer cutoff statement is cached again, as 3.0's cutoff is,
  so no plan is made under ORDER_LOCK. A generic plan prunes the leaves
  below the retained entry at executor start, which the bounded-probe
  guard checks.
- The public read's schema-readiness probe requires 028's cut_seq_0 and
  cut_seq_1, which audit reconstruction selects.
- The recovery-evidence tamper test also changes a snapshot's cut and
  expects the evidence to see it.
- migration.rs and the migration guide say 028 is taken, and 29 and 30
  stay reserved.
- The measurement harness drops 031's index for its no-index case. The
  refusal test drops its BRIN index before it restores 031's. The
  plan-shape guard sets a peer mark, as a synced ledger has one, so the
  peer read's walk is bounded and planned on 031's index. The
  dual-writer ops paragraphs are rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* scripts: classify the window-cut changes in the pool-acquire census

Three of this branch's checkout sites changed shape:
- audit reconstruction's read_range call now passes the stored cut;
- the snapshot's advance call now passes the cut;
- verify_durable_range's newer-row probe is now two timed checkouts, one
  per branch, with only one running.

The census entries are updated to match. The timed count goes from four to
five, and the policies stay the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism: bump the build daemon's serve protocol to 3 for window_cut

A build request's window_cut, added for PRISM 3.1's dual-writer windows,
changes what the daemon signs. A protocol 2 daemon ignores the field and
signs a reward manifest without the cut, and the build summary cannot show
that. The handshake now announces 3, so a client that checks the version
exactly refuses a pre-cut daemon instead of distributing work that does
not commit to the cut it asked for.

The native server builds in process and never starts the daemon. The doc
comment now says so, and that the client which checked the version was the
retired Python coordinator.

A new test sends the dual-writer fixture's window, with its cut, through
--serve. The daemon's job summary must equal a one-shot build's with the
cut, and that differs from the build without it. The serve tests that pin
the handshake, and the deleted-test map, now say 3. From Codex's review of
#768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* qbit-prism-server: compare a prepared row's window without its cut in every mode

The issued-job saves check that a prepared row's payload agrees with its
typed columns. In dual-writer mode the payload's window was compared
without its cut, which the columns never carry. A single writer issued
3.0's whole-window comparison. A single writer started under D-12's
downgrade would therefore refuse every live prepared row a dual-writer
frontend wrote, though the work is valid and lands under its stored cut.

The comparison now drops the cut key in every mode. On a 3.0 payload,
which has no cut, that changes nothing. The window must be an object, and
a cut in it an object too, so a malformed payload is a mismatch, as 3.0
reported it, and never a statement error. It is the statement's one
difference from 3.0's text: the second deliberate single-writer exception,
beside audit reconstruction's read of the cut columns, and for the same
D-12 reason.

A unit test pins the statement to 3.0's digest once that one expression is
swapped back. A gated test writes a dual-writer prepared row and checks it
on a ledger with no dual-writer identity. The row matches its columns
there, and 3.0's whole-window comparison refuses it. Another key, a null
or scalar cut, a scalar window or a changed column still fails. From Codex's review of #768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the cut fixtures fake a pull that refused nothing

D1's 7658fa2 made a cursor's ingested_through the highest peer row a
pull refused as a conflict, quarantined for good, NULL while none was.
The window-cut fixtures still set it to the mark, which now reads as a
pull that refused every peer row through it, a state the peer's
found-block wait would never confirm a block under. They now set only
scanned_through, as a clean pull leaves the cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: pin the audit snapshot's cut columns in the sync, and the quarantine in D-14's wording

D1's the_sync_carries_every_column_the_inventory_does_not_keep_local pins
every column the peer sync copies, and failed on the merge: 028 added
cut_seq_0 and cut_seq_1 to qbit_prism_audit_snapshots. The sync carries
that table whole, so a synced block's snapshot already brings its cut;
both columns are now pinned. The snapshot conflict check still compares
only the content-determined columns, as D1 wrote it for windows that
share one share array.

Since D1's 7658fa2 a peer row the sync refused as a conflict is
quarantined for good, below a mark that moves past it. Windows taken here
leave it out and stay whole, but one whose range spans it reproduces only
on the node that took it. cut.rs and the ops doc said every peer row at or
below the mark is committed here; they now name the exception. The
window-cut integration helper's mark upsert also sets peer_node, as its
twin in cut/dual_writer_tests does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a new template never reuses a window with a peer row pending its anchor

Codex on #768 (PRRT_kwDOS9jIOc6rEtvE): a peer row at or below the mark
but stamped after the window's anchor, by a peer clock running ahead,
joins a later anchor's window with no new row and no move of the mark.
The reuse rule compared only the mark and the accepted cutoff, so a new
template could keep the old window, and leave that share out of the
work, until the reanchor interval ran out.

The peer-cut statement now also returns the peer's newest accepted row
under the same bounds, whatever its stamps, by the same index walk on
031's index, which stops there. When that row is above the peer's entry,
the snapshot reports a peer row pending (PeerCut::pending, carried on
SnapshotCapture and RefreshWindow), and RefreshWindow::reusable refuses
the window, so the next template reads a fresh one. Same-template work
keeps 3.0's cadence, as before.

Tests: the gated clock-skew test asserts the flag while the rows wait,
its clearing once this node's clock passes them, and that a rejected row
above the entry is never pending. A unit test covers reusable, and a
coordinator test shows new templates at an unchanged revision reading a
snapshot while a row is pending and reusing the window once none is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the 2.x audit envelopes refuse a window cut by name

Codex on #768 (PRRT_kwDOS9jIOc6tByaz): the audit-body-ref.v1 and
audit-bundle.v2 envelopes check that their shares form one contiguous
share_seq run, which a dual-writer window with a cut need not be (a late
peer row outside the cut leaves a gap), so such an envelope failed as
non-contiguous.

These envelopes are 2.x storage artifacts: the cutover import, the
read-model API's legacy bodies and the verify and canonicalize CLIs read
them, no 3.x writer produces them, and 2.x never wrote a cut. Both
resolvers now refuse a bundle whose reward manifest has a cut, by name and
before any reconstruction, rather than half-checking it; such a bundle is
valid only as a flat logical bundle, which every 3.x path stores and
serves. A test wraps the gapped cut fixture in each envelope and checks
the named refusal, and that the flat bundle still parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 3.1 dual-writer (3/5): carry owner and peer settlement

Two independent writers, one carry owner (CONTRACT §4, §6). With
PRISM_DUAL_WRITER off nothing changes: every statement is 3.0's text.

- Carry gate (ledger/carry.rs): non-owner work is carry-free (empty
  priors), every change fenced by one payout revision bump; work is also
  carry-free whenever this node's journal no longer claims ownership, so a
  release fences later jobs at its commit.
- Owner guard (carry_owner.rs): pays only while the node identity,
  PRISM_CARRY_OWNER, this node's journal and a live peer read agree; after
  a peer release only a claim transfer vetted pays; rollbacks under a
  running frontend, conflicts and mismatches are carry-free and alert;
  seeding waits for the own-log latch and a live read; start-up states are
  quiet during start-up.
- Peer settlement (ledger/blocks.rs, divergence.rs): peer blocks are local
  derived state for this node's reconciler; the finder (coinbase
  extranonce1 half) decides found counts, divergence records and fanout
  sponsorship.
- Peer fanouts (broadcaster.rs, ledger/fanout.rs): the finder sponsors a
  zero-fee fanout; the other node takes it over once it is overdue and the
  finder silent, and finishes any package it holds.
- S8 adoption (coordinator/adoption.rs) of pool blocks whose finder died
  before their rows arrived, from its prepared record.
- qbit-prism-server carry-owner status|release|transfer, with the release
  height read under the lock, the scan bound, the journal tail copy and
  the pre-write re-read; docs/prism-ledger-ops.md.
- Tests: invariant model, gated carry-owner postgres, guard and adoption
  suites, observability pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: drop the tests' stand-in for 031's index; bound the release's tip read; a release depth of at least 2

On D2's d04cdc4 (D1 193e7fd, with migration 031):

- The carry-owner tests no longer create a stand-in for the
  (origin_node, share_seq) index: migration 031 creates it on every fixture.
  The adoption fixture turns D1's D-19 offer wait off; it has no peer.
- carry-owner release bounds its tip read under SETTLEMENT_LOCK at 5 s,
  since the lock holds work building back meanwhile.
- transfer waits for a release depth of at least 2 (MIN_RELEASE_DEPTH):
  carry-paying work built just before a release can still reach the tip
  after the one the release recorded.
- Work reads the gate only through job_prior_balance_sql_in, which also
  reads the journal; the gate-only selector is private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: say in the ops doc that a release fences work at its commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a live peer claim counts even behind a newer synced row; classify the carry owner's checkout sites

From Codex's review of cae3115 (PR #767): the guard took only the newest
of the peer's live and synced rows, so a peer whose database was rolled
back to an older claim, behind the release this node's copy holds, could
act on that claim while this node kept paying. The guard is now
carry-free on any live claim of the peer's, as transfer already refuses
one.

The pool-acquire census (scripts/check_prism_pool_acquires.py):
- the guard and the carry-owner commands check out through Ledger::acquire;
- the journal helpers run SQL on the caller's connection (&mut *connection);
- the peer reads through PeerJournal get their own policy, peer-journal;
- the remaining carry-owner, fanout, adoption and guard-spawn sites are
  classified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a live peer claim behind this node's copy is its own state, peer_journal_behind

From the local review of a1d8c7b: a claim in the peer's live journal
behind, or against, a newer row of the peer's this node holds (a rolled-back
peer database, or a lagging path) stops payments, as Codex asked, but it is
not two owners: the advice is to restart the peer so its own-log check
recovers its rows, never to release this node. The guard reports it as
peer_journal_behind (alerting), and the ops doc says so. The module doc
states the rule; its unit test stands alone and covers a same-epoch
disagreement. transfer's chain scan checks out through Ledger::acquire as
well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: read the peer's rows and latest claim in one snapshot; let transfer recognize earlier pool markers

From Codex's review of 7a2ea81 (PR #767):

- transfer read the peer's latest rows and its latest claim in two reads,
  which a primary and a lagging fallback URL could answer differently; a
  missing claim then left the scan unbounded. PeerJournal::view reads both
  in one REPEATABLE READ snapshot on one connection, and scan_start refuses
  a peer that released when no claim of its can be read.
- The chain scan recognized pool blocks by the current coinbase tag and
  pool-fee program only. carry-owner transfer takes the earlier ones
  (--also-tag, --also-fee-program, repeatable), and the ops doc says when to
  pass them, and that S8 adoption recognizes only the current ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: adoption waits for the own-log latch, as the submit loop does

An adopted block lands with this node as its origin (D-10), so its rows
are this node's own. The Stratum listeners, the refresh and the submit
loop wait for the own-log latch (D-8) before writing any; adoption did
not. A node restored from a backup could then land an adopted block
before own-log recovery had raised its key sequences and pulled back its
lost landings, reusing a key the peer holds or meeting its own earlier
landing of the same block with other facts. Recovery would then find an
own row that differs from the peer's copy, record the own log as
diverged and keep the node out of service until an operator decides.

The loop now waits for the latch before its first pass, inside
adoption_loop, so the gated refusal test pins it: its control runs the
loop and sees nothing land in 3 s with the latch unset, then the block
adopted from its own record once the latch is set, and adopting it again
rewrites nothing. A plain restart with the peer unreachable still sets
the latch at once, so a surviving node that restarts keeps adopting. The
ops doc says that until the latch is set adoption reports nothing either:
a block that leaves the 1,440-block lookback while no loop runs gets no
last ALERT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the adoption fixture fakes a pull that refused nothing

D1's 7658fa2 made a cursor's ingested_through the highest peer row a
pull refused as a conflict, quarantined for good, NULL while none was.
The adoption fixture still set it to the share mark, which now reads as
a pull that refused every peer share through it. It now sets only
scanned_through, as a clean pull leaves the cursor, like D2's f55e876
for the window-cut fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: count the guard's and the broadcaster's peer connections in the sync role's budget

The carry-owner guard and the broadcaster's check of a silent finder each
read the peer's database as prism_peer_sync, one connection per path, and
every carry-owner command does while it runs. The ops doc's budget still
counted only the sync and the found-block wait (four per frontend), so a
role limited to it could refuse the found-block wait or a sync pass. It
now asks for five per frontend, eight with a fallback path, plus one per
path for a running command.

It also names the new unadoptable case from D1's quarantine: a window
holding a peer share, or a prepared record, that this node's sync refused
as a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: Codex's two P1s on 4a06a8b, transfer's peer path and adoption's extranonce2 width

Transfer reads the peer over PRISM_PEER_DATABASE_URL only. With the
first path down, it read the peer through the fallback, which may reach
a copy that lags the peer's journal. There an older release would pass
every check, and the pre-write re-read could pick the same stale copy,
while the peer itself had acquired again: both nodes would then pay
carry. Its checks, its one-snapshot view and the re-read now all go
through the first path, so a transfer with that path down refuses on
peer_answered. The ops doc says to point PRISM_PEER_DATABASE_URL at
another path to the peer's own database, never a replica. The gated
transfer test runs one with the first path unreachable and a fallback
that answers.

Adoption reads the issuing node's extranonce2 width.
PRISM_STRATUM_EXTRANONCE2_SIZE is not in the pair's fingerprint, so
the nodes may differ, and adoption checked a peer record's coinbase
suffix with this node's width: it refused a valid record, so S8 could
not land the block. The suffix is the issuing node's tag and a zeroed
placeholder, and the tag is printable ASCII, so its trailing zeros are
exactly the placeholder: adoption takes the width from there
(ends_with_suffix, unit-tested over widths 1 to 32). The rebuilt
commitment root and the byte-for-byte coinbase still decide. Finder
attribution still reads extranonce1 at this node's width; the ops doc
says to keep the width the same on both nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: Codex's two P1s on 476fdd9, the transfer's tip at the acquire and carry-paying offers behind a closed or unfenced gate

transfer now checks, right before the acquire and under SETTLEMENT_LOCK,
that the scanned tip is still on the active chain. A shallow
reorganisation between the scan and the commit could bring in a branch
holding a carry-paying block of the peer's that the scan never saw and
that is not landed here. The acquire then refuses and asks to be run
again. Blocks found on top of the scanned tip are above the release
depth, so carry-free, and do not refuse. The read is bounded like
release's tip read. The gated transfer test runs a confirmed transfer
against a chain whose scanned tip is reorganised away: it is refused,
nothing is acquired, and the copied release row stands.

An offer no longer takes carry-paying work behind a closed gate for
current work. set_carry_paying moves the in-memory gate before its
payout revision bump. If the bump fails (a halted cluster, a lock
timeout, a lost connection), work built under the old mode stays at the
current revision until the guard's retry commits. A block found on it
was then offered and landed as current work, not through the #478
capture bound or, with capture off, abandoned. After the gate closed on
a competing owner, carry-paying work could then land here while the
peer also paid.

Ledger::reserve_offer_within, which reads the revision FOR SHARE, now
also treats a block whose prior balances are not the carry-free ones as
superseded while the dual-writer gate is closed, or while a change of it
awaits its bump (a gate closed and reopened before either bump commits),
whatever the revision says. Such a block takes the capture path. This needs no pending-fence state
and puts no lock on the offer path. Carry-free work while the gate is
open stays current: it pays no carried balance. Leased candidates are
reserved unconditionally, as in 3.0. A unit test pins the predicate
(carry_paying_not_current).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: recheck the carry gate at the offer's send, after the token renewal

Codex P1 on 0d9d9dd. The reservation decides whether carry-paying work
is current, but submit_block comes after the found-block wait (up to
PRISM_PEER_INGEST_WAIT_MS) and the token renewal. A gate that moved in
that interval let a block reserved as current be sent while the peer
might also pay carry.

Right after the strictly-live renewal, immediately before the send, the
offer now rechecks the gate in memory. A block reserved as current whose
work is carry-paying is not sent if the gate is now closed or unfenced,
or has changed at all since the reservation. That includes a gate closed
and reopened meanwhile, since the peer may have paid the same balances
while it was closed; the gate now counts its changes for this check.

Its reservation returns to pending through release_unmade_offer, the
#522 release for a call never made. It is due again at once rather than
after the pending backoff, and the next attempt's reservation takes the
#478 capture path, or abandons the block with capture off. The check
reads only memory, so the renewal stays the last database step before
the send. Leased candidates, reserved without a revision check, are
unchanged. The ops doc's offer_reserved row names the new release, and a
unit test pins carry_paying_unsendable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: classify release_unsent's checkout in the pool-acquire census

454ccca moved release_unsent_offer's transaction into release_unsent,
which release_unmade_offer shares, so the census saw an unclassified
site and a stale one. It is the same ledger checkout, renamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a coinbase names its finder only where the tag sits before extranonce1

Codex P1 on 835963e. PRISM_STRATUM_EXTRANONCE2_SIZE is not in the
pair's fingerprint. finder_node read extranonce1 at this node's width,
so on a peer block issued with another width it read four other bytes,
which could fall in this node's half: both nodes then took the block
for their own, and both funded conflicting CPFP children of its
zero-fee fanout, leaving the losing wallet's coin reserved.

finder_node now reads extranonce1 at this node's width only where the
coinbase tag sits right before it, as every coinbase this pool builds
has it. A block issued with another width therefore names no node, and
found_here falls back to its origin: a peer-landed block is never taken
for this node's own. This is a check of one placement, not a search
over widths, which a short tag or crafted extranonce bytes could fool.
An empty tag checks nothing, as before. The ledger records the tag with
the width (set_coinbase_layout), and unit tests cover a peer block
issued with widths 4 and 12 against a local 8, another tag, and an
empty one. The ops doc says to keep the width and a non-empty tag the
same on both nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: adopt across extranonce2 widths, end to end

This closes the extranonce2-width class. Only two paths read a peer's
coinbase with this node's PRISM_STRATUM_EXTRANONCE2_SIZE:
- adoption's suffix check, which reads the issuing width from the
  record since 476fdd9;
- finder attribution, which since 18c18b5 reads extranonce1 only where
  the tag sits right before it. A block issued with another width names
  no finder, and its origin decides.
Every other width use builds or serves this node's own work.

The gated refusal test's control now adopts the block on a second
frontend whose width is half the one the work was issued with. The
adoption loop waits for the own-log latch, then adopts the block from
its own record, and the rebuilt coinbase is byte for byte the block's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a coinbase that names no node is not this node's find

Codex P1 on fa9ca53. finder_node names no node for a block issued with
another extranonce2 width, and found_here then fell back to the block's
origin. An adopted block carries the adopter as its origin, so the
adopter took a cross-width adopted block for its own find. It sponsored
the zero-fee fanout at once, bypassing the takeover's overdue and
silence checks, while a recovered finder, whose own layout names it,
sponsored it too: conflicting CPFP children, and a wallet coin left
reserved.

Every coinbase a dual-writer node builds names it: the tag sits right
before its own extranonce1. A coinbase that names no node was therefore
issued elsewhere, and found_here no longer falls back to the origin for
it. Such a block is sponsored here only by takeover, adopted or not.
Without a recorded layout or a coinbase, the origin still decides. A
node that changes its own tag or width while its blocks still await
fanouts stops taking them for its own finds, which the ops doc already
rules out.

The gated refusal test now checks that, after the narrow-width frontend
adopts the block, only the issuing layout names its finder. Unit tests
cover an adopted cross-width block and an unparseable coinbase with this
node's origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: refuse to start without a coinbase tag of at least 4 bytes

Codex P1 on a7acc04. finder_node reads extranonce1 only where the
coinbase tag sits right before it, but PRISM_COINBASE_TAG may be empty,
and an empty tag verifies nothing. With unequal extranonce2 widths, both
nodes could then take a block for their own find and fund conflicting
CPFP children. A tag of a byte or two could also match by chance.

A dual writer now refuses to start unless PRISM_COINBASE_TAG has at
least 4 bytes (the default /PRISM/ has 7). Single writers keep their
choice, an empty tag included. A unit test covers the check, the README
and the ops doc say so, and D1's dual_writer and config_cli suites still
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a peer database with no work is not a silent finder until it stays so for the takeover delay

Codex P1 on 1aba741. When the finder's database answered with no work
of its node at all, finder_silent took that as silence on the first
read. That happens just after the frontend starts, or once its expired
work is pruned. A live finder could still sponsor its overdue fanout as
its own while this node took it over: conflicting CPFP children.

Such an answer is now unknown: the liveness state notes since when the
checks have found no work, any check that finds work clears that, and
the finder counts as silent only once there has been none for
SPONSOR_TAKEOVER_AFTER. The gated takeover test runs a finder whose
database holds no work: it is not taken over at once, and it is taken
over once the checks have found none for the delay
(assume_no_work_since). The ops doc says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a finder keeps its own block across a coinbase layout change

Codex P1 on 7c8c72b. finder_node reads extranonce1 at this node's current
PRISM_STRATUM_EXTRANONCE2_SIZE and coinbase tag. A block this node found
before either changed therefore named no node, and found_here took it for
the peer's. The peer read no finder either, so neither node sponsored the
zero-fee fanout as its finder.

A coinbase that names no node now counts as this node's find when the block
carries this node's origin and this node holds the block's outbox row. The
outbox is local and never copied, and an adopted block has none, so a
cross-width adoption is still sponsored here only by takeover (a7acc04).
Without a recorded layout or the coinbase, the origin still decides. The
fanout sponsor, reconcile and divergence reads each select the outbox flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: adoption loads a parent's prepared records one at a time

Codex P2 on 6264cd4. compact_prepared_on_parent fetched every prepared
record on a block's parent, each joined to its template and balances, in
one fetch_all. A parent that stays current for hours, as in a chain stall
on a busy mempool, holds many such records from both writers, so a single
adoption could hold all of them in memory at once on the survivor.

compact_prepared_on_parent now returns only the keys, newest first, and
compact_prepared_held loads and decodes one record at a time, expired or
not. A record that is gone, does not decode or has lost its blobs is
reported among the block's refusal reasons. The pool-acquire census
classifies the new checkout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: no takeover on a short outage without the finder's work

Codex P1 on 4d8897c. With no prepared record of the finder's held here
(none synced yet, or its copies expired after the 24-hour retention), the
fence found every matured fanout overdue at once. The broadcaster then took
it over after its finder's database failed three checks over one minute,
an outage that only means silence when the stale copy here already shows
the takeover delay. A live finder behind an asymmetric database outage
could have its fanout funded twice.

The fence now reports such a fanout as Unwitnessed rather than Overdue,
and the broadcaster takes it over on an outage only once the outage itself
has lasted SPONSOR_TAKEOVER_AFTER. A database that answers is read as
before. The cached verdict is re-evaluated against each caller's outage
threshold, so one fanout's verdict never shortens another's wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: an empty peer path keeps the finder's work age unknown

Codex P1 on a7ce81e. PeerJournal::work_age combined the peer's URLs by
the youngest age and let any age override a path that answered with no
work. When the current path holds none (a frontend just restarted) and a
lagging fallback still holds an old row, the stale age won, and the
broadcaster could take over a live finder's fanout once that row was 30
minutes old.

youngest_work_age now lets work younger than the takeover delay on any
path win, since it shows the finder alive. Otherwise a path with no work
makes the age unknown, which waits on the broadcaster's 30-minute no-work
timer. Only when every path shows stale work does the youngest decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: refuse a coinbase layout mismatch between the pair

Codex P1 on 9c0b64f. Under two PRISM_STRATUM_EXTRANONCE2_SIZE widths, a
miner can place the coinbase tag and a value from the other node's range
inside its own extranonce2 at the narrower width's offset. Both nodes then
read themselves as the block's finder and fund conflicting CPFP children.
The tag-adjacency check only made accidental collisions rare, and the
pair's fingerprint binds neither the width nor the tag.

Each node now reads the peer's tag and width from the coinbase suffix of
the peer's newest prepared record it has synced, so no schema or grant
changes. While they differ from this node's:
- the carry owner guard builds carry-free work on either node under a new
  alerting reason, layout_mismatch;
- fanout_sponsor sponsors nothing as finder or by takeover, and only
  finishes a CPFP package it already holds;
- adoption refuses each block, which the pass reports with its ALERT.
carry-owner status prints the mismatch. The metric label, scrape contract,
generated metrics doc, census and ops docs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: only the peer's primary confirms an owner or a seed

Codex P1 on 57972c7. The guard's live read takes the first peer URL that
answers. An owner restored from a backup older than its release, started
while the peer's primary was out of reach, could read a lagging fallback
that still showed the peer as non-owner and this node's old claim. A
fresh guard then confirmed the peer and opened the carry gate while the
peer, which had acquired, was paying too.

PeerRead::Answered now says whether PRISM_PEER_DATABASE_URL answered.
Only that answer confirms the peer or lets this node seed its journal, as
carry-owner transfer already reads only the primary. A fallback answer
still stops payments: a claim, a newer row of this node's, or an unseeded
peer it shows counts. Once confirmed, an owner keeps paying through a
fallback answer, as through an unreachable peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: read the peer's layout from its primary when none is held

Codex P1 on 02474ed. layout_mismatch reads the peer's coinbase layout from
its newest prepared record synced here, and took the layout to match when
none was held. While the jobs stream was refused or lagging, a pair with
two layouts could therefore pay carry, and sponsor fanouts as their finder.

With none of the peer's records held, the carry owner guard now reads the
peer's newest coinbase suffix live over PRISM_PEER_DATABASE_URL only
(PeerJournal::primary_suffix), and the ledger keeps it for the fanout and
adoption checks. The layout is unknown, and taken to match, only while the
primary shows no work or has never answered, so a peer whose records
expired and whose database is gone does not hold the survivor carry-free.
Held records decide and clear the live value. carry-owner status reads it
the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a fallback's stale work is an outage, not silence

Codex P1 on d42ea51. With PRISM_PEER_DATABASE_URL out of reach, a lagging
fallback that answered with work 30 minutes old or more gave work_age that
stale age, and finder_silent took the finder for silent at once. That
skipped the outage timing, including the 30 minutes an Unwitnessed fanout
needs. A live finder whose primary this node cannot reach could have its
fanout funded twice.

PeerJournal::work_age now knows whether the primary answered. Without it,
only work younger than the takeover delay counts, as the finder alive; a
fallback's stale or empty answer is no answer, so the broadcaster times it
as an outage against the fanout's threshold. With the primary answering,
the rules are as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a transfer's scan starts a maturity below the acquire

Codex P1 on 1b87916. carry-owner transfer's scan_start let --from-height
equal the tip the peer's last acquire recorded. If the chain reorganized
after that acquire to a fork below its tip, the peer, already owner,
could find carry-paying blocks at heights under it. The scan would miss
them, the transfer would pass without their landing rows, and this node
could pay the same carry again.

The scan must now start SCAN_REORG_MARGIN below the recorded tip: the
coinbase maturity, the depth PRISM already treats as final, where a
mature pool block disconnected is a fatal state. The default
--from-height 0 is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: the offer reads the journal before it sends carry work

Codex P1 on 11765f5. The offer rechecked only this frontend's in-memory
carry gate before submit_block. A carry-owner release, committed by the
operator's own process, reaches the gate only at the guard's next check, so
a carry-paying block reserved before the release could still be sent
after it. The new owner's transfer scan might already have read that tip,
and the block's carry could be paid twice.

Right before the send, alongside the gate, a carry-paying block reserved as
current work now also reads this node's latest journal row. If the row no
longer claims ownership, nothing is sent, and the reservation returns to
pending as for a moved gate. Carry-free work is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: keep a failed journal read from losing a found block; refuse equal-epoch claims

Two Codex P1s on b4dbb57.

The offer's journal read before submit_block returned its error with ?,
after the reservation had committed. The failed attempt then kept the
block offer_reserved, which recovery treats as a call that may have run,
so a valid block would never be offered again. A read that fails is now
taken as withdrawn: nothing is sent, and the reservation returns to
pending as for a moved gate.

carry-owner transfer took the newer of the peer's live and synced claims
by epoch, and silently chose one when they differed at the same epoch, a
sign that the peer's journal was rewritten. A different recorded tip could
then let a later --from-height pass scan_start. Such a conflict now fails
scan_start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer: a failed layout read closes the carry gate

Codex P1 on e776ee9. The guard's check returned with ? when the peer
layout read failed, after the live peer read and before Guard::decide.
The run loop keeps the gate as it is on an error, so a claim the peer read
had just shown went undecided, and an open gate stayed open while the
peer might also be paying. A layout read that fails now counts as a
layout mismatch, so the check decides carry-free and alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto 3.x.x after #767 was squash-merged (44fd3e4), as one commit:
the branch merged D3 heads, so a commit-by-commit replay conflicts. The
tree is identical to the reviewed head 571fc6b.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 3.1 dual-writer: a two-node pair simulation, its payout-invariant checker and CI lanes

crates/qbit-prism-dual-sim runs a whole PRISM pair on one host: a PostgreSQL 16
cluster per node (the 3.0 pair as a primary and its streaming standby, the 3.1
pair as two primaries), a regtest qbitd per node plus one for the network
(ramped once to 1e7fffc0 and cached), the real qbit-prism-server frontends, a
balancer stand-in that routes Stratum sessions by each node's health (A
preferred, B backup, inter 2 s fall 3 rise 2, sessions closed on mark-down,
paced failback; /readyz with its token for the dual-writer pair), Stratum load
through it from qbit-prism-load's client, and a block finder per node.

The fault injector kills or freezes a frontend or a PostgreSQL, drops a node's
network or cuts the link between the databases (a reset or a blackhole that
delivers held bytes on heal), and takes and restores base backups.

The checker holds CONTRACT.md section 4 to the chain's census of pool blocks:
per-account debt never rises except a recorded #478 race, the non-owner's blocks
are carry-free, every pool block is landed and identical on both nodes and its
audit verifies with qbit-prism-audit-verify against the chain's coinbase, CTV
fanouts are byte-identical, every acknowledged share is present except a
documented tail, nothing is credited twice, and windows are unchanged and
reproducible on both nodes. A negative control proves it fails when it must.

S10 (the 3.0 pair through a kill -9 of A's frontend) and the control pass on
today's code; S1 to S5 are written and wait for the 3.1 stack. CI: a required
fast lane in ci.yml (prism-dual-writer) and the full matrix nightly, on
dispatch and on the run-dual-writer-e2e label (dual-writer-nightly), each with
its gate-manifest proof, artifacts and a matrix summary; both lists are
registered in test/e2e-scenarios.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the dual-writer bootstrap, the restore, rebuild, cutover and transfer scenarios, and contract v1.9's checks

- Bootstrap a dual-writer pair as the cutover does (D-9, D-4): migrate,
  node-identity set, D1's exact peer-role grants and role settings, then start
  B before A. The balancer checks the token-protected /readyz (D-7) in dual
  mode and can switch probes mid-run.
- S6: plain restart with the peer unreachable serves, a restore onto a new
  timeline stays unready, and the node holds every own row the peer holds at
  its first ready reading (D-8, D-17). Restores recover the way pgBackRest
  does: archive recovery to the backup's consistent point, promoted onto a
  new timeline.
- S7: the replaced disk is a promoted physical copy of the peer,
  re-personalised (D-16); only the dead node's measured tail is excused.
- S8: an RPC gate holds or withholds the dying node's submitblock; the
  survivor's adoption, one landing per node and the D-19 wait on and off.
- S9: a live 3.0 to 3.1 cutover with ledger digests before and after on both
  nodes, every pre-cutover row node 0, D-12's refusal of a single-writer start,
  and the owner's first priors equal to the chain's sums.
- S11: D3's release and transfer drill, refused by the chain scan while an own
  block is unknown and accepted once it lands.
- Checker: D-1 and D-10 immutable landing facts compared across nodes and with
  the chain's coinbase txid, D-13 cut-aware window recompute, ownership epochs
  by height, the D-14 late-row negative control, a reconciliation row whose
  block has landed treated as bookkeeping, and block finding on settled,
  freshly delivered work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: D-2 local-state and D-5 landing-order checks

The checker holds each dual-writer node's never-copied state to its own rows (no candidate reserved by the peer's frontend, no divergence record for a peer-origin block, its own identity), and S1 and S5 sample every peer block as it arrives on a node, requiring its whole window there first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: provenance-aware D-2 check, an atomic D-5 sampler, S4's D-8 check

The D-2 check now looks only at rows written since the pair began writing
as two primaries (Sim::dual_since: the dual bootstrap or the cutover), so
the history a 3.0 cutover leaves on both databases no longer fails S9. A
copied candidate is one the peer's frontend claimed or reserved. A copied
divergence row is one with an offer decision for a block this node never
had as a candidate: only the offering frontend records one. The check no
longer flags divergence rows for peer blocks, which each node's reconciler
writes when it confirms them. After S7's rebuild the same check proves
D-16's re-personalise reset the local tables.

The D-5 sampler counts each peer block's window in the statement that
finds the block, so detection and count share one snapshot. It keeps
polling a block until its window record arrives, records failed polls
instead of swallowing them, and stops when dropped. S1 and S5 judge each
direction on its own and need at least one block counted complete as it
arrived. The lag sampler also counts failed polls and stops when dropped.

S4 adds D-8's "a later link loss never withdraws a serving node": the
balancer marks neither node down from the cut to the catch-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: D-2 copies by identical rows, D-10 torn landings, structured balancer records

D-2 now finds a copy however it was made. A candidate or an offer decision
that is the same row on both nodes (the same insert timestamp) is a copy:
each node finds its own blocks, and an adopting survivor's rows are made
on their own. A candidate from before a cutover that was reserved after it
means the drain left it unfinished, and the cutover now refuses to start
unless no candidate can still be offered. The candidate count in the
report covers every row, not only a node's own.

The D-5 sampler takes its baseline before the scenario goes on, so a block
that lands during early failed polls is sampled, not excluded. A block
first seen after a failed poll cannot show the order. A block seen without
its audit bundle or snapshot breaks D-10 (a peer block arrives with all
its child rows), so it fails instead of passing as unresolved. One D-13
eligibility predicate now serves the window checks and the sampler. Both
samplers share one poller that stops when dropped.

The balancer records each mark-up and mark-down and every failed check,
with whether its node was serving. S4's D-8 check uses them: both nodes
stay up from the cut until a full mark-down's worth of checks after the
catch-up, and failed checks while serving are counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a frozen peer never stalls the survivor (S2's freeze and a mid-pull variant)

A review of D1's engine found that the sync barrier could hold a lock on
the peer's database across a client round trip. Every job and landing
takes that barrier (sync_seq's default), so a puller frozen while holding
it would stall the survivor's writes. Two checks now guard it.

S2's freeze variant keeps A frozen for at least 60 s while B finds a block
every few seconds. Each block must land, and B must record new jobs after
each one before A thaws.

A new S2 variant, s02_a_frozen_again_and_again_mid_pull_never_stalls_b,
freezes A's frontend 20 times for 1 to 4 s with both nodes taking miners.
Each freeze is timed, where it can be seen within 3 s, to a moment A's
puller has a query, a transaction or an advisory lock open on B's
database. During each freeze a new tip arrives and B must record jobs on
it within 15 s; every fifth freeze B also finds a block, which must land.
The report counts the freezes that caught A mid-pull, and freezes.json
keeps the schedule's seed and every round. It is registered for the
nightly lane as not run yet: the balancer's checks need D4's readiness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: freeze checks prove what they claim; S4's D-8 judges nodes serving at the cut

The long freeze now requires B to build work on each of its blocks (a
job whose parent is the block), in one grouped query, and A's frontend to
still be the stopped process it froze at the end of the hold. The old
"held at least 60 s" condition could not fail.

The mid-pull variant times B's new work from the mint's return, not from
the freeze. Landing rounds solve on work B handed its finder before the
freeze, so the freeze stays short. A superseded solve is reported, not
judged. A is thawed before any error in a round is raised, and the pull
watch polls every 20 ms rather than every 2 ms. find_block is now a
prepare step and one solve-and-land attempt, which those rounds reuse.

S4's D-8 check judges only the nodes serving at the cut. Its horizon
allows a check timeout longer than the interval:
fall x max(interval, timeout) + interval. The balancer report gains
up_at. process::stopped reads a pid's state from /proc. The manifest
credits the 60 s hold to the S2 entry that runs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: checks for the three P1s of D1's engine review

The coordinator's review of D1's peer sync engine (cca56f14) found three
P1 failure modes. Each now has a nightly scenario, so the fixes stay fixed.

- S6, unreadable peer (P1 1). B's PostgreSQL accepts the peer role, but
  every read of its share ledger hangs (an ACCESS EXCLUSIVE lock held on
  B) or fails (SELECT revoked); a probe as the peer role shows it. A
  restarts with its database untouched, so its D-17 evidence matches, and
  must report ready and take miners within 120 s (D-8). B is then made
  readable again and the pair catches up.
- S2, puller death (P1 2), at 250 offered shares/s. For 20 s B is sampled
  every 10 ms and must never show A's peer backend idle in transaction:
  no pull holds a transaction across a round trip. Then A's database link
  is blackholed so B never sees the close, as when A's host dies, and A's
  frontend is killed while its puller is busy on B. A's backends on B
  must hold no snapshot or transaction beyond 10 s, and B's share answer
  latency must stay flat across the next minute (#738).
- S4, transient apply failure (P1 3). A holds EXCLUSIVE on its audit
  bundles, so landing inserts fail at A's 5 s lock_timeout while reads go
  on, for 12 s after B lands a block. Once the lock clears, the block
  must land and confirm on A, with no sync conflict recorded for it.

Each is registered for the nightly lane as not run yet until D1's fixes
and D4's readiness land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a discarding link for the puller death, evidence for the transient apply

S2's puller death no longer blackholes A's database link. A blackholed
relay stops reading, so a B backend still sending a result would block on
a full window and hold its snapshot even with single-statement reads. The
relay gains a Discard state: every byte is read and dropped and no close
is passed on, which is how a dead client looks to its server. Its flat
appends check now starts its early window 5 s after A is marked down, out
of the reconnect burst, and needs A to have no session left.

S4's transient apply now requires at least one of A's applies to be seen
waiting on the locked table (distinct backend and statement start in
pg_locks), so the check cannot pass without exercising the retry. S4's
D-8 check judges both nodes from the moment routing settled; the balancer
now records each mark before it publishes the state, so a node seen up
always has its mark-up on record.

S6's unreadable peer always makes B readable again before it raises an
error. The mid-pull rounds report landings apart from new tips. A landing
round is Landed or LandingFailed, never a placeholder. Load::accepted_on
counts in place instead of copying the share log. The manifest and README
say what each round does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: Discard resets spliced connections on heal; tighter evidence and error paths

The relay's Discard state now drains in every case it could stall. Bytes
read as the link turns to Discard are dropped instead of waiting for Open.
A write blocked on a stalled or dead client is abandoned and the sender
drained. A failed write while discarding keeps the drain going. Closes
are held, and a connection that lived through Discard is reset on both
sides when the link leaves it, as a dead host's restarted kernel answers
the old connection: its stream has a hole, and splicing it would
desynchronise the protocol. Two relay tests cover the stalled write and
the reset on heal.

S4's transient apply counts only statements of A's frontend (its role, its
database) waiting on the locked table, records their text, and releases
the lock before any error is raised. S4's D-8 check needs both nodes up
from routing settling to the cut, and judges only mark-downs after the
cut. S6 restores B before raising, and keeps the restart's error as the
root cause. S2's puller death looks for A's mark-down from the cut and
drops a sessions condition that could not fail. The mid-pull landing
time is named for what it measures, from the solve request to
confirmation. One ShareRecord::accepted_on predicate serves the scenarios
and the load's in-place count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: cancel-safe relay writes, apply evidence limited to inserts, a routing check for the puller death

The relay forwards each chunk a piece at a time with the cancel-safe
write, re-checking the link between pieces. A blackhole now holds the
rest of a chunk it was writing instead of finishing it, Discard drops it,
and a reset ends it. One wait_state helper replaces four copies of the
same watch loop. The Discard docs say what the code does: every
connection that lived through it is reset when the link leaves it, as the
dead host's restarted kernel would. The stalled-write test returns early
on a host whose buffers can hold the whole flood.

S4's transient apply counts only INSERTs into the locked table. A finds
no block of its own meanwhile, so these are B's block's apply, while A's
own backfills of the table are UPDATEs. It keeps the first error when
the unlock fails too. S6 records B's restore on the timeline even when
the restart failed. S2's puller death checks that no session is routed
to A after its mark-down. count_accepted_on serves the scenarios and the
load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: routing decisions dated by the balancer; S4's own-block guard; a stalled-write test that cannot pass blind

The balancer records each routed session when it chooses the node, so
S2's puller death counts sessions routed to A after A's recorded
mark-down from one source. A 100 ms grace covers a choice that read the
state the mark was about to change. It no longer compares a polled
baseline with a later count.

S4's transient apply now requires that A created no candidate of its own
while its audit bundles were locked. Otherwise its own landing's insert
could pass for B's block's apply. On any error after the lock it closes
A's pool, which ends a connection whose rollback failed and its lock.

The stalled-write test passes early only when an open link has delivered
every byte of the flood (a host whose buffers hold all of it), never when
bytes went missing. The Discard docs say exactly which connections it
resets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: exact routing dates; S4's own-block guard is an expectation over the whole dual run

The balancer dates each routed session before it reads the nodes' state,
and records a mark-down after publishing it, so every session routed to
a node is dated before that node's mark-down. A mark-up is still
recorded before it is published, so a node seen up always has its mark.
S2's puller death then counts sessions routed to A strictly after its
mark-down and before its next mark-up, with no grace. The routing log
stays out of the written report.

S4's transient apply now judges the "A has no candidate of its own"
condition as part of its expectation, not as an error that skips the
landing checks. It counts every candidate A created since dual mode
began, so a candidate made just before the lock is counted too. The lock
timestamp it no longer needs is gone, along with its unguarded read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: what the first runs on the stack showed: catch-up under load, partial reports, late sessions

The first runs against D1 and D2 surfaced three harness gaps.

- wait_synced required both databases to hold equal counts, unchanged a
  second later. Under load the counts never hold still, so every
  mid-scenario catch-up (S3, S4, S2's puller death, S6's unreadable peer)
  would time out. It now waits until each node holds every share and
  block its peer had originated when it was called. settle, which pauses
  the load first, keeps the strict equality as wait_converged.
- A scenario that failed part-way lost every expectation it had recorded:
  the driver only had the body a finished scenario returned. The driver
  now owns the body and lends it to each scenario, and a failed report
  carries its expectations, gaps and blocks.
- A session the balancer chose just before its node's mark-down could
  register after the mark-down closed every session and stay on the dead
  node. A session now closes itself if its node is down once it has
  registered.

S2's puller death drops its routing condition, which held by
construction, together with the routing log it needed. S4's transient
apply reads dual_since before it takes its lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a robust catch-up wait, failed reports keep the checker's verdicts, late sessions never reach a dead node

wait_synced polls once a second and retries a failed read until its
bound. A timeout now names each node's sync conflicts, since a row the
peer refuses never arrives. Its pools close on every path. Its doc says
it does not prove that a restored node pulled its own rows back (S6 and
S7 check that).

A failed report keeps the invariant report when the checker ran before
the failure, so the per-check verdicts in its expectations can be traced.

A session the balancer chose just before its node's mark-down, and that
registered after it, is reset before any byte reaches the node and is
not counted as routed. S2's puller death again checks that every miner
leaves dead A: no session stays open to it at the end of the watch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a bounded catch-up wait; the puller death judges only what P1 2 asks

wait_synced now retries its first reads too (each node's own rows and its
clock) and caps every read with the time left before its bound. A
stalled database therefore cannot carry it past the bound. The conflicts
a timeout names are those recorded during the wait, read with their own
short cap.

S2's puller death drops "every miner leaves dead A". The balancer's map
of open sessions empties on a mark-down whatever happens to the miners,
so the condition could not fail. P1 2 asks about B's backends and B's
appends; miners leaving a dead node is the other S2 variants' check.

A session reset because its node was marked down as it connected is now
counted as refused and logged on the timeline. The driver no longer
copies the invariant report to keep it for a failed report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the catch-up wait cannot hang on a stalled database; S2 checks B took every miner

wait_synced caps connecting by its deadline, and closing each pool to 5 s.
A read cancelled mid-query leaves its connection pinging a stalled
database, which an uncapped close would wait on. On a timeout it reports
each node's sync conflicts in total and those seen during the wait (a
row refused earlier stays missing just the same). A count it could not
read says so, instead of printing an error as if it were one.

S2's puller death checks that B took every miner: each session has
shares accepted on B's jobs 45 to 60 s after the death, the late window
whose latency it judges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: precise catch-up diagnostics; S2 names every miner session

When the wait never read a node's clock, its count of conflicts seen
during the wait reads "unread" rather than falling back to the harness's
clock and reporting 0. A connect that outlives the bound says the bound
was spent connecting to both databases, rather than blaming whichever
node was last. S2's every-miner check requires each session index of the
load to appear among B's late accepted shares, not merely as many
sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S2 names the miner sessions B did not take; the catch-up wait says when it read a clock late

S2's puller death lists the load sessions with no share accepted on B in
its late window, rather than a count that a finder session could round
up. When the catch-up wait first read a node's clock on a later pass, its
timeout says how far into the wait that was, so a count of conflicts
"seen during the wait" is never shorter than it claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: freeze A when its puller holds a lock on B, the moment a barrier bug shows

Two runs of the mid-pull scenario on the stack caught D1's sync barrier
held in 2 of 20 freezes, then in none: freezing at any busy moment makes
catching it a matter of timing. Both S2 freeze checks now wait first, for
two thirds of their wait, for a moment A's puller holds an advisory lock
on B's database. Only then do they fall back to any moment it is busy
there. Each freeze records which moment it got (lock held, busy, unseen),
and the mid-pull summary counts the freezes made at a held lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: each S2 check aims at its own moment; a dead puller's leaked barrier is caught

The lock-first timing suited the mid-pull freezes, not the puller death.
P1 2 is about a puller dying with a transaction open, and a lock-held
moment usually has none. Now:

- S2's puller death is timed first to a moment A's puller has a
  transaction open on B, then to any busy moment.
- Its watch after the death also counts advisory locks. A dead puller's
  backend that keeps D1's session-level sync barrier would stall every
  writer on B, and that now fails the check instead of passing it.
- The mid-pull new-tip rounds aim at D1's sync barrier itself (its key's
  two halves in pg_locks), not at any advisory lock.
- Landing rounds aim briefly at a busy moment, so the finder work handed
  out just before the freeze stays current.
- The pull state counts backends with a transaction open and the
  barrier separately. The doc comments, README and manifest say which
  moment each check aims at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: link latency makes the barrier and open-transaction moments catchable

D1's puller holds its sync barrier, and an explicit transaction, across
client round trips. On loopback those last a few milliseconds, so a
20 ms poll caught them only by luck: 2 freezes in 20 in one run, none in
the next. The relay can now add one-way latency to every chunk on a link
(Relay::set_latency, reported in its stats). S2's freeze and puller-death
checks put 40 ms each way on A's link to B, a path between two sites.
That holds a barrier or a transaction spanning round trips open long
enough to catch, while a single-statement pull is not stretched by it.

The puller death now aims at an explicit transaction idle between its
statements (state 'idle in transaction'). Before, it aimed at any backend
with xact_start set, which includes every running statement. A wait whose
first aim is a busy moment no longer spends two thirds of its time
looking for nothing. The barrier key is D1's own constant, so it cannot
drift. A death that fell back to a busy moment says the puller was busy
(a query, a transaction or a lock), not only that a query ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: link latency per burst, never on discarded bytes, never cut short

The relay's latency now models a path rather than a throttle.

- A chunk the pump had to wait for starts a burst and waits the latency.
  So does a connection's first chunk. A chunk already waiting to be read
  is the rest of a burst and follows at once (a non-blocking read tells
  the two apart). A round trip costs twice the latency, and bulk transfer
  is no longer capped at one chunk per latency.
- Bytes being discarded never wait, so a dead client's server drains at
  full speed and S2's puller death cannot see a lingering backend that
  the relay itself held up.
- The latency sleep runs to its deadline while the link stays open. A
  watch notification that does not change the state no longer cuts it
  short.
- Relay stats report the highest latency the link carried, not the value
  left after a scenario lifted it.

The latency test now also echoes 4 MiB through a 150 ms link, which must
finish within 20 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the relay's pumps are delay lines

Per-burst latency was a heuristic, and it was wrong in both directions. A
reply the server streamed in flushes paid the latency on every flush:
that stretched D1's single statements, which could hold the sync barrier
longer and fail a check after D1's fix. Bytes queued behind a delayed
chunk, or behind a blackhole, went out with no latency at all.

Each pump is now a delay line. It reads continuously, stamps each chunk
when it is read, and writes it once the link's latency has passed since
then, so every byte waits the same latency and a stream keeps its rate. A
blackhole stops both reading and writing, and held chunks keep their
stamps. Discard drops the line. The line holds at most 1 MiB, so a slow
receiver pushes back as over TCP. Writes stay partial and cancel-safe. A
new test streams 100 pieces of 8 KiB 5 ms apart through a 150 ms link: it
must arrive one latency after it starts, at the sender's pace, not at
150 ms a piece.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the delay line carries the close, never crosses a cut, and wakes on a new latency

- The sender's close is a line entry with its own stamp. It is passed on
  only when due, and only while the link is open: it waits the latency like
  the data, and no FIN crosses a blackhole.
- A change of state or of latency is acted on before anything is read or
  written (a biased select, with both changes first). No chunk slips
  across a link that has just been cut. Latency travels on a watch
  channel, so lowering or lifting it re-dates what the line holds at once.
- A write that fails just as the link turns to Discard drains on, as a
  dead client's server must, instead of ending the pump.
- Without latency the line holds one chunk, as a plain relay would, so a
  stopped receiver pushes back at once. With latency it holds up to 1 MiB
  and 4096 chunks.
- The streaming test checks that the first byte waits the latency, not
  only that the whole stream keeps its pace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a reset crosses the relay as a reset, and the delay line keeps its dates

An open link now passes each side's close on as it came. A FIN stays a
FIN. A reset, or any socket error, becomes a reset that ends the
connection both ways. Before, a sender's reset reached the far side as an
orderly close.

The delay line, from the review of ea2559ec:
- Without latency it holds one read, as a plain relay does, so a stopped
  receiver pushes back at once.
- Reads less than 1 ms apart share an entry, so a stream of small reads
  is not capped by the entry limit.
- A new latency re-dates what the line holds, but not a chunk already
  partly written, so a message is never split across the change.
- A write that fails just as the link turns to Discard marks the
  connection, so it is reset even if the link opens again before the pump
  looks.
- Setting an unchanged latency wakes no pump.

New tests: a close waits the latency, a reset crosses as a reset (this
one fails without the fix), a close held in the line never crosses a
blackhole, and lifting the latency releases what waited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S2 measures how fast A withdraws on its own when its PostgreSQL dies

With its PostgreSQL killed, A's frontend is still up and answering, so the
balancer's fall count starts only once A stops answering ready. S2 used to
wait the 12 s mark-down bound from the kill. That bound covers a node whose
checks fail at once, not one that has to withdraw itself, so the scenario
ended with an error and checked nothing else.

For this variant S2 now waits up to 35 s, past D4's 15 s admission
staleness backstop and the balancer's fall, and then the rest of the
scenario runs. A new expectation names the owner: A's first failed
readiness check must come within 10 s of the kill (D4's writer probe gives
up after 2 s and a writer unanswered for 4 s withdraws), and the
mark-down within 12 s of that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a write a side refuses resets the whole connection

This is the local review's finding on 882e3eb6. When the relay's write
toward a side meets that side's reset first, the write uses up the
error. The pump that reads the same socket then sees only an end of file,
and passes on a FIN. If that pump's line was full, it sees nothing at
all. Nobody reads the other socket after the writing pump ends, so the
other side's writes block for good and the connection never ends.

A write refused outside Discard now ends the connection both ways with a
reset at once, whichever direction meets the reset first. The new test
covers the stuck case: the server reads nothing, so the line toward it is
full, and the client resets while the relay is writing to it. The server
must be reset too. Without the fix its writes block until the test's
10 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: time A's own withdrawal from its readiness; S8's lost block may stay in reconciliation

S2 with A's PostgreSQL killed, from the local review of 6f33ac8d.

A sampler now checks A's /readyz every 200 ms, from just before the kill,
exactly as the balancer's check does: same path, token and 1 s timeout,
through a new Balancer::node_check handle. The expectation uses the start
of A's final not-ready stretch. Before, it used A's first failed balancer
check, which carries up to a 2 s check interval and can come from an
earlier failure that a pass later reset.

The bound is 12 s, to leave headroom over D4's own cadence. D4's
unanswered-writer rule withdraws 6 to 10 s after the kill: the probe
gives up after 2 s, waits out a second's reuse, and is judged at a later
probe. Its stale-decision backstop comes 14 to 15 s after the kill.

The mark-down wait is 60 s. A not-ready publication arriving late can open
a fresh grace and readmit A, so the backstop can take two rounds. The
expectation is recorded whether or not the wait succeeds, so a withdrawal
too slow even for that is still measured. The balancer's follow is timed
from the final not-ready stretch.

S8, lost variant. The block's submitblock never reached the node, and
nobody recorded its offer's outcome. PRISM keeps such a candidate for
reconciliation and never offers it again, as the scenario allows. The
checker's candidates-settled check failed the run on exactly that row. A
scenario can now name candidates that may stay in reconciliation with no
landing (CheckOptions::unresolved_candidates). S8 names its lost block
only after confirming the block never reached the chain. The check lists
the row instead of failing on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8 waits out the dead frontend's candidate lease; its failures name the claim and B's prepared records

S8's accepted variants first ran on the stack at D3 78ca1a30. A's
restarted frontend had not landed its own block 60 s after its return:
the candidate was still offer_reserved. The dead frontend's claim holds
for PRISM's 120 s candidate lease from its last renewal. Only after that
does the restarted frontend recover the reservation as unknown, find the
block on the chain and land it. The lost variant's settle also took until
the lease ran out. The wait after A returns is now 180 s
(RECOVERED_LANDING_BOUND), and the timeline records how long the landing
took.

Diagnostics:
- A block not confirmed on a node now also shows its candidate's claim
  holder, the seconds left on the claim, and the seconds to the next
  attempt. A parked row's next attempt is 'infinity', and a query error is
  reported as such, not as "no candidate row".
- When B does not adopt, the expectation says how many of A's prepared
  records on the block's parent each node held when A died (D-11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8's lost block keeps the prepared row its offer wrote

The rerun on the stack showed that the lost block is not unlanded. Its
offer wrote its qbit_pool_blocks row in the prepared state, and that row
never confirms. The excuse from 9d86fe03 required no row at all, so it
never applied. The excuse now covers a named candidate in reconciliation
whose block never confirmed, whether it has no row or the prepared one. A
confirmed block is still handled as bookkeeping awaiting its next attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S9 waits for B's journal seed before it checks D-12

D-12 refuses a single-writer start on a database whose carry-owner journal
holds rows. Since D3 78ca1a30, a node seeds its journal only once its own
log is caught up (D-8) and its peer's journal has answered that it holds
none of the node's rows. Before, it seeded on its first start.

S9 probed B's database about a second into B's dual start, before B could
seed. The journal was empty, the single writer ran, and the check failed
without testing D-12. S9 now waits up to 60 s for B's own journal row, as
an expectation of its own (D-4), and only then makes the single-writer
attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S2 samples A's readiness until the heal; A must not be readmitted while its database is dead

This is the local review's finding on 9d86fe03. The sampler stopped once
the balancer marked A down, so a later readmission went unseen.

In the 09:06Z run on the stack, A's PostgreSQL stayed dead until the heal
at 49.1 s. Meanwhile the balancer marked A down at 37.4 s, marked it up
again at 40.4 s, and failed 29 miner sessions back to A. Their shares
were rejected as backend database unavailable until the heal.

The sampler now runs from just before the kill until just before the
heal. The expectation requires three things:
- A's final not-ready stretch begins within 12 s of the kill, and so lasts
  until the heal.
- The balancer marks A down within 12 s of the stretch the mark-down came
  in. Before, it was timed from A's first not-ready answer, which blamed
  the balancer for a flap.
- The balancer never marks A up again before the heal.
The detail lists A's marks since the kill. If the balancer never follows,
the expectation is still recorded at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8 buries A's accepted block 60 deep before it waits for B's adoption

D3 adopts a dead node's block only once the block is at least
ADOPT_AFTER_CONFIRMATIONS (60) below the tip, so its finder has time to
land and sync it first. Adoption passes run every 30 s. S8 waited 90 s
with A's block at the tip, so B could never adopt it, and B had no
candidate row for it at the end.

S8's accepted variants now mint 60 external blocks on C on top of A's
block, after the chain's nodes agree on the tip, and only then wait the
90 s adoption bound: two passes and a landing. The lost variant has no
block on the chain to bury.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: temporary stand-ins for D1's pending 031 index and singleton grant

The PR now targets the whole 3.1 stack (D1 cca56f14, D2 c25bb19b, D3
78ca1a30, D4), and that stack still lacks two things D1's pending push
delivers. Without them, no dual-writer pair can run:
- Migration 031's (origin_node, share_seq) index. D2's window cut refuses
  to run without it.
- SELECT on qbit_prism_cluster.singleton for the peer role. D1's engine
  reads the fingerprint WHERE singleton, which D1's documented column
  grant does not cover (status/D6.md, finding 4).

Until that push, the bootstrap applies both as TEMPORARY_STAND_INS, after
PEER_ROLE_GRANTS, which stays exactly D1's list. The index uses 031's own
name, as D4's readiness fixture does (e5db2eb1). Both are no-ops once the
stack carries them, and the list is then removed. This way the dual-writer
scenarios run in CI on the stack meanwhile, instead of failing for D1's
known reasons. The README says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S1, S2 kill -9, S4 and S5 join the fast lane; the nightly matrix runs every scenario the stack can

The PR is retargeted onto the whole 3.1 stack, so the scenarios that
waited for it now run.

- Fast lane (test/prism-dual-writer-gated-tests.txt): S1, S2 kill -9, S4
  link cut and S5 join the checker control and S10. All six pass locally
  on D3 78ca1a30 + D4.
- Nightly matrix (test/prism-dual-writer-nightly-gated-tests.txt), 22
  scenarios: the fast lane plus S2 PostgreSQL kill, freeze and network
  drop; S2 frozen mid-pull and puller death; S3; S4 transient apply; S6
  restore A and B and the unreadable peer, locked and refused; S8 lost and
  accepted, with and without the D-19 wait; S9; and S11. Some fail today
  for findings their owners hold (D1's barrier and P1s 1 to 3), which is
  what the matrix report is for.
- S7 stays planned. D-16's node-identity repersonalise is not in the stack,
  and both variants stop there.
- test/e2e-scenarios.toml marks each of these as running, with its tests.
- The nightly job's limit goes from 90 to 150 minutes. At about 3 minutes
  a scenario, 22 take about 70, and a failing scenario can wait out its
  bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8 times B's adoption from the block's burial, and says so

The adoption wait starts once A's block is 60 blocks deep, after the
mint, not when A died. Its detail said "after A died".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the candidate diagnostic shows an unclaimed row as unclaimed, and the lease as PRISM takes it over

These come from the local review of the S8 commits.
- An unclaimed row's claim_expires_at is NULL. The 'infinity' guard
  turned that into Infinity, so a row nobody holds read as claimed
  forever. NULL now stays NULL.
- Since migration 021, a restarted frontend takes a dead claim over only
  once it has watched the claim go unrenewed for the whole 120 s lease on
  its own clock, not when claim_expires_at passes. The diagnostic now calls
  the expiry the database's view, and RECOVERED_LANDING_BOUND's comment
  describes the takeover as PRISM does it. The 180 s bound still covers
  it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S9 digests the divergence report it claims; the singleton stand-in goes by hand; S6 and S8 criteria say what they check

These come from the local review of the stand-ins and the lane change.
- S9's criteria and ledger_digest's doc said the cutover leaves the
  divergence report unchanged, but the digest never read it. It now
  includes qbit_prism_payout_divergence_report(), which holds only counts
  and sums.
- D1's fix for the peer role's singleton read drops the WHERE singleton
  predicate rather than granting the column. So the singleton stand-in
  never becomes a no-op, and it would hide that fault coming back. The
  comment and the README now say it goes with the list, by hand, at the
  rebase onto D1's push.
- S6 unreadable allows 120 s to ready and then 60 s for the first share.
  The criteria now say so.
- S8's criteria now describe the lost variant (never on the chain, never
  paid, the candidate kept for reconciliation) apart from the accepted ones
  (adopted once 60 deep, one landing per node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8 checks the lost block is never offered again; S6 and S8 docs match the tests

These come from the local review of 1303a261.
- S8's lost-variant criteria said A never offers the block again, but
  nothing checked it. A's RPC gate stays in front of its qbitd through A's
  restart, so at the end of the scenario the gate must have seen exactly
  one submitblock of the lost block: the call it held and discarded as A
  died. A resubmission would land as a stale sibling outside the census,
  and S8 would have passed it. Adds RpcGate::submissions_of.
- The README and S6's doc comment now give S6's timing as the test runs it
  (ready within 120 s, then a share within 60 s). The README now says B's
  adoption is required with the D-19 wait and only measured without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: drop the temporary stand-ins; D1's push carries 031 and the grant fix

D1's 193e7fd8 delivers migration 031's (origin_node, share_seq) index. It
also reads the peer's fingerprint with only the sync role's documented
column grant (e3fb8cc5). Once the stack sits on that push, the bootstrap
applies exactly D1's peer role grants and nothing else, as it did before
the stand-ins. The singleton grant goes too, as D1's fix drops the read
that needed it, so a regression to that read fails here again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S7 waits, as D1's runbook says, for the copied frontend rows before it repersonalises

The first S7 run against D1's node-identity repersonalise (193e7fd8)
failed: the command refused with "a frontend may be running on this
database: dual-sim-a (running, heartbeat 0.690 seconds old)".

The rebuilt node is a physical copy of its peer, so it carries the peer's
frontend instance rows. D1's runbook (docs/prism-ledger-ops.md,
"Rebuilding a node from its peer") says those rows stop counting 60 s
after the copy stopped following the peer, and until then the command
refuses without changing anything.

S7 now retries the command every 5 s, for up to 90 s, while that is the
refusal. Any other refusal fails at once, and the expectation reports how
long the wait took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S7 joins the nightly matrix; all 24 scenarios run on the stack

D1's 193e7fd8 delivers node-identity repersonalise (D-16). With the
runbook's wait for the copied frontend rows (942038e4), both S7 variants
pass on a local tree of D4 c22bd1f7 merged with D1 193e7fd8, with no
stand-ins:
- repersonalised after 60 s;
- only the dead node's unsynced tail lost (1 share);
- every invariant held, including D-2's reset after the rebuild.

S7 is now marked running in test/e2e-scenarios.toml, and its two tests are
in the nightly list, which now holds all 24 scenarios. The nightly job's
comment is updated; its 150 minute limit stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S7's runbook wait is on the timeline and in the README's list of retries

This comes from the local review of 942038e4. The README's "Bounds and
retries" said that no scenario retries the behaviour under test, and it
listed every retry. S7 now retries node-identity repersonalise for up to
90 s, for the one refusal D1's runbook says to wait out. The README now
names that wait as the single exception, and the timeline records how long
the wait took and how it ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: free_port never hands out the same port twice in one process

The full local matrix lost one scenario to "bind primary Stratum
listener: Address already in use". free_port binds port 0, reads the port
and drops the listener, and the port's user binds it later. Nothing stops
the next free_port from returning the same port in the meantime, so two
parts of one scenario could be given the same port.

free_port now records every port it hands out and draws again, up to 64
times, when the kernel offers one it already gave out. That rules out a
collision within one scenario. A collision with another process still
fails the start loudly; CI runs one scenario at a time on its runner. Adds
a unit test: 200 ports in a row are all distinct.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: relays, the balancer and the RPC gates take their ports through the same registry

This comes from the local review of 403e76ce. The registry covered only
free_port's own draws. Relay::open, the balancer and the RPC gate bound
127.0.0.1:0 themselves and kept the listener. One of them, opened after a
draw but before the port's user bound it, could still get that port. For
example, Sim::start draws A's Stratum port, then opens the public relays
before any frontend binds. The reviewer replayed Sim::start's bind order
and found this path at least as likely as the one 403e76ce closed.

All three now bind through fresh_listener(). It binds port 0, keeps the
listener only if the port is new to the registry, otherwise binds again,
and hands back a non-blocking std listener for tokio's from_std. The unit
test now checks that 200 handed-out ports and 50 kept listeners never
share a port.

The README also stated S7's retry too strongly. repersonalise joins every
refusal into one message, so a refusal that names the copied frontend rows
beside other reasons is retried until those rows age out, then fails on
the rest. The text now says that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: a failed bind of a relay, the balancer or a gate names what it was binding again

This comes from the local review of 11fc9378. In
from_std(fresh_listener()?).with_context(..), the ? returned
fresh_listener's error before the context was added. An EMFILE, or 64
draws without a new port, then reported no "binding relay public-stratum-a"
or "binding the balancer". The context now wraps both calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S1's lag percentiles count shares that never arrived; the share fingerprint keeps origin_node

These are Codex's two P1s on bfc581f6.

1. S1's lag check dropped unresolved samples from its percentiles. One
   share that synced quickly, among many that took tens of seconds, could
   pass the 2 s p95 bound.
   - lag_report now ranks every sample, an unresolved one above every
     resolved lag. A percentile that falls on one is None, which fails the
     check.
   - So that this is fair, the sampler stops taking samples when told to,
     but keeps polling the pending ones for LAG_DRAIN_MS (3 s). Every
     sample has then had more than the bound to arrive.
   - The report counts the unresolved samples, and S1's detail names them.
   - The unit test checks that one quick sample among nineteen that never
     arrived does not pass.
2. inv4-no-double-credit compared each header's row across nodes without
   origin_node. A sync that copied a share with the wrong provenance would
   have passed. origin_node is now part of the fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the share check compares every column across nodes and each header with its share's own hash

These are Codex's two P1s on 8c7ac95f. Both are gaps in
inv4-no-double-credit.

1. A header-to-share mapping was only required to point at some accepted
   ledger row. A sync that swapped two valid mappings would have passed.
   Each mapping's header must now equal its share id's hash
   (lower(right(share_id, 64)), the server's own mapping), or the check
   fails with "header mapped to another share".
2. The cross-node comparison used a hand-picked list of columns, which
   left out payout_order_key, template_height, credit_policy and others.
   The ledger is append-only and copied whole (D1's table inventory leaves
   no column out). So each header's row is now compared on both nodes as
   to_jsonb, every column, read under SET LOCAL TIME ZONE 'UTC' so that
   timestamps render alike. A mismatch names each differing column with
   both values. A unit test covers that naming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: acknowledged shares are found by their full id, and the pair converges by content, not counts

These are Codex's two P1s on e21a428e.

1. inv4-acked-shares-present looked each acknowledged share up by its
   header only. A committed row under the same header but with another
   worker prefix would have passed for it. The check now binds the exact
   share ids the miners were answered for, which the load client builds as
   the server does ({username}:{header hash}). Each must be an accepted
   ledger row with its mapping.
2. wait_converged, which settle uses for the dual-writer pair, compared
   the share and block counts. A sync that dropped one A row and a
   different B row would have left the counts equal and the ledgers
   different. Each reading is now a count plus an md5 digest of every
   share mapping (header=share_id) and every block hash, ordered under the
   C collation so that both clusters sort alike. The pair converges only
   when the two digests match and still match a second later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: landings compare in every column the sync carries, and a peer block counts as arrived only whole

These are Codex's two P1s on ac54ef80.

1. inv3-landing-rows compared a hand-picked set of landing facts across
   nodes. A block copied with the wrong origin_node, or with another
   carried column wrong (sync_seq, found_at, the solver), would have
   passed. Each node's landing is now one document holding every row of
   it: the block, bundle, payout entries, carry rows, fanout set and
   artifacts. Each row has exactly the columns D1's peer sync carries of
   its table, read from that node's catalog with D1's CarriedColumns. The
   snapshot is compared by its content-determined facts. Timestamps are
   read in UTC. A block both nodes hold must have the same document on
   both, and a difference names each differing column by its path, with
   both values. Only the columns the sync leaves to each node are left
   out: chain and maturity state, publication order and claims.
2. The D-10 sampler of S1 and S5 settled a peer block once its audit
   bundle and snapshot were there. Payout, carry or fanout rows committed
   in a later transaction would have gone unseen. A block now counts as
   arrived only when its whole landing is there in the same statement:
   the bundle, the snapshot, a payout entry per manifest account, a carry
   row per miner account, and the fanout set and artifacts the bundle
   names. That is the condition inv3-landing-rows holds a landed block to.
   A block seen with part of its landing missing is torn and fails the
   scenario.

S8's adoption is the one path on which both nodes could land one block
themselves. B adopts A's block while A is down, and A's restarted
frontend could land it too. A cannot do so before it has waited out the
120 s candidate lease, and by then it has pulled B's adoption, so it
skips its own. If it ever did land its own, the pair would hold two
landings of one block, and the checker now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8's accepted block must be on the chain, and a missing share counts once

These are Codex's P1 and P2 on efc90dff.

1. S8's accepted variants never required the block to reach the chain.
   The RPC gate records a submitblock as answered once its forward ends,
   also when the forward failed. Without the D-19 wait, the adoption
   expectation passes either way, and every landing check sits under
   `if on_chain`. So the no-wait variant could pass without its premise:
   that the node accepted the block before A died. Both variants now
   expect the block on the chain. Only the adoption's timing stays
   ungated without the wait. The gate's docs now say what "answered"
   records.
2. inv4-acked-shares-present counted a missing acknowledged share once
   per database that lacked it. S7's unsynced tail is missing from both
   the survivor and the rebuilt copy, so the report's excused tail
   doubled. A lost share missing from both nodes was likewise reported
   twice, and the report counts those problems as shares lost. A pure
   tally_missing now sorts the missing ids into excused shares (by
   reason) and lost shares (with the nodes that lack each). Each share
   counts once and gets one problem naming its nodes. A unit test covers
   a share missing from both databases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S8 asks the network whether it knows the block, and only a "not found" means no

S8 read "the block reached the chain" as any successful getblockheader
on C, and anything else as "not there". A failed call to C (C
unreachable, a timeout) therefore passed the lost variant's "the lost
block never reached the chain" without proving it.

The chain node now has knows_block. It returns false only on the node's
own "block not found" answer (RPC error -5, as qbitd and the server's own
RPC code use it), and an error for any other failure. S8 propagates that
error, so an unreachable C fails the scenario instead of deciding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: D-2 checks deferred shares, and S9's cutover digest covers every carried column

These are Codex's two P1s on 68a87ade.

1. d2-local-state-not-copied checked candidates and offer decisions, but
   never qbit_prism_deferred_shares. D1's table inventory keeps that
   table local and clears the peer's rows on re-personalise, because a
   copied deferred solving share could be credited twice. A deferred
   share's foreign key ties it to a candidate in its own node's outbox,
   but a copied candidate with fresh timestamps and no claim would slip
   past the candidate checks. So the check now also requires that no
   deferred share whose candidate dates from the dual epoch is on both
   nodes, by block and share digest. The counts per node are in the
   check's data.
2. S9's before/after digest covered picked columns of some tables and
   none of qbit_ctv_fanout_sets. A cutover that corrupted a fanout set,
   or any unpicked column, identically on both promoted databases would
   have passed it, and every later cross-node check too. Each copied
   table is now digested whole over every column the peer sync carries,
   read from the database's catalog with D1's CarriedColumns: shares,
   share hashes, blocks, bundles, payout entries, carry rows, fanout sets
   and artifacts. Snapshots are digested by their content-determined
   facts. Timestamps are read in UTC. Only each node's own columns are
   left out (chain and maturity states, claims, schedules). The cutover
   never rewrites a carried column: origin_node and sync_seq are set
   only at insert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: copied state, scenario premises and fair report numbers, from a sweep of the families Codex keeps finding

Before pushing round 7, three read-only passes over the whole harness
looked for the classes Codex's rounds kept finding: never-copied and
copied state left unchecked, a variant's premise assumed rather than
measured, and report numbers that misstate an outcome. All harness-only.

Copied and never-copied state (D-1, D-2, D-11, D-16, D-17):
- copied-state-identical, a new check. For each origin node against its
  peer: the carry-owner journal is identical. Every unexpired prepared
  record the origin wrote, up to the peer's cursor, is on the peer, equal
  in every carried column but expires_at (D1's own comparison). Every
  unexpired copy is still on its origin. The templates and balance
  snapshots they reference are byte-identical. The check retries for up
  to 20 s for rows in flight.
- D-2 covers issued jobs: an issued job of the peer's, by origin or
  instance, since the epoch, is a copy that resume_job could replay.
- S7 checks re-personalise's work before the rebuilt node starts, from
  D1's TABLE_INVENTORY:
  - every cleared table is empty, and no Rows or Reset predicate matches;
  - the identity is the node's own, and the lineage is unverified;
  - every cursor follows the peer from at or below its highest key.
- S6's "held every own row before it was ready" now includes unexpired
  prepared records and journal rows.
- inv3-audits-verify reports a stopped frontend, unless its node is
  listed as away.
- The checker control runs the D-2 check on A's database in both slots,
  where it must name a candidate that is the same row in both outboxes.

Premises measured, not assumed:
- S2 and S10: every session that mined before the fault has a share
  accepted on the survivor's work within 30 s, read before the heal. Before,
  one share sufficed. S10's failback must route every session back to A.
- S2 (kill, freeze, network drop): A holds none of B's blocks before the
  heal. A's tail is checked to be on B at the end, not hard-coded true.
- S4's transient apply needs at least two attempts, which shows the
  retry.
- S6: right after the restore, the node lacks the block it found after
  the backup and own shares the peer holds.
- S9's D-12 refusal must be D-12's own text.
- A frontend that reports a PRISM setting unread ("set but unread by
  native PRISM") fails the scenario. This covers S8's no-wait override
  and every other one.
- The checker control's expected failures must name their causes through
  both databases: each block, fanout, share and window missing on the
  other node.

Fair numbers:
- A scenario that panics or is stopped leaves a failed report, written
  when its run starts, instead of vanishing from the matrix.
- S3 and S4 are judged by the longest stretch with no accepted share over
  each whole window (report::longest_gap), not only the one across the
  fault.
- S7 excuses only tail shares answered within the sync lag of the death.
  Older ones fail as lost.
- The matrix summary shows "not checked" and "not measured" for a run
  that stopped before its checker or before any fault.
- Unplaced offers are reported. The puller-death p95 counts refusals and
  unanswered shares, and S10's failback window ends where the load
  stopped.

Not changed: the checker control's owner-balances failure stays
conditional on B's block leaving a carry. Forcing one would need a payout
floor that template building rejects.

Unit tests cover the longest gap, per-session failover and the tail age
bound. Python tests cover the summary's rows for runs that stopped early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: the D-2 control counts the copies it finds on both nodes, past the capped problem list

The checker control runs the D-2 check on A's database in both slots, and
expected a "same row in both outboxes" problem among those listed. The
check failed as it must, with 245 problems. But it lists at most 40, and
its cross-node findings come after every node's own: the candidates A's
frontend claimed, and A's issued jobs, about 240 of them. So the expected
words were never listed, and the control failed on a correct check.

The D-2 check now counts its cross-node findings in its data
(same_rows_on_both_nodes: candidates, offer decisions, deferred shares).
The control requires each way of finding a copy to fire: a candidate
claimed by the peer's frontend and an issued job of the peer's (both
listed first), and at least one candidate that is the same row on both
nodes (counted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: copied-state-identical bounds the prepared records it requires by what the origin had written, not by the peer's cursor

copied-state-identical required on the peer only the origin's prepared
records at or below the peer's cursor over that stream. A stream that
stopped would then pass. When apply_prepared fails, the engine retries
every pass and leaves the cursor where it is, so nothing would be
compared, and the fast lane would stay green while the peer could adopt
nothing. Only S8 would notice.

The bound is now the origin's highest prepared sync_seq when the check
begins. Every unexpired record up to it must reach the peer within the
check's 20 s retry, which already absorbs rows still in flight, whatever
the cursor says. The cursor stays in the check's data for diagnosis,
beside the new highest_at_start.

From the local review of 99d45284.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S9 keeps the prepared state whole through the cutover, and copied-state-identical fixes what it requires when it begins

These are Codex's P1 and P2 on b91dde24.

1. S9's cutover digest left out four copied tables: prepared records,
   their templates and balance snapshots, and the carry-owner journal. A
   promotion or bootstrap that lost or damaged them identically on both
   databases would have passed, and new work would then replace the lost
   records. A digest across the whole cutover cannot see this, because
   prepared records live about as long as the jobs that use them, which is
   shorter than a cutover. Instead, cutover_to_dual reads the four tables,
   whole and in UTC, at two points:
   - on A right after the drain;
   - on each node after the promotion and the bootstrap, before either
     frontend starts.
   Nothing writes or prunes these tables between those reads. S9 requires
   every row from the drain to survive identically on both nodes, and
   prepared records to exist at all, so the check cannot pass empty.
2. copied-state-identical recomputed the origin's unexpired prepared
   records on every retry. A missing record that drifted into the 5 s
   expiry cushion during the 20 s retry dropped out, and the check passed.
   Each origin's targets are now read once, when the check begins:
   - its unexpired prepared records up to its highest sync_seq;
   - its journal rows;
   - the md5s of the blobs those records reference.
   The retries then compare only the peer's copies against that fixed set.
   Copies are kept 24 h past expiry, so a record that ages meanwhile stays
   required. The rows the origin lost while its peer kept them are judged
   once, at the start: unexpired copies, journal rows, and blobs its
   records reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: copied-state-identical reads the peer before the origin when it looks for lost rows

copied-state-identical judges once, at the start, which rows the origin
lost while its peer kept them. It read the origin first and the peer
after. A prepared record or journal row could be written on the origin
just after that first read and pulled by the peer before the second.
That row then looked lost on the origin, and the check failed for good on
a correct pair: a rare flake, since the load-paused frontends still write
a prepared record about every second and the peer pulls every 250 ms.

The peer's unexpired copies and journal rows are now read first. Anything
the peer holds, the origin wrote before, so the origin's later read holds
it too, unless it really was lost there. An unexpired copy's original
cannot be pruned in between, since its expiry is the later one.

From the local review of ed500cb1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 dual-writer E2E: S9's prepared state must be exactly what the drain left, nothing added

This is Codex's P1 on 1e5403e0. S9 compared the prepared state on each
node after the promotion and the bootstrap with A's right after the
drain, but only the rows from before. A promotion or bootstrap that
inserted a prepared record, a blob or a journal row would have passed,
and so would every later cross-node check once both databases held it.

Neither step writes these tables. node-identity set changes only column
defaults and seq…
… 031, and p0 is never rewritten (#773)

* 3.1 pre-migrate: rc.7 keeps serving while 3.1's migrate applies 027, 028 and 031, and p0 is never rewritten

The 3.0 -> 3.1 cutover can run 3.1's migrate against A's database before
its window, while the 3.0 pair serves on 3.0.0-rc.7. Two gated dual-sim
tests hold that to what the cutover relies on, in the fast lane and the
nightly matrix:

- R1: the 3.0 pair on rc.7 under load (database made by rc.7's migrate,
  every start with PRISM_POSTGRES_INIT_SCHEMA=0). 3.1's migrate runs once
  with A's frontend environment while a reader holds a snapshot, so 031's
  CREATE INDEX CONCURRENTLY is caught part-way. 027 and 028 commit with no
  share refused and none waiting the 5 s lock timeout, appends continue, a
  block lands, A restarts and serves with 31 unrecorded and the index
  invalid, then 31 is recorded, the index is valid on the writer and the
  standby, B restarts at 31, a last block lands, and the checker's
  invariants hold.
- p0: on a populated ledger with an appender under the frontends' lock
  timeout, p0 and its TOAST table keep relfilenode, relfrozenxid and
  relminmxid and their vacuum and analyze counters, the statement log holds
  no VACUUM, ANALYZE, CLUSTER or REINDEX, and p0's only index build is
  CREATE INDEX CONCURRENTLY (qbit-tools #1345).

A by-hand measurement runs the second at millions of rows. The gate gains
PRISM_TEST_RC7_SERVER_BIN, and the prism-rc7-server action builds the
pinned rc.7 commit (#772's head b7b6923) once per pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 pre-migrate tests: catch every index build on p0, and anchor R1's restarts

From a local review of #773:
- An index build on p0 is any CREATE INDEX on it, named or not, or on the
  ledger without ONLY (which recurses into every partition); the scratch
  apply's statements, under the migrator's rolled-back savepoint, are left
  out, and maintenance commands are counted everywhere.
- p0's TOAST table's vacuum and analyze counters are compared too
  (pg_stat_all_tables), and log timestamps are compared by date and time,
  so a run across midnight measures right.
- The seed is refused unless every row stays in p0.
- R1's restarts wait for the balancer to mark the node down before starting
  it, require a graceful stop, and count shares from when the balancer had
  the node up again; the one-off migrate and the appender are aborted, and
  the migrate killed, when a step fails first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.1 pre-migrate tests: the pair serves rc.6, not rc.7

rc.7 was skipped: the pair serves v3.0.0-rc.6 (81c592c) until the 3.1
repin, so the pre-migrate runs beside rc.6 frontends, and R1 and the p0
test run rc.6 (PRISM_TEST_RC6_SERVER_BIN, the prism-rc6-server action at the
v3.0.0-rc.6 commit). rc.6's start gate, require_schema_version, its
NATIVE_CAPABILITIES and its connect-time capability checks are identical to
rc.7's (4c13491), as are the migration files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 3.x.x: version 3.1.0-rc.1

VERSION, the workspace version and both lockfiles move to 3.1.0-rc.1.
The fuzz lockfile also picks up hyper and hyper-util, which #769 added to
the server without refreshing it. The 3.0.0 and development-line notes
point at the new candidate; its own notes follow in this pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0-rc.1 release notes

The notes cover everything since v3.0.0-rc.6: #770's import-audits fix
(#765) and #760's idle-connection bound (#759), which would have shipped as
rc.7, and the 3.1 dual writer (#766, #768, #767, #769, #763), off by
default. They describe the new settings, migrations 027, 028 and 031 (029
and 030 reserved), the upgrade from rc.6 with the migrate run while rc.6
serves, the rollbacks, and the dual writer's known limits. The migration
guide's import-audits paragraph names this candidate in place of the
skipped rc.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0-rc.1 notes: #773's pre-migrate tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0-rc.1 notes: the rollback to one writer stops and syncs the peer first

Codex on #774: DOWNGRADE fences nothing, so the peer must be stopped, and its
tail pulled, before the single-writer start; declare the tail lost only for a
peer that never returns with its own database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…tls for six RustSec advisories (#777)

cargo deny fails on RUSTSEC-2026-0382 (rustls-webpki < 0.103.16) and
RUSTSEC-2026-0381 (webpki-roots < 1.0.10), and the fuzz lockfile still
has rustls 0.23.45 (RUSTSEC-2026-0346, -0347, -0348 and -0349); the root
lockfile already has 0.23.46. Move each to its first fixed patch release
with cargo update --precise, as #354 did. No other dependency changes.
The same fix for 3.0.0 is in #776.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 3.x.x: version 3.1.0, and tokio 1.53.3 for RUSTSEC-2026-0386

VERSION, the workspace version and both lockfiles' workspace members move
to 3.1.0. tokio moves from 1.53.1 to 1.53.3, its first fixed patch
release for RUSTSEC-2026-0386, in the root and fuzz lockfiles, as main
took it in #781. cargo update -p tokio --precise 1.53.3 also moved the
windows-sys edges of four unrelated crates to 0.52.0, so, as in #781,
only tokio's version and checksum change. Nothing in the workspace uses
AsyncFd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0 release notes, and 3.x.x as the production line

doc/release-notes-3.1.0.md covers the changes since 3.1.0-rc.1 (#777's
TLS updates and the tokio update), the upgrade from rc.1 (a binary swap)
and from 3.0.0 or 3.0.0-rc.6 (#773's pre-migrate, the binary, then the
optional dual-writer cutover), and the known issues carried from rc.1
plus #779's L3 failures and the acknowledgement bound's connection drop
that main fixes in #781. The README banner, the development-line note
and the migration guide now say 3.x.x is the production line, deployed
from the v3.1.0 tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0 notes: review fixes

The migration guide and ledger operations cite the released 3.0.0 notes'
D5 boundary at v3.0.0, not this branch's pre-release copy. The
development-line note's header no longer calls the line "not a release".
The 3.1.0 notes say a dual-writer-off frontend serves as 3.0's does once
the migrations are applied, tell a 2.0.x upgrade that #765's guard is not
needed, and list the required prism-dual-writer lane and rc.1's evidence
set under Verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0 notes: second review round

The #779 known issue counts the async preset's failed repeats (four of
six) and names the sync preset rather than a repeat's job. The cutover
step names node-identity repersonalise and defers the cutover itself to
the pair's runbook, as rc.1's notes do. Verification says docs changed
since rc.1 too. The development-line note says 3.0.x is not an ancestor
of 3.1.0, and the migration guide no longer implies the 3.0.0
candidates were a supported production upgrade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3.x.x: 3.1.0 notes: rewrap two lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Merge 3.x.x at the v3.1.0 tag (59194d0) into main and take its tree, as
#778 did for 3.0.0. Changes kept on top of the tag:

- #781's disposition-poll change, which main took after 3.1.0's cutoff:
  each block-proof acknowledgement poll runs in its own task, so the
  bound never drops a connection partway through a checkout. Its two
  acquire tests, the pool-acquire metrics guide and the checkout census
  come with it. miner_submit.rs is identical in v3.0.0 and v3.1.0, so
  the change applies unchanged. tokio is 1.53.3 on both sides.
- docs/releasing.md, which only main has, records 3.1.0's promotion.
- The README banner names the version, not the branch.
- doc/release-notes-3.0.0.md is the released v3.0.0 text, in place of
  3.x.x's pre-release copy. The links that pointed at blob/v3.0.0/ for
  it (the 3.1.0 notes, the development-line note, the migration guide
  and ledger operations) point at the local file again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/releasing.md says #781 is on main because 3.1.0 changed no runtime
code since rc.1, not because it came after the cutoff; names only v2.0.0
as tagged on main; and requires the post-3.1.0 sync into 3.x.x, which
lacks #781's fix and the released 3.0.0 notes. The development-line
note names 3.x.x where it said this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 3.1.0 paragraph lists every doc edit the promotion keeps and says
3.1.0 took #781's tokio update but not its disposition-poll change. The
tag example no longer claims v3.0.0 and v3.1.0 used its message. The
sync step says neither 3.x.x nor 3.0.x has merged a released main yet
and lists what each lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 3.1.0 paragraph says the links to the 3.0.0 notes already pointed at
the v3.0.0 tag and now point at the local copy, that 3.1.0 made #781's
tokio update itself in #782, and that #779 records 3.0.0's L3 failures.
The banner names the version. 3.x.x is the development branch and
production line. The sync step says 3.x.x lacks only the local copy of
the released notes. Edited paragraphs are rewrapped.

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

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T20:19:31.176597Z aeed395 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aeed395f38

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/qbit-prism-server/src/ledger/peer_sync.rs

@qbit-reviewer qbit-reviewer Bot 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.

Incomplete review: only the available changes were reviewed.

No findings in the reviewed changes.

@RobertClarke

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: aeed395f38

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

4 participants