Skip to content
🚧 Early alpha — building the foundation. See the roadmap →

Re-import is identity reconciliation, not path migration

Created Updated

The current work phase is one sentence: making it safe to import the same thing twice. One piece of that — what happens when an import’s output layout changes and a vault already has notes in the old layout — failed two rounds of fixes.

The decision: it failed because it was matching notes by where they live (paths), when it should match them by what they are (the identity every note already carries in its frontmatter). A re-import becomes a reconciliation between two identity sets: what the source produces versus what the vault holds. Same identity at a different path is a move, not a duplicate. An identity the source no longer produces is an orphan to report, not silently keep. Duplicates become impossible by construction, because one identity maps to one note.

No further remediation of the parked path-matching code. The mapping-set work gets rebuilt on this foundation.

2. Two minutes — why path matching kept failing

Section titled “2. Two minutes — why path matching kept failing”

The parked P3 work changed where crosswalk notes are written (a per-mapping-set folder instead of one shared folder). To avoid leaving old notes behind, it tried to detect the old layout and migrate or refuse. Two remediation rounds later it still had four holes: duplicates when metadata changed, detection failing open on a cold cache, detection bypassed by a custom output folder, and cleanup advice that left phantom database rows.

Those four look unrelated. They share one root: a path is a guess about a note, not a fact about it. Every path-pattern rule has an exception — a renamed folder, a user-chosen destination, a moved vault — so every detector built on paths fails open somewhere.

Meanwhile, verified 2026-08-21:

FactEvidence
Every generated note already carries a stable identitycurie: "nist-mini:AC-1" in frontmatter, on every note
But generation looks existing notes up by pathgetAbstractFileByPath at six call sites in src/generation/generation-engine.ts; nothing in src/ looks a note up by curie
And the project’s stated differentiator is the opposite”Identity, not address” — the reason P2 exists at all

The tool preaches identity and joins on address. That inconsistency is the bug factory.

A re-import reconciles identity sets. For each row, compute the identity (curie) and the desired address via render() (the one function that turns a recipe plus an identity into an address). Then look the identity up in the vault — via Obsidian’s metadata cache, which already indexes frontmatter — and classify:

Vault state for this identityAction
Note exists at the desired addressOrdinary merge (current behavior)
Note exists at a different addressMove it using Obsidian’s rename API, which updates every wikilink pointing at it — then merge. Never create a second note
No note existsCreate
Note exists whose identity the source no longer producesOrphan: report it; never silently delete, never silently keep counting it

Scope guard: reconciliation only ever touches notes that carry _crosswalker provenance matching the recipe being run. User notes are structurally unreachable.

This also answers the open question “should Crosswalker move files in a user’s vault?” — yes, but only identity-preserving renames of its own notes through the link-updating API, shown in the import preview before anything is written.

Identity inputs are declared, not accumulated. The parked branch changed every concept’s content hash by quietly adding mapping-only fields into the identity computation. Rule: what feeds a note-kind’s identity is declared in the contract per kind; an importer may never widen it as a side effect. (This is the same “authority comes from declaration, not from what happened this row” rule that fixed field-clearing — applied to identity.)

Derived stores prune by the same key. The query database (Tier 2) mirrors the vault. When a full projection runs, rows whose identity no longer exists in the vault are deleted. A partial projection deletes nothing. Same reconciliation, one layer down.

Unblocks: the P3 rebuild — partly; see the correction in §7; the framework-update story (Ch 43 — a new framework release is also just a changed identity set, so this is its substrate); and the org-rollout safety story, since duplicates-on-upgrade was a ranked blocker in Ch 45.

Costs: an identity index (curie → file) built from the metadata cache per run — cheap, the cache is already in memory; a reconciliation preview in the import flow; and honest handling of the one genuinely hard case, identity collisions (two rows claiming one curie), which today produce a collision error and continue to.

Owner calls embedded here (defaults chosen, easy to reverse): orphans are reported, kept, and excluded from coverage counts by default, with delete-after-confirm offered; moves are shown in the preview and on by default. If either default feels wrong in practice, it is a setting, not a redesign.

R1’s three planned engine pieces now exist:

Problem closedShipped behavior
A layout change duplicated an existing conceptGeneration resolves Crosswalker notes by curie and moves the existing file before merging
A removed source identity disappeared without explanationComplete, error-free owned runs return an orphan report; they do not delete Markdown
The query database kept rows after their notes disappearedA full projection (a complete vault-to-index rebuild) prunes unseen derived rows; partial projection never prunes

The build exposed three safety preconditions the decision had not made explicit:

  • Completeness must be proven. A partial, failed, or ambiguously owned run cannot establish that an identity disappeared.
  • Ownership must use the exact provenance key. The comparison set is recipe-scoped; path, folder, and note kind are not substitutes.
  • Pruning must fail closed. If Obsidian cannot provide readable metadata for a possible Crosswalker note, the query database (Tier 2) must preserve its prior rows rather than infer deletion. The current violation of this rule is recorded as a pre-alpha limit below.

