Documents / Issues / issue-bfa5d4331c35
delete on a duplicated id deletes one file, strips every citer's edge, and drops the other from the index
Every step of delete reads the index, which holds one row per id, so a delete against two files claiming one id acts on a file nobody chose and breaks edges to the survivor.
What is wrong¶
docir delete <id> does not see the state check --strict reports as duplicate-id. Against
an id two files claim — what a merge of two branches on --id-style sequential produces, or a
copied file — it does three things, and each is silent.
1. It acts on whichever file sorts last. reindex walks scan() in sorted path order and
save upserts by id, so each later file overwrites the earlier one's row and the index holds
one. The caller is never told there was a choice.
2. --force strips the edge from documents citing the file it is not deleting. Edges are
addressed by id and both files answer to it, so incoming(doc_id) returns citers of both. Their
frontmatter is rewritten to drop an edge whose target is still on disk.
3. The surviving file drops out of the index. documents.delete(doc_id) removes the only
row, so a file that is still there is invisible to get, query and context — and
check --strict exits 0, because the duplicate-id scan now finds one file and nothing
compares the files against the index.
Without --force, the refusal lists citers of both documents as if they cited one.
Why nothing catches it afterwards¶
Measured on a store damaged by the reporter's own script:
| moment | what reports it |
|---|---|
| right after the delete | doctor → index-behind-files, a warning that already existed |
after any reindex |
nothing — doctor clean, check --strict exit 0 |
| in CI | never: the job runs reindex → doctor --strict → check --strict, so it heals the index before it looks |
| with the daemon | never: the watcher reindexes on the next change under docs/ |
The stripped edge survives all four. related: [] is a valid state and the only trace is an
orphan warning indistinguishable from a document nobody has linked yet.
Resolution¶
FIXED — --force overrides incoming references, not ambiguity about which document is meant. delete refuses an id more than one file claims, --force
included, naming the files and the repair. The count comes from scan() rather than the index,
which cannot answer the question, and the refusal runs before the unit of work opens so nothing
is read or written on the way to it.
Prevention only, because nothing can find the damage after the fact; git log -p on the citing
document is the only recovery for an edge already stripped.
Five guards in tests/modules/documents/test_merge_safety.py, verified by removing the call:
the three that assert the refusal fail, and the two that pin what did not move — an ordinary
delete, and a forced delete against an unambiguous id still stripping edges — pass either way.
The reporter's reproduction script now exits 0.
update against a duplicated id makes the same arbitrary choice and is deliberately left
alone. Verified across every write that could clobber: a title change keeps the filename (so it
cannot be renamed onto the other claimant), --type moves one file and leaves the other, and
archive touches frontmatter only. Both bodies survive each and check keeps reporting
duplicate-id as an error. What is left is a usability defect — the edit lands on one of two
documents and nobody says which — priced at 40x the warm write to detect. The reasoning is in
--force overrides incoming references, not ambiguity about which document is meant.
Reported as GitHub #27.