Data integrity: zero-byte files silently mis-tracked as directory nodes, poisoning the path for all future commits
Summary
muse code add/muse commit in the code domain silently corrupts tracking for zero-byte files. This is a data-integrity bug: the CLI reports success at every step, but real content committed later to the same path can be silently excluded from the actual snapshot manifest — with no error, no warning.
Severity
Highest tier. This is not a cosmetic display bug — a commit that reports success can produce a snapshot that does not contain the file the user just committed. Anyone trusting muse commit's exit code / success message would have no way to know their content never actually landed, short of independently reading back muse read --json --manifest.
Reproduction (confirmed in an isolated scratch repo, not just incidentally)
mkdir /tmp/empty-file-bug && cd /tmp/empty-file-bug && muse init
touch empty.txt
muse code add empty.txt
Result — staged as a directory-shaped node, not a file:
{"staged": {"added": ["empty.txt/"]}}
Note the trailing slash. empty.txt is a regular, tracked, zero-byte file on disk — not a directory.
muse commit -m "add empty file"
⚠️ Nothing tracked — working tree is empty.
{"error": "empty_workdir", ...}
The commit refuses outright here (correctly detecting nothing trackable) — but that's only true when the zero-byte file is the only change being committed. The real danger shows up in the next step.
The poisoning effect (this is the actually dangerous part)
Once a path has been staged/committed in this broken directory-shaped state alongside other real changes (so the commit succeeds), that exact path becomes permanently poisoned for all future commits:
- Committed 19 zero-byte placeholder files (e.g.
episode-04/identity-without-passwords.md) alongside one real file change in the same commit. Commit succeeded, CLI reportedStaged 1 directory, 19 added files. - Confirmed via
muse read --json --manifestthat none of the 19 files were actually present in the resulting snapshot — the commit silently excluded all of them despite reporting success. - Later, wrote real, 10KB content into one of those exact paths (
episode-03/everything-is-content-addressed.md) and committed it on its own, with no other files involved this time. - Commit succeeded (
exit_code: 0, realcommit_idreturned). - Confirmed via
muse read --json --manifestthat the file was still absent from the manifest.muse cat-objecton the path's would-be object ID returnednull. The 10KB of real content the user just wrote was silently dropped, with the CLI reporting total success at every step.
muse diff --staged, run after step 3, shows the corrupted path as a phantom deletion of a directory that was never a directory:
D episode-03/everything-is-content-addressed.md/
...despite muse rm --cached on that same path reporting fatal: pathspec ... did not match any tracked files (i.e. the flat file manifest agrees nothing is tracked there — the phantom entry lives only in some domain-specific structural state, not the base object manifest).
Root cause (hypothesis, not fully confirmed — needs an engineer to trace it properly)
The code domain plugin's structure dimension ("Module / file tree. Tracks which files exist and how they relate to each other" — see muse domain-info --domain code) appears to have separate bookkeeping from the base content-addressed file manifest. A zero-byte file appears to get mis-classified as a directory node in that structure tree (rather than a file/blob node) at add-time. That misclassification then persists in commit history at the exact path, and something in the structure-dimension diff/staging logic refuses to reclassify the same path as a file again later — even when real, non-empty content is written there — causing the base manifest write for that path to be silently skipped.
Confirmed workaround
Renaming defeats the poisoning. Committing the real content under a different filename in the same directory, then using muse mv to rename it to the originally-poisoned path, lands correctly and verifiably (confirmed via muse read --json --manifest + muse cat-object round-trip after the rename).
# poisoned path: episode-03/everything-is-content-addressed.md
muse code add episode-03/_tmp-script.md # different filename, same real content
muse commit -m "..."
muse mv episode-03/_tmp-script.md episode-03/everything-is-content-addressed.md
muse commit -m "..."
# now verify:
muse read --json --manifest | jq '.manifest["episode-03/everything-is-content-addressed.md"]'
# → real object_id, not null
Ask
- Root-cause exactly where the structure-dimension tree and the base file manifest diverge for zero-byte files.
- Fix zero-byte file handling in
muse code addso it's classified as an empty file (blob of length 0), never a directory node. - Fix the poisoning: a path previously misclassified must be able to be corrected by a normal
add/commitof real content — no rename workaround should ever be required. - Add a regression test: add zero-byte file → commit → add real content at the same path → commit → assert the manifest actually contains it.
- Audit whether
muse commit's success reporting can be made to fail loudly instead of silently excluding content in any related case — a commit that omits requested content should never report unqualified success.
Related
Discovered while writing Season 1 Episode 03 of "Build With Muse" (~/ecosystem/presentations/build-with-muse) — a real, in-production repo, not a synthetic test. Ironically discovered while writing the episode about content addressing and integrity guarantees.
Source
Found by Claude Sonnet 5 while committing episode scripts, 2026-09-12.
Already fixed — this is the same root cause reported (independently) as #101, #102, #103, #104, and resolved by commit
47e554234on 2026-09-22 ("fix: eliminate EMPTY_DIR_OID/empty-file collision with explicit kind discriminator"), about 10 days after this ticket was filed. This ticket and that cluster never got cross-linked, so it stayed open.Confirming your hypothesis exactly (Ask #1)
EMPTY_DIR_OID = blob_id(b"")was used as the sentinel for "this stage entry is a tracked empty directory" — but it's also the literal, correct content hash of a genuinely empty file. Every call site (~19 across 6 files) that checkedobject_id == EMPTY_DIR_OIDto decide file-vs-directory treated a staged empty file identically to an empty-directory sentinel, and silently dropped it from the manifest. Because the misclassification lived in that equality check rather than any per-path state, it persisted across commits at the same path — exactly the "poisoning" you reported, where even real content written later at that path kept getting silently excluded.The fix (Ask #2/#3)
Added an explicit
kind: "file" | "dir"discriminator toStagedEntry(stage schema v4), and updated every one of the ~19 call sites to checkkindinstead of inferring fromobject_idequality. A path previously poisoned under the old scheme now migrates cleanly on next write — no rename workaround needed, confirmed below.Verification — live-reproduced your exact scenario against current code
Using disposable
/tmpsandboxes viamuse-dev(not trusting that the fix commit's own tests covered your specific numbers):size_bytes: 0, not classified as a directory.muse mvworkaround required.Regression test (Ask #4)
tests/test_empty_dir_sentinel_invariants.pyalready had 9 passing tests covering this mechanism from the #101-#104 fix, but none matched your reported scenario verbatim (a path poisoned by a committed 0-byte file, then real content committed there in isolation afterward). Addedtest_path_poisoned_by_empty_file_accepts_real_content_laterto close that gap and formally anchor this ticket to its fix in history.Ask #5 (fail loudly on silent exclusion) — not addressed here; this ticket's actual mechanism (the
kinddiscriminator) already eliminates the silent-exclusion path entirely rather than needing a loud-failure audit on top of it. If you want a broader audit of other, unrelated ways a commit could silently omit requested content, that's worth its own ticket rather than folding into this one.Targeted regression suite —
test_empty_dir_sentinel_invariants.py(10, including the new test),test_cmd_code_add.py,test_cmd_status.py,test_cmd_mv.py,test_cmd_diff.py— all pass (294 passed), zero regressions.Merged to
dev→main, pushed tolocal/staging/productionfor both branches.