Skip to content

fix(trusty-common): persist the HNSW vector-id allocator so two live stores cannot alias drawers (#5005) - #5013

Merged
bobmatnyc merged 8 commits into
mainfrom
fix/5005-hnsw-id-aliasing
Aug 7, 2026
Merged

fix(trusty-common): persist the HNSW vector-id allocator so two live stores cannot alias drawers (#5005)#5013
bobmatnyc merged 8 commits into
mainfrom
fix/5005-hnsw-id-aliasing

Conversation

@bobmatnyc

@bobmatnyc bobmatnyc commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #5005. Owning crate is trusty-commonhnsw_store.rs lives in its memory-core feature, not in trusty-memory, which matters for the gates (see below).

Is overlapping HnswStore open over one file intended?

Yes, in-process — and that is where the aliasing comes from. This is the question the fix hinges on, so the evidence:

That is the trigger the issue's follow-up comment narrowed to, and it explains the measured negative result: a single writer allocating 15 ids from empty tables was never going to fail.

So the fix is the first branch the brief named — persisted, atomically-reserved allocation plus a uniqueness guard — not blocking the second open.

The fix

Allocation moved into redb. New single-row table vector_id_seq (kg_store.rs). HnswStore no longer carries a counter at all; allocate_vector_id reads and bumps the persisted one inside the same write transaction that writes the row claiming it. redb permits one write transaction at a time per database, so allocation is now serialisable against every other writer on that file, and the id and its row commit or roll back together.

Uniqueness guard on insert. allocate_vector_id will not return an id that already has a VECTORS row. It jumps past the highest occupied id and retries (bounded, MAX_ALLOC_PROBES = 8), then fails with HnswStoreError::IdAllocationFailed. It never overwrites. Both branches log at WARN, so a counter that ever falls behind is visible rather than inferred.

Single-store id assignment is unchanged: a fresh palace still issues 1, 2, 3 in order, exactly as the old max_seen + 1 seed did.

Migration, and the rolling-upgrade case

Every palace on disk predates vector_id_seq. Every read-write open raises the counter to max(VECTORS, VECTOR_KEYS) + 1 — as a max, never a set-if-missing. That one choice covers both cases the brief asks about:

The old binary is never concurrent with the new one — redb's flock forbids two live writers — so the interleaving is strictly sequential and the max is sufficient. Read-only/snapshot opens skip the seed entirely; every write path there returns ReadOnly before it could allocate.

Detection (#5000 requirement, and how I verified the fix)

AliasAudit compares VECTOR_KEYS row count against distinct mapped vector_id count — arithmetic that never leaves that one table, so nothing about key presence can fool it. Wired where embed health already lives:

  • PalaceHandle::embed_health carries an AliasAuditMeasured { key_rows, distinct_vector_ids, aliased_drawer_ids } or Unavailable { reason }. is_healthy() requires no missing drawers and an audit that ran and came back clean; key presence alone was never the health condition, and neither is an audit that could not run.
  • palace_reembed returns alias_audit (clean | aliased | unavailable), alias_audit_error, vector_key_rows, distinct_vector_ids, aliased, aliased_ids. Gate a deletion-bearing workflow on alias_audit == "clean" as well as missing == 0.
  • A failed scan reports unavailable with null counts, never zeros. The Result-to-outcome mapping is AliasAudit::from_scan, a named function with its own test, so embed_health has no error branch of its own to get wrong.

What I did NOT do

  • The 4 aliased trusty-tools drawers are untouched. Not repaired, not re-embedded, not read beyond what the issue already recorded.
  • unalias() is code only and was never run. HnswStore::unalias / UsearchStore::unalias free every uuid in a collision group and tombstone the shared id, so the group reads as ordinary "missing" and the existing backfill repairs it. It frees the whole group, including the reachable member: VECTORS holds whichever vector was written last and search resolves the id to whichever uuid sorts last, and those two are unrelated — so the reachable drawer's content is not reliably its own either. It is tested but not wired to any CLI or MCP surface. A follow-up needs one flag on palace_reembed (or a small palace_unalias tool) plus an operator run; that is a deliberate stop, so nobody can repair the owner's palace by accident.
  • No fix for in-memory graph divergence. Two live stores each hold only the points they inserted, so a cannot find b's newest writes until the file is reopened. Redb has every row and the next open rebuilds, so nothing is lost — unlike the id collision, which destroyed content. Separate defect, not filed; say the word and I will.

Tests — and what each one goes red on

Every test below was broken deliberately and confirmed red, one mechanism at a time. Raw output for each break is in the gate section.

Test Names Break that turns it red Result
two_live_stores_over_one_file_never_alias_ids the defect: two live stores, one file, interleaved upserts restore the process-local AtomicU64 seeded at open FAILED
upsert_refuses_to_reuse_an_id_already_present_in_vectors the uniqueness guard, with the counter rewound onto an occupied id delete the vectors.get(candidate)?.is_none() probe FAILED
old_palace_without_a_seq_row_is_seeded_on_open the migration: rows 1..=5, no counter row remove the open-time seed FAILED
reopen_raises_a_counter_an_old_binary_left_behind the rolling upgrade: rows past a stale counter change the seed to insert-only-when-absent FAILED
audit_detects_two_uuids_mapped_to_one_id the row-count vs distinct-id arithmetic — (covered by the embed_health break)
alias_audit_surfaces_a_collision embed_health reporting missing: 0 and naming all three aliased make embed_health drop the alias audit FAILED
unalias_frees_every_uuid_in_a_collision_group, unalias_marks_the_whole_group_for_reembed the repair frees the whole group spare the lexicographically-last uuid FAILED (both)
alias_audit_failure_is_never_reported_as_clean (review finding 1) a failed scan is unhealthy, has no counts, and keeps its reason restore the zeros in AliasAudit::from_scan's Err arm FAILED
upsert_refuses_an_id_that_only_vector_keys_still_claims (review finding 2) a VECTOR_KEYS row surviving its VECTORS row does not get its id re-issued revert high_water to vectors.last()? + 1 FAILED
table_definitions_have_distinct_names (review finding 3) VECTOR_ID_SEQ's table name is unique rename it to "vectors" FAILED

One note on discipline: the first attempt at finding 1's test built an AliasAudit::Unavailable value by hand, and the break did not go red — the test never reached the mapping. It was rewritten to drive AliasAudit::from_scan(Err(..)), the branch embed_health actually takes, and then reproduced red. The vacuous version is not what shipped.

Each break failed only the tests naming it — the other 26 stayed green, so none of these assertions is being satisfied by a second mechanism.

Reproducing the defect is expressible as a test: two_live_stores_over_one_file_never_alias_ids opens two HnswStores over one Arc<Database>, exactly what open_or_get_cached_db does in production. On the pre-fix allocator it fails at the first pair — both stores seed to 1, so store b's first upsert takes the id store a just issued. It asserts on a rehydrated index rather than on either live store, because each live store's hnsw_rs graph only holds its own inserts; redb is the authority and the rebuild is what proves the persisted state is complete and unaliased.

Fixtures that the fixed public API can no longer produce — an aliased VECTOR_KEYS mapping, a rewound counter — are written at the redb level.

SLOC split

hnsw_store.rs reached 780 SLOC and vector.rs 526 with the new tests (the 500 cap counts inline #[cfg(test)] modules). Both inline test modules moved to child tests.rs files — child, not sibling, because the tests reach the store's private db handle. No production code moved; check_line_cap.sh is clean.

Gates — rung 5, trusty-common

🔴 Gate trap, and it applies here. hnsw_store.rs is in trusty-common's memory-core feature, and that crate's default = []. A bare cargo test -p trusty-common compiles none of this and still exits 0. Every command below passes --features memory-core,embedder-test-support.

$ cargo fmt --check                              → EXIT=0
$ cargo check --workspace --all-targets \
      --exclude trusty-mpm-gui --exclude trusty-code-gui --exclude trusty-agents-ui
                                                 → EXIT=0
$ cargo clippy -p trusty-common --features memory-core,embedder-test-support \
      --all-targets -- -D warnings               → EXIT=0
$ cargo clippy -p trusty-memory --all-targets -- -D warnings
                                                 → EXIT=0
$ cargo test -p trusty-common --features memory-core,embedder-test-support
  test result: ok. 900 passed; 0 failed; 13 ignored
  test result: ok. 6 passed; 0 failed; 0 ignored
  test result: ok. 11 passed; 0 failed; 0 ignored
  test result: ok. 4 passed; 0 failed; 0 ignored
  test result: ok. 3 passed; 0 failed; 0 ignored
$ cargo test -p trusty-memory                    (direct dependent)
  test result: ok. 563 passed; 0 failed; 4 ignored     (+ 19 further binaries, all ok)
$ cargo test -p trusty-agents --lib              (the other memory-core consumer)
  test result: ok. 3375 passed; 0 failed; 11 ignored
$ bash scripts/check_line_cap.sh
  line-cap: measured 3744 tracked .rs file(s) (floor 500); 7 allowlisted, 0 violations — OK.
$ bash scripts/check_sld.sh
  sld-lint: scanned 56 spec doc(s) + 3119 code file(s); 0 error(s), 0 warning(s)
$ bash scripts/check_changelog_fragment.sh
  OK   trusty-common: changelog.d fragment present and valid
  OK   trusty-memory: changelog.d fragment present and valid

--include-ignored: 2 failures, both proven pre-existing

$ cargo test -p trusty-common --features memory-core,embedder-test-support -- --include-ignored
test result: FAILED. 909 passed; 2 failed; 0 ignored
failures:
    memory_core::retrieval::tests::cold_restart_recalls_beyond_l1_snapshot
    memory_core::retrieval::timeout_tests::tests::timeout_fires_on_embedder_init_with_tiny_limit

Neither was made green by ignoring, cfg-gating or excluding anything. Both are ONNX-embedder-dependent tests reachable only via --include-ignored:

  • timeout_fires_on_embedder_init_with_tiny_limitpasses in isolation (1 passed; 0 failed). It asserts shared_embedder() returns Err on a tiny timeout and got Ok; with a warm model cache and a shared embedder already initialised by an earlier test in the same process, init returns instantly. Cross-test ordering artifact. Nothing in this diff touches embedder init.
  • cold_restart_recalls_beyond_l1_snapshotfails identically on clean origin/main. Checked out 7045aa18 in a throwaway worktree and ran the single test: test result: FAILED. 0 passed; 1 failed; 902 filtered out. Same panic, same line (tests.rs:631). It is the HuggingFace-model-dependent flake class of #852; every returned hit scores ~0.3257, i.e. the embeddings are degenerate because the real model is not loaded on this host.

Change-specific gates pass; those two are blocked by the environment, not by this branch.

cargo check --workspace without the three exclusions fails in trusty-mpm-gui / trusty-code-gui with frontendDist ... ui/dist doesn't exist — a missing pnpm build, unrelated to this diff. The exclusion list is the one CI itself uses (ci.yml:811).

LOC

Added 1403 / Removed 525 / Net +878 — of which 866 lines are the two test modules being moved out of their parent files, not new code.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools


Follow-up in this PR: the repair is now runnable (#5005)

The section above says unalias() is "code only… not wired to any CLI or MCP
surface". That was the gap that made this PR insufficient: it stops NEW aliasing
and makes existing aliasing visible, but leaves the three already-destroyed
drawers in the live trusty-tools palace durable-but-unretrievable — which is
what blocks #4834.
grep -rn "unalias" crates/ confirmed zero non-test call sites.

Two new pieces: PalaceHandle::repair_aliases (the operator primitive, in
embed_repair.rs beside backfill_missing_vectors) and palace_unalias (the
MCP tool over it). Tool count 44 → 45.

Dry run by default, and it names ids

palace_unalias mirrors palace_reembed: dry_run defaults to true, and the
default run reports what it would free while writing nothing. More is at stake
here — this one deletes VECTOR_KEYS rows, not just embeddings.

The result carries freed_ids, the drawer id set. Not a count. #5005 was a
count (missing: 0) reporting all-clear over four destroyed drawers; a repair
answering "3 repaired" without saying which three is the same defect one layer
up. Those ids are also the operator's re-embed worklist, so the caller needs
them by name regardless.

It cannot report success over a partial repair

AliasRepairOutcome has five variants and Repaired is reachable through
exactly one path: the post-repair audit ran, came back clean, and
accounted for every id the pre-repair audit named. Anything else is its own
variant, and is_success() is false for all of them:

Outcome When is_success()
Clean audit ran, nothing aliased true
Planned dry run false — it repaired nothing
Repaired freed, and verification confirms clean true
Partial freed, but something is still wrong false
Unavailable an audit could not run false

Two guards worth calling out:

  • An unreadable audit refuses to write at all. Unavailable is checked
    before the dry-run branch and before the read-only guard, so a failed scan
    never reaches unalias. Deleting vector keys with no idea which (or whether
    any) are aliased, then reporting a clean palace, is the exact shape this
    ticket exists to remove.
  • UsearchStore::unalias no longer drops keys it cannot parse. It returned
    Vec<Uuid> built with filter_map(…ok()), so a freed key that would not
    parse vanished from the worklist — a drawer left with no vector and nobody
    knowing to repair it, reported inside a success. It now returns
    UnaliasOutcome { freed, unparsed_keys }, and a non-empty unparsed_keys
    forces Partial.

Idempotent: the second run finds no group, reports clean, frees nothing.
reembed_required says outright when a palace_reembed run is still owed —
freeing a group turns an invisible drawer into an ordinary missing one, and only
the backfill makes it findable again.

Not run against any live palace. That stays an operator action, with a
backup taken immediately beforehand.

Tests — each one broken and confirmed red

Test Break Result
repair_aliases_dry_run_names_the_group_and_changes_nothing Default returns dry_run: false FAILED (other 3 stayed green)
repair_aliases_never_reports_success_over_a_partial_repair drop the unparsed_keys term from the Repaired guard FAILED
repair_aliases_frees_the_group_and_verifies_it + 2 others never call unalias FAILED (3 of 4; the dry-run test correctly stayed green)
an_unavailable_or_partial_repair_is_never_a_success is_success!matches!(self, Self::Planned) FAILED
repair_aliases_never_reports_success_over_a_partial_repair restore the silent drop of unparseable freed keys FAILED
dispatch_palace_unalias_dry_run_names_ids_and_writes_nothing remove the MCP dispatch arm FAILED

repair_aliases_then_reembed_makes_a_lost_drawer_retrievable is the end-to-end
claim, and it asserts on recall rather than on table arithmetic: it seeds three
drawers with distinct content sharing one vector_id, discovers which
members their own content cannot retrieve (the collision collapses the group
onto one reachable uuid), then runs repair → backfill → search and asserts each
previously-unretrievable drawer now answers its own query. Fail-before is
asserted in the same test, including that embed_health reports zero missing
while they are lost.

One test was dropped rather than faked

repair_aliases_refuses_to_run_on_an_unreadable_audit is not in the diff. The
Unavailable branch fires when UsearchStore::alias_audit errors, which needs
a redb read failure — and every fixture that breaks that read also breaks
UsearchStore::new, so the store cannot be constructed in the state the test
needs. Corrupting the file gets it silently recreated; removing the
vector_keys table fails at open (Table 'vector_keys' does not exist).

The branch is kept — a redb read genuinely can fail on a real disk error — but
its trigger is not reproducible in-process. an_unavailable_or_partial_repair_is_never_a_success
covers the contract the MCP layer actually depends on, and its doc comment says
which half is unproven. A test built by hand-constructing AliasAudit::Unavailable
would have been vacuous in exactly the way review finding 1 already caught once
in this PR, so it was not written.

Gates — rung 5, rebased onto b8746f59

$ cargo fmt --check                                        → EXIT=0
$ cargo clippy -p trusty-common --features memory-core,embedder-test-support \
      --all-targets -- -D warnings                         → EXIT=0
$ cargo clippy -p trusty-memory --all-targets -- -D warnings
                                                           → EXIT=0
$ cargo test -p trusty-common --features memory-core,embedder-test-support
  test result: ok. 905 passed; 0 failed; 13 ignored
  (+ 5 further binaries, all ok)
$ cargo test -p trusty-memory
  test result: ok. 564 passed; 0 failed; 4 ignored
  (+ 19 further binaries, all ok)
$ bash scripts/check_line_cap.sh
  line-cap: measured 3747 tracked .rs file(s); 7 allowlisted, 0 violations — OK.
$ bash scripts/check_sld.sh
  sld-lint: scanned 56 spec doc(s) + 4089 code file(s); 0 error(s), 0 warning(s)
$ bash scripts/check_test_pointers.sh
  test-pointers: resolved 22145 Test: citation(s) — 0 dangling pointers — OK.
$ bash scripts/check_changelog_fragment.sh
  OK   trusty-common / trusty-memory: fragments present and valid

--include-ignored still reports the same 2 pre-existing failures, unchanged by
the rebase and by this work:

$ cargo test -p trusty-common --features memory-core,embedder-test-support -- --include-ignored
test result: FAILED. 916 passed; 2 failed; 0 ignored
    memory_core::retrieval::tests::cold_restart_recalls_beyond_l1_snapshot
    memory_core::retrieval::timeout_tests::tests::timeout_fires_on_embedder_init_with_tiny_limit

git diff --name-only origin/main...HEAD touches neither test's file.
timeout_fires_… passes in isolation (1 passed; 0 failed) — a cross-test
ordering artifact. cold_restart_… fails in isolation with the signature this
PR documented before the rebase: every hit scoring ~0.3257, i.e. degenerate
embeddings because the real model is not loaded on this host
(#852). Change-specific
gates pass; those two are blocked by the environment.

Rebase note on #5039

#5039 landed a clippy fix
in embed_repair.rs — the only file both this branch and the last 12 commits of
main touch. It rewrote the drawer-liveness filter at what is now line 256; this
branch's hunks are at 46 / 162 / 268 / 373. No textual overlap, and both changes
are present on the rebased head: the De Morgan form (!d.is_expired_at(now) || d.is_tier_c()) and the AliasAudit surface. Neither side was taken blindly.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools


Review round 2 — the HIGH is fixed

The critic found the fail-open shape I killed in UsearchStore::unalias had
survived one layer up, in the detector. Reproduced before touching anything:

raw alias_audit -> key_rows=2 distinct=1 ids=[]
is_clean=true   is_healthy=true
repair_aliases(dry_run:false) outcome=clean is_success=true freed_ids=[]
after "repair"  -> key_rows=2 distinct=1        <- collision untouched

Two VECTOR_KEYS rows on one vector_id is a real collision. It reported
clean and did nothing — this PR's own defect, on the machine-readable field
the PR tells callers to branch on, with #4834's deletion gate as that caller.

Fix. alias_audit carries the unnameable keys instead of dropping them, and
is_clean() now consults key_rows vs distinct_vector_ids. Those counts come
straight off the table — no parse, no filter can shrink them — which is what
makes them the signal that cannot be fooled. repair_aliases gates on
is_clean() rather than expected.is_empty(), so an all-unnameable group falls
through to unalias and ends as Partial: freed, with the worklist honestly
incomplete. palace_unalias reports unnameable_keys on the wire.

The MEDIUM, taken as suggested. classify_repair is now a pure function over
(&AliasAudit, &UnaliasOutcome, &[Uuid]). It takes the audit as a parameter, so
the post-repair Unavailable branch — the one that fires after keys are
deleted — is reachable with a hand-built value and no fault-injection seam. No
indirection added to the production path: the function tested is the function
called. It also subsumes what the dropped pre-repair test was reaching for.

Three new mutation proofs, each red, each restored:

Break Result
is_clean back to the id list alone a_collision_whose_keys_do_not_parse_is_never_clean FAILED
repair_aliases gate back to expected.is_empty() same test FAILED (25 others green)
classify_repair's Unavailable arm falls through classify_refuses_to_call_an_unverified_write_repaired FAILED

Gates, rebased onto e97e40e6

cargo fmt --check                                          → EXIT=0
cargo clippy -p trusty-common --features memory-core,embedder-test-support
      --all-targets -- -D warnings                         → EXIT=0
cargo clippy -p trusty-memory --all-targets -- -D warnings → EXIT=0
cargo test -p trusty-common --features memory-core,embedder-test-support
  test result: ok. 908 passed; 0 failed; 13 ignored
cargo test -p trusty-memory
  test result: ok. 564 passed; 0 failed; 4 ignored
check_line_cap.sh / check_sld.sh / check_changelog_fragment.sh → all EXIT=0

The critic's vacuous-green warning is worth keeping: cargo test -p trusty-common
without --features memory-core compiles none of this module and still exits
0. Every command above carries the flag.

Does this unblock #4834? Unconfirmed — and the check is read-only

palace_reembed reporting missing: 0 over unretrievable drawers is consistent
with aliasing and with #852 degenerate embeddings. The e2e test uses
MockEmbedder, so it proves the key/vector plumbing, not that the real embedder
returns a retrievable vector for those three drawers. Nothing here was run
against the live palace.

The two causes are cleanly separable from read-only output already on the wire:

Signal Aliasing #852 degenerate embeddings
vector_key_rows vs distinct_vector_ids differ — by exactly the number of lost drawers equal — every drawer owns its own id
palace_unalias dry run names them in aliased_before_ids returns clean, frees nothing
Recall scores normal spread every hit pinned in a hair-wide band (~0.3257)

So: run palace_unalias with the default dry run (read-only, deletes nothing)
and read vector_key_rows / distinct_vector_ids off palace_reembed
directly. If the counts are equal and the dry run says clean, aliasing is not
the cause and #4834 needs #852, not this PR. This PR is still correct and worth
landing either way — it closes a real data-destroying defect — but it should not
be described as unblocking #4834 until that dry run has named the three drawers.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools


Review round 3 — the three MEDIUMs, fixed here

Correction to my round-2 table first, from the critic's independent re-run:
mutation 3 degrades to Partial, not Repaired, because after.is_clean() is
a second term in the guard. Red either way — doubly covered, not less.

1. palace_reembed's description omitted the guard the payload gained.
The text an MCP caller actually reads still promised only "drawers that have no
vector". A correct guard nobody is told to reach had moved the capstone shape
out of the code and into the tool contract, with #4834's deletion gate as the
reader. It now says missing: 0 is not a complete account of what is
retrievable, and to act only on alias_audit.is_clean.

2. The daemon success path had never produced a non-zero result. Every
dispatch_tool test ran against an empty palace, so outcome: "repaired" was
proven at the store layer and assumed through the tool — including the
is_read_only() routing a dry run skips. New test seeds two uuids onto one
vector_id in the palace's own index.usearch.redb and drives dry_run: false
through dispatch_tool. Forcing dry_run = true in the handler fails it:

assertion `left == right` failed: explicit write run:
{"aliased_before_ids":["7a1bf50b-…","7b651fd4-…"],"dry_run":true,
 "freed_ids":["7a1bf50b-…","7b651fd4-…"],"outcome":"planned","success":false,…}
  left: Bool(true)   right: false

That payload also shows the seed is a real collision the audit detects, not a
fixture the assertion merely walks past.

3. Stale 🔴 doc block on HnswStore::unalias said it was wired to no MCP
surface. This PR wires it. Corrected to name the palace_unalias path and keep
only the claim still true — no live-palace run.

postcard joins trusty-memory's dev-dependencies: the seeded vector must encode
at the real 384 dimensions or HnswStore rejects the file. One line in
Cargo.lock, no refresh.

Gates at f8d7eaa0

cargo fmt --all --check                                    → EXIT=0
cargo clippy -p trusty-common --features memory-core,embedder-test-support
      --all-targets -- -D warnings                         → EXIT=0
cargo clippy -p trusty-memory --all-targets -- -D warnings → EXIT=0
cargo test -p trusty-common --features memory-core,embedder-test-support
  test result: ok. 908 passed; 0 failed; 13 ignored          (EXIT=0)
cargo test -p trusty-memory
  test result: ok. 565 passed; 0 failed; 4 ignored           (EXIT=0)
check_line_cap.sh → 0 violations          check_sld.sh → 0 errors, 0 warnings
check_changelog_fragment.sh → 2/2 crates  check_test_pointers.sh → 0 dangling

Every trusty-common command carries --features memory-core; a bare run
filters out all 294 memory-core tests and still exits 0.

Scope note: cold_restart_recalls_beyond_l1_snapshot is pre-existing on
origin/main — bit-identical scores on both trees, #[ignore]d pending a real
ONNX embedder. Not touched, not chased.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

@bobmatnyc bobmatnyc added the trusty-mpm trusty-mpm platform and related work label Aug 6, 2026
@bobmatnyc bobmatnyc self-assigned this Aug 6, 2026
@bobmatnyc

Copy link
Copy Markdown
Owner Author

Verdict: WARN

Adversarial review, RUNG 5 (persistence / concurrency / data integrity). Independent worktree off origin/fix/5005-hnsw-id-aliasing @ cc0ae392. Nothing was run against a live palace.

The core fix holds. Allocation is genuinely serialisable, the migration is genuinely idempotent, the bound is genuinely safe, and the test suite is honest — I re-broke it and it went red where the PR says it does. One HIGH: the new detection surface fails open, which is the same defect shape #5005 exists to close.


What I verified rather than accepted

1. Serialisability — CONFIRMED

allocate_vector_id is called at hnsw_store.rs:509, inside the wtx opened at :493; keys.insert (:510) and vectors.insert (:514) are in the same transaction, committed once at :518. No early commit, no read-then-write gap, no second call site.

I searched for an escape hatch. VECTOR_KEYS is written in exactly three places in the whole workspace — upsert (insert), delete (remove), unalias (remove). There is no second allocation path.

Empirically, break #1 — restore the pre-fix process-local AtomicU64 seeded at open:

assertion `left == right` failed: two live stores must never issue the same vector_id twice:
  [1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10]
test result: FAILED. 37 passed; 1 failed

That is the defect, reproduced, and the persisted counter is what removes it.

2. Migration under rolling upgrade — CONFIRMED, with one caveat below

The max seed (hnsw_store.rs:422-433) can only move forward, and it runs on every read-write open before any id is issued. The windows I probed and closed:

  • Read-only opens skip the seed. Safe: upsert :482, delete :602, compact_orphans :783, unalias :735 all return ReadOnly first.
  • Missing counter row at allocation time. Unreachable in practice: current defaults to 0 and floor is at least 1, so a read-write open always writes the row.
  • Counter below the true high-water when a write arrives. Needs an old binary's rows to land while a fixed store is already open — impossible cross-process under redb's flock, and the only in-process writer is the fixed allocator.
  • The seed's own read-then-write gap (read txns at :374-409, write txn at :423). Benign because it is a max: a concurrent store that raises the counter higher in between simply wins.

Breaks #3 (remove the seed) and #4 (change max to set-if-missing):

break #3: old_palace_without_a_seq_row_is_seeded_on_open   FAILED
          reopen_raises_a_counter_an_old_binary_left_behind FAILED   (36 passed; 2 failed)
break #4: reopen_raises_a_counter_an_old_binary_left_behind FAILED   (37 passed; 1 failed)

3. The bound — CONFIRMED SAFE, no degradation

MAX_ALLOC_PROBES = 8 cannot fail a healthy palace. hnsw_store.rs:202 sets candidate = high_water(vectors).max(candidate + 1), and high_water (:220) is vectors.last() + 1 — strictly greater than every id in the table. So probe 2 always succeeds; the budget of 8 is never approached. last() is O(log n) on a redb B-tree, so there is no O(n)-per-insert path. IdAllocationFailed is reachable only at u64::MAX saturation, where failing loud is correct.

4. Test honesty — CONFIRMED, 5 of 6 breaks reproduced

Baseline cargo test -p trusty-common --features memory-core,embedder-test-support --lib over the three affected modules: 38 passed; 0 failed. Each break red only its own tests; the rest stayed green.

Break applied Red Other tests
#1 pre-fix AtomicU64 allocator two_live_stores_over_one_file_never_alias_ids 37 green
#2 delete the vectors.get(candidate)?.is_none() probe upsert_refuses_to_reuse_an_id_already_present_in_vectors 37 green
#3 remove the open-time seed old_palace_without_a_seq_row_is_seeded_on_open, reopen_raises_a_counter_an_old_binary_left_behind 36 green
#4 seed as set-if-missing reopen_raises_a_counter_an_old_binary_left_behind 37 green
#5 embed_health drops the alias audit alias_audit_surfaces_a_collision 37 green

Tree restored, 38 passed; 0 failed again, git status --porcelain empty.

Break #3 fails one more test than the PR table claims (it also reds the rolling-upgrade test). That is a superset, not a shortfall.

5. Detection surface — arithmetic is sound, the error branch is not

audit_aliases (hnsw_store.rs:684-714) accumulates key_rows as a row counter and distinct_vector_ids as HashMap cardinality in one pass over VECTOR_KEYS. Neither is derived from the other, and neither consults drawer presence — so it cannot repeat the missing mistake. Correct.

The Err branch is the problem. See the finding below.

6. unalias() — the claim checks out

Search builds its reverse map at hnsw_store.rs:553-556 by iterating VECTOR_KEYS ascending and letting reverse.insert(id, uuid) overwrite, so the resolved uuid is the lexicographically last one; VECTORS independently holds whichever vector was written last. Unrelated, exactly as claimed — so freeing the whole group is the sound repair, not an over-correction.

The "reachable member also needs re-embedding" consequence is documented: hnsw_store.rs:716-733 states it, and unalias_marks_the_whole_group_for_reembed asserts all three members read as missing afterwards. I would keep the code. It is 30 lines, tested, read_only-guarded, marked 🔴 unwired at both layers, and reachable from no CLI or MCP surface — shipping it with the detection is better than a follow-up that has to re-derive the group semantics.

7. The gate trap — the feature set is right, and CI is covered

Every command I ran used --features memory-core,embedder-test-support, and the changed code compiled and ran under it. I also checked the narrower case: the new tests run under memory-core alone (12 passed). Since trusty-agents and trusty-memory both request trusty-common/memory-core, cargo's workspace feature unification means CI's cargo test --workspace (ci.yml:885) does exercise them. The bare--p trusty-common-exits-0 trap is real but does not reach CI.

Both --include-ignored failures are not yours:

  • timeout_fires_on_embedder_init_with_tiny_limit — I ran it isolated on this branch: test result: ok. 1 passed; 0 failed. Ordering artifact, confirmed.
  • cold_restart_recalls_beyond_l1_snapshot — fails here with every hit at layer: 1 and scores 0.32576936, 0.32574186, 0.32571387, …, i.e. monotonically-decaying near-identical scores from degenerate embeddings with no real model loaded. That signature cannot be produced by an id-allocator change, and nothing in this diff touches recall or embedder init. trusty-common: memory-core tests hit live HuggingFace model → CI 429 flake (same class as #813) #852 class, confirmed environmental.

Findings

Severity File Line Issue Fix Disposition
HIGH crates/trusty-common/src/memory_core/retrieval/embed_repair.rs 177-184 A failed alias audit is swallowed into (0, 0, Vec::new()), which makes is_healthy() true and palace_reembed report aliased: 0 — a false all-clear on the one signal #5005 exists to provide Carry the failure: add alias_audit_failed: bool to EmbedHealth, set it in the Err arm, and make is_healthy() return false when it is set. Surface it in palace_ops.rs's JSON so a deletion gate can see "unknown" instead of "clean" Fix here
MEDIUM crates/trusty-common/src/memory_core/store/hnsw_store.rs 191, 220-225 The uniqueness backstop probes VECTORS only. high_water does too, so the collision jump can land on an id that VECTOR_KEYS already claims — re-creating an alias through the exact door this PR closes On the (rare) collision path, take the high-water across both tables: scan VECTOR_KEYS for its max value and .max() it into the candidate. Matches what the open-time seed at :397-409 already does Fix here
LOW crates/trusty-common/src/memory_core/store/kg_store.rs 616-634 table_definitions_have_distinct_names was not extended with VECTOR_ID_SEQ, so the new table name is outside the collision guard Add VECTOR_ID_SEQ.name() to the names array Fix here

On the HIGH

This is not hypothetical severity. Break #5 substituted (0, 0, Vec::new()) for the audit — the exact value the Err arm produces — and alias_audit_surfaces_a_collision went red, including its assert!(!health.is_healthy()). The codebase's own test declares that state broken. The Err arm ships it as healthy.

The comment at :174-176 says the zeros "read as unknown rather than clean because the row count is zero too", but nothing consumes vector_key_rows == 0 that way. is_healthy() (:78-80) does not. The palace_reembed JSON does not. The PR body tells operators to "gate a deletion-bearing workflow on aliased" — and on a scan failure that gate passes.

One related line to fix at the same time: UsearchStore::alias_audit (vector.rs:426-432) drops unparseable uuids, so aliased_drawer_ids can be empty while key_rows > distinct_vector_ids. Making is_healthy() also require vector_key_rows == distinct_vector_ids closes that one in the same edit.

On the MEDIUM's reachability

The precondition is a VECTOR_KEYS row whose id has no VECTORS row. The seed at :397-409 already assumes that state is reachable ("in case VECTORS was cleared but the mapping survived (defensive)"), and there is a concrete producer: HnswStore::compact_orphans (:787-845) reads live_ids in one read txn, computes orphan_ids in a second, and removes in a third. An upsert that commits between the first and second — the in-process concurrency this PR argues is intended and load-bearing — has its brand-new VECTORS row classified as an orphan and deleted while its VECTOR_KEYS row survives.

That race is pre-existing and unchanged here, so I am not filing it against this PR. But it is what makes the MEDIUM worth the one-line widening rather than dismissing: the counter alone would still protect (it is seeded above the VECTOR_KEYS high-water), and the probe is the backstop for when the counter does not.


Required Changes

  1. embed_repair.rs:177-184 — stop reporting a failed alias audit as a clean one. is_healthy() must be false when the audit could not run, and palace_reembed must expose that so a deletion gate can distinguish "0 aliased" from "could not tell".

Notes

  • Out of scope, correctly, but worth stating for the follow-up: upsert reuses existing id without an alias check (:504-506), so on the owner's live palace an ordinary re-write of one member of the 5-uuid group at vector_id 988 still clobbers the other four. Unchanged behaviour, and the new detection surface now makes it visible, but the repair genuinely is still owed.
  • In-memory graph divergence between two live stores is real and correctly triaged as separate — redb holds every row and the next open rebuilds. Not a data-loss class.
  • Both changelog fragments are present, single-category, and correctly placed.
  • I ran nothing against the owner's palaces, bounced no daemon, and installed nothing. Worktree left clean at cc0ae392.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

bobmatnyc pushed a commit that referenced this pull request Aug 6, 2026
…bound allocation by both vector tables (#5005)

Three review findings on PR #5013.

1. HIGH — `embed_health` swallowed a failed alias audit into `(0, 0, [])`,
   which made `is_healthy()` true and `palace_reembed` report `aliased: 0` on
   the exact signal #5005 exists to provide. A failure branch leaving state
   that looks successful is the defect shape this PR was written to remove.
   `AliasAudit` is now an enum: `Measured { .. }` or `Unavailable { reason }`.
   `is_healthy()` is false for `Unavailable`, `palace_reembed` names the state
   (`clean` / `aliased` / `unavailable`) and reports null counts rather than
   zeros, and the Result-to-outcome mapping lives in `AliasAudit::from_scan`
   so `embed_health` has no error branch of its own left to get wrong.

2. MEDIUM — the allocator's `high_water` bound read `VECTORS` only, but a
   `VECTOR_KEYS` row can outlive its `VECTORS` row: `compact_orphans` splits
   the live-id read, the orphan computation, and the delete across three
   transactions, so an `upsert` landing between the first and the third has
   its brand-new id classed as an orphan and its vector row removed while the
   key survives. `high_water` now clears the highest id either table knows
   about, so the correction path cannot hand an id back to a surviving key.

3. LOW — `VECTOR_ID_SEQ` was missing from the table-name collision test.

Each fix has a test that fails without it, confirmed by breaking the named
mechanism: restoring the zeros in `from_scan`'s error arm, reverting
`high_water` to `vectors.last()? + 1`, and giving `VECTOR_ID_SEQ` a colliding
name each turn exactly one test red.

Refs #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc

Copy link
Copy Markdown
Owner Author

Verdict: APPROVE

Re-review scoped to the delta cc0ae392..c6a8be56. The seven-angle pass from the previous round is not repeated — serialisability, the migration windows, the bound, the honest test suite, and both --include-ignored claims stand as verified.

All three findings cleared. Every claim below was broken and observed red rather than read and believed.


Finding 1 (HIGH) — CLEARED

The Result → outcome mapping is now the only fallible constructor, and the shipped test drives it.

The test is the second version, not the vacuous one. embed_repair_tests.rs:880 calls AliasAudit::from_scan(Err(anyhow::anyhow!(...))) — the branch embed_health actually takes at embed_repair.rs:266 — not a hand-built enum value. I applied the zeros-restored break (from_scan's Err arm returning Measured { key_rows: 0, distinct_vector_ids: 0, aliased_drawer_ids: vec![] }):

thread '…::alias_audit_failure_is_never_reported_as_clean' panicked at
  crates/trusty-common/src/memory_core/retrieval/embed_repair_tests.rs:883:5
test result: FAILED. 39 passed; 1 failed

Red at line 883 — assert!(!unavailable.is_clean(), …), the first assertion — and only that test. The self-reported catch is real.

The test also carries its own control: the measured block at :913-921 asserts the same EmbedHealth shape with a measured-clean audit is healthy, so the !is_healthy() assertion above it is about the unknown state and not about some unrelated field.

No other path reaches a zero-valued audit that reads as clean. I checked every route into the type:

Route Result
#[derive(...)] on the enum (:45) Debug, Clone only — no Default, so there is no derived Measured{0,0,[]}
unwrap_or_default() / .default() on any audit none in the workspace
Production construction sites exactly one: from_scan at :266
is_clean() (:95) matches!(Measured { .. } if empty)Unavailable is structurally false, not false-by-value
counts() (:112) None for Unavailable → JSON null, never 0
alias_audit_state() (palace_ops.rs:326) tests unavailable_reason().is_some() first, so "unavailable" cannot be shadowed by "clean"

The enum is the right shape for this: "could not tell" is unrepresentable as a number, which is what makes the class of defect structurally gone rather than patched.

Finding 2 (MEDIUM) — CLEARED, and the O(n) claim is proven, not asserted

high_water (hnsw_store.rs:220-243) now bounds by both tables, and the compact_orphans three-transaction producer is documented at the function where a future reader will need it.

I did not take the "only on the correction path" claim on trust. I installed a panic! on entry to high_water and ran the full affected surface:

test result: FAILED. 38 passed; 2 failed
failures:
    upsert_refuses_an_id_that_only_vector_keys_still_claims
    upsert_refuses_to_reuse_an_id_already_present_in_vectors

Exactly the two correction-path tests. Everything else passed without ever entering high_water — including two_live_stores_over_one_file_never_alias_ids (20 upserts across two live stores over one file), upsert_and_search_round_trips, persist_and_reload, hydration_restores_index, compact_orphans_removes_dangling, and the whole vector-store and embed-repair suites.

So a healthy upsert costs one seq.get + one vectors.get + one seq.insert. The VECTOR_KEYS sweep is reachable only via (a) a missing counter row, which a read-write open always writes, or (b) a candidate already occupied in VECTORS, which requires the counter to be behind. No input reaches it on a healthy palace, so there is no per-insert O(n) regression on a large palace. And when it does fire, it fires once — the correction raises the counter past everything.

Finding 3 (LOW) — CLEARED, and the reasoning is sound

The author is right that deleting the VECTOR_ID_SEQ.name() entry cannot fail the test: table_definitions_have_distinct_names is a pairwise inequality over the array, so removing an element removes comparisons without creating a collision — it passes greener. The only break that means anything is an actual name collision. Renaming VECTOR_ID_SEQ to "vectors":

panicked at crates/trusty-common/src/memory_core/store/kg_store.rs:640:17:
assertion `left != right` failed
test result: FAILED. 0 passed; 1 failed

Correct choice of break.


Gate coverage — the reasoning holds, and I closed it rather than accepting it

You are right that an enum replacing a triple in a type crossing into palace_reembed's response shape is wider than a pure-internal change, so I went looking for a third consumer instead of reasoning about one. A workspace-wide grep for EmbedHealth, VectorBackfillReport, embed_health, backfill_missing_vectors, alias_audit, aliased_drawer_ids, vector_key_rows, and distinct_vector_ids returns files in exactly two crates — trusty-common and trusty-memory. Nothing outside them names or destructures either type. hnsw_store::AliasAudit (the older struct, which is Default-able and whose Default would read as clean) is not re-exported past its own module, so it cannot be reached from outside the store layer.

Rather than stop there I ran the two gates the author skipped:

$ cargo check -p trusty-agents --all-targets     → EXIT=0
$ cargo test  -p trusty-memory                   → 563 passed; 0 failed; 4 ignored (+ 21 further binaries, all ok)
$ cargo test  -p trusty-common --features memory-core,embedder-test-support
                                                 → 900 passed; 0 failed; 13 ignored
                                                 (+ 6, 11, 4, 3 in the further binaries, all ok)

The author's judgement was correct and is now evidenced. --include-ignored I am deliberately not requiring: I established at the prior head that both failures there are environmental — the timeout test passes isolated, and cold_restart fails with every hit at layer: 1 scoring 0.3257… from degenerate embeddings — and this delta touches strictly fewer paths than the one I already cleared.


Findings

Severity File Line Issue Fix Disposition
LOW crates/trusty-memory/src/tools/palace_ops.rs 313 "aliased" reports 0 for an Unavailable audit while its two neighbours report null — the one field where a number still stands in for "could not tell" report.alias_audit.counts().map(|_| report.alias_audit.aliased_drawer_ids().len()), so it nulls out with the rest. Or make the accessor return Option<&[Uuid]> and let the call site follow Fix here

This does not gate the APPROVE. The state word at :309, the error string at :310, and the two null counts at :311-312 all say "unavailable" in the same object, and the inline comment at :304-308 directs callers to gate on alias_audit == "clean". A consumer would have to read aliased while ignoring four adjacent signals. I flag it because it is the same reasoning the fix is built on, applied one field short, and it is a one-line change.

Notes

  • The accessor doc at embed_repair.rs:99-101 ("callers that branch on emptiness MUST check is_clean() instead — empty here means 'none found OR none looked for'") is the right warning in the right place. The LOW above is the one in-tree caller that takes the discouraged branch.
  • Two public types now share the name AliasAudit — the store-layer struct and the retrieval-layer enum. Only the enum is re-exported and they sit at different layers, so I am not flagging it; noting it so nobody re-derives the question later.
  • Still outstanding and correctly out of scope: unalias() remains unwired, and the 4 aliased drawers on the live trusty-tools palace are untouched. The repair is still owed.
  • I ran nothing against the owner's palaces, bounced no daemon, installed nothing. Worktree restored after every break; git status --porcelain empty at c6a8be56, full suite green again.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

bobmatnyc pushed a commit that referenced this pull request Aug 7, 2026
…bound allocation by both vector tables (#5005)

Three review findings on PR #5013.

1. HIGH — `embed_health` swallowed a failed alias audit into `(0, 0, [])`,
   which made `is_healthy()` true and `palace_reembed` report `aliased: 0` on
   the exact signal #5005 exists to provide. A failure branch leaving state
   that looks successful is the defect shape this PR was written to remove.
   `AliasAudit` is now an enum: `Measured { .. }` or `Unavailable { reason }`.
   `is_healthy()` is false for `Unavailable`, `palace_reembed` names the state
   (`clean` / `aliased` / `unavailable`) and reports null counts rather than
   zeros, and the Result-to-outcome mapping lives in `AliasAudit::from_scan`
   so `embed_health` has no error branch of its own left to get wrong.

2. MEDIUM — the allocator's `high_water` bound read `VECTORS` only, but a
   `VECTOR_KEYS` row can outlive its `VECTORS` row: `compact_orphans` splits
   the live-id read, the orphan computation, and the delete across three
   transactions, so an `upsert` landing between the first and the third has
   its brand-new id classed as an orphan and its vector row removed while the
   key survives. `high_water` now clears the highest id either table knows
   about, so the correction path cannot hand an id back to a surviving key.

3. LOW — `VECTOR_ID_SEQ` was missing from the table-name collision test.

Each fix has a test that fails without it, confirmed by breaking the named
mechanism: restoring the zeros in `from_scan`'s error arm, reverting
`high_water` to `vectors.last()? + 1`, and giving `VECTOR_ID_SEQ` a colliding
name each turn exactly one test red.

Refs #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc
bobmatnyc force-pushed the fix/5005-hnsw-id-aliasing branch from 5738e5d to 7bb2ac1 Compare August 7, 2026 03:37
bobmatnyc added a commit that referenced this pull request Aug 7, 2026
…unalias tool (#5005)

`unalias()` had zero call sites. #5013 stops new aliasing and makes existing
aliasing visible through `palace_reembed`'s audit, but the repair itself was
code an operator could not run — so the three aliased drawers in the live
trusty-tools palace stayed durable-but-unretrievable, which is what blocks
#4834.

Adds `PalaceHandle::repair_aliases` and the `palace_unalias` MCP tool over it.

Dry-run by default, mirroring `palace_reembed` — with more at stake, since this
one deletes `VECTOR_KEYS` rows. The result names the drawer id SET, never a
count: #5005 was a count (`missing: 0`) reporting all-clear over real loss, and
a repair answering "3 repaired" without saying which three is that same defect
one layer up. Those ids are also the operator's re-embed worklist.

It cannot fail open. `Repaired` is reachable only after a post-repair audit ran,
came back clean, and accounted for every id the pre-repair audit named; every
other ending is its own variant (`Partial`, `Unavailable`), and neither reads as
success. An unreadable audit refuses to write at all rather than deleting keys
blind. `UsearchStore::unalias` now returns `UnaliasOutcome` and carries the keys
it freed but could not parse into a drawer id — it used to drop them, which put
a freed drawer nobody knew to repair inside a reported success.

Idempotent: a second run finds no group, reports `clean`, and writes nothing.

Not run against any live palace — that stays an operator action, with a backup
taken immediately beforehand.

Closes #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc

Copy link
Copy Markdown
Owner Author

Code Critic — Verdict: WARN

Reviewed at head 26f980fa. Zero CRITICAL, one HIGH. The two halves do what they claim; the HIGH is the fail-open shape surviving one layer up from where the author killed it.

What I verified rather than read

All four mutation proofs reproduce exactly as the table claims (worktree reverted clean after each):

Mutation Observed
Defaultdry_run: false only repair_aliases_dry_run_names_the_group_and_changes_nothing fails
Drop unparsed_keys from the Repaired guard only ..._never_reports_success_over_a_partial_repair fails
Never call unalias 3 of 4 fail (frees_the_group, partial, then_reembed); dry-run test correctly still green
Restore the silent unparseable-key drop only ..._never_reports_success_over_a_partial_repair fails

Gates at this head — the EXIT=$? trap you flagged is real, so each was run with the status read directly, not transcribed:

  • check_test_pointers.shREAL_EXIT=0, "resolved 22145 Test: citation(s) — 0 dangling pointers — OK". Genuinely fixed.
  • cargo test -p trusty-common --features memory-core --lib memory_core504 passed; 0 failed; 3 ignored
  • cargo test -p trusty-memory564 passed; 0 failed; 4 ignored
  • cargo fmt --check, check_line_cap.sh (0 violations), check_changelog_fragment.sh (both crates recorded), cargo clippy -p trusty-common --features memory-core --all-targets -- -D warnings and same for trusty-memory → all exit 0. The 4 clippy warning: lines are pre-existing multiple build targets manifest noise in trusty-mpm / trusty-installer, not lints on this diff.

A first run of --lib memory_core without --features memory-core reported 0 passed; 294 filtered out — a vacuous green. Worth knowing: any gate on this module needs the feature flag or it silently tests nothing.

Allocator atomicity holds. two_live_stores_over_one_file_never_alias_ids is the right shape — two HnswStore over one Arc<Database>, which is exactly what vector_db_cache produces and exactly the #5005 mechanism. Pre-fix both stores seed a private AtomicU64 from an empty file and both issue 1, so the test genuinely discriminates. The read-and-increment sits inside the insert's write transaction and redb allows one writer per database, so it serialises.

MAX_ALLOC_PROBES is sound and fail-closed. The bound can only be consumed once: a collided candidate is ≤ VECTORS.last(), and high_water returns strictly greater than every VECTORS key, so probe 2 always succeeds. Exhaustion returns IdAllocationFailed, the ? drops the write txn uncommitted, and the upsert rolls back rather than aliasing. 8 is generous, not load-bearing.

Flaky-test claim verified. timeout_fires_on_embedder_init_with_tiny_limit and cold_restart_recalls_beyond_l1_snapshot live in retrieval/tests.rs and retrieval/timeout_tests.rs. Neither is in git diff --name-only origin/main...HEAD. The one diffed sibling, retrieval/mod.rs, is a re-export block only.


Findings

Severity File Line Issue Fix Disposition
HIGH crates/trusty-common/src/memory_core/store/vector.rs 446 alias_audit drops unparseable keys with filter_map(…ok()) — the same fail-open shape fixed in unalias, one layer earlier, in the detector Carry the unparsed keys, and make is_clean() / the repair_aliases early return consult key_rows vs distinct_vector_ids Fix here
MEDIUM crates/trusty-common/src/memory_core/retrieval/embed_repair.rs 429–441 The post-repair Unavailable branch fires after a destructive write and has no test Extract the decision into a pure fn over (&AliasAudit, &UnaliasOutcome, &[Uuid]) so both branches test without I/O injection Parent

HIGH — the detector can still report clean over a real collision

// vector.rs:446
.filter_map(|s| match Uuid::parse_str(s) {
    Ok(u) => Some(u),
    Err(e) => { tracing::warn!(...); None }   // <- group silently shrinks
})

I built a palace with a genuine collision — two VECTOR_KEYS rows on one vector_id, neither key a uuid — using the same redb-level fixture the author's own partial test uses. Observed:

raw alias_audit  -> key_rows=2 distinct=1 ids=[]
alias_audit.is_clean()      = true
embed_health.is_healthy()   = true
repair_aliases(dry_run:false) outcome = "clean"   is_success = true   freed_ids = []
after "repair"   -> key_rows=2 distinct=1        <- collision untouched

repair_aliases never reaches unalias: expected.is_empty() short-circuits at line 403, so the unparsed_keys guard that makes the mixed case Partial never runs. The arithmetic that would catch it is sitting in the same struct — hnsw_store.rs:255-261 calls key_rows vs distinct_vector_ids "the one comparison that cannot be fooled" — and is_clean() doesn't consult it.

Not CRITICAL: no supported write path can produce a non-uuid VECTOR_KEYS key (UsearchStore::upsert always writes Uuid::to_string()), so this needs an already-corrupt table, and palace_reembed does put vector_key_rows and distinct_vector_ids on the wire, so a human reading the payload sees 2/1 next to aliased: 0. HIGH rather than MEDIUM because the field that's wrong is the machine-readable one the PR explicitly tells callers to branch on ("require alias_audit == "clean" as well as missing == 0"), and #4834's deletion gate is the caller.

Concretely:

// embed_repair.rs:94
pub fn is_clean(&self) -> bool {
    matches!(
        self,
        Self::Measured { key_rows, distinct_vector_ids, aliased_drawer_ids }
            if aliased_drawer_ids.is_empty() && key_rows == distinct_vector_ids
    )
}

plus the same shortfall check on the expected.is_empty() early return at line 403, so an all-unparseable group falls through to unalias and ends as Partial — which is the correct ending, since the worklist genuinely cannot be named.

For the record, the two sibling filter_maps are not findings: all_ids (vector.rs:412) dropping a key makes the drawer read as missing, so the backfill re-embeds it — fails closed. compact_orphans (vector.rs:508) skipping one leaves data in place — also closed. Only the audit path fails open.

MEDIUM — the untested branch that matters is the second one, not the first

I accept the reasoning for dropping repair_aliases_refuses_to_run_on_an_unreadable_audit, and I checked it rather than taking it: after open_with_mode creates VECTOR_KEYS in a write txn, no public API can make audit_aliases fail while leaving the store constructible. Hand-constructing AliasAudit::Unavailable would have been vacuous — you cannot hand it to repair_aliases, which builds its own, so the test would collapse into AliasRepairOutcome::Unavailable.is_success() == false, which an_unavailable_or_partial_repair_is_never_a_success already asserts. And alias_audit_failure_is_never_reported_as_clean covers from_scan(Err(..)) → Unavailable through the real constructor. The Option-returning aliased_drawer_ids() makes the branch shape compiler-enforced, which is the strongest argument of the set.

The doc comment marks the pre-repair half as unproven. The half worth marking is the post-repair one at 429–441: it runs after keys have already been deleted, and "wrote, then couldn't verify" is a materially worse state than "refused to write". A fault-injection seam is not worth it, but a pure decision function is nearly free and covers both:

fn classify(before: &AliasAudit, after: &AliasAudit, freed: &UnaliasOutcome, expected: &[Uuid])
    -> AliasRepairOutcome

Callable directly with a hand-built Unavailable for after, no indirection in the production path, and it would have caught the HIGH above as a natural extension.


Does landing this unblock #4834?

Not on its own, and the honest answer is "run the dry run to find out."

  • If the three drawers are aliased in the VECTOR_KEYS sense, palace_unalias (dry run, then dry_run:false) followed by palace_reembed repairs them. repair_aliases_then_reembed_makes_a_lost_drawer_retrievable drives that exact operator sequence and asserts each previously-unretrievable drawer answers its own query.
  • That diagnosis is unconfirmed. palace_reembed reporting missing: 0 over unretrievable drawers is consistent with aliasing, but equally consistent with trusty-common: memory-core tests hit live HuggingFace model → CI 429 flake (same class as #813) #852 degenerate embeddings — and the second --include-ignored failure, cold_restart_recalls_beyond_l1_snapshot with every hit pinned at ~0.3257, is that signature in this same crate. Aliasing and degenerate embeddings produce the same missing: 0.
  • The e2e test uses MockEmbedder, so it proves the key/vector plumbing, not that the real embedder returns a retrievable vector for those three.

The next step is palace_unalias with the default dry run against the live palace — read-only, and it prints aliased_before_ids. If it names the three, the chain works. If it returns clean while the drawers stay unfindable, the cause is elsewhere and #4834 needs #852, not this. Given the HIGH above, also read vector_key_rows and distinct_vector_ids off palace_reembed directly rather than trusting alias_audit: "clean".

Required changes

  1. vector.rs:446 / embed_repair.rs:94,403 — close the audit fail-open so a collision whose keys don't parse cannot report clean / is_success. One regression test asserting the probe above ends as partial, not clean.

Everything else is sound: the persisted allocator is the right mechanism, the Repaired guard is genuinely three-part, the dry-run default is wired correctly through the MCP layer (44 → 45 asserted in both lib_tests.rs and tools/tests.rs), and freed_ids is a set throughout.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

bobmatnyc pushed a commit that referenced this pull request Aug 7, 2026
…bound allocation by both vector tables (#5005)

Three review findings on PR #5013.

1. HIGH — `embed_health` swallowed a failed alias audit into `(0, 0, [])`,
   which made `is_healthy()` true and `palace_reembed` report `aliased: 0` on
   the exact signal #5005 exists to provide. A failure branch leaving state
   that looks successful is the defect shape this PR was written to remove.
   `AliasAudit` is now an enum: `Measured { .. }` or `Unavailable { reason }`.
   `is_healthy()` is false for `Unavailable`, `palace_reembed` names the state
   (`clean` / `aliased` / `unavailable`) and reports null counts rather than
   zeros, and the Result-to-outcome mapping lives in `AliasAudit::from_scan`
   so `embed_health` has no error branch of its own left to get wrong.

2. MEDIUM — the allocator's `high_water` bound read `VECTORS` only, but a
   `VECTOR_KEYS` row can outlive its `VECTORS` row: `compact_orphans` splits
   the live-id read, the orphan computation, and the delete across three
   transactions, so an `upsert` landing between the first and the third has
   its brand-new id classed as an orphan and its vector row removed while the
   key survives. `high_water` now clears the highest id either table knows
   about, so the correction path cannot hand an id back to a surviving key.

3. LOW — `VECTOR_ID_SEQ` was missing from the table-name collision test.

Each fix has a test that fails without it, confirmed by breaking the named
mechanism: restoring the zeros in `from_scan`'s error arm, reverting
`high_water` to `vectors.last()? + 1`, and giving `VECTOR_ID_SEQ` a colliding
name each turn exactly one test red.

Refs #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc
bobmatnyc force-pushed the fix/5005-hnsw-id-aliasing branch from 26f980f to 4f53bd3 Compare August 7, 2026 04:31
bobmatnyc added a commit that referenced this pull request Aug 7, 2026
…unalias tool (#5005)

`unalias()` had zero call sites. #5013 stops new aliasing and makes existing
aliasing visible through `palace_reembed`'s audit, but the repair itself was
code an operator could not run — so the three aliased drawers in the live
trusty-tools palace stayed durable-but-unretrievable, which is what blocks
#4834.

Adds `PalaceHandle::repair_aliases` and the `palace_unalias` MCP tool over it.

Dry-run by default, mirroring `palace_reembed` — with more at stake, since this
one deletes `VECTOR_KEYS` rows. The result names the drawer id SET, never a
count: #5005 was a count (`missing: 0`) reporting all-clear over real loss, and
a repair answering "3 repaired" without saying which three is that same defect
one layer up. Those ids are also the operator's re-embed worklist.

It cannot fail open. `Repaired` is reachable only after a post-repair audit ran,
came back clean, and accounted for every id the pre-repair audit named; every
other ending is its own variant (`Partial`, `Unavailable`), and neither reads as
success. An unreadable audit refuses to write at all rather than deleting keys
blind. `UsearchStore::unalias` now returns `UnaliasOutcome` and carries the keys
it freed but could not parse into a drawer id — it used to drop them, which put
a freed drawer nobody knew to repair inside a reported success.

Idempotent: a second run finds no group, reports `clean`, and writes nothing.

Not run against any live palace — that stays an operator action, with a backup
taken immediately beforehand.

Closes #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc

Copy link
Copy Markdown
Owner Author

code-critic — round 3 @ 4f53bd32

Verdict: APPROVE

The round-2 HIGH is closed. I re-ran all three mutations myself rather than
trusting the table; each one goes red on the named test and only on that test,
and the restored tree returns to green.

Mutation re-verification (independent, not taken on trust)

Gate: cargo test -p trusty-common --features memory-core --lib memory_core.
Note the feature flag — without --features memory-core this filter matches
0 of 294 tests and exits 0, which is a silent-skip trap for anyone
re-running this.

# Break applied Result
0 none (baseline) test result: ok. 507 passed; 0 failed; 3 ignored; 411 filtered out
1 is_clean back to aliased_drawer_ids.is_empty() alone FAILED. 506 passed; 1 faileda_collision_whose_keys_do_not_parse_is_never_clean panics at embed_repair_tests.rs:1381, "a collision the audit cannot name is still a collision"
2 repair gate back to expected.is_empty() FAILED. 506 passed; 1 failed — same test, embed_repair_tests.rs:1395, left: "clean" / right: "partial"
3 classify_repair's Unavailable arm falls through FAILED. 506 passed; 1 failedclassify_refuses_to_call_an_unverified_write_repaired, left: "partial" / right: "unavailable"
4 all restored git diff --stat empty; test result: ok. 507 passed; 0 failed; 3 ignored

One correction to the PR's own framing on mutation 3: with the Unavailable
arm removed the outcome degrades to Partial, not Repaired, because
after.is_clean() is a second term in the Repaired guard. The test still
goes red, and is_success() stays false either way. The guard is doubly
covered, which is better than claimed, not worse.

The counts claim, checked against the query

Verified at crates/trusty-common/src/memory_core/store/hnsw_store.rs:704-734,
not the doc comment. key_rows increments once per VECTOR_KEYS row inside
the iterator; distinct_vector_ids is by_id.len() keyed on the raw u64
value. No parse, no filter, no filter_map anywhere on that path. A mid-scan
redb error propagates through entry? and becomes Unavailable. The two
counts are copied verbatim through UsearchStore::alias_audit
(vector.rs:479-483) and AliasAudit::from_scan (embed_repair.rs:85-90).
The claim holds.

Is the fix reachable?

Partly proven, and the gap is worth naming.

  • The library success path is observed producing a non-zero result:
    repair_aliases_frees_the_group_and_verifies_it frees 3 real ids and ends
    repaired, and repair_aliases_then_reembed_makes_a_lost_drawer_retrievable
    drives repair → backfill → retrieve_l2 and asserts previously-unretrievable
    drawers answer their own queries.
  • The daemon path is proven only for the empty case. The single dispatch
    test runs against a palace with nothing aliased, so it proves routing, arg
    parsing and palace resolution work — the "completely inert at argv" failure
    mode is ruled out — but outcome: "repaired" with a non-empty freed_ids
    has never been produced through dispatch_tool. See MEDIUM 2.

The MockEmbedder boundary

MockEmbedder::hash_to_vec (crates/trusty-common/src/embedder/mock.rs:32-42)
is a deterministic content-dependent hash, so the three seeded drawers get
three distinct vectors and each retrieves itself by its own text. That is
enough to prove the thing this PR actually fixes: the drawer-uuid ↔ vector-id
mapping, the HNSW round trip, and that freeing a group plus re-embedding
restores retrievability. What it does not prove is anything about a real
ONNX embedder's output quality, which is not what #5005 is about. The
engineer's caveat is accurate and does not undercut the coverage.

Fail-open sweep

Every failure branch the diff adds or touches fails closed:

  • scan error → Unavailable, never zeros (AliasAudit::from_scan), and
    is_clean()/is_healthy() are both false for it
  • pre-repair audit unreadable → refuses to write, returns Unavailable
    (embed_repair.rs:481-492)
  • post-repair audit unreadable → Unavailable even though the delete
    succeeded (classify_repair)
  • allocator cannot find a free id → IdAllocationFailed, upsert fails rather
    than overwriting a live vector
  • all_ids() redb failure → empty set → every drawer reads as missing →
    unhealthy (fail-closed direction; pre-existing)

No arm advances state, downgrades to a default, or reports success on failure.

Partial semantics

AliasRepairOutcome::is_success() is matches!(self, Clean | Repaired)
Partial and Unavailable are false by construction. The only production
consumer in the tree is handle_palace_unalias
(crates/trusty-memory/src/tools/palace_ops.rs:337-397), which emits
"success": false and populates still_aliased_ids / not_freed_ids /
unparsed_keys. Nothing else reads AliasRepairReport. A Partial cannot be
read as success anywhere in-tree.

Findings

Severity File Line Issue Fix Disposition
MEDIUM crates/trusty-memory/src/tools/definitions.rs 299 palace_reembed's tool description still reads "report drawers that have no vector … Defaults to a dry run." The PR adds alias_audit, vector_key_rows, distinct_vector_ids and aliased to that payload, and the source comment at palace_ops.rs:303-308 tells callers to "Gate deletions on alias_audit == \"clean\" as well as missing == 0" — but the description an MCP caller actually reads never says so. The guard is correct and the caller is never told to reach it. This is the surface #4834's deletion gate will read. Extend the description: "missing: 0 is NOT sufficient — an aliased drawer HAS a vector key and is still unretrievable. Also require alias_audit == \"clean\"; \"unavailable\" means the scan failed and is a block, not a pass. Repair with palace_unalias." Fix here
MEDIUM crates/trusty-memory/src/tools/tests.rs 253-280 dispatch_palace_unalias_dry_run_names_ids_and_writes_nothing runs against a freshly created palace, so it only ever reaches outcome: "clean" with freed_ids: []. Through dispatch_tool the destructive branch has never produced a non-zero result — including the is_read_only() check inside repair_aliases, which a dry run skips entirely. If the daemon opens the palace read-only, every real palace_unalias run errors and the dry-run test still passes. Add a dispatch test that seeds a collision into the test palace's idx.usearch.redb (the seed_aliased_vector_file pattern already exists in embed_repair_tests.rs:752), calls palace_unalias with dry_run: false, and asserts outcome == "repaired", non-empty freed_ids, and reembed_required == true. Fix here
MEDIUM crates/trusty-common/src/memory_core/store/hnsw_store.rs 738-739 The 🔴 doc block on HnswStore::unalias says "Not wired to any CLI or MCP surface, and never run against a live palace in the PR that added it (#5005)". This PR wires it — palace_unaliasrepair_aliasesUsearchStore::unalias → here. Half the warning is now false, and it is the half a reader uses to judge whether the repair is reachable. Replace with: "🔴 Never run against a live palace. Reached through palace_unaliasPalaceHandle::repair_aliases, which adds the dry run and the post-repair verification; call those, not this." Fix here
LOW crates/trusty-common/src/memory_core/retrieval/embed_repair.rs 481-492 The pre-repair Unavailable arm — refuse to write when the audit could not run — has no test through the real path. an_unavailable_or_partial_repair_is_never_a_success asserts the enum contract only, and classify_repair covers the post-repair audit. The same extract-a-pure-function move that made the post branch reachable was not applied here. Fails closed, so the risk is a silent regression rather than data loss. Either extract the pre-repair gate the way classify_repair was extracted, or accept it and say so in the doc comment's coverage note, as an_unavailable_or_partial_repair_is_never_a_success already does for its own branch. Parent
LOW crates/trusty-memory/src/tools/palace_ops.rs 369-372 "error" is populated only for Unavailable. A Partial run carries error: null alongside success: false, so a caller that checks error == null reads an incomplete destructive repair as fine. outcome and success are both correct; this is the third field disagreeing in tone. Populate error on Partial too, e.g. "freed the group but the worklist is incomplete: N unnameable keys, M still aliased". Parent

Notes

  • Not flagged, below 80 %: allocate_vector_id (hnsw_store.rs:192-195)
    probes VECTORS only on the first attempt, while high_water correctly
    consults both VECTORS and VECTOR_KEYS. A VECTOR_KEYS row can outlive
    its VECTORS row — the PR documents that state itself and tests it in
    upsert_refuses_an_id_that_only_vector_keys_still_claims, but only with the
    counter row absent. Reaching it with the counter present but stale needs a
    hand-edited file or a pre-trusty-memory: HnswStore vector-id allocator aliases across processes — upsert has no uniqueness check, silently overwrites drawers #5005 binary writing between two opens, and
    open-time seeding raises the counter to max(VECTORS, VECTOR_KEYS) + 1
    while redb's exclusive lock bars two live writer processes. I can't assert
    it is reachable, so it is a note, not a finding.
  • No tests were deleted. Every test function present on origin/main in
    vector.rs and hnsw_store.rs is still present after the split into
    vector/tests.rs and hnsw_store/tests.rs (verified by comm over sorted
    function lists). The −331 / −193 in the diffstat is relocation, not removal.
  • cold_restart_recalls_beyond_l1_snapshot is pre-existing on origin/main
    — do not hold this PR for it.
    It is #[ignore]d ("requires real ONNX
    embedder (issue consolidate trusty-mpm to a single binary (6 [[bin]] → 1) #850)") and only surfaces under --include-ignored. I ran
    it on both trees. Branch 4f53bd32: FAILED. 0 passed; 1 failed, hits at
    0.32576936, 0.32574186, 0.32571387, …. origin/main e97e40e6f:
    FAILED. 0 passed; 1 failed, hits at 0.32576936, 0.32574186, 0.32571387, … — bit-identical scores. The failure is the mock hash embedder standing in
    for ONNX, unrelated to this branch.

Gates run

cargo test -p trusty-common --features memory-core --lib memory_core
  → test result: ok. 507 passed; 0 failed; 3 ignored; 0 measured; 411 filtered out

cargo test -p trusty-memory
  → test result: ok. 564 passed; 0 failed; 4 ignored (lib)
  → all 21 further test binaries ok, 0 failed

( bash scripts/check_line_cap.sh
  && cargo fmt --check
  && cargo clippy -p trusty-common --features memory-core --all-targets -- -D warnings
  && cargo clippy -p trusty-memory --all-targets -- -D warnings )
  → EXIT=0

Test ladder: rung 4 (cross-crate — trusty-common library plus its
trusty-memory consumer). Changelog fragments present for both crates.

Zero CRITICAL, zero HIGH → APPROVE. The three MEDIUMs are all one-edit
fixes; MEDIUM 1 should land before #4834 builds a deletion gate on
palace_reembed.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

bob-duetto and others added 8 commits August 7, 2026 07:58
…stores cannot alias drawers (#5005)

`HnswStore` allocated vector ids from a process-local `AtomicU64` seeded at
open from `max(VECTORS, VECTOR_KEYS) + 1`, and `upsert` inserted at the
allocated id with no uniqueness check. Two live stores over one palace file —
a configuration the in-process vector-db cache deliberately supports, since it
hands the same `Arc<Database>` to every `UsearchStore` opened for a palace —
each seeded a private counter from the same high-water mark and then issued the
same ids. `VECTOR_KEYS` aliased several drawers onto one `vector_id` and
`VECTORS` overwrote in place, silently: on the live `trusty-tools` palace,
`vector_id` 988 is shared by 5 uuids and 4 of them are embedded nowhere.

The counter now lives in redb (`vector_id_seq`) and is reserved inside the same
write transaction that writes the row claiming it, so every writer on the file
serialises against it. `upsert` additionally refuses an id that already has a
`VECTORS` row — it allocates past it, or fails with `IdAllocationFailed`, but
never overwrites.

Existing palaces have no counter row. Every read-write open raises it to
`max(VECTORS, VECTOR_KEYS) + 1`, as a max rather than a set-if-missing, so the
seed is idempotent and a rolling upgrade that interleaves a pre-fix binary
cannot leave the counter behind the tables.

Detection, which #5000 also needs: `embed_health` and `palace_reembed` now
report `vector_key_rows`, `distinct_vector_ids`, and the aliased drawer ids.
Key presence — the only thing they checked before — reported a false all-clear
for this class, and `is_healthy()` is now false when any drawer is aliased.

Closes #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
…bound allocation by both vector tables (#5005)

Three review findings on PR #5013.

1. HIGH — `embed_health` swallowed a failed alias audit into `(0, 0, [])`,
   which made `is_healthy()` true and `palace_reembed` report `aliased: 0` on
   the exact signal #5005 exists to provide. A failure branch leaving state
   that looks successful is the defect shape this PR was written to remove.
   `AliasAudit` is now an enum: `Measured { .. }` or `Unavailable { reason }`.
   `is_healthy()` is false for `Unavailable`, `palace_reembed` names the state
   (`clean` / `aliased` / `unavailable`) and reports null counts rather than
   zeros, and the Result-to-outcome mapping lives in `AliasAudit::from_scan`
   so `embed_health` has no error branch of its own left to get wrong.

2. MEDIUM — the allocator's `high_water` bound read `VECTORS` only, but a
   `VECTOR_KEYS` row can outlive its `VECTORS` row: `compact_orphans` splits
   the live-id read, the orphan computation, and the delete across three
   transactions, so an `upsert` landing between the first and the third has
   its brand-new id classed as an orphan and its vector row removed while the
   key survives. `high_water` now clears the highest id either table knows
   about, so the correction path cannot hand an id back to a surviving key.

3. LOW — `VECTOR_ID_SEQ` was missing from the table-name collision test.

Each fix has a test that fails without it, confirmed by breaking the named
mechanism: restoring the zeros in `from_scan`'s error arm, reverting
`high_water` to `vectors.last()? + 1`, and giving `VECTOR_ID_SEQ` a colliding
name each turn exactly one test red.

Refs #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
…udit (#5005)

`palace_reembed` reported `"aliased": 0` when the alias audit could not run,
while the two fields beside it correctly reported null. Nothing is misled
today — four adjacent signals in the same object say `unavailable` — but a
zero standing in for "I could not tell" is the exact defect class this PR
exists to eliminate, and a consumer reading that one field in isolation is
the failure mode #5005 documents.

Fixed at the source rather than at the consumer: `AliasAudit::aliased_drawer_ids`
returned `&[]` for `Unavailable`, so a caller could reach a length without ever
deciding what to do about the unknown. It now returns `Option<&[Uuid]>`, which
makes the zero unrepresentable — `Some(&[])` means "looked, found nothing", the
only state a zero legitimately describes. `aliased` and `aliased_ids` are both
null when the audit did not run.

Systematic sweep of the rest of the payload and its siblings found no second
instance of this failure mode. Two other swallow sites exist and are
deliberately unchanged: `UsearchStore::all_ids` returns an empty vec on a redb
scan failure, which makes every live drawer read as missing — fail-CLOSED, it
over-reports and blocks, so it cannot produce a false all-clear; and
`embed_ledger::load` treats an unreadable ledger as empty, which is #4906's
tested, deliberate behaviour and feeds context rather than a gate.

Refs #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
…unalias tool (#5005)

`unalias()` had zero call sites. #5013 stops new aliasing and makes existing
aliasing visible through `palace_reembed`'s audit, but the repair itself was
code an operator could not run — so the three aliased drawers in the live
trusty-tools palace stayed durable-but-unretrievable, which is what blocks
#4834.

Adds `PalaceHandle::repair_aliases` and the `palace_unalias` MCP tool over it.

Dry-run by default, mirroring `palace_reembed` — with more at stake, since this
one deletes `VECTOR_KEYS` rows. The result names the drawer id SET, never a
count: #5005 was a count (`missing: 0`) reporting all-clear over real loss, and
a repair answering "3 repaired" without saying which three is that same defect
one layer up. Those ids are also the operator's re-embed worklist.

It cannot fail open. `Repaired` is reachable only after a post-repair audit ran,
came back clean, and accounted for every id the pre-repair audit named; every
other ending is its own variant (`Partial`, `Unavailable`), and neither reads as
success. An unreadable audit refuses to write at all rather than deleting keys
blind. `UsearchStore::unalias` now returns `UnaliasOutcome` and carries the keys
it freed but could not parse into a drawer id — it used to drop them, which put
a freed drawer nobody knew to repair inside a reported success.

Idempotent: a second run finds no group, reports `clean`, and writes nothing.

Not run against any live palace — that stays an operator action, with a backup
taken immediately beforehand.

Closes #5005

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
…vived (#5005)

`repair_aliases_refuses_to_run_on_an_unreadable_audit` was dropped: the branch
it targeted is only reachable on a redb read error, and every fixture that
breaks that read also breaks `UsearchStore::new`, so the store cannot be built
in the state the test needed. `an_unavailable_or_partial_repair_is_never_a_success`
covers the contract instead. Two doc pointers still cited the removed name and
`check_test_pointers.sh` flagged both.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
…l had (#5005)

Review HIGH. The `filter_map(…ok())` shape fixed in `UsearchStore::unalias`
survived one layer up, in `alias_audit` — the detector rather than the repair.
A collision group whose keys are not uuids shrank to nothing, and `is_clean()`
tested only that id list. Reproduced before fixing:

  raw alias_audit -> key_rows=2 distinct=1 ids=[]
  is_clean=true  is_healthy=true
  repair_aliases(dry_run:false) outcome=clean is_success=true freed_ids=[]
  after "repair" -> key_rows=2 distinct=1        <- collision untouched

Two rows on one vector_id is a real collision, reported as clean and left in
place — this PR's own defect, on the field it tells callers to branch on, with
#4834's deletion gate as the caller.

`alias_audit` now carries the unnameable keys instead of dropping them, and
`is_clean()` consults `key_rows` vs `distinct_vector_ids`. Those counts come
straight off `VECTOR_KEYS`; no parse can shrink them, which is what makes them
the signal that cannot be fooled. `repair_aliases` gates on `is_clean()` rather
than `expected.is_empty()`, so an all-unnameable group falls through to
`unalias` and ends as `Partial` — freed, with the worklist honestly incomplete.

Also extracts `classify_repair`, the post-repair decision, as a pure function
over `(&AliasAudit, &UnaliasOutcome, &[Uuid])`. That branch fires after keys are
already deleted, so "wrote, then could not verify" is the ending most worth
proving, and taking the audit as a parameter makes it reachable without a
fault-injection seam. No indirection added to the production path: the function
tested is the function called.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
… the write path (#5005)

Review round 2, three MEDIUMs, fixed in this PR.

1. `palace_reembed`'s tool description omitted the guard the payload gained.
   The text an MCP caller reads still promised only "drawers that have no
   vector", so a correct guard nobody is told to reach had moved the capstone
   shape from the code into the tool contract — with #4834's deletion gate as
   the reader. It now says `missing: 0` is not a complete account of what is
   retrievable, and to act only on `alias_audit.is_clean`.

2. The daemon success path had never produced a non-zero result: every
   `dispatch_tool` test ran against an empty palace, so `outcome: "repaired"`
   was proven at the store layer and assumed through the tool — including the
   `is_read_only()` routing a dry run skips. Adds a test that seeds two uuids
   onto one `vector_id` in the palace's own `index.usearch.redb` and drives
   `dry_run: false` through `dispatch_tool`, asserting both uuids in
   `freed_ids`, `reembed_required: true`, and `clean` on a second call.
   Forcing `dry_run = true` in the handler fails it (`outcome` "planned",
   `success` false), so the assertion is load-bearing.

3. Stale 🔴 block on `HnswStore::unalias` still said it was wired to no MCP
   surface; this PR wires it. Corrected to name the `palace_unalias` path and
   keep only the claim that is still true — no live-palace run.

`postcard` joins trusty-memory's dev-dependencies so the seeded vector encodes
at the real 384 dimensions; HnswStore rejects the file otherwise. One line in
Cargo.lock, no refresh.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
@bobmatnyc
bobmatnyc force-pushed the fix/5005-hnsw-id-aliasing branch from f8d7eaa to 1c409c0 Compare August 7, 2026 12:05
@bobmatnyc

Copy link
Copy Markdown
Owner Author

Rebased onto 3c2d787f (current main) — now 1c409c00

The conflict was #5048, as predicted. One file: crates/trusty-memory/src/tools/tests.rs, an append-vs-append at the file tail where both sides added tests. Resolved as the union — both sides' assertions were truncated mid-assert sharing one trailing );/}, so each side got its own closer rather than one serving both. Both sides' tests confirmed present and passing:

test tools::tests::a_failed_index_call_queues_the_palace_for_repair ... ok   (#5048)
test tools::tests::dispatch_palace_unalias_frees_a_real_collision_and_is_idempotent ... ok   (#5005)

Cargo.lock also auto-merged. Checked semantically rather than trusted: my delta against new main is exactly the one intended postcard line, and cargo metadata regenerates the file byte-identical.

Full gate re-run after the rebase

cargo fmt --all --check                                    → EXIT=0
cargo clippy -p trusty-common --features memory-core,embedder-test-support
      --all-targets -- -D warnings                         → EXIT=0
cargo clippy -p trusty-memory --all-targets -- -D warnings → EXIT=0
cargo test -p trusty-common --features memory-core,embedder-test-support
  test result: ok. 910 passed; 0 failed; 13 ignored          (EXIT=0)
cargo test -p trusty-memory
  test result: ok. 603 passed; 0 failed; 4 ignored           (EXIT=0)
check_line_cap.sh → 0 violations          check_sld.sh → 0 errors, 0 warnings
check_changelog_fragment.sh → 2/2 crates  check_test_pointers.sh → 22279 resolved, 0 dangling

Counts rose with main's new tests (common 908→910, memory 565→603); zero failures on either crate.

One thing worth flagging beyond this PR: cargo test -p trusty-memory on macOS regenerates crates/trusty-memory/ui/package-lock.json and strips the Linux libc fields from every optional rollup dep (39 deletions). Reverted here so it stays out of the diff, but any macOS contributor running the crate's tests will keep re-introducing it.

🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools

@bobmatnyc
bobmatnyc merged commit c9d6637 into main Aug 7, 2026
30 checks passed
@bobmatnyc
bobmatnyc deleted the fix/5005-hnsw-id-aliasing branch August 7, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trusty-mpm trusty-mpm platform and related work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

trusty-memory: HnswStore vector-id allocator aliases across processes — upsert has no uniqueness check, silently overwrites drawers

2 participants