feat(state): native secondary field index on state slots - #17
Conversation
Adds a persisted, incrementally-maintained secondary index on a JSON field (numeric range or string equality) extracted from every item in a state slot — analogous to the existing sequence-keyed RecordIndex, but keyed on an application-chosen field (e.g. `/timestamp` or `/metadata/external/channelId`) instead of sequence. Lets a consumer query "ordinals with field in [A,B]" or "field == X" in O(log n + k) against a state slot without decoding every item's payload, and get distinct-value counts in O(index size) with zero content decoding. New napi surface on JsStore: registerStateFieldIndex, queryStateIndexRange, queryStateIndexEq, getStateIndexValueCounts. Query methods return `null` (not `[]`) when no such index exists (unregistered, wrong kind, or poisoned), distinguishable from a genuine empty match set. Incrementally maintained from the single StateManager::record_update hook point that already sees every Append/Edit/Redact/Set/Snapshot operation. Persisted to its own `state-indexes.bin` file, separate from state.bin: missing or stale is never fatal to Store::open, it just means the index rebuilds (bounded by slot item count, not log bytes) or starts empty until re-registered. Branch-scoped and fails closed: a write on a branch other than the one an index was registered against poisons that index rather than silently applying the wrong branch's mutation to it. Any payload parse failure during maintenance likewise poisons the affected index instead of leaving it silently misaligned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
🔴 BLOCKING
Reviewer: Codex (GPT-5.6 Sol)
Reviewed head: 64070ba8bd82cb746fc05a0607eee7cb59238be6
1. Blocking — reject an index registered on a different current branch
src/store.rs:1386
self.state
.query_field_index_range(state_id, field_path, gte, lte, limit, offset, reverse)All three query entry points discard the current branch identity. FieldIndex records branch_id, but queries never compare it with Store::current_branch(), and switch_branch() does not poison or refresh indexes. The write-time poison only helps if a write occurs after registration on the other branch.
I reproduced this with a side branch created after three items, a fourth item appended and indexed on main, then a switch to the already-divergent side branch with no further writes. The side slot length is 3, but the range query returns the main index, including a phantom ordinal:
side_len=3 side_query=Some([0, 1, 2, 3])
assertion failed: an index from main must not be served on side
That violates the branch-scoping guarantee and can make consumers fetch a nonexistent ordinal or associate a filter match with the wrong item. Pass the current BranchId through every query/value-count path and return None unless it matches the stored index branch (or invalidate indexes during every branch switch). Add a regression where the branches diverge before registration and the only operation after registration is switch_branch; the current write-after-switch test does not cover this case.
2. Blocking — validate persisted index freshness before serving it
src/state/manager.rs:1297
if let Some(field_indexes) = FieldIndexManager::load(&self.field_index_path)? {
*self.field_indexes.write() = field_indexes;
}The loaded manager is accepted without comparing each index's stored (branch_id, head_offset) with the just-loaded StateIndex. This matters because save() durably writes state.bin first and deliberately swallows a later field-index save failure. A crash in that window, or an open/permission failure that leaves the old valid index file intact, therefore reopens newer state with an older index and immediately serves silent false negatives.
I reproduced the valid-stale-file case by saving an index at one item, advancing and syncing the state to two items, restoring the earlier valid state-indexes.bin, and reopening:
current_len=2 stale_query=Some([0])
assertion failed: a persisted index whose head_offset is behind state.bin must not be served
The changelog promises that a stale index starts empty, and the persisted shape already carries the data needed to enforce that. On load, retain only indexes whose branch/state head exactly matches StateIndex, or perform the same freshness check on every query and return None on mismatch. Add a regression with a structurally valid but one-head-behind index file; the current corrupt-file test only covers parse rejection.
Tooling results
git diff --check origin/main...HEAD— passed.cargo clippy --offline --locked -- -W clippy::allwith isolated writableCARGO_HOME/CARGO_TARGET_DIR— passed; 16 warnings, including existing lint debt and the new eight-argument range query, no errors.cargo test --offline --lockedwith the same isolated Cargo directories — passed: 273 tests, 0 failed, 6 ignored.cargo clippy --offline --locked --features napi-bindings -- -W clippy::all— passed; 18 warnings, no errors.- Branch-switch reproduction compiled against the reviewed library and failed exactly as shown above.
- Persisted-staleness reproduction compiled against the reviewed library and failed exactly as shown above.
- GitHub checks at the final refresh: Build & Test and Changelog both green.
- The first online Cargo attempt could not resolve crates.io in the sandbox, and the default Cargo registry was read-only. The successful checks reused the already-cached registry through isolated writable directories; no dependency download was used. The N-API feature compiled locally, but
npm run build:debugwas not rerun because this detached worktree had no installed N-API CLI.
Verdict
The incremental update paths and existing regressions pass, but branch selection and persisted freshness can both produce confidently wrong ordinals. Those are correctness blockers for an index intended to front agent-facing search and retrieval. Fix both fail-closed checks and add the two missing regressions before merge.
— Reviewed by GPT-5.6 Sol via OpenAI Codex.
…ale persisted indexes on load Two blocking correctness gaps from review (both in paths the prior branch-scoping/freshness fix didn't cover): - Queries (query_state_index_range/eq, get_state_index_value_counts) never compared the index's stored branch against the CURRENT branch at query time — only writes were branch-guarded. A pure read after switch_branch() with no intervening write could serve another branch's index wholesale, including ordinals that don't exist in the current branch's slot. Fixed by resolving the current branch id at query time in Store and threading it down to a branch-match check in FieldIndexManager::query_range/query_eq/value_counts. - A persisted state-indexes.bin was accepted on load without checking whether it's still fresh relative to the just-reconstructed StateIndex. Store::save() durably writes state.bin first and swallows a later field-index save failure, so a crash in that window (or a restored older backup of just that file) could leave a structurally-valid-but-stale index that gets trusted and served. Fixed with FieldIndexManager::prune_stale, called right after load, which drops any index whose stored head_offset doesn't match the real chain's current head_offset for that (state_id, branch_id) — enforcing the "a stale index starts empty" promise the changelog already made. Public Store/napi signatures unchanged; branch resolution is internal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks — both confirmed and fixed in 7862d52. Bug 1 (query branch leak): `Store::query_state_index_range`/`query_state_index_eq`/`get_state_index_value_counts` now resolve `self.branches.current_branch().id` fresh at query time and pass it down to `FieldIndexManager`, which returns `None` unless it matches the stored index's branch — closing the pure-read-after-`switch_branch`-with-no-write gap. Public `Store`/napi signatures unchanged. Added `test_field_index_pure_read_after_switch_does_not_leak_other_branch` reproducing your exact repro shape (branches diverge before registration, only op after is `switch_branch`). Bug 2 (stale persisted index served): added `FieldIndexManager::prune_stale`, called immediately after `FieldIndexManager::load` succeeds in `StateManager::load_from_file`, comparing each index's stored `head_offset` against the just-reconstructed `StateIndex`'s real current head_offset for that `(state_id, branch_id)` and dropping anything that doesn't match. Added `test_field_index_stale_but_parseable_persisted_file_is_not_served` (a structurally-valid-but-one-head-behind file, not a corrupt one). Re-ran the full tooling pass after both fixes: `cargo build --lib` clean, `cargo clippy --features napi-bindings -- -W clippy::all` — 20 warnings, all pre-existing in unrelated code, none in anything touched this round or last, `npm run build:debug` succeeds (napi surface unchanged, only internal signatures changed), Node smoke test green, `cargo test` full suite all passing (lib 130, integration 34, everything else unchanged). 🤖 Generated with Claude Code |
|
Recommendation: hold merge until the four P2 findings below are fixed and covered by regression tests. The P3 signed-zero issue is lower priority and could be tracked separately. Reviewed head:
Validation:
The existing suite is green, but the new probes expose stale results, an open-time panic, and persistence/performance regressions. I recommend fixing the four P2 issues before merging. — Reviewed with OpenAI Codex. |
…ne_stale interaction, and register cost Four review-flagged bugs in the state field-index feature (PR #17), on top of 7862d52: - record_update now rebuilds a slot's index on StateOperation::Delta (the Delta-strategy whole-state replace), matching Set/Snapshot handling. It previously fell into the "not an ordinal array" catch-all alongside Tree ops, leaving indexes silently stale after a Delta-strategy write. - FieldIndexManager::load now validates the persisted length field against the file's actual remaining bytes before allocating/reading based on it. A corrupt or adversarially-crafted state-indexes.bin (valid magic+version, garbage length) could previously trigger a capacity-overflow panic during Store::open, defeating the "corrupt derived index is nonfatal" guarantee. - DeltaSnapshot's no-op (correct: it consolidates already-indexed Appends, changing no content) now still advances the index's tracked head_offset via a new touch_head_offset, so the prune_stale check added last round doesn't wrongly discard an otherwise-correct index on reopen just because a DeltaSnapshot happened since its last content-changing update. - register_field_index's freshness check (kind/branch_id/head_offset) now runs before materializing and JSON-parsing the slot, not after — an idempotent already-fresh re-registration no longer pays full materialization cost (~110ms measured for a 20k-item slot vs ~12us for a query). New FieldIndexManager::is_fresh exposes the check standalone. Also folded in the flagged P3: normalize -0.0 to 0.0 both when indexing a value and when evaluating query bounds, since total_cmp (unlike ordinary numeric equality) would otherwise exclude one from an inclusive [0, 0] range match. Tests: 10 new regressions (3 unit in field_index.rs for the panic + zero normalization, 3 unit for prune_stale already covered the load-freshness gap from last round, 3 integration for Delta/reopen-after-delta/register cost). Verified the register-cost regression actually catches the bug by temporarily reverting the reorder (50 re-registrations: 595ms broken vs under threshold fixed). cargo test: 133 lib + 37 integration + all other suites, 0 failures. cargo clippy --features napi-bindings -W clippy::all: same 20 pre-existing warnings as baseline, none in touched code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
All 4 P2s fixed, plus the P3 (was cheap enough to fold in). Fixed in 4a625c5. 1. `Delta` op not handled: moved `Delta` into the same `on_full_replace` arm as `Set`/`Snapshot` in `record_update` — it's a whole-state replace exactly like those two. `Field` (object-shaped, not an array replace) stays excluded. New test: `test_field_index_delta_operation_rebuilds_index_live`. 2. Load-time panic on oversized length: `FieldIndexManager::load` now validates the persisted length against the file's actual remaining bytes (via `file.metadata()`) BEFORE allocating — an out-of-range length (including `u64::MAX`) now falls through to `Ok(None)` like any other corrupt-file case instead of panicking during `Store::open`. New tests: `test_load_oversized_length_field_does_not_panic`, `test_load_length_field_exceeding_remaining_bytes_does_not_panic`. 3. DeltaSnapshot/prune_stale interaction: added `FieldIndexManager::touch_head_offset` — advances only `head_offset` (leaves `by_ordinal`/reverse index untouched) on a DeltaSnapshot, so an index that's only seen DeltaSnapshots since its last real mutation survives `prune_stale` correctly on reopen instead of being wrongly discarded as stale. New test: `test_field_index_survives_reopen_after_delta_snapshot_only` (reproduces your exact repro: 4 appends with `delta_snapshot_every: 4`, sync, reopen with no further mutation). 4. Freshness check ordering: `register_field_index` now checks a new `FieldIndexManager::is_fresh` (plain hashmap lookup against `(kind, branch_id, head_offset)`) BEFORE calling `get_state`/deserializing — an idempotent re-registration never materializes the slot. Verified the regression actually catches a regression: temporarily reverted the reorder locally and confirmed 50 re-registrations against a 20k-item slot took 595ms (vs the test's 200ms threshold), then restored and confirmed it passes. New test: `test_register_field_index_idempotent_reregistration_skips_materialization`. 5. [P3] Negative zero: folded in — added `normalize_zero`, applied both when indexing a value and when evaluating query bounds. New test: `test_negative_zero_matches_positive_zero_in_range_query`. Full tooling pass: `cargo build --lib` clean, `cargo clippy --features napi-bindings -- -W clippy::all` — same 20 pre-existing warnings as baseline (verified by line number, none in anything touched), `npm run build:debug` succeeds, `npm test` green, `cargo test` full suite all passing (lib 133, integration 37, everything else unchanged). 🤖 Generated with Claude Code |
|
Recommendation: merge. All four P2 findings and the P3 signed-zero finding from my previous comment are resolved in I reviewed the fixes and surrounding mutation, persistence, and registration paths, and reran the original five review probes against this exact head. No new actionable findings.
Validation on the reviewed head:
Temporary probes were removed from the checkout; the working tree is clean. The documented limits around large numeric IDs, redact cost, and cold rebuild memory remain follow-up work rather than blockers for this merge. — Re-reviewed with OpenAI Codex. |
Chronicle 0.4.0 published (anima-research/chronicle#17) — ships the native secondary field-index capability this PR's queryByTime/ queryByChannel/etc. depend on. Unblocks CI: the 15 tests that were failing against the old published 0.3.0 (which lacks the native capability) now run for real instead of hitting the graceful- degradation error path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
RecordIndex, but keyed on an application-chosen field (e.g./timestamp,/metadata/external/channelId) instead of sequence.JsStore:registerStateFieldIndex,queryStateIndexRange,queryStateIndexEq,getStateIndexValueCounts. Query methods returnnull(not[]) when no such index exists — distinguishable from a genuine empty match set.StateManager::record_updatehook that already sees everyAppend/Edit/Redact/Set/Snapshot. Persisted to its ownstate-indexes.bin, separate fromstate.bin: missing/stale is never fatal toStore::open, it just rebuilds (bounded by slot item count, not log bytes) or starts empty.by_ordinalsilently misaligned.Built to back agent-facing "search/stats/extract over full history" tools in
context-manager/agent-framework(companion PRs incoming) that need to stay fast against multi-GB production stores without a second fullStore::open().Review history
An independent adversarial review of the first pass found two confirmed, reproducible silent-corruption bugs:
DeltaSnapshotwas double-indexing every item consolidated since the last snapshot (it doesn't add new items, just consolidates already-indexedAppends — the index was treating it as new appends).state_id).Both are fixed (regression-tested:
test_field_index_delta_snapshot_does_not_double_index,test_field_index_cross_branch_writes_do_not_contaminate,test_field_index_create_branch_at_does_not_contaminate), along with five follow-on hardening items the same review surfaced:null-vs-[]query semantics, crash-mid-write-safe freshness keying (keyed on(kind, branch_id, head_offset)not item count alone), non-fatalStore::sync()on a field-index write failure,write_lockinregister_state_field_indexto avoid a registration racing a concurrent mutation, and poison-on-parse-failure instead of a silent skip.Three lower-priority items are left as
// TODO:comments rather than implemented now:on_redact's reverse-index walk is O(index size) not O(redacted range);f64numeric keys collide above 2^53 (usestringkind for ID-shaped fields);register/rebuild materializes the whole slot intoVec<Value>at once.Test plan
cargo build --libcleancargo test(full suite): 127 lib tests, 32 integration tests, all other existing suites unchanged — all pass, 0 failuresnpm run build:debug(napi build) succeeds,index.d.ts/index.jsregenerate correctlytest.mjs) greencontext-manager/agent-frameworkconsumers built on this branch (companion PRs); a synthetic 300k-message/25-channel workload measured sub-second for all realistic bounded queries (native channel counts: 0ms; channel-page fetch: 1ms; time+channel intersection over 4000 matches: 64ms)🤖 Generated with Claude Code