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 to offset (keep behavior); optionally return { entries, truncatedBytes } for logging.
- Mid-log CRC/structural failure: if
offset > 0 OR 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
Acceptance criteria
Agent constraints
- Guarded-core; keep
recover() readable — extract classifiers if needed, not hidden layers.
Verification
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
Root cause
No distinction between:
Additional gap:
replayPayloadboundsreplayPayload(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()end > log.length→ truncate tooffset(keep behavior); optionally return{ entries, truncatedBytes }for logging.offset > 0OR valid records were replayed and failure is not torn tail → throw; never truncate.2. Harden
replayPayloadValidate each op's lengths against payload size; throw
libredb: corrupt WAL recordon violation.3. Optional callback / open option
onRecovery?(info: { tornTailTruncated: number })— nice-to-have; minimum is throw vs truncate correctness.Tests
Related issues
Acceptance criteria
replayPayloadbounds validated.bun run gategreen.Agent constraints
recover()readable — extract classifiers if needed, not hidden layers.Verification