|
| 1 | +# Pre-announcement engineering audit |
| 2 | + |
| 3 | +LibreDB — expert review before the public launch |
| 4 | +@libredb/libredb v0.1.3 · audited 2026-07-02/03 · 8 review dimensions, 69 agents, every critical/high finding independently verified by two adversarial refuters against the real code. |
| 5 | +Result: 42 confirmed findings (2 critical, 13 high, 12 medium, 15 low), 2 claims refuted, 3 additions from a completeness pass. |
| 6 | + |
| 7 | +2 **Critical** |
| 8 | +13 **High** |
| 9 | +12 **Medium** |
| 10 | +15 **Low** |
| 11 | +2 **Refuted** |
| 12 | + |
| 13 | +Verdict: do not announce yet — but the distance is short |
| 14 | +The kernel is genuinely well built for the failure model it was designed around (clean crash, torn tail). The problems are concentrated in what sits outside that model: IO errors that are not a crash, a second writer, a wrong file, a caller-held buffer, an async callback. Two findings are critical (one destroys arbitrary user files on a typo'd path), and one cluster — the poisoned-WAL-after-failed-append chain — directly falsifies the README's headline durability claim, the sentence most likely to be publicly reproduced and disproven after a launch. Nearly everything below is fixable in days, and several fixes are one-guard-plus-one-test small. Fix the blockers, re-run the gate, then announce. |
| 15 | + |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +1. Announcement blockers |
| 20 | +Reachable by a normal user doing normal things; each violates a promise the docs currently make. The 13 raw high-severity findings cluster into the root causes below (several were independently found by 3–4 different review dimensions — a strong signal). |
| 21 | + |
| 22 | +**Critical** |
| 23 | +B1 — open({ path }) on a non-LibreDB file silently and permanently erases it |
| 24 | + |
| 25 | +src/core.ts:413 · partially tracked in #9 (magic header) |
| 26 | + |
| 27 | +recover() cannot parse the first record of a foreign file, stops at offset 0, and file.truncate(0) wipes the file. A typo'd path or libredb set notes.txt k v destroys arbitrary user data instantly, with no error and no undo. |
| 28 | + |
| 29 | +Fix — WAL magic/version header (#9) plus refuse-don't-truncate semantics: unknown or missing header must throw and leave the file byte-for-byte untouched. Note this changes the on-disk format — cheapest to do before the announcement, with a migration read-path for headerless v0.1.x files. |
| 30 | + |
| 31 | +**Critical** |
| 32 | +B2 — transact(async tx => ...) is silently accepted; writes after the first await never reach the WAL |
| 33 | + |
| 34 | +src/core.ts:502 |
| 35 | + |
| 36 | +An async callback returns a pending Promise; transact() commits immediately with whatever the journal held at the first await. Later writes mutate the already-swapped working array — visible in memory, absent from disk. TypeScript happily infers T = Promise<...>, so it type-checks. This is the default reflex of every modern DB user; the data reappears missing only after a restart. |
| 37 | + |
| 38 | +Fix — one guard: if run() returns a thenable, throw "transact() body must be synchronous" before committing. One test pins it. |
| 39 | + |
| 40 | +**High** |
| 41 | +B3 — a failed append/fsync poisons the WAL: later acknowledged commits are silently destroyed at next recovery |
| 42 | + |
| 43 | +src/core.ts:435,508 · src/adapter/node-fs.ts:36 · found independently by 4 dimensions; adjacent to #9 but the kernel-side latch is untracked |
| 44 | + |
| 45 | +ENOSPC/EIO mid-writeSync leaves a torn record on disk; transact() re-throws but the database keeps accepting writes, appending fresh records after the garbage. recover() breaks at the torn record and truncates everything after it — including commits whose transact() returned success and fsync'd. The comment at core.ts:503 ("memory and disk still agreeing on the prior state") is factually wrong for partial writes. This directly falsifies README line ~266 / RELIABILITY.md ("a returned transact() is durable") and the claim "a crash can only ever damage the last record". |
| 46 | + |
| 47 | +Fix — on any append/fsync failure, either latch the database failed (every later transact throws until reopen — SQLite/Postgres behavior), or truncate back to the pre-append offset before accepting the next write. Never continue past a failed fsync (fsyncgate). Add the matching DST fault profile (see section 3). |
| 48 | + |
| 49 | +**High** |
| 50 | +B4 — no double-open protection: two open() calls on one file silently diverge, lose updates, and can corrupt the log |
| 51 | + |
| 52 | +src/core.ts:474 · src/adapter/node-fs.ts:25 · found by 4 dimensions |
| 53 | + |
| 54 | +Each open recovers an independent in-memory store and appends to the same file; neither sees the other. In the stated beachhead (test/dev) this is an everyday event: parallel test workers on one fixture, a stale dev-server surviving hot-reload, the CLI run against a live app. Every comparable embedded DB locks (SQLite, LMDB, LevelDB). The lock machinery already exists in src/cli/lock.ts — but only the CLI uses it. |
| 55 | + |
| 56 | +Fix — exclusive lock at open(), released at close(): an O_EXCL <path>.lock via the FileSystem seam plus an in-process open-paths set. If deferred, at minimum a loud documented constraint — today it appears nowhere. |
| 57 | + |
| 58 | +**High** |
| 59 | +B5 — keys/values are stored and returned by reference: caller buffer reuse silently corrupts the committed store |
| 60 | + |
| 61 | +src/core.ts:250-262 |
| 62 | + |
| 63 | +tx.set stores the caller's Uint8Array as-is; get/getRange return internal buffers. Reusing one scratch buffer in a loop (the standard byte-key pattern from LMDB/RocksDB) retro-corrupts every previously written key: binary search misses, the sorted invariant breaks, memory diverges from disk. Untested and undocumented. |
| 64 | + |
| 65 | +Fix — defensive-copy on set (and on returned values), or document transfer-of-ownership loudly. Copying is the right default for an embeddable DB; cost is one allocation per op. Pin whichever contract with tests. |
| 66 | + |
| 67 | +**High** |
| 68 | +B6 — getRange iterator is silently invalidated by tx.set/tx.delete: delete-while-scanning skips entries |
| 69 | + |
| 70 | +src/core.ts:262-271 |
| 71 | + |
| 72 | +The lazy generator walks the live working array by index; a delete shifts elements left and the scan skips the next entry (inserts duplicate). "Scan a prefix and delete what you find" — the most natural use of the API — silently processes every other entry and reports success. |
| 73 | + |
| 74 | +Fix — snapshot the matching entries at first next() (entries are immutable objects; this is cheap), or detect structural modification and throw. Either way, document and test it. |
| 75 | + |
| 76 | +**High** |
| 77 | +B7 — a colon in a collection/table name escapes its namespace: silent cross-collection collisions |
| 78 | + |
| 79 | +src/lens/document.ts:116 |
| 80 | + |
| 81 | +doc(db, `tenant:${tenantId}`) — the natural multi-tenant pattern — produces keys that collide with other namespaces and leak into other scans; for relational tables it can also shadow catalog entries. Data corruption with no error. |
| 82 | + |
| 83 | +Fix — reject ':' (and the empty name) in the name validator with the same loud-error style as the reserved-marker rule, or length-prefix/escape names in the key codec. |
| 84 | + |
| 85 | +**High** |
| 86 | +B8 — recovery is silently destructive: mid-log corruption truncates everything after it, with zero reporting |
| 87 | + |
| 88 | +src/core.ts:408-413 · partially tracked in #9 |
| 89 | + |
| 90 | +One flipped bit in an early record of a long-lived WAL makes reopen silently delete every later transaction — and the truncate makes it permanent. Torn-tail truncation is correct for the crash model; mid-log corruption (bit rot, partial copies, cloud-sync tools, a second writer) is exactly what CRC exists to catch, and the response is to destroy the evidence without telling the user. |
| 91 | + |
| 92 | +Fix — distinguish torn tail (truncate, but surface a count/callback) from mid-log corruption (valid records exist after the bad one: refuse to open, never truncate). Related: replayPayload has no bounds validation (core.ts:371, low) — a CRC-valid but structurally invalid record replays as garbage ops instead of erroring. |
| 93 | + |
| 94 | +**High** |
| 95 | +B9 — no parent-directory fsync on file creation: a fresh database can vanish entirely after power loss |
| 96 | + |
| 97 | +src/adapter/node-fs.ts:25,43 |
| 98 | + |
| 99 | +POSIX does not make a new file's directory entry durable until the directory is fsync'd. A user creates a DB, commits fsync'd transactions, power fails — the whole file can be gone. Also: truncateSync during recovery is never followed by an fsync, so tail truncation itself is not durable. |
| 100 | + |
| 101 | +Fix — fsync the parent directory once when the file was created; fsync the fd after recovery truncation. Until then, RELIABILITY.md and the README Reliability section must carry the caveat (they currently overstate the guarantee). |
| 102 | + |
| 103 | +**High** |
| 104 | +B10 — a transient short read during recovery permanently truncates intact committed data (OPFS read() does not loop) |
| 105 | + |
| 106 | +src/core.ts:399,413 · src/adapter/opfs.ts · related to #10 |
| 107 | + |
| 108 | +recover() treats "fewer bytes than size()" as a torn tail and truncates. The OPFS adapter's read() does not loop to fill the requested length (the API returns a byte count precisely because short reads are legal), so one transient short read at reopen destroys fsync'd commits. The DST short-read test currently asserts this loss as acceptable. |
| 109 | + |
| 110 | +Fix — loop reads in the OPFS adapter (mirroring its append loop); in recover(), compare bytes-read to file.size() and fail loudly instead of truncating when they disagree. |
| 111 | + |
| 112 | +**Medium** |
| 113 | +B11 — the README's headline durability claim is publicly falsifiable as written |
| 114 | + |
| 115 | +README.md:268 · docs/RELIABILITY.md:3 · from the completeness pass |
| 116 | + |
| 117 | +"A crash can only ever damage the last, un-fsync'd record" is disprovable with a ~20-line reproduction (B3). For a project whose manifesto stakes its identity on honesty, this is the sentence a Hacker News commenter will quote. Fix B3/B9 or scope the claim precisely ("on a healthy disk; IO-error handling is tracked in #…"). |
| 118 | + |
| 119 | + |
| 120 | + |
| 121 | + |
| 122 | +--- |
| 123 | + |
| 124 | + |
| 125 | +2. Fix before or at announcement |
| 126 | +Not data-destroying, but each is either a correctness surprise, a supply-chain gap reviewers will notice, or doc drift that undercuts the honesty positioning. |
| 127 | + |
| 128 | +| **Sev** | **Finding** | **Where** | **Fix in one line** | |
| 129 | +| ------- | ----------- | --------- | ------------------- | |
| 130 | +| Med | NaN/Infinity pass "number" column validation, stored as null — schema round-trip broken | lenses/relational.ts:64 | Use Number.isFinite, throw like other validation errors | |
| 131 | +| Med | doc() on a cataloged relational table bypasses schema validation, breaking the catalog's faithful-view contract (Studio renders wrong) | lenses/document.ts:206 | Throw when the name is cataloged with kind "relational" | |
| 132 | +| Med | CLI lock has no owner identity: release() deletes whoever's lock is present; two --force writers both acquire | cli/lock.ts:49 | Write pid+host+nonce; release only your own; check liveness on --force | |
| 133 | +| Med | npm publish still without provenance; workflow comment still claims the repo is private | publish.yml:50 · #7 | Add id-token: write + --provenance before the launch release | |
| 134 | +| Med | JSR job runs unpinned npx --yes jsr publish holding a publish-capable OIDC token | publish.yml:84 | Pin jsr@<exact-version> | |
| 135 | +| Med | No Dependabot/Renovate: every SHA-pinned action and image digest goes stale with no update loop | Dockerfile:29 | Add .github/dependabot.yml (actions, docker, npm weekly) | |
| 136 | +| Med | Declared Node.js support (engines >=22, npx usage) is never exercised by CI — Bun-only gate | ci.yml:20 | Add a Node smoke job (install tarball, open/write/reopen) | |
| 137 | +| Med | Performance envelope undocumented: full store copy per transaction (quadratic bulk writes), whole DB in RAM — seed loops look "hung" | core.ts:500 | README section: ceilings + "wrap bulk loads in one transact()" | |
| 138 | +| Low | README status says "pre-alpha (0.0.x)" while announcing 0.1.3 — self-contradiction at first read | README.md:296 | Update the status line | |
| 139 | + |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +3. Test & DST gaps |
| 144 | +The suite is excellent for the fault model it covers — and structurally blind to the fault classes behind section 1. "100% coverage" is true for lines, not for behaviors. These extend issue #9's DST direction with concrete profiles. |
| 145 | + |
| 146 | +**High** |
| 147 | +No IO-error fault injection anywhere: SimFS append/fsync never throw |
| 148 | + |
| 149 | +src/sim/simfs.ts:60 · src/sim/dst.ts:119 |
| 150 | + |
| 151 | +A failed fsync producing a phantom commit, a partial append poisoning the tail (B3), fsync lies, crash-during-recovery, multi-crash loops — none can be expressed. Each of the kernel fixes in section 1 needs its matching seeded fault profile: armAppendError(partialBytes), armFsyncError(), error-then-continue schedules, and N crash-recover-write cycles per seed with a durability lower bound (everything fsync'd before the fault must survive). |
| 152 | + |
| 153 | +**Medium** |
| 154 | +Workload alphabet is 16 short ASCII keys / "vN" values — no binary, empty, or large-payload fuzz of the record codec; recovered sortedness never asserted |
| 155 | + |
| 156 | +src/sim/workload.ts:88 |
| 157 | + |
| 158 | +Lens composite keys embed 0x00 separators and arbitrary document bytes — exactly the inputs the DST never generates. Add a seeded round-trip fuzz (random Uint8Array ops, lengths 0..N, full byte alphabet → encode → replay → deep-equal) and assert the recovered array is sorted. Also add aliasing tests once the B5 contract is pinned. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +4. Lower priority (roadmap) |
| 163 | +| **Sev** | **Finding** | **Where** | |
| 164 | +| ------- | ----------- | --------- | |
| 165 | +| Low | No typed error contract — all failures are bare Error with prefix strings; callers must match message text. Worth deciding before the API surface is public-locked. | core.ts:494 | |
| 166 | +| Low | close() during an open transaction surfaces raw EBADF instead of a libredb error | core.ts:517 | |
| 167 | +| Low | Lone-surrogate string keys silently collide after UTF-8 encoding (kv keys, doc ids) | lenses/kv.ts:62 | |
| 168 | +| Low | find({ field: undefined }) matches documents lacking the field — inverted meaning for JS callers | lenses/document.ts:79 | |
| 169 | +| Low | CLI get/scan print raw bytes — terminal escape-sequence injection when inspecting untrusted data | cli/run.ts:125 | |
| 170 | +| Low | node-fs read() reads the whole file per call and goes by path, not fd (transient 2× memory at open; wrong inode if path is swapped) | node-fs.ts:33 · #12 | |
| 171 | +| Low | OPFS flush() lacks fsync-strength power-loss guarantees — document the weaker contract; verify in #10's E2E | opfs.ts:69 · #10 | |
| 172 | +| Low | Docker image runs as root — root-owned files in bind mounts; switch to distroless :nonroot | Dockerfile:30 | |
| 173 | +| Low | Publish workflow never verifies release tag == package.json version | publish.yml:141 | |
| 174 | +| Low | No export/dump command and no documented backup story (file-copy rule is fine — say it) | docs/CLI.md:41 | |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +5. What survived scrutiny |
| 179 | +For balance — the audit adversarially attacked far more than it confirmed. Things that held up: |
| 180 | + |
| 181 | +**Held** |
| 182 | +The core design decisions are sound |
| 183 | + |
| 184 | +The crash model (append-only + CRC + fsync-before-visible + torn-tail truncation) is correct for crashes; the trust-boundary file structure is real (no lens reaches around the kernel); the reserved-namespace/catalog contract is enforced where claimed; the workflows are SHA-pinned with least-privilege tokens; secretlint/CodeQL/SonarCloud are wired; commit and record encoding round-trips correctly under the tested alphabet. Two review claims were refuted outright (require() interop — verified empirically as intended ESM-only behavior; Docker root as a CLI-blocking issue — real but reclassified low). The 100%-coverage gate, changesets discipline, and docs volume are genuinely above the bar for a project at this stage. |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +6. Suggested sequence |
| 189 | +|**Wave** |**Scope** |**Contents** | |
| 190 | +| ------- | ----------- | --------- | |
| 191 | +|1 — before announcing | Kernel guards + honest docs (mostly small, each with a pinning test) | B2 async guard · B3 failure latch/truncate-back · B4 open lock · B5 buffer copy contract · B6 iterator semantics · B7 name validation · B9 dir-fsync + truncate-fsync · B1 magic header with refuse-don't-truncate (the one format change — do it now, with a headerless-v0.1.x read path) · B11 + README status/claims fixes | |
| 192 | +|2 — same release train | Prove it + supply chain | DST fault profiles + codec fuzz (section 3) · B10 read loops · provenance (#7) · jsr pin · Dependabot · Node CI job · CLI lock ownership · perf-envelope + backup docs | |
| 193 | +|3 — tracked roadmap | Already-open issues | #12 compaction · #10 real-browser OPFS E2E · #9 remainder (group commit) · typed errors · remaining lows | |
| 194 | + |
| 195 | +--- |
| 196 | + |
| 197 | + |
| 198 | +**Method** |
| 199 | +8 parallel expert reviewers (kernel durability, API contract, lenses, CLI/multi-process, adapters, test adequacy, supply chain, database must-haves) → every finding adversarially verified (critical/high: two independent refuters, unanimous confirmation required) → completeness critic. All findings cite real code read from the working tree at commit ccaca81. Refuted claims were discarded; severities shown are post-verification. |
0 commit comments