Skip to content

GH #81: Surface b2id collisions and identity restamps, never auto-fix - #84

Merged
samkeen merged 2 commits into
mainfrom
claude/b2-duplicate-id-strategy-nkm59i
Jul 25, 2026
Merged

GH #81: Surface b2id collisions and identity restamps, never auto-fix#84
samkeen merged 2 commits into
mainfrom
claude/b2-duplicate-id-strategy-nkm59i

Conversation

@samkeen

@samkeen samkeen commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Implements the GH #81 specification for detecting and surfacing cross-note b2id collisions and identity restamps during projection passes, with incumbent-wins collision resolution and no automatic fixes (W4 — the human decides).

Summary

When two or more files present the same b2id (e.g., a note duplicated in Finder), or when a file's b2id line is externally removed/blanked causing an identity change, B2 now:

  • Detects collisions via a pre-pass scan before any rows are written
  • Resolves incumbent-wins: the path the index already attributes the id to keeps it (the one confident signal); on a memory-less rebuild, first-in-sorted-walk tie-breaks reproducibly
  • Surfaces, never fixes: collision and restamp notices re-appear on every pass until the human resolves (delete the copy, remove its b2id: line, or delete the original)
  • Guards single-note ingest: add/link/mv refuse to project a file claiming an id whose incumbent still exists and claims it, preventing silent shadowing

Key Changes

Core engine (crates/b2-core/src/ingest.rs):

  • Refactored ProjectedNote from a 5-tuple to a struct carrying restamped_from: Option<String> to track identity churn
  • Added B2idCollision, CollisionPrecedence, and RestampedNote types to represent the anomalies
  • Implemented scan_b2id_claims() to pre-scan all files' claimed ids before projection
  • Implemented resolve_collisions() to decide contested ids: incumbent-wins when the index remembers, tie-break on first-in-walk otherwise
  • Implemented guard_single_note_collision() to refuse identity steals in single-note ingest
  • Updated project_note_and_chunks() to detect when a stamp changes an identity (external blank/removal)
  • Updated ProjectOutcome and IngestOutcome to carry collisions and restamped vectors

Vault façade (crates/b2-core/src/vault.rs):

  • Re-exported B2idCollision, CollisionPrecedence, RestampedNote for the public contract
  • Added collisions and restamped fields to ReindexReport and ProjectReport
  • Added PlannedRestamp and updated ReindexPlan to preview restamps in dry-run

Error handling (crates/b2-core/src/error.rs):

  • Added B2idCollision error variant for single-note ingest refusals

CLI (crates/b2-cli/src/main.rs):

  • Added dry-run output showing which notes lack ids, which would restamp, and collision details
  • Added reindex output reporting collisions and restamps with explanations (incumbent vs. tie-break)

Desktop UI (ui/src/):

  • Updated reconcile.ts to pass projection reports to onReport callback (for pulse notices)
  • Implemented anomalyNotice() to generate compact, actionable flash messages per anomaly
  • Updated main.ts to display anomaly notices in reindex flashes
  • Updated types.ts with TypeScript interfaces for collisions and restamps

Tests (crates/b2-core/tests/collision.rs):

  • Comprehensive test suite covering: incumbent-wins over walk-order, tie-break on memory-less rebuild, collision resolution by deletion/fork/move, external blank detection, dry-run preview, and single-note ingest guard

Notable Details

  • No auto-fix: collisions and restamps are surfaced on every pass until resolved; B2 never edits either file
  • Incumbent signal: the index's memory of which path holds an id is the only confident way to distinguish original from copy (copies preserve every byte)
  • Tie-break reproducibility: on a from-scratch rebuild (no incumbent memory), first-in-sorted-walk keeps the id purely for

https://claude.ai/code/session_01TXYN4zb8iFtLPP1mHm1KuQ

Summary by CodeRabbit

  • New Features

    • Reindexing now detects duplicate note identities and deterministically keeps one copy while reporting shadowed files.
    • Reindex dry-run previews identity stamps, restamps, and collisions without changing files.
    • Identity changes report old and new values, including potential broken-link impact.
    • The desktop app displays anomaly warnings during reindexing and external-change updates.
    • Projected but unembedded vaults now fall back to BM25-only search.
  • Bug Fixes

    • Prevented single-note operations from silently taking over another note’s identity.
  • Documentation

    • Documented collision handling, identity restamping, and dry-run behavior.

