PR #63 — test-only fix for the flaky store-digest comparisons in BootstrapActionTests and BootstrapClassifierTests. Reviewed as origin/main...HEAD (2 commits, 6 files). No code was modified by this review.
ModelContainer does not fold its WAL synchronously; LibraryRepository.shutdown() only drops the reference. A sibling run without this fix, overlapping this review on the same host, failed aLockTimeoutChangesNothing, aFailedOpenChangesNothing and classificationIsTotalAndWritesNothing; this branch passed all of them twice.RootDigest.store still carries the main file's bytes, and a TRUNCATE checkpoint moves only committed frames. What is missing is a cheap canary proving a real write still fails the comparison (finding 4).-wal (no SQLITE_FCNTL_PERSIST_WAL). Verified empirically on this Mac's SQLite 3.51.0 (Apple build): a plain last-connection close leaves -wal at 0 bytes and -shm in place. Not an issue here; worth one comment line.before = try root.digest() already folds, so the explicit fold on the next line is a no-op and "frames are guaranteed to still be in the log when before is taken" is false. The test still pins digest() settles, which is the property that matters.OpenerParityTests.readingsPerOpener() does await repository.shutdown() then reads the store through a read-only raw SQLite connection. It failed once in the first full run here (AsterismV3.sqlite has no Z_METADATA row), green in isolation. Not this PR's change; recommend a follow-up that applies StoreSettling before that read.docs/agent-notes/testing.md rewrite are accurate. specs/work-and-reading-status/verification-run.md:59 still quotes the old heading "Known flaky family: …" which no longer exists.Ready to push
The fix is correct for the platform it runs on and does what the report says: StoreSettling.foldWriteAheadLog(at:) runs PRAGMA wal_checkpoint(TRUNCATE) at both digest points so the byte comparison is invariant under Core Data's deferred close, guarded by a WAL-magic check so the classifier's garbage families are never opened. Two full AsterismCore runs on this branch (2,350 tests each) had every store-digest cell green, and a concurrent run on another worktree without this fix failed three of those very cells under the same host load — direct evidence the mechanism is the one the report names. The one full-run failure seen here (OpenerParityTests, no Z_METADATA row, green in isolation and in the second run) is the same shutdown()-is-not-a-close race in a suite this PR does not touch; it belongs in a follow-up ticket, not this PR. All findings are minor or nits: a false doc-comment claim in the two regression tests, a missing positive-control test, duplicated helpers across the two suites, and a report section (## Related) the other 18 bugfix reports carry. None blocks the push.
Pass rate: 100% (2319 of 2319)
New tests: 2
Diff coverage: 71% (119 of 167 added lines)
32bcdee Fix T-2293: settle the store before hashing it in the bootstrap digest suites 0e8d7ca T-2293: Record the unexercised could-not-settle path in the bugfix report Two test suites check that certain operations (refusing to open a corrupt library, timing out on a lock, classifying what is on disk) do not write anything. They do this by taking a fingerprint of every file in the library before and after, and requiring the two fingerprints to match. Sometimes the fingerprints did not match even though nothing had been written, and the test failed at random — usually a different test each time, and never when run on its own.
This change adds a small helper, StoreSettling, that tidies the database into a stable state right before each fingerprint is taken, so the two fingerprints describe the same thing. It also makes the failure message say which file changed, adds tests proving the tidy-up does not itself change the fingerprint, and rewrites the project note that had blamed the wrong cause.
A test that fails randomly gets re-run instead of read. Two earlier features each spent a section of their write-ups arguing that this failure was not their fault. Fixing it makes the pre-commit test run trustworthy again.
-wal) first, and folds them into the main file later. Think of a notebook of pending edits that gets copied into the ledger at some point.Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift (new, test target): foldWriteAheadLog(at:) opens a raw sqlite3 connection with SQLITE_OPEN_READWRITE, sets a 5 s busy timeout, runs PRAGMA wal_checkpoint(TRUNCATE), and retries up to three times. It returns early unless the -wal starts with SQLite's header magic (0x377F0682/0x377F0683). If the log is still non-empty after all attempts it calls Issue.record. writeAheadLogByteCount(at:) reports the log size.BootstrapActionTests.ActionRoot.digest() and BootstrapClassifierTests.ClassifierRoot.digest() call the fold first. RootDigest.difference(from:) (duplicated in both files) names the members that moved; every digest assertion interpolates it into its message.digestIsInvariantUnderADeferredFold, one per suite) hold a second ModelContainer open, write a Site, require a non-empty log, then assert the digest is unchanged across a fold. settlingLeavesPathologicalFamiliesAlone pins the magic-number guard.docs/agent-notes/testing.md (the "known flaky family" section replaced with the real mechanism), and a bugfix report under specs/bugfixes/bootstrap-store-digest-flake/.Canonicalise rather than exclude: instead of dropping the -wal from the digest (which would also drop the main file's bytes on paths where byte identity is the point), fold the log at both readings so the comparison is invariant under the close the harness cannot time. The guard matters because the classifier sweep deliberately seeds families that are not databases, and merely opening a connection over one truncates its log and creates a -shm.
digest() is now an intervention (it opens a connection and checkpoints) rather than a pure observation. Harmless on Apple's SQLite, which persists the WAL sidecars on close, but the name hides it.-wal; the 36 mainFileOnly × atOrAboveV5 cells delete the companions right after seeding through a container, so a late close there would checkpoint from the unlinked WAL fd into the main file and the fold could not help. Never observed as a failing cell; noted as residual.The flake was a third-party write inside the assertion window: Core Data's SQLite connection outlives the last Swift reference to the ModelContainer, and its close performs a checkpoint that rewrites the main file and truncates the log. The suites were byte-hashing the family, so the checkpoint read as a write. The report's measurements (111,272-byte log after shutdown() in one process, 0 in the next) and the probe against a live idle container establish this cleanly; the earlier note's "process-wide state from earlier suites" theory is ruled out by each root being a per-test UUID directory in serialized suites.
foldWriteAheadLog is sound for the intended case. The retry loop is guard genuine → attempt → guard byteCount != 0 → return; a 0-byte log cannot pass the 4-byte header read, so a completed TRUNCATE exits on the byte-count guard before the magic check sees the emptied file. Column 0 of PRAGMA wal_checkpoint is the busy flag; the big-endian reduce reconstructs 0x377F0682 correctly; the open-failure path reads sqlite3_errmsg before sqlite3_close_v2, which is a no-op on NULL. #_sourceLocation has ~20 precedents in the test targets; the package is Swift 6 language mode and the statics are Sendable lets.
None on shipped code. In the test target it establishes a rule the note now states: anything that byte-compares a SQLite store after a ModelContainer touched it must fold first. The same rule is already violated by OpenerParityTests.readingsPerOpener() (shutdown() then a SQLITE_OPEN_READONLY raw read), which failed once during this review under load with no Z_METADATA row — a read-only connection racing the deferred close. StoreSettling lives in the same target and is the obvious fix there.
Issue.record on latent states: a genuine -wal beside a missing main file (SQLITE_OPEN_READWRITE without CREATE → SQLITE_CANTOPEN) or beside a not-a-database main file (log = -1 or SQLITE_NOTADB) burns three attempts and records. Unreachable through today's Cell.crossProduct — companionsOnly collapses to .indeterminate, whose log is the garbage string — but one axis change away.-wal at 0 bytes. The doc comment's claim that the sidecars survive is true on Apple platforms; a one-line SQLITE_FCNTL_PERSIST_WAL would make it true by construction.foldWriteAheadLog silently did nothing, both digests would still match. A #require(writeAheadLogByteCount == 0) after before, and a sibling test that writes a row and expects digest() != before, would close both gaps for ~10 lines.sourceLocation is never forwarded from digest(), so a could not settle issue would point at the one line inside digest() for all 288 sweep cells.Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift
Why it matters. This is the fix. It turns a byte comparison that depended on when Core Data happened to close its connection into one that is invariant under that close. Every digest assertion in two suites (5 action cells, 288 classifier cells) now runs through it.
What to look at. StoreSettling.swift:44-130 — foldWriteAheadLog(at:sourceLocation:), attemptFold(at:), hasGenuineWriteAheadLog(at:)
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift
Why it matters. The single call site per suite that makes every existing digest assertion benefit. logicalDigest() goes through the same path, which is correct: it still compares the file set, and the -wal name is part of it.
What to look at. BootstrapActionTests.swift:481-499 (ActionRoot.digest), BootstrapClassifierTests.swift:592-612 (ClassifierRoot.digest)
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift
Why it matters. The old assertion printed two opaque RootDigest blobs, which is most of why the flake went misdiagnosed for months. Every digest assertion now interpolates which of files / store / write-ahead log / marker / historical marker / migration artefact changed, with byte counts.
What to look at. BootstrapActionTests.swift:546-565 and BootstrapClassifierTests.swift:641-659; call sites at :105, :151, :198, :222, :279 and :81
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift
Why it matters. They turn the race into a deterministic reproduction (both failed before the fix, per the report) and pin the magic-number guard that keeps the classifier's garbage families untouched. Their doc comments overclaim, though: before = try root.digest() already folds, so the explicit fold that follows is a no-op.
What to look at. BootstrapActionTests.swift:228-259, BootstrapClassifierTests.swift:149-192
docs/agent-notes/testing.md
Why it matters. The previous section blamed 'process-wide state earlier suites influenced' and told investigators to re-run rather than read. The rewrite names the real mechanism, the two load-bearing details of the fix, and the rule for anything new that hashes a store.
What to look at. docs/agent-notes/testing.md:82-127
From the report's Alternatives considered: excluding the log drops the main file's bytes from the comparison on the paths where byte identity is the point; folding at seeding leaves every future seeding helper responsible and does not cover a container the code under test opens; polling for an empty log is not proof of a close; comparing rows instead of bytes stops detecting a write that changes the file without changing a row.
Measured: opening a connection over a garbage main file beside a garbage log truncated the log to 0 and created a -shm. The classifier sweep seeds exactly those families on purpose. A log that is not a log is not something a deferred close would fold either, so the guard costs no coverage (StoreSettling.swift doc comment; report.md).
The connection the fold races is the one still holding the store; without the timeout the checkpoint came back database is locked twice in one full run. A silent skip would reinstate the flake, so the failure is recorded naming the store (report.md, Two details are load-bearing). The could not settle path is deliberately untested: reproducing it means holding the checkpoint lock through 3 × 5 s (report.md, Not covered; commit 0e8d7ca).
Not stated by the author. logicalDigest() calls digest() and nils store and writeAheadLog, so the fold cannot affect the two members it compares by bytes — but it still compares the file set, which includes the -wal name, and on Apple's SQLite the fold never removes that file. The fold there is therefore harmless and wasted rather than wrong.
Not stated by the author. The doc comment says Core Data asks SQLite to persist the sidecars; that is true of Core Data's connection, not of this one. On Apple's SQLite build (verified here, 3.51.0) a plain last-connection close also leaves the -wal in place at 0 bytes, so the omission has no effect on this platform.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | Regression tests, both suites (BootstrapActionTests.swift:228-259, BootstrapClassifierTests.swift:149-167) | The doc comment claims 'its frames are guaranteed to still be in the log when before is taken', but before = try root.digest() folds first, so the log is already empty at that point and the explicit StoreSettling.foldWriteAheadLog(...) on the next line is a guaranteed no-op. The #require message ('what makes the fold observable') overclaims the same way. The test does still pin 'digest() settles' — if digest() stopped folding, before would capture the unfolded log and the explicit fold would move it — which is the property that matters, but it passes for a different reason than its comment gives, and it would also pass if foldWriteAheadLog silently did nothing. | Reword the comment to say the test pins that digest() settles; add try #require(root.writeAheadLogByteCount == 0, "digest() must have folded the log before the first reading") after before. Not applied: review is read-only. |
| minor | Test coverage — no positive control (both suites) | Every digest assertion in the repo is an equality; nothing proves a real write between the two readings still fails the comparison now that digest() checkpoints. The argument that it does (store bytes are still compared; TRUNCATE moves only committed frames) is sound, but a silently weakened digest() would turn all 293 cells green with no canary. | One test: seed, before = digest(), insert a Site through the held container and save, #expect(try root.digest() != before). LibraryRepository.insertSiteRow(hostname:) already exists at BootstrapActionTests.swift:568. Not applied: review is read-only. |
| minor | StoreSettling.foldWriteAheadLog — latent spurious Issue.record (StoreSettling.swift:64-79) | A genuine -wal beside a missing main file (SQLITE_OPEN_READWRITE without CREATE fails SQLITE_CANTOPEN) or beside a not-a-database main file (checkpoint reports log = -1 or step returns SQLITE_NOTADB) burns three attempts and records 'could not settle'. Both are legitimate states elsewhere in these suites (companionsWithoutAMainFileAreAPresentStore, orphanedWALDoesNotResurrectContent) but unreachable through digest() today because Cell.crossProduct collapses companionsOnly to .indeterminate, whose log is the garbage string. | Return early when the main file does not exist, and treat SQLITE_CANTOPEN / SQLITE_NOTADB / log == -1 as nothing-to-fold. Add the genuine-log-without-main-file case to settlingLeavesPathologicalFamiliesAlone. Not applied: review is read-only. |
| minor | Duplication across the two suites | RootDigest.difference(from:) (21 lines), the writeAheadLogByteCount forwarder, and the digestIsInvariantUnderADeferredFold body are byte-identical between BootstrapActionTests.swift and BootstrapClassifierTests.swift apart from doc comments. The RootDigest struct itself was already duplicated on main; this branch grows the copy. The test target already keeps shared helpers as sibling files (CitationSurgery.swift, OrderAlgebra.swift, M5RepositoryTestSupport.swift) and StoreSettling.swift is added in exactly that shape. | Move RootDigest + difference(from:) into StoreSettling.swift (or a sibling RootDigest.swift), drop private, and let both roots use it; BootstrapStateCoverageTests' RootSnapshot is a subset the same type could cover. Not applied: review is read-only and the refactor would touch test files beyond a bug fix. |
| minor | StoreSettling.foldWriteAheadLog(at:sourceLocation:) — attribution | sourceLocation is never forwarded: both digest() call sites use the default, so a 'could not settle' issue is attributed to the one line inside ActionRoot.digest() / ClassifierRoot.digest(), identical for all 288 sweep cells. The parameter buys nothing as wired. | Either thread digest(sourceLocation:) through from the assertions or drop the parameter and rely on Swift Testing's enclosing-test attribution. Not applied. |
| minor | Worst-case latency (StoreSettling.swift:31-35) | 5 s busy timeout × 3 fresh connections = 15 s per fold, twice per cell, with no time-limit trait on either suite. Each attempt gets a fresh full timeout, so the budget multiplies rather than bounds. Never fires in practice (seven clean full runs counting this review's two), but a genuinely stuck store would hang the run for minutes before reporting. | Make it a single wall-clock deadline (busy_timeout = remaining time) so the worst case is 5 s per fold regardless of attempts; or note the 15 s ceiling in the attempts doc comment. Not applied. |
| minor | specs/bugfixes/bootstrap-store-digest-flake/report.md — missing ## Related | All 18 other bugfix reports end with a ## Related section; this one ends at ## Prevention. Otherwise the section order matches the house template exactly and Alternatives considered sits in its usual place. | Add ## Related pointing at docs/agent-notes/testing.md, specs/recent-window-cap/implementation.md:296, specs/configurable-work-types/verification-run.md:17 and specs/work-and-reading-status/verification-run.md:59. Not applied. |
| minor | specs/work-and-reading-status/verification-run.md:59 — dangling heading reference | Quotes the testing.md heading 'Known flaky family: the store-digest comparisons', which this branch renamed to 'Fixed (T-2293): …'. The observation is a dated record and fine to keep; the pointer no longer resolves. | Retitle the quoted heading or add '(superseded by T-2293)'. Not applied. |
| nit | StoreSettling.attempts doc comment (StoreSettling.swift:34-35) | 'Fresh-connection retries after a wait that still came back locked' on attempts = 3, but the loop is for _ in 0..<attempts, i.e. one initial try plus two retries. The report's 'two fresh-connection retries' and 'three times over … fifteen seconds' are correct against the code; the constant's own comment is not. | Reword to 'Connection attempts: the first try plus two fresh-connection retries'. Not applied. |
| nit | StoreSettling.attemptFold return value | Returns a failure string even on success ('the checkpoint reported success and the log survived it'); the caller assigns it to lastFailure unconditionally and decides success by a subsequent stat. lastFailure's initial value is dead since attempts >= 1 always overwrites it. | Return String? (nil = folded) or a two-case enum and exit the loop on nil. Not applied. |
| nit | RootDigest.difference(from:) — same-length mutations | Prints byte counts only, so a same-length mutation renders 'store 111272 -> 111272 bytes', as opaque as the blobs it replaces. | Add a 'same length, first differing offset N' branch. Not applied. |
| nit | docs/agent-notes/testing.md:82 heading style | '## Fixed (T-2293): the store-digest comparisons (AsterismCore)' is the only heading under docs/ with a status prefix or ticket number; the file's other headings are declarative rules. | Rename to the note's own closing rule, e.g. 'A byte digest over a SQLite store must fold the write-ahead log first', keeping the history in the body. Not applied. |
| nit | BootstrapClassifierTests.swift:71-73 — stale comment | 'taking it before the digest keeps the -shm it may create out of the comparison' — digest() now opens its own connection and may create a -shm at either reading; the ordering no longer buys what the comment says (harmless: regularFiles() filters -shm). | One-line amendment. Not applied. |
| nit | Quality agent: 'fold connection deletes -wal when last to close' (StoreSettling.swift:98-104) | Raised as major: without SQLITE_FCNTL_PERSIST_WAL, a connection that is the last to close deletes -wal/-shm, which would perturb RootDigest.files and writeAheadLog. Checked empirically on this host (Apple SQLite 3.51.0 via the sqlite3 CLI): a plain last-connection close leaves -wal at 0 bytes and -shm present. Does not apply on Apple platforms; the doc comment's claim holds here. | False positive on this platform. A one-line sqlite3_file_control(db, nil, SQLITE_FCNTL_PERSIST_WAL, &on) would make the doc claim true by construction rather than by platform; optional. |
Source: local run at 2026-09-05T17:08:44+10:00 · snapshot 0e8d7ca
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: 1 file · Baseline: absent
Coverage scope: every test in the repository
Totals: 2319 passed · 0 failed · 31 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
| File | Added lines | Covered | Diff coverage |
|---|---|---|---|
| Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift | 130 | 42 | 72% |
| Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift | 90 | 37 | 70% |
| Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift | 95 | 40 | 71% |
| docs/agent-notes/testing.md | 46 | — | no coverage data |
| specs/bugfixes/bootstrap-store-digest-flake/report.md | 230 | — | no coverage data |
| CHANGELOG.md | 22 | — | no coverage data |
Aggregate diff coverage: 71% (119 of 167 measurable added lines).
Head 93.7% (77755 of 82988 lines)
3 of 6 changed files matched coverage data.
docs/agent-notes/testing.md — no candidatespecs/bugfixes/bootstrap-store-digest-flake/report.md — no candidateCHANGELOG.md — no candidateFiles that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 30c1573050a3432567c9cbdf96d94eab46194f0f.
Click to expand.
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swiftnew file mode 100644index 0000000..76be5ed--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift@@ -0,0 +1,130 @@+import Foundation+import SQLite3+import Testing++/// Folds a store's write-ahead log back into its main file, so a byte comparison+/// taken afterwards describes the library's *data* rather than the journal's+/// position (T-2293).+///+/// **Why the digest suites need this.** `BootstrapActionTests` and+/// `BootstrapClassifierTests` prove that an operation which must not write left+/// the library byte-identical, by hashing the store family before and after. Both+/// seed through a `ModelContainer`, and releasing one does *not* fold its log+/// synchronously: SwiftData's release is deferred by an unpredictable amount, and+/// SQLite folds the log when Core Data's connection actually closes. Measured on+/// this repository, `LibraryRepository.shutdown()` left a 111,272-byte log behind+/// on one run and an empty one on the next, in the same process — so the close+/// landed *between* the two hashes often enough to fail one cell per full+/// `make test-core`, while every isolated run passed. The suites were reading the+/// journal's position, not a write.+///+/// **What it does, and why that is not a weakening.** `PRAGMA+/// wal_checkpoint(TRUNCATE)` copies every committed frame into the main file and+/// empties the log. It moves bytes; it changes no row, and Core Data performs+/// exactly this fold itself when it closes. Running it at both hash points makes+/// the comparison invariant under that close rather than blind to it: anything+/// the code under test wrote still lands in the main file's bytes and still fails+/// the comparison. It canonicalises the same guarantee instead of relaxing it.+///+/// **What it refuses to touch.** The classifier's cross-product deliberately+/// seeds pathological families — opaque bytes at the `-wal` path, a main file+/// that is not a database — and SQLite perturbs those the moment a connection is+/// opened over them: measured, opening a garbage main file beside a garbage log+/// truncated the log to zero and created a `-shm` that was not there before. So+/// this opens nothing unless the log carries SQLite's own header magic. A log+/// that is not a log is not something a deferred close would fold either, so the+/// guard costs no coverage.+///+/// **Why it can fail, and why that is an issue rather than a shrug.** The+/// connection this races is the one still holding the store, so the checkpoint+/// can come back `database is locked` — observed twice in one full run before the+/// busy timeout below was added. A log that could not be folded makes the+/// comparison meaningless, and a silent skip would put the flake straight back,+/// so an unfoldable log is recorded as an issue naming the store instead.+enum StoreSettling {++ /// How long to wait for whichever connection still holds the store. Generous+ /// on purpose: the wait is only ever paid where a close is genuinely in+ /// flight, and the alternative to waiting is an unusable comparison.+ private static let busyTimeoutMilliseconds: Int32 = 5_000++ /// Fresh-connection retries after a wait that still came back locked.+ private static let attempts = 3++ /// SQLite's write-ahead-log header magic, in its two legal spellings: the low+ /// bit records whether the page checksums are big- or little-endian.+ private static let writeAheadLogMagicNumbers: Set<UInt32> = [0x377F_0682, 0x377F_0683]++ /// Folds the write-ahead log beside `storeURL` into the main file, leaving an+ /// empty log in place. A no-op unless a genuine SQLite log is there to fold.+ static func foldWriteAheadLog(+ at storeURL: URL,+ sourceLocation: SourceLocation = #_sourceLocation+ ) {+ var lastFailure = "the log was still there and nothing said why"+ for _ in 0..<attempts {+ guard hasGenuineWriteAheadLog(at: storeURL) else { return }+ lastFailure = attemptFold(at: storeURL)+ guard writeAheadLogByteCount(at: storeURL) != 0 else { return }+ }+ // One last look: a log that vanished or emptied between the attempt and+ // here is folded, however it got that way.+ guard hasGenuineWriteAheadLog(at: storeURL) else { return }+ Issue.record(+ """+ could not settle \(storeURL.lastPathComponent) before hashing it — \(lastFailure). \+ A store whose write-ahead log cannot be folded gives a byte comparison \+ that moves on its own; see StoreSettling.+ """,+ sourceLocation: sourceLocation)+ }++ /// The number of bytes in the write-ahead log beside `storeURL`, or zero where+ /// there is none. A settled store's log is present but empty: Core Data asks+ /// SQLite to persist the sidecars, so the file survives the fold at length 0.+ static func writeAheadLogByteCount(at storeURL: URL) -> Int {+ let logURL = URL(fileURLWithPath: storeURL.path + "-wal")+ let values = try? logURL.resourceValues(forKeys: [.fileSizeKey])+ return values?.fileSize ?? 0+ }++ /// One connection's worth of folding. Returns what went wrong, for the issue.+ private static func attemptFold(at storeURL: URL) -> String {+ var handle: OpaquePointer?+ guard sqlite3_open_v2(storeURL.path, &handle, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK,+ let database = handle+ else {+ let reason = handle.map { String(cString: sqlite3_errmsg($0)) } ?? "the store would not open"+ sqlite3_close_v2(handle)+ return reason+ }+ defer { sqlite3_close_v2(database) }+ sqlite3_busy_timeout(database, busyTimeoutMilliseconds)++ var statement: OpaquePointer?+ guard sqlite3_prepare_v2(+ database, "PRAGMA wal_checkpoint(TRUNCATE)", -1, &statement, nil) == SQLITE_OK,+ let checkpoint = statement+ else { return String(cString: sqlite3_errmsg(database)) }+ defer { sqlite3_finalize(checkpoint) }++ guard sqlite3_step(checkpoint) == SQLITE_ROW else {+ return String(cString: sqlite3_errmsg(database))+ }+ let busy = sqlite3_column_int(checkpoint, 0)+ return busy == 0+ ? "the checkpoint reported success and the log survived it"+ : "the checkpoint was blocked by another connection"+ }++ /// Whether the file at the `-wal` path is one SQLite wrote, rather than the+ /// opaque bytes a partial restore — or a seeded test case — leaves there.+ private static func hasGenuineWriteAheadLog(at storeURL: URL) -> Bool {+ let logURL = URL(fileURLWithPath: storeURL.path + "-wal")+ guard let handle = try? FileHandle(forReadingFrom: logURL) else { return false }+ defer { try? handle.close() }+ guard let header = try? handle.read(upToCount: 4), header.count == 4 else { return false }+ return writeAheadLogMagicNumbers.contains(+ header.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) })+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swiftindex 59adf92..1b055aa 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift@@ -23,6 +23,10 @@ import Testing /// cross-process lock file are excluded: creating both is explicitly permitted. /// Two paths are compared at logical granularity instead, because they cannot /// decide without opening a container — see `ActionRoot.logicalDigest()`.+/// Both readings are taken over a *settled* store: the log is folded into the+/// main file first, so the comparison describes the library's data rather than+/// the journal's position, which a deferred container close moves on its own+/// (T-2293, `StoreSettling`). /// /// Paths are literals for the reason `BootstrapStateCoverageTests` gives — the /// accessors are renamed by later tasks while the values are frozen (Req 2.14).@@ -98,7 +102,9 @@ struct BootstrapActionTests { #expect(log.events.count == 1, "a refused state may not reach a ModelContainer, got \(log.events)") #expect(try root.markerText() == digit, "a refused open may not republish readiness")- #expect(try root.digest() == before, "the refused open changed the library")+ let afterRefusal = try root.digest()+ #expect(afterRefusal == before,+ "the refused open changed the library: \(afterRefusal.difference(from: before))") withExtendedLifetime(root) {} } @@ -142,7 +148,9 @@ struct BootstrapActionTests { } #expect(!root.exists(root.markerURL), "an unverifiable state may not certify itself")- #expect(try root.logicalDigest() == before, "the refused open changed the library")+ let afterUnmarked = try root.logicalDigest()+ #expect(afterUnmarked == before,+ "the refused open changed the library: \(afterUnmarked.difference(from: before))") if withLeftoverArtefact { #expect(root.exists(root.migrationArtefactURL), "the evidence is preserved") }@@ -187,7 +195,9 @@ struct BootstrapActionTests { try await LibraryRepository.openForApp(root.configuration) } - #expect(try root.digest() == before, "\(refusal.rawValue): the refused open wrote something")+ let afterRefusal = try root.digest()+ #expect(afterRefusal == before,+ "\(refusal.rawValue): the refused open wrote something: \(afterRefusal.difference(from: before))") withExtendedLifetime(root) {} } @@ -209,10 +219,47 @@ struct BootstrapActionTests { try await LibraryRepository.openForApp(root.configuration) } - #expect(try root.digest() == before, "a lease the app could not take wrote something")+ let afterTimeout = try root.digest()+ #expect(afterTimeout == before,+ "a lease the app could not take wrote something: \(afterTimeout.difference(from: before))") withExtendedLifetime((root, held)) {} } + /// Regression, T-2293. The two cells above compared the store family byte for+ /// byte, and a *third party* moved those bytes between the two readings:+ /// releasing the `ModelContainer` the seeding opened does not fold its+ /// write-ahead log synchronously, so Core Data's close — and the fold that+ /// comes with it — landed inside the comparison window whenever SwiftData's+ /// release ran late. That is load-dependent, which is why one cell failed per+ /// full `make test-core` while every isolated run passed.+ ///+ /// Holding a container open makes that state deterministic instead of lucky:+ /// its frames are guaranteed to still be in the log when `before` is taken.+ /// The fold that follows is exactly what the deferred close performs, and the+ /// digest has to be invariant under it — a checkpoint relocates bytes and+ /// changes no row, so a digest that calls one a write cannot tell a real write+ /// from a journal that moved.+ @Test("A digest is invariant under the write-ahead-log fold a deferred close performs")+ func digestIsInvariantUnderADeferredFold() async throws {+ let root = try ActionRoot()+ try await root.seedReadyLibrary(hostname: "deferred.example")++ let holder = try LibraryRepository.openContainer(at: root.storeURL)+ let context = ModelContext(holder)+ context.insert(Site(hostname: "unfolded.example"))+ try context.save()+ try #require(root.writeAheadLogByteCount > 0,+ "expected frames still in the log, which is what makes the fold observable")++ let before = try root.digest()+ StoreSettling.foldWriteAheadLog(at: root.storeURL)++ let afterFold = try root.digest()+ #expect(afterFold == before,+ "the fold a deferred container close performs moved the digest: \(afterFold.difference(from: before))")+ withExtendedLifetime((root, holder, context)) {}+ }+ @Test("A corrupt store is refused with its bytes intact") func aCorruptStoreChangesNothing() async throws { let root = try ActionRoot()@@ -229,7 +276,9 @@ struct BootstrapActionTests { try await LibraryRepository.openForApp(root.configuration) } - #expect(try root.digest() == before, "a refused open may not fabricate a replacement")+ let afterCorrupt = try root.digest()+ #expect(afterCorrupt == before,+ "a refused open may not fabricate a replacement: \(afterCorrupt.difference(from: before))") withExtendedLifetime(root) {} } }@@ -356,6 +405,9 @@ private final class ActionRoot { func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) } + /// Frames still waiting in the write-ahead log. Zero once the store is settled.+ var writeAheadLogByteCount: Int { StoreSettling.writeAheadLogByteCount(at: storeURL) }+ // MARK: - Seeding /// The certified state, reached the way the app reaches it: the app-role@@ -426,8 +478,19 @@ private final class ActionRoot { /// The main file, the write-ahead log, all three evidence files' bytes and the /// set of files present. SQLite's `-shm` and the lock file are excluded: /// creating either is explicitly permitted.+ ///+ /// The store is settled first (T-2293). Releasing the `ModelContainer` the+ /// seeding opened does not fold its write-ahead log synchronously — Core Data+ /// folds it when its SQLite connection actually closes, at an unpredictable+ /// later moment — so without this the close lands *between* the two readings+ /// under load and moves bytes no write produced. Folding at both readings+ /// makes the comparison invariant under that close rather than blind to it: a+ /// checkpoint relocates committed frames into the main file and changes no+ /// row, so anything the code under test wrote still shows up in the bytes+ /// below. See `StoreSettling` for the measurements and the guard. func digest() throws -> RootDigest {- RootDigest(+ StoreSettling.foldWriteAheadLog(at: storeURL)+ return RootDigest( files: try regularFiles(), store: try? Data(contentsOf: storeURL), writeAheadLog: try? Data(contentsOf: companionURL("-wal")),@@ -479,6 +542,27 @@ private struct RootDigest: Equatable { var marker: Data? var historicalMarker: Data? var migrationArtefact: Data?++ /// Which members moved, so a failure names the thing that changed rather than+ /// printing two opaque blobs. `try root.digest() == before` on its own could+ /// not distinguish a rewritten marker from a journal that shifted, which is+ /// most of why T-2293 went uninvestigated for as long as it did.+ func difference(from other: RootDigest) -> String {+ var moved: [String] = []+ if files != other.files {+ moved.append("files \(other.files.sorted()) -> \(files.sorted())")+ }+ func compare(_ name: String, _ lhs: Data?, _ rhs: Data?) {+ guard lhs != rhs else { return }+ moved.append("\(name) \(rhs?.count.description ?? "absent") -> \(lhs?.count.description ?? "absent") bytes")+ }+ compare("store", store, other.store)+ compare("write-ahead log", writeAheadLog, other.writeAheadLog)+ compare("marker", marker, other.marker)+ compare("historical marker", historicalMarker, other.historicalMarker)+ compare("migration artefact", migrationArtefact, other.migrationArtefact)+ return moved.isEmpty ? "nothing" : moved.joined(separator: "; ")+ } } // MARK: - Writing through the surviving opener
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swiftindex 003b736..3f499a9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift@@ -41,7 +41,12 @@ import Testing /// 3. **It writes nothing.** The digest covers the main file, the `-wal`, all /// three evidence files' bytes and the set of files present. SQLite's `-shm` is /// excluded: it is derived index state SQLite creates and rebuilds by design,-/// and Req 2.8 permits exactly that.+/// and Req 2.8 permits exactly that. Both readings are taken over a *settled*+/// store — the log is folded into the main file first — so the comparison+/// describes the library's data rather than the journal's position, which a+/// deferred container close moves on its own (T-2293, `StoreSettling`). The+/// fold opens nothing that is not a genuine SQLite log, which is what keeps+/// this sweep's pathological families exactly as they were seeded. /// /// The recorded-version axis is *seeded* by choosing what bytes the main file /// holds, but the expectation is derived from what `StoreMetadata` actually reads@@ -73,7 +78,9 @@ struct BootstrapClassifierTests { #expect(state.matches(cell.expectedState(recordedVersion: recordedVersion)), "\(cell): classified \(state), expected \(cell.expectedState(recordedVersion: recordedVersion))")- #expect(try root.digest() == before, "\(cell): classifying the state changed it")+ let afterClassification = try root.digest()+ #expect(afterClassification == before,+ "\(cell): classifying the state changed it: \(afterClassification.difference(from: before))") withExtendedLifetime(root) {} } @@ -139,6 +146,57 @@ struct BootstrapClassifierTests { withExtendedLifetime(root) {} } + /// Regression, T-2293. The sweep's third assertion compares the store family+ /// byte for byte, and the `fullFamily` × `atOrAboveV5` cell is the one that+ /// seeds through a `ModelContainer`. Releasing one does not fold its+ /// write-ahead log synchronously, so Core Data's close — and the fold that+ /// comes with it — landed inside the comparison window whenever SwiftData's+ /// release ran late, failing that cell in a full `make test-core` while every+ /// isolated run passed. Holding a container open makes the unfolded state+ /// deterministic; the digest must be invariant under the fold, which moves+ /// bytes and changes no row. See `StoreSettling`.+ @Test("A digest is invariant under the write-ahead-log fold a deferred close performs")+ func digestIsInvariantUnderADeferredFold() throws {+ let root = try ClassifierRoot()+ try root.seedBornAtLiveStore()++ let holder = try LibraryRepository.openContainer(at: root.storeURL)+ let context = ModelContext(holder)+ context.insert(Site(hostname: "unfolded.example"))+ try context.save()+ try #require(root.writeAheadLogByteCount > 0,+ "expected frames still in the log, which is what makes the fold observable")++ let before = try root.digest()+ StoreSettling.foldWriteAheadLog(at: root.storeURL)++ let afterFold = try root.digest()+ #expect(afterFold == before,+ "the fold a deferred container close performs moved the digest: \(afterFold.difference(from: before))")+ withExtendedLifetime((root, holder, context)) {}+ }++ /// The pathological families the sweep seeds are *not* logs, and settling must+ /// leave every one of them exactly as it was: opening a connection over a+ /// garbage store truncates its companion and creates a `-shm` that was not+ /// there, which would rewrite the very input the classifier is about to read.+ @Test("Settling refuses to touch a store family that is not a database")+ func settlingLeavesPathologicalFamiliesAlone() throws {+ let root = try ClassifierRoot()+ try root.createStoreDirectory()+ try Data("not a database".utf8).write(to: root.storeURL, options: .atomic)+ try Data("orphan log".utf8).write(to: root.walURL, options: .atomic)+ let before = try root.digest()++ StoreSettling.foldWriteAheadLog(at: root.storeURL)++ let afterSettling = try root.digest()+ #expect(afterSettling == before,+ "settling perturbed a family it may not open: \(afterSettling.difference(from: before))")+ #expect(!root.exists(root.shmURL), "settling created a companion that was not seeded")+ withExtendedLifetime(root) {}+ }+ @Test("Nothing on disk classifies pristine") func nothingOnDiskIsPristine() throws { let root = try ClassifierRoot()@@ -459,6 +517,9 @@ private final class ClassifierRoot { func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) } + /// Frames still waiting in the write-ahead log. Zero once the store is settled.+ var writeAheadLogByteCount: Int { StoreSettling.writeAheadLogByteCount(at: storeURL) }+ // MARK: - Seeding func createStoreDirectory() throws {@@ -528,8 +589,20 @@ private final class ClassifierRoot { /// The main file, the write-ahead log, all three evidence files' bytes, and /// the set of files present. The `-shm` is excluded by design.+ ///+ /// The store is settled first (T-2293). Releasing the `ModelContainer`+ /// `seedBornAtLiveStore` opened does not fold its write-ahead log+ /// synchronously — Core Data folds it when its SQLite connection actually+ /// closes, at an unpredictable later moment — so without this the close lands+ /// *between* the two readings under load and moves bytes no classification+ /// produced. Folding at both readings makes the comparison invariant under+ /// that close rather than blind to it. `StoreSettling` opens nothing unless+ /// the log carries SQLite's own header magic, which is what keeps this sweep's+ /// pathological families — opaque bytes at the `-wal` path, a main file that+ /// is not a database — exactly as they were seeded. func digest() throws -> RootDigest {- RootDigest(+ StoreSettling.foldWriteAheadLog(at: storeURL)+ return RootDigest( files: try regularFiles(), store: try? Data(contentsOf: storeURL), writeAheadLog: try? Data(contentsOf: walURL),@@ -564,4 +637,23 @@ private struct RootDigest: Equatable { var marker: Data? var historicalMarker: Data? var migrationArtefact: Data?++ /// Which members moved, so a failure names the thing that changed rather than+ /// printing two opaque blobs across 288 cells.+ func difference(from other: RootDigest) -> String {+ var moved: [String] = []+ if files != other.files {+ moved.append("files \(other.files.sorted()) -> \(files.sorted())")+ }+ func compare(_ name: String, _ lhs: Data?, _ rhs: Data?) {+ guard lhs != rhs else { return }+ moved.append("\(name) \(rhs?.count.description ?? "absent") -> \(lhs?.count.description ?? "absent") bytes")+ }+ compare("store", store, other.store)+ compare("write-ahead log", writeAheadLog, other.writeAheadLog)+ compare("marker", marker, other.marker)+ compare("historical marker", historicalMarker, other.historicalMarker)+ compare("migration artefact", migrationArtefact, other.migrationArtefact)+ return moved.isEmpty ? "nothing" : moved.joined(separator: "; ")+ } }
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex be5cd09..7735e1f 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -79,46 +79,52 @@ failing. The fix is a lock around the new state, not around the whole mock; see `projectComposedRequests`. Anything else recorded per call in a method a test can overlap needs the same treatment. -## Known flaky family: the store-digest comparisons (AsterismCore)--Several core tests assert that an operation which must not write left the-library byte-identical, by comparing a digest of the whole store *family* taken-before and after:--- `BootstrapActionTests.aFailedOpenChangesNothing` and- `BootstrapActionTests.aLockTimeoutChangesNothing` (`try root.digest() == before`)-- `BootstrapClassifierTests`' "classifying the state changed it" cell- (`BootstrapClassifierTests.swift:76`, `store=fullFamily`,- `seededVersion=atOrAboveV5`)--**How it presents**: a *different* one of them fails per run, only in a full-`make test-core`, and it passes both in isolation (`CORE_TEST=…`) and on a clean-HEAD — so it reads like a regression the current branch caused. The classifier-cell failed in 2 of 3 full runs during `configurable-work-types` with five-isolated runs green; the two `BootstrapActionTests` cells did the same during-`recent-window-cap`, a branch that changes no file under `Packages/` at all.--**Root cause**: the digest hashes the store family, `-wal` included, and SQLite-chooses when to checkpoint the WAL back into the main file based on process-wide-state that earlier suites in the same run have influenced. Bytes therefore move-between the two hashes without a single row changing.--The rate is branch-dependent and can look deterministic: during T-2295-(2026-08-29) `aFailedOpenChangesNothing` failed in 3 of 4 full runs on the-branch and 0 of 2 on `main`, passed in isolation every time, and the fourth-full branch run was green — the branch only added reads to the tolerance scan.-Two clean baseline runs on `main` are not enough to call it a regression;-one green full run on the branch is what separates the flake from a real one.--**Suggested hardening** if the rate persists: checkpoint the store (or exclude-the `-wal`/`-shm` files) before hashing, so the digest describes the data rather-than the journal. Recorded from the other side in-`specs/configurable-work-types/verification-run.md` and that spec's-`implementation.md`, for the classifier suite only.--**Before investigating**: re-run the single test in isolation and re-run the-full suite. Two consecutive full-run failures of the *same* cell would be new;-one failure of a different cell each time is this.+## Fixed (T-2293): the store-digest comparisons (AsterismCore)++`BootstrapActionTests` and `BootstrapClassifierTests` assert that an operation+which must not write left the library byte-identical, by comparing a digest of+the whole store *family* taken before and after. Until 2026-09-05 a *different*+one of those cells failed per full `make test-core`, and every isolated re-run+passed — so it read like a regression the current branch had caused, and two+specs (`recent-window-cap`, `configurable-work-types`) each spent a section+arguing that it had not.++**The real root cause** was not "process-wide state earlier suites influenced",+which is what this note used to say. It is local to each test: **releasing a+SwiftData `ModelContainer` does not fold its write-ahead log synchronously.**+SQLite folds it when Core Data's connection actually closes, and that close is+deferred by an unpredictable amount after the last Swift reference goes away —+`LibraryRepository.shutdown()` left a 111,272-byte log behind on one run of a+process and an empty one on the next. Whenever the close landed *between* the two+digests it rewrote the main file and truncated the log, and the comparison read+that as a write. Each root is its own `UUID`-named temp directory and both suites+are `.serialized`, so no neighbouring test was ever involved.++**The fix** is `StoreSettling.foldWriteAheadLog(at:)` in the test target, called+from both suites' `digest()`. It runs `PRAGMA wal_checkpoint(TRUNCATE)` at both+hash points, so the comparison is invariant under the deferred close rather than+blind to it — a checkpoint relocates committed frames and changes no row, so a+real write still shows up in the main file's bytes. Two details are load-bearing:++- It opens **nothing** unless the `-wal` carries SQLite's own header magic. The+ classifier sweep seeds pathological families (opaque bytes at the `-wal` path,+ a main file that is not a database), and merely opening a connection over one+ truncates its log and creates a `-shm` that was not seeded.+- It sets a **busy timeout** and retries. The connection it races is the one+ still holding the store, so the first attempt came back `database is locked`+ twice in one full run before that was added.++An unfoldable log records an issue naming the store rather than being skipped: a+digest that cannot be settled is not a comparison, and a silent skip would put+the flake straight back.++**If a store-digest cell fails again**, read the message before re-running — the+assertions now name which member moved (`store … -> … bytes`, `write-ahead log+…`, `files …`) instead of printing two opaque blobs. A `could not settle …`+issue means the harness lost the race, not that the code under test wrote.++**Anything new that hashes a SQLite store** needs the same fold. Releasing the+container is not enough, and `shutdown()` is not a close. ## UI reachability must be proven through navigation
diff --git a/specs/bugfixes/bootstrap-store-digest-flake/report.md b/specs/bugfixes/bootstrap-store-digest-flake/report.mdnew file mode 100644index 0000000..2c4aaf0--- /dev/null+++ b/specs/bugfixes/bootstrap-store-digest-flake/report.md@@ -0,0 +1,230 @@+# Bugfix Report: Bootstrap store-digest flake++**Date:** 2026-09-05+**Status:** Fixed+**Ticket:** T-2293++## Description of the Issue++`BootstrapActionTests` and `BootstrapClassifierTests` each prove that an+operation which must not write left the library byte-identical, by hashing the+store family before and after. Under a full `make test-core` a *different* one of+those cells failed per run, and every isolated re-run passed:++ "A lock timeout leaves the library unchanged" recorded an issue at+ BootstrapActionTests.swift:212: Expectation failed: try root.digest() == before++The cells involved:++- `BootstrapActionTests.aLockTimeoutChangesNothing`+- `BootstrapActionTests.aFailedOpenChangesNothing` — four separate agent runs on+ 2026-08-29, each green on re-run+- `BootstrapActionTests.retiredMarkerGenerationIsRefused` — same shape, observed+ during this investigation+- `BootstrapClassifierTests`' sweep cell `store=fullFamily,+ seededVersion=atOrAboveV5`++**Reproduction steps:**++1. `make test-core` on a branch that changes nothing under `Packages/`.+2. Observe one store-digest cell fail.+3. Re-run that cell with `CORE_TEST=...` — it passes.++**Impact:** test-suite only; no shipped code is involved. The cost was+misattribution. The failure reads like a regression the current branch caused,+and both `specs/recent-window-cap/implementation.md` and+`specs/configurable-work-types/` had to spend a section arguing that it was not.+It also erodes the pre-commit bar: a suite that cries wolf gets re-run rather+than read.++## Investigation Summary++- **Symptoms examined:** the two failing assertions plus the third recorded in+ `docs/agent-notes/testing.md`; which cells flake and which never do.+- **Code inspected:** `BootstrapActionTests.ActionRoot`,+ `BootstrapClassifierTests.ClassifierRoot`, `LibraryRepository.shutdown()`,+ `LibraryRepository.openContainer(at:mirroring:)`, `StoreMetadata`,+ `V4RecordedStoreFixture`.+- **Hypotheses tested:**+ - *The ticket's hypothesis — a neighbouring test's store bleeding in.* **Ruled+ out by reading.** Every root is its own+ `FileManager.default.temporaryDirectory.appending(path: "…-\(UUID())")`, and+ both suites are `.serialized`. No other test can reach those files.+ - *SQLite auto-checkpointing mid-test.* **Ruled out.** Auto-checkpoint fires on+ a commit once the log passes 1000 pages; no commit happens between the two+ digests.+ - *A deferred container close folding the log.* **Confirmed.**++Evidence came from a throwaway probe suite run through `swift test --no-parallel`+and deleted before the fix landed:++| Probe | Result |+|---|---|+| Async seed, `shutdown()`, digest, `await`, digest | run 1: log `111272 -> 0` and the main file **changed**; runs 2-5: log already empty at the first digest and nothing moved |+| Log size immediately after `shutdown()`, across processes | `0` in one run, `111272` in another — same code, same machine |+| `PRAGMA wal_checkpoint(TRUNCATE)` against a live idle container | `busy=0 log=0 ckpt=0`; log `57712 -> 0`; the container's later release then moved nothing, 4/4 |+| The same fold over a garbage main file beside a garbage `-wal` | prepare fails, but SQLite **truncated the log to 0 and created a `-shm`** |+| The fold under a full-suite run, instrumented | came back `database is locked` twice in one run — the connection it races is the one still holding the store |++The failing assertion was then re-run with the digest members printed, which+showed exactly the predicted shape: `writeAheadLog: 111272 bytes` before,+`0 bytes` after, the store's own bytes rewritten, every other member identical.++## Discovered Root Cause++**Releasing a SwiftData `ModelContainer` does not fold its write-ahead log+synchronously.** SQLite folds it when Core Data's connection actually closes, and+that close is deferred by an unpredictable amount after the last Swift reference+goes away. `LibraryRepository.shutdown()` only sets `container = nil`; it is not+a close.++Both suites seed through a container and then take `before`. When the close ran+early, `before` already described a folded store and the comparison held. When it+ran late — which full-suite load makes likely — the close landed *between* the+two digests, rewriting the main file and truncating the log. The suites were+comparing the journal's position, not whether anything had been written.++**Defect type:** race condition in the test harness — an unsynchronised+third-party write inside an assertion window.++**Why it occurred:** the digest was written as a byte comparison of the store+*family* on the reasonable assumption that a store nobody is using does not move.+That holds for a store whose connection is closed, and the harness had no way to+know when that was.++**Contributing factors:** the previous note in `docs/agent-notes/testing.md`+attributed it to "process-wide state that earlier suites influenced", which+pointed every subsequent investigation away from the test's own seeding.++## Resolution for the Issue++Canonicalise the store family before hashing it: fold the write-ahead log into+the main file at *both* digest points, so the comparison is invariant under the+deferred close rather than blind to it.++**Changes made:**++- `Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift` — new+ test-target helper. `foldWriteAheadLog(at:)` runs+ `PRAGMA wal_checkpoint(TRUNCATE)` over the store, with a busy timeout and+ retries; `writeAheadLogByteCount(at:)` reports what is still waiting.+- `BootstrapActionTests.ActionRoot.digest()` and+ `BootstrapClassifierTests.ClassifierRoot.digest()` — settle before reading.+- Both suites' digest assertions now report *which member* moved, through a new+ `RootDigest.difference(from:)`.++**Why this does not weaken the assertion.** A checkpoint copies committed frames+into the main file and empties the log. It relocates bytes; it changes no row,+and Core Data performs exactly this fold itself on close. Running it at both hash+points means a write by the code under test still lands in the main file's bytes+and still fails the comparison — the guarantee is byte identity of the same+content, canonicalised so the journal's position cannot impersonate a write. The+file-set, marker, historical-marker and artefact halves are untouched, and+`logicalDigest()` keeps its narrower scope for the two paths that genuinely+cannot do better.++**Two details are load-bearing.**++1. *The magic-number guard.* The classifier's cross-product deliberately seeds+ pathological families, and SQLite perturbs those the moment a connection is+ opened over them — measured: it truncated the garbage log and created a `-shm`+ that was not seeded, which would rewrite the very input the classifier is+ about to read. So the helper opens nothing unless the `-wal` carries SQLite's+ own header magic (`0x377F0682` / `0x377F0683`). A log that is not a log is not+ something a deferred close would fold either, so the guard costs no coverage;+ `settlingLeavesPathologicalFamiliesAlone` pins it.+2. *The busy timeout.* The connection the fold races is the one still holding the+ store. Without a busy timeout the checkpoint came back `database is locked`+ twice in a single full run, and the flake survived the first version of this+ fix. With `sqlite3_busy_timeout` at 5 s and two fresh-connection retries, six+ consecutive full runs were clean.++An unfoldable log records an issue naming the store rather than being skipped: a+digest that cannot be settled is not a comparison, and a silent skip would put+the flake back exactly as it was.++**Alternatives considered:**++- **Exclude the `-wal` from the digest** (the ticket's second suggestion) —+ rejected: it drops the store's own bytes from the comparison on the very paths+ where byte identity is the point, which is the weakening the ticket forbids.+- **Fold at seeding time instead of digest time** — rejected: it leaves the+ suites depending on every future seeding helper remembering to settle, and it+ does not cover a container the *code under test* opens. The invariant belongs+ where the comparison is taken.+- **Wait or poll for the log to empty before hashing** — rejected: an empty log+ is not by itself proof that the close has happened, so this trades a flake for+ a slower flake.+- **Compare logical content (rows) instead of bytes** — rejected: the weakening+ the ticket rules out, and it would stop detecting a write that changes the file+ without changing a row.++## Regression Test++**Test files:**+`Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift`,+`Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift`++**Test names:**++- `digestIsInvariantUnderADeferredFold` — one per suite. Seeds, then holds a+ second `ModelContainer` open so frames are *guaranteed* still in the log,+ requires `writeAheadLogByteCount > 0`, takes `before`, performs the fold a+ deferred close would perform, and asserts the digest is unchanged. This turns+ the race into a deterministic reproduction: both cells failed with+ `Expectation failed: try root.digest() == before` before the fix and pass+ after it.+- `settlingLeavesPathologicalFamiliesAlone` — classifier suite. A garbage main+ file beside a garbage `-wal` must survive settling untouched, with no `-shm`+ created. Guards the magic-number check.++**Run command:**++ make test-core CORE_TEST='digestIsInvariantUnderADeferredFold|settlingLeavesPathologicalFamiliesAlone'++**Not covered:** the `could not settle …` issue `StoreSettling.foldWriteAheadLog`+records when the log stays locked through every attempt. Reproducing that+deterministically means holding SQLite's checkpoint lock from another connection+for longer than the 5 s busy timeout, three times over, which would cost the+suite fifteen seconds to prove a message string. The branch is reached only when+the fold loses a race it was given every chance to win, and its wording is+covered by review rather than by a test.++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Tests/AsterismCoreTests/StoreSettling.swift` | New: the fold, its guards, and the measurements behind them |+| `Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift` | `digest()` settles first; regression test; `writeAheadLogByteCount`; `RootDigest.difference(from:)` wired into every digest assertion |+| `Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift` | The same, plus the pathological-family guard test |+| `docs/agent-notes/testing.md` | The known-flaky section replaced with the real mechanism, the fix, and what to check if a digest cell ever fails again |++## Verification++**Automated:**++- Both `digestIsInvariantUnderADeferredFold` cells fail before the fix and pass+ after it.+- Six consecutive full `swift test --no-parallel` runs of `AsterismCore` clean,+ with the fold instrumented to report any log it could not settle: none.+- `make test-core` passes; no new compiler warnings. The repo has no style+ linter; `make verify-identity` runs as a `test-core` prerequisite.++**Manual verification:** the probe measurements above were taken on this machine+through `swift test --no-parallel`; the probe suite was deleted before the fix+landed.++## Prevention++- A byte comparison over a SQLite store is only meaningful once the store is+ settled. Anything that opens a `ModelContainer` and then hashes the file it+ wrote needs the same fold — releasing the container is not enough, and+ `shutdown()` is not a close.+- When a flake is blamed on "process-wide state earlier suites influenced",+ check first whether the test's *own* seeding left something unfinished. Both+ roots here are per-test temporary directories, which ruled the cross-test+ theory out in one reading and should have done so a year earlier.+- Assertions over opaque blobs should say which member moved. The old message+ printed two `RootDigest` values and could not distinguish a rewritten marker+ from a journal that shifted, which is most of why this went uninvestigated for+ as long as it did.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex dceb4b5..84cb15a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **The bootstrap store-digest tests no longer flake (T-2293).** A+ different cell of `BootstrapActionTests` or `BootstrapClassifierTests`+ failed per full `make test-core` and passed on every isolated re-run,+ reading like a regression the branch had caused. Both suites prove an+ operation which must not write left the library byte-identical, by+ hashing the store family before and after — and releasing a SwiftData+ `ModelContainer` does not fold its write-ahead log synchronously:+ SQLite folds it when Core Data's connection actually closes, which is+ deferred by an unpredictable amount (`shutdown()` left a 111,272-byte+ log behind on one run of a process and an empty one on the next).+ Under load that close landed *between* the two hashes and moved bytes+ no write produced. Both readings are now taken over a settled store —+ `StoreSettling.foldWriteAheadLog` runs `PRAGMA wal_checkpoint(TRUNCATE)`+ at each hash point, which relocates committed frames and changes no+ row, so a real write still fails the comparison. The fold opens+ nothing that is not a genuine SQLite log, keeping the classifier+ sweep's pathological families as seeded, and an unfoldable log is+ recorded as an issue rather than skipped. The assertions also name+ which member of the digest moved. Test-only; no shipped behaviour+ changes. Report in+ `specs/bugfixes/bootstrap-store-digest-flake/report.md`.+ - **Saving an entry now dismisses it (T-2301).** The navigation bar's checkmark on entry detail committed the note and rating and then left the screen where it was, from Recent, the Works list and a work's
First full run: everyOpenerRecordsTheSameSchemaVersion recorded Caught error: AsterismV3.sqlite has no Z_METADATA row at OpenerParityTests.swift:218. Green in isolation (3/3) and in the second full run. readingsPerOpener() (lines 176-191) does await repository.shutdown() and then reads through sqlite3_open_v2(…, SQLITE_OPEN_READONLY) — a read-only connection racing Core Data's deferred close, on a store whose schema may still be in the WAL. The run overlapped another session's full test run on this host, which is the load condition the report describes. Suggest a follow-up ticket: call StoreSettling.foldWriteAheadLog(at: store) before the raw read; the helper is already in that target.
seedStore seeds through a container and then deletes -wal/-shm (BootstrapClassifierTests.swift:556-558). If Core Data's connection is still open at that point, its later close checkpoints from the unlinked WAL fd into the main file — the same bytes-moved-between-readings shape — and the fold cannot help because there is no -wal file to detect. This cell has never been reported as the failing one (it was always fullFamily), so it may be that the removal forces an earlier close; unmeasured. Worth a probe if a mainFileOnly cell ever fails with store … bytes in the message.
A sibling review session ran the same package on another worktree (branch without this fix) at the same time as this review's first run. Its JUnit shows failures in aLockTimeoutChangesNothing, aFailedOpenChangesNothing(refusal:) and classificationIsTotalAndWritesNothing(cell:) — the exact cells this PR fixes — while this branch had all of them green under identical load. That is stronger evidence than the report's six clean runs, because it is a same-time A/B rather than a before/after.
The full run emits ~178 warning lines, none in the three changed files (all are pre-existing actor-isolation warnings in ConstellationKit/app-adjacent code and an unused-result warning elsewhere). The changed files compile clean.
make test-core emits no JUnit, so per the skill's tier rules the review ran make verify-identity followed by the same swift test --package-path Packages/AsterismCore --no-parallel invocation the target uses, plus --enable-code-coverage --xunit-output. --no-parallel was kept as CLAUDE.md requires. The first run's JUnit was lost because the reviewer deleted a stale file at that path mid-run (Swift Testing had it open); the second run is the one reported.