Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version - #2259
Fix multi-worker lost counter increments and stale reads when a resequenced write reuses a version#2259kriszyp wants to merge 14 commits into
Conversation
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to handle version-reuse cache staleness in RocksDB-backed tables. It prevents the Verification Table (VT) from vouching for reused versions (where a resequenced write keeps an existing version, meaning one version could identify multiple different stored values) by marking such records with a VERSION_REUSED metadata flag, parking a VERSION_UNVOUCHABLE sentinel in the VT, and introducing an uncachedRead option to bypass the cache vouch on critical commit paths. The changes also include comprehensive integration tests, unit tests, and a benchmark script to measure the sentinel probe overhead. There are no review comments provided, so I have no feedback to address.
d32a9fa to
ec607a1
Compare
There was a problem hiding this comment.
Reviewed ec607a1 and found no blocking issues. The version-reuse flag and snapshot-direct commit-base reads consistently address the stale-cache path. No confirmed blocking defects were found on changed lines.
—
Generated by Barber AI
heskew
left a comment
There was a problem hiding this comment.
Reviewed exact head ec607a1.
No blocking defects introduced by this PR. Codex independently traced the VERSION_REUSED contract through Harper encoding and rocksdb-js VerificationTable behavior, the uncached transaction-snapshot reload of state-deriving commit bases, same-key staged-write ordering, replay and copy-apply exclusions, and the published/pinned rocksdb-js v2.8.0 dependency. The paths are internally consistent and address the stale-read/lost-increment failure without exposing a new correctness or security issue.
Prior-review attribution: @gemini-code-assist reviewed an earlier head and reported no findings; @cb1kenobi independently found no blockers at this exact head; @claude likewise reported no blockers at this exact head. I agree after the independent pass above.
Validation at this head: npm ci and npm run build passed; the focused PrimaryRocksDatabase tests passed 13/13; changed-file oxlint and Prettier checks passed; the focused QA-431 four-worker convergence test finished 100/100 clean windows with zero stuck-short, over-counted, or inconclusive windows; and GitHub CI is green. A full local resource-suite process aborted late after hundreds of passing tests, but its isolated active suite passed and all GitHub unit-test matrices passed, so I do not attribute that local runner failure to this PR.
Branch state only: the branch currently conflicts with main in resources/RecordEncoder.ts and unitTests/resources/caching-rocks-database.test.js. It must be updated before merge, but this is not a correctness finding against the reviewed head.
🤖 Posted by Codex on behalf of @heskew
…sion a resequenced write reused A resequenced (out-of-order CRDT) write stores its merged record under the version it merged onto rather than advancing it — the version is the max applied update timestamp and must stay that way for cross-node convergence — so one version can identify two different stored values. The record cache's freshness oracle is exactly version equality (the rocksdb-js VerificationTable), so a worker still holding the pre-merge value is told it is fresh and serves it, and an addTo folding onto that stale base silently drops the increment the merge applied. That is the lost count QA-431(5) catches intermittently under multi-worker stress. Mark such a record VERSION_REUSED at the write that reuses the version (recordUpdater covers every record write), and park an unvouchable sentinel in the VerificationTable slot when a read encounters one, so no worker's cold read republishes that version and nothing caches the record until a later in-order write gives it a version of its own. Reproduced end-to-end on unpatched main (4-CPU-constrained amplified QA-431(5) traffic with concurrent GET pressure): windows durably stuck below their acked count across 20 polls; green with this change. The new unit tests fail on unpatched main at the invariant assertions. Builds on the abandoned branch fix/record-cache-stale-on-reused-version (worktree agent-harper-ttl-rate-limiter-lost-counts), validated here with a reproduced red/green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…d-version sentinel from the write The prior commit's read-side machinery was not enough: instrumented runs showed every lost increment came from a commit-path base read that the cross-worker version vouch confirmed as fresh. Two paths kept it alive: - The commit path (first attempt via the resource-phase read, and every coordinated-retry reload) read its base through the cache-vouch fast path. The vouch answers "is this the latest committed version?", which is the wrong question for a snapshot read — and for a version a resequenced write reused it is wrong outright, so an addTo or patch folded on the pre-merge value and overwrote the concurrent update it merged over. Incremental updates now always reload their base at commit through the committing transaction with a new uncachedRead option that bypasses the vouch, the VerificationTable seeding, and the cache entirely. - A reader-side sentinel park cannot close the read path: on a VT miss the native layer re-confirms freshness against the version stored in the record itself and republishes it — over the sentinel — so with every worker holding a warm cache no reader ever decodes the record to discover the VERSION_REUSED flag, and a stale holder is confirmed fresh forever (observed as GETs pinned below the acked count until expiry while the store held the correct value). The write now parks the sentinel itself on the transaction's success path — the writer knows before any reader can — and warm reads consult the sentinel before trusting version equality. Validated under the QA-431(5) reproduction (4-CPU-constrained, GET pressure during 4-worker bursts): unpatched main lost increments in 3-15 windows per 900; with this change 900/900 windows exact across three runs and zero stale vouches in the instrumented audit. The remaining exposure is the native soft-miss re-confirm racing the writer's park (instruction-scale); closing it fully needs rocksdb-js to honor a no-vouch flag, tracked as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…edicate, discriminating tests From the cross-model review round (Codex + Gemini + domain adjudication): - Full updates now reload their base at commit too: a put's existingEntry drives index diffing, blob retention, and residency, and a vouch-stale base with a reused version passes the optimistic check while diffing against the wrong old record (orphaned secondary-index rows; the previousResidency line also read the stale resource-phase closure entry and is now fed by the reloaded base). Bulk copy-apply rows and crash-recovery replays keep their pre-read base — one read per row, as before — since their convergence contract is the post-copy/replay pass. - parkUnvouchable now verifies the sentinel took and the transaction logs when a concurrent write's intent refused it, so the abort race the review identified is detectable instead of silent. - "This write stores under a reused version" is now derived once (versionIsReused in RecordEncoder) instead of by two expressions that agreed by coincidence. - The uncachedRead unit test now discriminates by object identity (the vouch path serves the cached object; a regression into it would have passed the old equal-values assertion), and the sentinel assertions use the exported constant. - The multi-worker convergence discriminator that reproduced the defect is committed as integrationTests/resources/ttl-rate-limiter-convergence.test.ts (10x10x50 with in-burst GET pressure; classifies converged-late vs stuck-short so a read-timing race is never mistaken for a lost write). - Warm-read probe cost measured (unitTests/resources/cache-probe.bench.js): 141ns/op, 525 vs 384 ns/op warm getEntry — noise per HTTP request, real in tight loops; the rocksdb-js no-vouch follow-up folds it into the existing native crossing. - Trimmed narrating comments flagged by the review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…tried parks, contained late throws - The reload predicate is now an explicit reloadCommitBase write-kind flag (the round-1 fullUpdate inference silently excluded deletes, invalidates and relocates): a delete tearing down index entries from a vouch-stale base left phantom index hits for a deleted record, and a tie-timestamp tombstone set the durable VERSION_REUSED flag with nothing marking it for a park — delete commits now mark storedReusedVersion like updates do. - A refused park (concurrent write intent) is retried once after the intent has had time to clear, and a still-refused park logs at warn with store and key: if the competing write aborted, nothing else re-parks, and warm peers would silently serve the pre-merge value until the key's next write. - parkReusedVersionSentinels and parkUnvouchable contain any native throw: they run after durability, and a closing-store throw was skipping clearWrites/releaseContext and leaking the context. - Convergence test: a window whose burst was wholly rejected counts as inconclusive instead of clean (the measurable-fraction assertion then catches a run that rejected most increments). - Warm-read probe cost re-measured against a pristine-main build rather than by subtraction: 373 ns/op → 525 ns/op warm getEntry (+152 ns, the probe). The rocksdb-js no-vouch follow-up folds it into the existing native crossing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…resolve parks, tolerant test oracle - _recordRelocate always stores at the unchanged version but writes outside the tracked-write flow, so its park now happens directly after the write; _writeInvalidate and _writeRelocate mark storedReusedVersion for the nodeId-won timestamp tie their version guard admits. The park now covers everything that sets the durable flag. - parkUnvouchableWithRetry centralizes the refusal handling: bounded backoff (10/50/250ms) while a concurrent write's intent holds the slot, and the final refusal is resolved against the stored head — a competitor that advanced the version resolved the key legitimately (debug), a still- flagged head is the silent stale-read hole (warn with store and key). - Convergence test: a stored count in (acked, acked+errs] is a timed-out request that was applied, not a double-apply; the over assertion now only trips beyond acked+errs. The remaining open review major is the warm-read probe cost (373→525 ns/op vs pristine main), consciously carried until the rocksdb-js no-vouch follow-up folds the check into the existing native crossing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLwSQZt7J2Ygn3B2EvNson
…inel The record metadata word Harper writes at value offset 8 is the same header word the VerificationTable reads, so marking a reused version there (VERSION_NOT_UNIQUE_FLAG) is what stops the native layer vouching for it — removing the per-read sentinel probe and the writer-side slot parking, and with them the warm-read regression they cost. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…lag bit - only a base that feeds stored state (or a conflict-retry reload) gives up the cache vouch; publish/message/sourcedFrom cold reads keep it - stamp created time from the reloaded base, matching the residency sibling - fail at load if rocksdb-js moves VERSION_NOT_UNIQUE_FLAG onto a Harper flag or into the tag byte, since the bit is persisted in every resequenced record - wrap the warm-read bench in describe() so mocha cannot exit mid-suite on it Co-Authored-By: Claude Opus <noreply@anthropic.com>
…he sync base read Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
rocksdb-js 2.8.0 pins msgpackr exactly at 2.0.6, so Harper's 2.0.5 top-level pin forced a second copy under node_modules/@harperfast/rocksdb-js — two msgpackr instances in one process, each with its own structure cache and extension registry, and a duplicate msgpackr-extract native binding. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…base bypass, name the required rocksdb-js version in the flag-drift throw - resources/DatabaseTransaction.ts: uncachedRead ignored isReplay even though reloadsCommitBase (the gate above it) excludes it — a replay's cold read (operation.entry === undefined) took the uncached bypass anyway, skipping the cache-warming path recovery relies on. Now consistent with the comment above it. - resources/RecordEncoder.ts: the VERSION_NOT_UNIQUE_FLAG shape-mismatch throw named the bad value but not the fix; a build resolving rocksdb-js <2.8.0 now gets a message that says what's required. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ix a test-id collision, retarget caching.test.js's VERSION_NOT_UNIQUE_FLAG import The rebase onto main folded PR #2065's redundant VERSION_NOT_UNIQUE_FLAG/inline recordUpdater block into this branch's generic VERSION_REUSED mechanism (both already resolved to the same rocksdb-js constant), but three spots needed a manual follow-up the per-commit auto-merge couldn't catch: - unitTests/resources/caching-rocks-database.test.js ended up requiring VERSION_REUSED from RecordEncoder.ts twice (once from this branch's own history, once reintroduced when a later commit's independent edit auto-merged around it). - The new expiresAt-preservation test (adapted from #2065's now-dropped duplicate) reused id 10, which collides with the pre-existing "Third read hits VT fast path" test. - unitTests/resources/caching.test.js (#2065's own, untouched by this branch) still imported and asserted on VERSION_NOT_UNIQUE_FLAG, an export this consolidation removed from RecordEncoder.ts — the import would have resolved to undefined, silently turning `metadataFlags & VERSION_NOT_UNIQUE_FLAG` into `metadataFlags & undefined` (always 0), weakening rather than failing the two assertions that depend on it. Also updates DESIGN.md's getFromSource() prose to name the surviving VERSION_REUSED export instead of the dropped alias, and to state explicitly that the flag applies generically to every reused-version RocksDB write, not just this source-fill path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ec607a1 to
f9037dd
Compare
…malization, correct the snapshot-free conflict-guarantee overclaim
RecordEncoder.ts: the consolidation moved `metadataInNextEncoding = assignMetadata` ahead of the
VERSION_REUSED OR instead of after it, as main's replaced line had it. With the documented
`assignMetadata = -1` ("no metadata word") default, `-1 | VERSION_REUSED` stays -1, silently
dropping the metadata word (and the flag with it) for a resequenced write that reaches
recordUpdater() through that default — latent in-repo (every Table.ts caller passes 0) but live
via the exported function's direct callers (unitTests/apiTests/computedDurableEncoding-test.mjs).
Restores the dropped Math.max(assignMetadata, 0) normalization, scoped to the flag assignment.
DatabaseTransaction.ts: the reload's guiding comment claimed a write landing between the reload and
commit "still surfaces as a conflict and retries" unconditionally. Verified against rocksdb-js's
binding (transaction_handle.cpp gates every SetSnapshot() call on !disableSnapshot) that this does
not hold for a snapshot-free transaction (this.snapshotFree, set after a mid-scope-commit
rotation) — there is no snapshot for RocksDB's optimistic conflict validation to check the Put
against. Corrected the comment instead of overclaiming; the persisted VERSION_REUSED flag and
native VerificationTable refusal (the primary defense) do not depend on this reload. Also trimmed
the two comment blocks that only narrated adjacent code, per the same round's nit finding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxP3CYDYdCZZQmR5MmTR4E
…on readiness-loop timeout, assert a client-level read sees the merged value - DatabaseTransaction.ts: drop the "see PR discussion" pointer — codex correctly flagged it as a reference that doesn't survive outside this review round; state the snapshot-free gap directly instead. - ttl-rate-limiter-convergence.test.ts: the readiness loop fell through silently if the fixture never left 503 within its 30s deadline, so a broken fixture would run the real tests against a dead server and surface as confusing fetch errors instead of a clear setup failure (Gemini). - caching-rocks-database.test.js: "VT does not vouch..." asserted only internal native-layer state (verifyVersion, metadataFlags); added a client-level TestTable.get(9) to prove a caller actually sees the merged value rather than a stale cache vouch, which is the user-visible symptom this PR fixes (Gemini). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxP3CYDYdCZZQmR5MmTR4E
Fixes the intermittent
QA-431(5): N window(s) with lost counts under multi-worker stressfailure inintegrationTests/resources/ttl-rate-limiter-concurrent.test.ts— investigated and reproduced as a genuine product defect, not a test timing assumption.Root cause
A resequenced (out-of-order CRDT) write stores its merged record under the version it merged onto rather than advancing it — the version is the max applied update timestamp and must stay that way for cross-node convergence — so one version can identify two different stored values. Every freshness decision keyed on version equality is then wrong:
getEntry(key, {transaction})). The vouch answers "is this the latest committed version?" — the wrong question for a snapshot read, and wrong outright for a reused version — so anaddTo/patch folded onto the stale pre-merge value and durably overwrote the concurrent increment it had merged over. A patch stores a full record derived from its base, so a stale base also silently resurrects old values of fields the patch didn't mention; a put's stale base corrupts index diffing, blob retention, and residency the same way.Fix
Harper already writes
[8-byte BE version][4-byte BE metadata word]at the front of every primary record, and that metadata word is the header word rocksdb-js's VerificationTable reads at value offset 8 — itsACTION_32_BITtag byte (14) is exactly theVERSION_HEADER_TAGthe native predicate requires. So the whole cross-worker half of the fix is one durable bit:VERSION_REUSEDis rocksdb-js'sconstants.VERSION_NOT_UNIQUE_FLAG, set inrecordUpdaterwhenever a RocksDB record write does not advance past the version it replaces. Every record write funnels through that one place. rocksdb-js 2.8.0 then never answersFRESHfor such a value and never publishes it to a slot, on every read path — sync, async, and both transactional ones (HarperFast/rocksdb-js#766).uncachedReadoption onPrimaryRocksDatabase.getEntry: a plain transaction-snapshot read — no vouch trust, no VT seeding, no cache publish. Every record-write kind that derives stored state from its base (update, put, delete, invalidate, relocate — marked with an explicitreloadCommitBaseflag) reloads that base at commit through it; bulk copy-apply rows and crash-recovery replays keep their pre-read base (one read per row, as before — their convergence contract is the post-copy/replay pass), and a cold read for a kind that keeps its own conflict semantics (publish/message/sourcedFrom resolve, and a replay's cold read) still takes the vouch fast path. BothpreviousResidencyand created-time retention are derived from the reloaded base. Without the delete-path reload, a delete diffing indices from a stale base left phantom index entries for the deleted record.Because the bit lands in a persisted record, a module-load guard fails fast if a future rocksdb-js moves
VERSION_NOT_UNIQUE_FLAGonto one of Harper's own metadata flags, into the tag byte, or removes it — a silently-unset flag is a silent return of the data-loss bug; the message names the minimum rocksdb-js version it requires (>= 2.8.0).This replaces the earlier JS-only compensation (a
VERSION_UNVOUCHABLEsentinel parked in the VT slot by the writer, plus averifyVersionprobe on every warm read). That machinery — the writer-side park with its backoff/verify/resolve ladder, the reader-side sentinel checks, and the residual re-vouch race it could not close — is entirely gone.Consolidated with #2065
mainindependently landed an overlapping fix for the same bug (PR #2065, merged 2026-08-29, same day this PR was opened): it set the identicalVERSION_NOT_UNIQUE_FLAGbit, with the identical guard predicate, in the samerecordUpdater()function, for its owngetFromSourcesource-fill write path. This branch's generic in-recordUpdaterfix already covers that call site (every write, including source-fill, funnels through the one patched function), so the rebase consolidated onto this PR's implementation as canonical — broader (every write, not just source-fill), sourced from rocksdb-js's ownconstantsexport instead of a hardcoded local value, guarded against the constant drifting out of Harper's reserved bit range, and kept out ofassignMetadata/the auditextendedTypebits thatassignMetadataalso feeds. #2065's redundant inline block and its two duplicate-intent tests were folded into this PR'sVERSION_REUSEDmechanism rather than dropped; #2065's disjointTable.tssource-version capping/tie-breaking changes are unrelated to the flag and carried through untouched.Verification
tasksetto 4 CPUs) lost increments in 3–15 windows per 900, with a per-vouch audit proving the losses durable. The bit is load-bearing on 2.8.0: withmetadataInNextEncoding |= VERSION_REUSEDdisabled and everything else in place,unitTests/resources/caching-rocks-database.test.jsfails atVT must not vouch for a version shared by two stored values— the native layer vouches the reused version. Both new unit assertions (flag set, refusal to vouch) fail without it, and a client-level read proves a caller sees the merged value rather than a stale vouch.ttl-rate-limiter-convergence.test.ts— 100/100 windows exact, zero converged-late, zero stuck-short, zero over-count, run four separate times across this rebase (twice pre- and twice post- the round-12/13 fixes below); the unmodifiedttl-rate-limiter-concurrent.test.ts5/5 including QA-431(5).main@5cd0e2c12,origin/mainunmoved since):npm run buildclean;npm run test:unit:resources— 2020 passing / 0 failing / 29 pending (2 timeout-based failures in an unrelatedbranchDatabase.test.jssuite were box-load contention from other agents' concurrent runs on this shared machine, not this change — confirmed by an isolated re-run: 70/70 passing);unitTests/resources/caching-rocks-database.test.js+caching.test.js47/47 passing.For the human reviewer
reloadCommitBasewrite still pays a second point read + decode of its base at commit time, on both storage engines — LMDB pays it despite having no VerificationTable defect to dodge. Now that the native layer refuses to vouch for a reused version, independent review (codex, twice across rounds) argues the reload is redundant belt-and-braces — the cheaper shape proposed is to make the existing resource-phase pre-read snapshot-direct and reuse it, one read instead of two. I kept the reload because the pre-read's base can also arrive fromthis.#entry, which may have been populated by an applicationget()well outside the committing transaction, and narrowing that correctly touches three write paths; reversing it later is one predicate change plus a re-run of the convergence suite to prove the flag alone holds.Unit Test (Node.js v22)hit its 10-minute job timeout twice (push and a manual re-run), stalled insideinstall_node_modules's realnpm installsubprocess tests. More tellingly, all threeNext.js adapter integrationjobs (Node 20/22/24) independently hit their 30-minute timeout stalled on "Install fixture dependencies" — a plain dependency install in a downstream fixture repo that never reaches Harper's code at all, so it cannot be this diff's write-path cost. That's the same failure shape (annpm installstep stalling) recurring across unrelated jobs and repos in the same CI run, which reads as today's npm registry/network conditions rather than a regression. I did not find a clean way to fully rule out a smaller, real contribution from the reload's added write-path cost — the completed unit-test jobs on this head (8m53s for v24, 9m53s for v26) ran somewhat slower than the same job on the same-daymainbaseline (8m8s) — but given the much larger, code-independent stalls alongside it, I'd re-run CI once it's confirmed to have settled down before reading anything into that difference. Recommend re-running the two failing checks before merge rather than merging on this state.Putagainst (verified against rocksdb-js's binding:SetSnapshot()is gated on!disableSnapshoton every Get/Put/Delete path). Harper setsthis.snapshotFree = trueafter a mid-scope-commit rotation (a scope that commits more than once), and a snapshot-free transaction gets no such validation — the reload narrows the lost-update window to the read-to-put span instead of closing it (see the comment atDatabaseTransaction.tsaround the reload). Closing it needs either a rocksdb-js API to force a snapshot on one read of an otherwise snapshot-free transaction, or having Harper skip the snapshot-free rotation for a scope that will do areloadCommitBasewrite — both bigger than this PR. The primary defense (the persistedVERSION_REUSEDflag + native VT refusal) does not depend on this reload and is unaffected.newVersion <= existing) too, so same-timestamp deletes and relocates that deliberately hold their version are flagged as well.acked === 0windows as inconclusive with a 50% floor, admits convergence within a 400 ms poll, and treatshits <= acked + errsas exact-or-explained. Each absorbs a real failure it could otherwise catch. It is deliberately tolerant so the test is not flaky on loaded CI runners; a deterministic cross-worker regression test (including a tombstone that reuses a version, which nothing covers today) would be strictly better and is not in this PR.validate()runs once: created-time retention now reads the reloaded base, butvalidate()is not re-run on a conflict retry, so a retry that reloads a newly created base still keeps the first round's decision. Pre-existing structure, not introduced by this PR; re-runningvalidate()on retry would also re-run schema validation and audit dedup, which is its own change.reloadCommitBase— worth a follow-up look rather than widening this PR.main, consolidating with Fix sourcedFrom cache-fill conflict convergence #2065 (see above) — main's overlapping flag logic and two duplicate-intent tests were folded into this PR's mechanism rather than kept side-by-side; main's disjoint source-version capping/tie-breaking changes in the same files carried through untouched. Independent review caught and this rebase fixed two real regressions the consolidation introduced: (1) the merge reorderedmetadataInNextEncoding = assignMetadataahead of theVERSION_REUSEDOR instead of after it, so the documentedassignMetadata = -1default silently dropped the metadata word — and the flag with it — for any resequenced write reachingrecordUpdaterthrough that default (latent in-repo, but live via the function's direct callers); restored the droppedMath.max(assignMetadata, 0)normalization main's replaced line had. (2) A comment overclaimed the snapshot-free conflict guarantee described above — corrected instead of left wrong. Also strengthened two tests independent review flagged as gaps: the integration test's 30s readiness wait now throws instead of silently falling through on timeout, and the reused-version unit test now asserts a client-level read (not just internalverifyVersion/metadataFlagsstate).Refs #1881 (same version-reuse family, index-scan surface).
Signed: Claude Sonnet 5 (dispatch agent)
🤖 Generated with Claude Code
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=14 @ 222ffae
Human-Review-Need: 4 @ 222ffae