…y restamps (GH #81)

Reindex-time diagnostics for the two external-edit pathologies #79 left open,
surfacing only — no auto-fix, no writes beyond W1's missing-id stamp (W4).

- Duplicate-file collision (a Finder copy): the projection pass pre-scans
  claims and resolves each contested b2id BEFORE any row is written —
  incumbent-wins (the path the index already attributes the id to, when that
  file still claims it), falling back to first-in-sorted-walk *flagged as a
  tie-break* on a memory-less rebuild. Walk-order last-wins is gone (note:
  "a copy.md" sorts BEFORE "a.md", so any vault-pure order rule would hand
  the identity to the copy). Shadowed claimants stay on disk but get no row,
  and the collision is re-reported on every pass until the human resolves it;
  all three resolutions (delete the copy / strip its b2id line / delete the
  original) heal on the next pass.
- Identity restamp (a b2id line blanked or removed outside b2): the fresh
  stamp is reported (path, old id -> new id) instead of silently churning
  identity. Never auto-restored: removing the line is also the documented
  gesture for requesting a fresh identity, so restoring would guess intent.

Changes:
- b2-core: ProjectOutcome/IngestOutcome and ReindexReport/ProjectReport carry
  `collisions` + `restamped`; plan_reindex previews everything read-only
  (stamp_paths — the "notes without a b2id" view — would_restamp, collisions);
  ingest_file refuses an outright identity steal (Error::B2idCollision) while
  the ordinary external-move repointing still works; project_file stays
  unguarded (the in-app save path — the whole-vault pass owns that state).
- b2-cli: reindex prints the notices (stderr, like `skipped`); --dry-run lists
  id-less notes, pending restamps, and collisions; --json carries them all.
- desktop/ui: ProjectReport flows through IPC unchanged; the reindex flash and
  the fs-watch pulse (reconcile onReport) surface anomalies the moment they
  land; new pure anomalyNotice formatter with node tests.
- docs: S3 carve-out in invariants.md (a collision has no well-defined
  projection; incumbent-wins is anomaly containment, surfaced every pass,
  equivalence resumes on resolution); index-engine.md §8 documents the
  mechanics; CLAUDE.md Flow ① summarizes.
- tests: crates/b2-core/tests/collision.rs pins incumbent-beats-walk-order,
  the flagged tie-break, all three resolution flows, restamp reporting, the
  dry-run preview, and the single-note guard (refuse steal / allow move).

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

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@samkeen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc7b5f85-226e-4e6e-8e86-18ddef5f6608

📥 Commits

Reviewing files that changed from the base of the PR and between a0dd61e and 8a333dd.

📒 Files selected for processing (2)
  • crates/b2-core/src/ingest.rs
  • docs/design/index-engine.md
📝 Walkthrough

Walkthrough

The PR adds b2id collision and identity-restamp detection to projection, ingest, and dry-run planning. Reports now carry anomaly details through core, CLI, desktop, and UI layers, with integration tests and documentation covering resolution and repeated reporting.

Changes

Identity anomaly handling

Layer / File(s) Summary
Anomaly contracts and report shapes
crates/b2-core/src/error.rs, crates/b2-core/src/ingest.rs, crates/b2-core/src/vault.rs, ui/src/types.ts
Public types and reports now represent collision precedence, shadowed paths, and old/new b2id restamps.
Collision resolution and restamp detection
crates/b2-core/src/ingest.rs
Vault projection resolves duplicate claims before writing rows, skips shadowed notes, reports restamps, and rejects single-note identity stealing.
Reindex planning and façade propagation
crates/b2-core/src/ingest.rs, crates/b2-core/src/vault.rs
Dry-run planning exposes stamp paths, restamp candidates, and collisions, while reindex and projection reports propagate anomaly vectors.
Validation and user-facing notices
crates/b2-core/tests/collision.rs, crates/b2-cli/src/main.rs, crates/b2-desktop/src/error.rs, ui/src/reconcile.ts, ui/src/main.ts, ui/src/reconcile.test.ts, docs/design/*, CLAUDE.md
Tests cover collision and restamp scenarios; CLI, desktop, UI, and documentation surface the resulting diagnostics and resolution guidance.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • AlteredCraft/B2#42 — Both modify the note projection and chunking path in ingest.rs.
  • AlteredCraft/B2#73 — Both modify UI reconciliation through reprojectThenList and its main.ts wiring.

Suggested reviewers: claude

Poem

I nudge each b2id into light,
Keep the rightful note in sight.
Copies wait while warnings chime,
Restamps trace the changing time.
Dry runs whisper, “no writes today!”
— A rabbit hops along the way.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: surfacing b2id collisions and restamps without automatic fixes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/b2-duplicate-id-strategy-nkm59i

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/b2-core/src/ingest.rs (1)

736-754: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Every projection pass now reads every note's file twice.

scan_b2id_claims (736-754) does a full read of every note in the vault to build the claim map; then the Phase 1 loop in project_vault (857-885) calls project_note_and_chunks, which reads the same file again from disk. Previously each note was read once per pass; now it's read twice, and — per the ProjectReport doc comment — this whole-vault pass runs on every desktop fs-watch pulse, not just an explicit reindex. For vaults with many notes this doubles I/O on a hot, frequently-triggered path.

The two-pass structure (scan claims fully before writing any row) is a reasonable way to avoid walk-order dependence, but the raw content read in the scan could be captured and threaded into Phase 1 instead of re-reading, trading some transient memory for the avoided I/O.

💡 Sketch: thread scanned content into Phase 1
-fn scan_b2id_claims(vault_root: &Path, rel_paths: &[String]) -> HashMap<String, Vec<String>> {
-    let mut claims: HashMap<String, Vec<String>> = HashMap::new();
-    for rel in rel_paths {
-        let Ok(raw) = fs::read_to_string(vault_root.join(rel)) else {
-            continue;
-        };
-        if let Some(id) = note::parse(&raw).fields().b2id.clone() {
-            claims.entry(id).or_default().push(rel.clone());
-        }
-    }
-    claims
-}
+fn scan_b2id_claims(
+    vault_root: &Path,
+    rel_paths: &[String],
+) -> (HashMap<String, Vec<String>>, HashMap<String, String>) {
+    let mut claims: HashMap<String, Vec<String>> = HashMap::new();
+    let mut contents: HashMap<String, String> = HashMap::new();
+    for rel in rel_paths {
+        let Ok(raw) = fs::read_to_string(vault_root.join(rel)) else {
+            continue;
+        };
+        if let Some(id) = note::parse(&raw).fields().b2id.clone() {
+            claims.entry(id).or_default().push(rel.clone());
+        }
+        contents.insert(rel.clone(), raw);
+    }
+    (claims, contents)
+}

Then pass the cached contents map into project_note_and_chunks so it skips its own fs::read_to_string when a cached entry exists.

Also applies to: 857-885

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/b2-core/src/ingest.rs` around lines 736 - 754, Update scan_b2id_claims
and the Phase 1 flow in project_vault to retain each successfully read note’s
raw contents in a cache, then pass that cache into project_note_and_chunks so
cached files are parsed without a second fs::read_to_string. Preserve
unreadable-file handling and the existing full claim-map-before-writing
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/index-engine.md`:
- Around line 404-406: Update the collision-guard wording near
Error::B2idCollision to describe the full single-note ingest scope as “add” and
“mv,” replacing the narrower “add” reference while leaving “write” excluded
because it does not use ingest_file.

---

Nitpick comments:
In `@crates/b2-core/src/ingest.rs`:
- Around line 736-754: Update scan_b2id_claims and the Phase 1 flow in
project_vault to retain each successfully read note’s raw contents in a cache,
then pass that cache into project_note_and_chunks so cached files are parsed
without a second fs::read_to_string. Preserve unreadable-file handling and the
existing full claim-map-before-writing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01747efa-79f6-431d-860d-bff4ad0c3f42

📥 Commits

Reviewing files that changed from the base of the PR and between 403b3cb and a0dd61e.

📒 Files selected for processing (13)
  • CLAUDE.md
  • crates/b2-cli/src/main.rs
  • crates/b2-core/src/error.rs
  • crates/b2-core/src/ingest.rs
  • crates/b2-core/src/vault.rs
  • crates/b2-core/tests/collision.rs
  • crates/b2-desktop/src/error.rs
  • docs/design/index-engine.md
  • docs/design/invariants.md
  • ui/src/main.ts
  • ui/src/reconcile.test.ts
  • ui/src/reconcile.ts
  • ui/src/types.ts

Comment thread docs/design/index-engine.md Outdated
…bbit)

The single-note guard lives in `ingest_file`, so its scope is the
`add`/`mv`/`link` path — not `add` alone (and not `write`, which goes
through the deliberately-unguarded `project_file`). Also document why the
projection pass's claims pre-scan deliberately re-reads files in Phase 1
instead of caching bytes: the re-read is page-cache-warm, and stamping from
pre-scan-cached bytes would widen the window in which a stamp write-back
could clobber a mid-pass external edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXYN4zb8iFtLPP1mHm1KuQ
@samkeen
samkeen merged commit 5c140db into main Jul 25, 2026
1 check passed
@samkeen
samkeen deleted the claude/b2-duplicate-id-strategy-nkm59i branch July 25, 2026 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants