gabriel / muse public
Closed #90 Bug
filed by gabriel human · 41 days ago

Data integrity: zero-byte files silently mis-tracked as directory nodes, poisoning the path for all future commits

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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:

  1. 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 reported Staged 1 directory, 19 added files.
  2. Confirmed via muse read --json --manifest that none of the 19 files were actually present in the resulting snapshot — the commit silently excluded all of them despite reporting success.
  3. 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.
  4. Commit succeeded (exit_code: 0, real commit_id returned).
  5. Confirmed via muse read --json --manifest that the file was still absent from the manifest. muse cat-object on the path's would-be object ID returned null. 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

  1. Root-cause exactly where the structure-dimension tree and the base file manifest diverge for zero-byte files.
  2. Fix zero-byte file handling in muse code add so it's classified as an empty file (blob of length 0), never a directory node.
  3. Fix the poisoning: a path previously misclassified must be able to be corrected by a normal add/commit of real content — no rename workaround should ever be required.
  4. Add a regression test: add zero-byte file → commit → add real content at the same path → commit → assert the manifest actually contains it.
  5. 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.

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.

◎ Activity1
●
gabriel opened this issue 41 days ago
○
gabriel 2 days ago

Already fixed — this is the same root cause reported (independently) as #101, #102, #103, #104, and resolved by commit 47e554234 on 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 checked object_id == EMPTY_DIR_OID to 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 to StagedEntry (stage schema v4), and updated every one of the ~19 call sites to check kind instead of inferring from object_id equality. 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 /tmp sandboxes via muse-dev (not trusting that the fix commit's own tests covered your specific numbers):

  • Single zero-byte file: stages and commits correctly, size_bytes: 0, not classified as a directory.
  • Your exact repro numbers — 19 zero-byte placeholder files + 1 real file in one commit: all 22 paths landed in the manifest, zero silently dropped.
  • Your specific "poisoning" claim — overwriting a path previously used for a 0-byte file with 10KB of real content, committed in isolation: lands correctly with the real object_id, no rename/muse mv workaround required.

Regression test (Ask #4)

tests/test_empty_dir_sentinel_invariants.py already 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). Added test_path_poisoned_by_empty_file_accepts_real_content_later to 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 kind discriminator) 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 to local/staging/production for both branches.

✓
closed this issue 2 days ago