The accepted report-and-prune limits are recorded in CHANGELOG.md [Unreleased]; they do not change the decision or authorize Markdown deletion.

R2 adds explicit relationship identities and mapping-set provenance without reviving the parked path detector. Release isolation — giving each assertion an identity derived from its mapping set so two releases can coexist — was built and then deliberately held; see §7.

Assertion identity therefore stays endpoint-derived, and all mapping sets continue to share one destination folder. Delivery details and the other accepted verification limits live only in CHANGELOG.md [Unreleased].

feat/p2-p3-identity-provenance stays parked as reference material. Its good parts — the P2 identity fields, the normalization helpers, the negation plumbing, the repaired specs — get cherry-picked into the rebuild on top of reconciliation. Its path-detection code is not remediated further; this decision supersedes it.

6. What shipped, and what the build taught

Section titled “6. What shipped, and what the build taught”

Sprint R1 landed the engine layer of this decision on 2026-08-21:

PieceBehavior
Identity lookup + moveA re-import resolves a note by curie wherever it sits, and relocates it to the rendered address via the link-updating rename API instead of creating a duplicate. Ambiguous identity is an error, not a silent pick
Orphan reportAfter a complete, error-free run with unambiguous ownership, identities the source no longer produces are listed. Report only; nothing is deleted
Tier 2 pruningA full projection removes rows whose note is gone and invalidates dependent closure caches. A filtered projection cannot request pruning, and any error suppresses it

Three things the build taught that the decision did not anticipate

Section titled “Three things the build taught that the decision did not anticipate”

Choosing the right key is necessary but not sufficient — you also need proof the pass was complete. Identity tells you what to compare; it says nothing about whether your view of the vault was whole. Every deletion inference therefore needs a second, independent guarantee: this pass saw everything. That is now explicit (projectionMode, defaulting to partial) rather than inferred.

Absence of evidence is not evidence of absence. A verified deletion bug: reading a note’s frontmatter returns nothing both when a note genuinely has none and when its frontmatter exists but failed to parse. The first is safely skippable; the second is unknown. Treating them alike meant a note with malformed YAML left no trace of having been seen, and its rows were pruned while the note sat untouched on disk. Unknown must now fail closed. The discriminator was already available — Obsidian records that a note has a frontmatter block regardless of whether it parsed — and was simply unused.

An ownership key that several imports share is not an ownership key. Orphan detection asks “which identities did this import produce before, that it no longer produces?” That question is only answerable if the ownership key identifies exactly one import. Unnamed classic-wizard configs all fall back to a single shared identifier, so one framework’s re-import could report another framework’s notes as missing. Rather than guess, detection now stands down when ownership is ambiguous — including for enrichment-enabled runs, whose synthetic hub notes are not yet tracked as produced and would otherwise be flagged as orphans the moment they were created.

The third is a design gap, not a coding error: stable per-import identity is still unmodelled, and it gates both full orphan coverage and any future delete-after-confirm flow. That is architect work, not a patch.

7. Correction: this decision predicted one thing wrong

Section titled “7. Correction: this decision predicted one thing wrong”

§4 above claimed the P3 rebuild would be unblocked because “mapping-set folders become trivial — the layout change is just new desired addresses; reconciliation moves the notes.” Building it proved that wrong, and the reason is worth keeping.

Release isolation does not merely move a note to a new address. It changes the note’s identity, because an assertion’s identity is derived from its mapping set. And identity reconciliation can only follow a note whose identity is stable — that is the entire premise of this decision. So the two are in direct tension: the feature that lets releases coexist is precisely the feature reconciliation cannot bridge.

The failure mode is the one this decision exists to prevent, arriving through a different door. Old notes keep their old identity, the import creates notes at the new identities, and the user gets duplicates — silently, because orphan reporting is inactive on that path (a reusable recipe id is not unambiguous import ownership).

What shipped instead: assertion identity stays endpoint-derived and all mapping sets share one destination folder, exactly as before. mapping_set_id is still recorded on each note as provenance, which changes no identity and therefore migrates nobody. The accepted cost, restored from pre-P3 behavior: two assertions sharing endpoints collapse onto one note.

The generalizable lesson. “Reconcile by identity, not by path” is right, but it carries a corollary this log did not state: a change to identity itself is a migration, and no amount of reconciliation makes it free. Address changes are cheap under this design. Identity changes are not, and they need an explicit transition mechanism — an alias, a declared predecessor, or a regeneration instruction — decided before the change ships, not after.

Release isolation is therefore blocked on modelling per-import ownership, already recorded above as architect work. That is the same gap that keeps orphan reporting off the recipe path, which is a useful signal: two independent features stalled on one unmodelled concept usually means the concept is the real work.