Repository navigation
[P1][kernel] Refuse open on mid-log corruption; surface torn-tail truncation #22
Description
Summary
Recovery treats all parse failures the same: stop and truncate everything after the failure point. That is correct for a torn tail (crash model) but destructive and silent for mid-log corruption (bit rot, cloud sync conflict, second writer, flipped bit in an early record). One bad early record → reopen silently deletes all later fsync'd transactions permanently.
Audit ID: B8 (High) — pre-announcement blocker
Partially overlaps: WAL header issue (B1 / #9) but kernel response policy is separate.
Impact
- Long-lived WAL: single flipped bit → catastrophic silent data loss.
- User receives no error, no count of dropped records, no recovery mode.
- CRC exists precisely to detect this; current response destroys evidence.
Root cause
while (offset + RECORD_HEADER <= log.length) {
// ...
if (end > log.length) break; // torn tail
if (crc32(payload) !== checksum) break; // mid-log corrupt — same path as torn
replayPayload(entries, payload);
offset = end;
}
if (offset < log.length) file.truncate(offset);No distinction between:
| Case | Valid records after failure? | Correct action |
|---|---|---|
| Torn tail | No (EOF before promised length) | Truncate tail; optional warning |
| Mid-log corruption | Possibly yes | Refuse to open; do not truncate |
Additional gap: replayPayload bounds
replayPayload (core.ts:371-388) trusts payload structure after CRC pass. CRC-valid but malformed payload (bogus lengths) can replay garbage ops. Add bounds checks; throw on overflow/mismatch.
Proposed fix
1. Classify failure in recover()
- Torn tail:
end > log.length→ truncate tooffset(keep behavior); optionally return{ entries, truncatedBytes }for logging. - Mid-log CRC/structural failure: if
offset > 0OR valid records were replayed and failure is not torn tail → throw; never truncate. - Foreign file at offset 0: covered by B1 (magic header); align error messages.
2. Harden replayPayload
Validate each op's lengths against payload size; throw libredb: corrupt WAL record on violation.
3. Optional callback / open option
onRecovery?(info: { tornTailTruncated: number }) — nice-to-have; minimum is throw vs truncate correctness.
Tests
- Valid DB + torn last record → truncates tail only; prior commits kept.
- Valid records + corrupt middle record + more data after → open throws; file byte length unchanged on disk.
- Malformed payload lengths with valid CRC → throw during replay (test vector).
Related issues
- B1 — foreign file at offset 0
- B3 — IO-error torn record vs mid-log
- Explore kernel durability hardening (WAL record headers, fsync-batching mode, expanded DST fault profiles) #9 — exploratory hardening
Acceptance criteria
- Mid-log corruption never silently truncates committed tail.
- Torn tail recovery still works.
-
replayPayloadbounds validated. - Tests pinned;
bun run gategreen. - Changeset required.
Agent constraints
- Guarded-core; keep
recover()readable — extract classifiers if needed, not hidden layers.
Verification
bun run gate
Activity
feat: pre-announcement hardening across kernel, adapters, lenses, CLI…
cevheri commentedon Jul 3, 2026
Implemented and shipped in PR #43 (merge commit 69f7161), released as v0.2.0. The PR description's comma-separated Closes list only auto-closed the first reference — closing manually. For #39/#28-style partials: the shipped scope is done; the real-browser verification half of #39 continues under #10, and the typed-error contract (#28) shipped as LibreDbError with stable codes across kernel, adapters, lenses, and CLI.