Skip to main content
← Back to list
01Issue
BugShippedSwamp CLIPublic
Assigneesstack72

Relationships

#3736 v3 recovery sweep: dot-prefixed data names escape it, HFS+ non-ASCII names are deleted by it, and an old mtime keeps a dropped write's file (bug hunt group 8)

Opened by stack72 · 10/21/2026· Shipped 10/21/2026

Background

swamp is reworking its datastore layer (tracking: swamp-club#2865). On a v3 datastore, when a writer drops its work or dies without tearing down, recovery replays the commit log and then sweeps: it removes the files no commit owns (#sweepUnowned in src/infrastructure/datastore_v3/sqlite_read_model.ts), so every checkout lists the same records. The sweep's scope decides what survives. Too narrow, and phantom versions stay in one checkout. Too wide, and committed files are deleted.

A v3 bug hunt (2026-10-16) found four gaps in that scope. Two of the four are S2. This is the last S2 group before v3 is dogfooded (datastore rework Phase 4 I). The code has moved since the hunt (#3617, #3690, #3691), so the function names are given. Reproduce each against main first; the hunt's repro files aren't in the repo.

The bugs

1. Dot-prefixed data names and methods escape the sweep (V3-05, S2). isCheckoutLocalKey (src/infrastructure/datastore_v3/record_path_guard.ts) treats any path segment starting with . as checkout-local, and the sweep skips the whole subtree under it. But data names and method names may start with .: the data-name validation in data_metadata.ts, and method names in model_kind_adapter.ts and model_output.ts. The commit filter (datastoreKeyFilter) has no dot rule either, so such keys are committed.

  • Reproduced in the hunt:
    • with data name .kubeconfig, after a dropped save and a "recovered" recoverDroppedWork, checkout A lists versions [1, 2] and B lists [1];
    • if the failed save was the first, A keeps a readable v1, including latest, that the datastore never had;
    • with method .probe, 4 of 40 random SIGKILLs of the compiled binary left a phantom output in A;
    • non-dot controls pass.
  • Trigger: dot-file-style data names (.kubeconfig, .env, .terraformrc) or extension methods with a leading ., plus a crash, refusal or drop between save and commit.

2. On HFS+, the sweep deletes committed files with non-ASCII names (V3-39, S2). The sweep builds keys from Deno.readDir names, which HFS+ returns in NFD. It looks them up exactly in the ownership table, which holds NFC. recover() replays first and refreshes every mtime, so the mtime guard doesn't protect these files. So on an HFS+ checkout, every recovery deletes every committed file with a non-ASCII name, and it never heals. record_key.ts and data_metadata.ts don't normalise. My verification reproduced this on an HFS+ disk image (hdiutil create -fs HFS+); ext4 and APFS preserve the form, so they aren't affected.

  • Trigger: older Macs or external drives formatted HFS+, non-ASCII data names, and any interrupted or dropped write.

3. An old mtime keeps a dropped write's file alive (V3-23, S3). The sweep keeps any unowned file whose mtime is older than the recovery's since. Content written through DataWriter.getFilePath keeps whatever mtime the tool gave it. Reproduced: a method that moves an old file in as its artifact, then fails, leaves artifact/1/raw behind in A (A lists [1, 2], B lists [2]). The control without the old mtime passes.

  • Trigger: a method that registers an existing file by move or hard link, or extracts preserving times, then fails or is killed before committing.

4. Replays rewrite files the checkout keeps local (V3-13, S3). Check whether it's still reachable. The hunt found recover(), catch-up, the reboot check and loadBase materialising keys the checkout's directories/exclude keep local, rolling back an excluded latest pointer. Since then:

  • swamp-club#3691 never materialises a solo or own-namespace key the filter keeps local (unprojectedReason), and refuses a changed filter at open, which was this finding's trigger;
  • #3617 stopped the reboot check resetting.

Confirm with a test that every path (commit apply, recover replay, loadBase, reboot check, unreadable retry) goes through the same filter check. If one doesn't, fix it here. If all do, the test is the deliverable.

What to do

  1. Narrow the dot exemption (1). isCheckoutLocalKey exempts only the real checkout-local artefacts:

    • the batch temp files (.<uuid>.tmp);
    • locks;
    • run indexes and logs;
    • install staging;
    • pulled extension sources.

    It does that by exact names or patterns, not "any dot segment".

    #3690's join check and v3 setup also use isCheckoutLocalKey (v3_checkout_join.ts, setup.ts). After narrowing, a v2 checkout's dot-named records must count as records there, and the checkout's own dot files must not. Keep both behaviours, with a test each.

    Correct design/enablers/datastores.md's statement that dot segments are only checkout-local. The alternative, refusing a leading . in every key segment on the commit path, is a breaking change for existing data names, so don't do it.

  2. Normalise for the ownership lookup (2): NFC-normalise the names the sweep reads before looking them up, and anywhere else a directory listing is matched against stored keys (search the read model for readDir). Keep stored keys as they are. Where swamp writes the file, it's already NFC.

  3. Drop the mtime as the only guard for dropped work (3): sweep every unowned file under the version directories the dropped unit allocated, whatever its mtime; the unit knows which versions it allocated. Keep the mtime rule only where nothing better is known, and document it.

  4. Settle V3-13 (4) as above.

Tests

Each must fail on main first, except item 4's if it's already covered.

  • 1:
    • a dropped save of data .kubeconfig (and a first-save variant) leaves both checkouts listing the same versions;
    • a dropped or killed .probe method output is swept;
    • the join check counts a v2 .kubeconfig record as a record, and ignores .<uuid>.tmp, locks and run logs.
  • 2: an in-process test that injects NFD directory names (no HFS+ needed). A committed NFC-keyed file whose listing comes back NFD survives recovery. Run one manual check on an HFS+ disk image (hdiutil) and report it in the PR.
  • 3: a dropped write whose content file has an old mtime is swept, and both checkouts agree.
  • 4: a key the filter keeps local is never written or removed by recover replay, loadBase, the reboot check or a retry.
  • swamp-uat passes unchanged.

Severity: two S2s (phantom versions in one checkout; committed files deleted on HFS+) and two S3s. It must land before v3 is dogfooded (datastore rework Phase 4 I).

Blocked by: nothing (#3691 has shipped). It runs in parallel with the macOS chunk barrier issue, which touches the chunk storage write path, not the read model's sweep.

02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 4 MOREREVIEW+ 12 MOREPR_LINKED+ 2 MORESESSION_SUMMARIZED

Shipped

10/21/2026, 9:41:26 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack7210/21/2026, 6:02:14 PM

Sign in to post a ripple.