prism branch T-2275/bugfix-state-persistence-tests-isolation commits 2 files 18 touched lines +1053 / -324

Pre-push review: T-2275 state persistence test isolation

PR #404 — SessionFileManager becomes an injectable struct so tests stop deleting the user's live unsaved sessions. Reviewed as git diff origin/main...HEAD (2 commits, incl. follow-up 514d599d).

At a glance

  • SessionFileManager: caseless static enumSendable struct over an injected directory; shared is the app store, the only production spelling lives in PrismApp.
  • StatePersistence (4 entry points) and DocumentServices take sessionFiles with no default; DocumentFlowCoordinator reads it off services at five call sites.
  • Tests: per-test SessionFileManager.temporary() + tearDown(); the blanket production-directory delete is gone; .serialized dropped from three suites.
  • New SessionStoreIsolationTests: a Task.detached concurrent race regression plus a ProductionSourceScan over prismTests/ (first scan of the test tree; directoryName parameter added).
  • Follow-up 514d599d (landed during review) captures and tears down the two inline .temporary() stores and documents the restorePersistedSession early-return ordering.
  • Verified: make lint 0 violations; targeted run of the 6 touched suites 30 passed / 0 failed.

Verdict

Ready to push

The fix is correct and complete: the production seam is threaded with no defaults, the three persistence suites are isolated by construction, the concurrent regression is genuinely concurrent and non-vacuous, lint is clean and all six touched suites pass (30/30). One real gap remains in the mechanical guard, not the fix: the source scan bans the spelling SessionFileManager.shared but not the implicit-member form sessionFiles: .shared, which is the production spelling and the natural thing to type at a DocumentServices(...) call site in a test. The compiler-enforced no-default still holds, so this is a hole in the tripwire rather than a re-opening of the bug. Recommended as a one-line follow-up (add a second token), not a blocker. The remaining findings are doc accuracy (two live exemptions, not one) and small tidy-ups.

Review findings

11 raised · 1 fixed · 10 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism keeps the text of pasted-but-unsaved documents in a folder so it can offer them back after a relaunch. The code that managed that folder had exactly one folder baked in, so a test that wanted a clean slate could only say "empty the folder" — and that was the user's real folder. Now the folder is something you hand the manager when you create it, so every test gets a throwaway folder of its own.

Why it matters

Running the test suite used to silently delete the user's recoverable documents, and also deleted files other tests were using at the same time, which showed up as random test failures.

Key concepts

  • Dependency injection: pass the thing you depend on (the directory) instead of hard-coding it.
  • Isolation by construction: two stores over two directories cannot interfere, so no locking or serialisation is needed.
  • Tripwire test: a test that reads the test source files and fails if someone names the shared store again.

Architecture

SessionFileManager is now a value type (let directory: URL, Sendable) with instance methods; static let shared is the production instance. The dependency is threaded, not defaulted: StatePersistence.save/load/clear(_:sessionID:)/clear(_:ownedBy:) all take sessionFiles:, and DocumentServices carries it so the coordinator's five persistence sites read services.sessionFiles. restorePersistedSession() now guards on services before cleanupOldSessions(); the sole production caller configures services first in the same onAppear (comment added in 514d599d).

Patterns

  • SessionFileManager.temporary() / tearDown() / fileURL(for:) in prismTests/Support/IsolatedSessionStore.swift.
  • ProductionSourceScan gained a directoryName parameter so the same scanner can police prismTests/; exemption marker // prism-session-store-exempt: <reason>.
  • The scan file assembles its banned token from two string halves so it does not trip its own rule.

Trade-offs

Every persistence entry point grew a parameter (the author rejected a @TaskLocal override and a mutable static override for sound reasons — task inheritance and shared mutable state). StatePersistence is now a static enum carrying a dependency parameter on every call, the same shape just removed from SessionFileManager; a struct holding the store would be the natural next step.

Deep dive

The concurrent regression concurrentCleanupCannotReachAnotherStore is correctly built: this target defaults to MainActor isolation, so async let would inherit the actor and serialise the two halves; Task.detached with Sendable stores actually overlaps them, and the reader asserts per round so a delete-then-refill cannot pass. Verified red by pointing both stores at one directory (0.35s).

Guard analysis

The scan's rule token is SessionFileManager.shared. ProductionSourceScan.stripComment runs before matching, so doc-comment mentions are safe, and the marker-above form is honoured by coversLineBelow. Two live exemptions exist (SessionFileManagerTests.sharedStoreUsesResolvedDirectory and SessionStoreIsolationTests.temporaryStoreIsNotTheSharedStore), both read-only; the docs say one. The genuine gap: sessionFiles: .shared at a DocumentServices literal is not matched. It cannot re-open the data-loss bug on its own (the store is still passed explicitly, so the author sees it), but it is exactly the spelling a copy-paste from prismApp.swift:497 produces.

Edge cases

  • resolveDirectory() now mkdir -ps and init does so again for shared; harmless, once per process, but three SessionFileManagerTests still create the production directory as a side effect (they did before too). "Never touch" in CLAUDE.md is really "never write into".
  • init(directory:using:) accepts a FileManager it does not store while deleteContent/cleanupOldSessions take their own — half-injected.
  • The cleaner half of the race test never asserts it did work; a failed mkdir would make it enumerate nothing and still pass.

Completeness

Fully implemented: seam, threading, test isolation, regression + scan, CHANGELOG, CLAUDE.md, bugfix report. Partial: the scan's coverage of the .shared spelling; doc counts of exemptions. Missing: nothing required.

Important changes — detailed

SessionFileManager: static enum → injectable Sendable struct

prism/Services/SessionFileManager.swift

Why it matters. This is the missing seam. With one process-wide directory, 'clean my store' had no spelling other than 'empty the user's directory'.

What to look at. prism/Services/SessionFileManager.swift:117-143 (struct, shared, init)

Takeaway. A process-wide store with no injection point is a latent data-loss bug, not merely an untestable one. The tell is a test that cleans up by enumerating a directory it does not own.
Rationale. Two stores over two directories share no state, so isolation needs no serialisation. @TaskLocal override rejected (Task.detached escapes inheritance); mutable static override rejected (shared mutable state is half the bug).

StatePersistence / DocumentServices take the store with NO default

prism/Services/StatePersistence.swift

Why it matters. A defaulted dependency is reachable by omission — precisely how a test comes to write into the live directory without saying so. The compiler now enforces what the scan can only approximate.

What to look at. StatePersistence.swift:31-35, 78-81, 116-120, 178-182; DocumentServices.swift:30

Takeaway. Prefer no default over a convenient one for a dependency that names a real location.
Rationale. Stated in the doc comments and the bugfix report's rejected alternatives.

DocumentFlowCoordinator reads the store off services; restorePersistedSession guards early

prism/ViewModels/DocumentFlowCoordinator.swift

Why it matters. Behaviour change: cleanupOldSessions() no longer runs when services are unconfigured. Safe — the only production caller configures services first in the same onAppear, and an existing test already expects a no-op on a bare coordinator.

What to look at. DocumentFlowCoordinator.swift:663-669, 713-719, 728-740, 848-870

Takeaway. When a guard changes ordering assumptions, write the ordering dependency down at the guard (514d599d does).
Rationale. The coordinator has no store of its own to clean; running cleanup against a store it was not given would be the same by-omission reach the fix removes. (inferred — not stated by the author)

SessionStoreIsolationTests: concurrent race regression + test-tree source scan

prismTests/SessionStoreIsolationTests.swift

Why it matters. The behavioural half proves isolation under real concurrency; the mechanical half stops a future test from naming the shared store. Both verified red before acceptance.

What to look at. SessionStoreIsolationTests.swift:94-140 (race), 146-236 (scan)

Takeaway. Under SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor, `async let` inherits the actor and serialises; use Task.detached with Sendable values when a test must actually overlap work. Assert per-round, not post-hoc.
Rationale. The injected directory makes isolation possible; nothing makes a future test take it. The scan token is split in two so the scan file does not flag itself.

ProductionSourceScan gains directoryName so a scan can read prismTests/

prismTests/Support/ProductionSourceScan.swift

Why it matters. First scan in the codebase over the test tree; makes the next test-source rule cheap.

What to look at. ProductionSourceScan.swift:483-487, 498-511

Takeaway. Parameterise the tree, keep the sentinel: the sentinel proves the right directory was found, the count guard (>50 files) proves it was not empty.
Rationale. Stated in the doc comment: the rule is about test source, and nothing else in the scanner cares which tree it reads.

Per-test temporary stores replace the blanket production-directory delete

prismTests/StatePersistenceIntegrationTests.swift

Why it matters. This is where the data loss happened. cleanupSessionFiles() is gone; every test owns a temporary() store and tears it down.

What to look at. StatePersistenceIntegrationTests.swift (all tests); StatePersistenceTests.swift; SessionFileManagerTests.swift; LateSaveFinalisationTests.swift Fixture

Takeaway. `.serialized` is not isolation — it serialises within a suite. If two suites share state it buys nothing and hides the collision rate.
Rationale. Stated in the report: three suites dropped .serialized because nothing is shared any more.

Key decisions

Injected directory over @TaskLocal or static override.

Rejected @TaskLocal because isolation would depend on task inheritance (a Task.detached in production would see the live store again) and a mutable static var because shared mutable state cannot isolate concurrent suites. Source: bugfix report.

No default for the store parameter.

A defaulted dependency is reachable by omission; 'a test forgot to pass a store' must be impossible rather than discouraged. Source: doc comments and report.

Fix the seam, not just the one offending test.

Deleting only the files the integration suite wrote would stop the data loss but leave every persistence test writing into the user's directory. Source: report.

restorePersistedSession returns early without services.

Documented in 514d599d: the coordinator has no store to clean until configured, and the only production caller configures first.

Keep StatePersistence as a static enum with a threaded parameter.

Not stated. A struct StatePersistence { let sessionFiles } would remove the parameter from four signatures; left as-is, presumably to keep the bug-fix diff narrow.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorSessionStoreIsolationTests scan tokenThe scan bans `SessionFileManager.shared` only. The implicit-member spelling `sessionFiles: .shared` — the production spelling at prismApp.swift:497 and the natural copy-paste at a DocumentServices literal in a test — passes the scan with no marker.Add a second token (e.g. `sessionFiles: .shared`; the rule regex tolerates whitespace) and pin it in scanReachesTheTestTree. Compiler-enforced no-default still holds, so not blocking. Review is read-only.
minorConsecutiveSaveAsTests / DocumentFlowCoordinatorRecentFileTestsInline `sessionFiles: .temporary()` with no tearDown() leaked one tmp directory per test per run (init mkdirs eagerly).Fixed by follow-up commit 514d599d (store captured, deferred tearDown).
minorCLAUDE.md / report.md exemption countDocs say there is one live `prism-session-store-exempt` marker; there are two (SessionFileManagerTests.sharedStoreUsesResolvedDirectory and SessionStoreIsolationTests.temporaryStoreIsNotTheSharedStore), both read-only.Say 'two live exemptions, both read-only', or drop temporaryStoreIsNotTheSharedStore (largely implied by the tmp-vs-Application-Support roots).
minorSessionFileManager.resolveDirectory + initresolveDirectory() now creates the directory and init creates it again for `shared`; three SessionFileManagerTests still create the production directory as a side effect via resolveDirectory().Make resolveDirectory pure and let init be the single creator; directoryIsCreated then tests the store, as named.
minorSessionFileManager.init(directory:using:)The FileManager parameter is used once and not stored, while deleteContent/cleanupOldSessions take their own; no caller passes it.Drop the init parameter or store it and drop the per-method ones.
minorSession file naming duplicated`directory.appendingPathComponent("\(id.uuidString).md")` appears three times in production and again as the test-only fileURL(for:); a naming change would silently stale every existence assertion.Promote fileURL(for:) to production and use it at all three sites.
nitStatePersistenceTests model testspersistedSessionStateSetsCurrentDate and persistedSessionMetadataIsCodable create and tear down a store they never use.Remove the four lines.
nitconcurrentCleanupCannotReachAnotherStoreThe cleaner half never asserts it did anything; a failed mkdir would enumerate nothing and pass.Add a final #expect that cleaner.directory contains a file.
nitCHANGELOG wording'fails the build if a test reaches for the real one' — it fails the test run, not the build. 'reads the store at all five call sites' is five functions / six reads.Reword.
nitStale historical specsspecs/operational-hardening decision_log.md:44,56 and design.md:150, specs/clipboard-render/design.md:396 still describe SessionFileManager as a static enum.Optional: annotate 'superseded by T-2275'. Dated records; leaving them is defensible.
nitStatePersistence shapeEvery entry point now carries `sessionFiles:` on a caseless enum — the shape just removed from SessionFileManager.Consider `struct StatePersistence { let sessionFiles }` in a follow-up.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bf353052..9c60586a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Running Prism's own test suite no longer deletes the unsaved documents Prism is holding for you (T-2275). The app keeps the text of every pasted document you have not saved yet in its Application Support folder, so it can be offered back to you after a relaunch. One test suite cleaned up after itself by emptying that folder — the real one, not a copy — before and after most of its tests, because there was only ever one of it and no way to ask for another. Anyone who ran the tests on a machine they also used Prism on lost whatever was waiting there, silently. The same blanket delete was also removing files that two other test suites were using at that moment, since test suites run at the same time as each other, which is where an occasional unexplained persistence failure had been coming from. Session storage is now something a caller is handed rather than something everyone shares, every test gets its own throwaway folder, and a check over the test sources fails the build if a test reaches for the real one again. Developer tooling and test isolation only; nothing in the app behaves differently. - A remote document opened from a URL is no longer left downloading indefinitely against a server that trickles the body slowly enough to dodge the 30-second timeout (T-2138). The timeout applied only to network inactivity, so a byte sent just before each interval elapsed kept the load open with no end-to-end bound; the whole download is now also bounded by an explicit 30-second deadline covering redirects and streaming together. Implementing that deadline also surfaced, and fixed, a separate, pre-existing problem: accumulating the downloaded body ran on the main thread, where it is roughly 150 times slower. Measured on this project's own build, accumulating a 10 MB body takes 0.88 seconds off the main thread and 133 seconds on it, which could freeze the interface for well over a minute while opening a large document. That accumulation now genuinely runs off the main thread, so a large remote document opens in about a second instead of holding the interface still for minutes; the final UTF-8 decode of the (at most 10 MB) result still runs on the main thread afterwards, at roughly 10 milliseconds, which stays negligible. A file that really is over 10 MB is refused for its size, with the message that says so, rather than as a network timeout — with one trade-off: the new end-to-end deadline applies regardless of why a download is slow, so an honest, otherwise-successful download that used to take longer than 30 seconds to complete now fails with a timeout instead of eventually finishing. - Headings written inside a collapsible section now appear in the table of contents, on iPhone and on iPad/Mac, and choosing one opens the section it lives in before scrolling to it (T-1928). The contents list was built from a model that only ever looked at the top level of the document, so a heading inside a `<details>` block was missing from it entirely and there was no way to navigate to it — even though a separate, unused model in the app had been collecting those headings all along. A nested heading is now listed under whichever heading precedes it, marked with the same chevron the app already uses elsewhere for collapsible content, and is not itself collapsible from the contents list: the collapsible section it sits in is the thing that opens and closes. Following a link to a nested heading's anchor opens its section too, which it previously did not. A document with no collapsible sections is grouped and ordered exactly as before, with two deliberate improvements that also reach it: a heading containing a footnote marker or an HTML comment now lists with those stripped out, matching what the iPhone sheet always showed, and a heading with no text at all now reads "(Empty heading)" instead of appearing as a blank row. Fixing the navigation also uncovered a second problem in the same area, which is fixed here too: from the second collapsible section in a document onwards, the app was identifying those sections by a position that shifts as it counts through the contents of earlier ones, so it could not find them to open. Nothing had noticed because nothing had ever asked it to open one this way. Choosing a heading also no longer competes with the position the app restores when you reopen a document: only a heading you chose yourself is held briefly and re-applied once the section it lives in has opened, and any scroll, wheel flick, key press or click of your own cancels that immediately. - Documents containing HTML comments (`<!--…-->`) no longer stall while opening (T-2147). Several steps that look for comments — in a block of raw HTML, inside a link's label, and in the text Prism searches and exports — cost time in proportion to the *square* of what they were given, so a document that would otherwise open instantly could hold the app for tens of seconds. A run of comment openers with no closing `-->`, which is what a document being written, generated, or truncated mid-comment looks like, was the trigger: every opener read the whole rest of the document looking for a close before giving up. One step was worse than slow. Deciding whether a block of HTML is nothing but comments cost roughly four times as much for every two comments added, so a 145-byte document took 13 milliseconds, a 217-byte one 3.5 seconds, and a 235-byte one 22 seconds, with no upper bound beyond that — and it needed only a handful of ordinary, correctly closed comments followed by a single other character, not a malformed document at all. Removing comments from link labels had two further problems on top of the first. The entire document was rebuilt from scratch once per label carrying a comment, which cost 1.9 seconds for a 480 KB paragraph. And before either of those ran, finding the labels themselves had the same square-law shape on a `[` that is never closed: 32,000 unclosed brackets took 8.4 seconds, and an ordinary 96 KB paragraph that simply opens a few brackets without closing them took 6.2 seconds — so plain prose, not a malformed document, was enough on its own. Every one of these comment-scanning steps, and the label-finding step in front of them, now reads the document once, from left to right, and grows in step with its length rather than with its square. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. Nothing about how comments are displayed changes: each replacement was checked against the exact step it replaced, character for character, over tens of thousands of generated fragments as well as hand-written awkward cases.
CLAUDE.md Modified +24 / -0
diff --git a/CLAUDE.md b/CLAUDE.mdindex d549b483..cf4e2019 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -332,6 +332,30 @@ is static because the abort is a scheduling race — the guilty suite passes in isolation every time, and the suite the cascade *names* is usually not the guilty one. See `docs/agent-notes/development-tooling.md`. +### Tests Never Touch the User's Session Store (T-2275)++`SessionFileManager` is a `struct` over an **injected** directory, not a+static-only type over one. It has to be: with a single process-wide store, a+test that wants a clean slate can only say "empty *the* store", and+`StatePersistenceIntegrationTests` said exactly that — enumerating the app's real+`Application Support/UnsavedSessions/` and deleting every entry, before and after+most of its tests. Under the test host that is the live app container, so a test+run destroyed the user's recoverable unsaved documents; and because suites run+concurrently, the same blanket delete removed files two sibling suites were+mid-way through using. `@Suite(.serialized)` never helped — it serialises within+a suite, and the collision was across suites. The three persistence suites no+longer carry it.++Two things keep it fixed, and only the second is mechanical. `StatePersistence`+and `DocumentServices` take the store with **no default**, so the production+store is not reachable by omission — the compiler enforces that. And+`SessionStoreIsolationTests` scans `prismTests/` (via `ProductionSourceScan`,+whose `directoryName` now varies — this is the first scan that reads the test+tree rather than `prism/`) and fails on any test naming `SessionFileManager.shared`+without a `// prism-session-store-exempt: <reason>` marker. Use+`SessionFileManager.temporary()` from `prismTests/Support/IsolatedSessionStore.swift`.+The one live exemption asserts where the production store lives and never writes.+ ### Test Coverage  - Unit tests (`prismTests`): parsers, cache, file observer, models
prism/Services/DocumentServices.swift Modified +10 / -0
diff --git a/prism/Services/DocumentServices.swift b/prism/Services/DocumentServices.swiftindex 672f4c26..09a5f71c 100644--- a/prism/Services/DocumentServices.swift+++ b/prism/Services/DocumentServices.swift@@ -20,4 +20,14 @@ struct DocumentServices {     let recentFilesManager: RecentFilesManager     let persistedSessionData: Binding<Data>     let bundledDocumentState: BundledDocumentState++    /// The store holding unsaved session content.+    ///+    /// The two halves of clipboard persistence travel together: the binding+    /// above holds the metadata, this holds the bodies. It carries no default —+    /// the sole production spelling of `SessionFileManager.shared` is the call+    /// site in `PrismApp`, so a test that constructs `DocumentServices` is+    /// obliged to name a store of its own rather than silently inheriting the+    /// user's live unsaved-session directory (T-2275).+    let sessionFiles: SessionFileManager }
prism/Services/SessionFileManager.swift Modified +65 / -21
diff --git a/prism/Services/SessionFileManager.swift b/prism/Services/SessionFileManager.swiftindex afdd634f..f1f6e98a 100644--- a/prism/Services/SessionFileManager.swift+++ b/prism/Services/SessionFileManager.swift@@ -21,28 +21,64 @@ private func isFileNotFound(_ error: Error) -> Bool {  /// Manages file-based persistence for unsaved session content. ///-/// Content is stored in Application Support/UnsavedSessions/ directory.-/// This avoids SceneStorage size limits for large clipboard content,-/// which can be up to 10MB.+/// Content is stored in a directory of session files, one per session, named+/// with the session's UUID and using the `.md` extension for consistency with+/// saved markdown files. The application's store is ``shared``, rooted at+/// Application Support/UnsavedSessions/. This avoids SceneStorage size limits+/// for large clipboard content, which can be up to 10MB. ///-/// Each session's content is stored in a file named with its UUID,-/// using the .md extension for consistency with saved markdown files.+/// ## Why the directory is a stored property (T-2275)+///+/// This was a caseless `enum` whose whole API was static, over one static+/// `directory` resolved from Application Support. There was therefore exactly+/// one session store in the process, and a test that wanted a clean slate had+/// no way to ask for one except to empty *that* store —+/// `StatePersistenceIntegrationTests` enumerated it and deleted every entry+/// before and after each of its tests. Under the test host that directory is+/// the real app container's, so a test run destroyed the user's recoverable+/// unsaved documents; and because Swift Testing runs suites concurrently, the+/// same blanket delete removed files that `StatePersistenceTests` and+/// `SessionFileManagerTests` were in the middle of using, which is where the+/// duration-bearing `saveOverwritesExistingState` failure came from.+///+/// An injected directory is not a convenience here: it is the only thing that+/// makes "clean up everything in my store" a safe sentence to write. Two stores+/// over two directories share no state at all, so per-test isolation costs+/// nothing and needs no serialisation. `Sendable` by construction (one `let+/// URL`), so a store may be handed to concurrent work directly.+///+/// The store is threaded rather than defaulted at the persistence layer:+/// ``StatePersistence`` takes a `sessionFiles` argument with **no** default, so+/// a caller cannot reach the production store by omission. The only production+/// reference to ``shared`` is `DocumentServices`, which is constructed once per+/// scene. /// /// Requirements covered: /// - 7.1: Persist content when app moves to background /// - 7.2: Restore on scene restoration-enum SessionFileManager {-    /// Directory for storing unsaved session files.+struct SessionFileManager: Sendable {+    /// Directory holding this store's session files.+    let directory: URL++    /// The application's session store, rooted at+    /// Application Support/UnsavedSessions/.     ///-    /// Located at: Application Support/UnsavedSessions/-    /// Created lazily on first access if it doesn't exist.+    /// Falls back to a temporary directory if Application Support is+    /// unavailable — see ``resolveDirectory(using:)``.+    static let shared = SessionFileManager(directory: resolveDirectory())++    /// Creates a store over `directory`, creating the directory if needed.     ///-    /// Falls back to a temporary directory if Application Support-    /// is unavailable, logging a warning. This avoids a crash when-    /// the system returns an empty search path array.-    static let directory: URL = resolveDirectory()+    /// Directory creation is best-effort and logged rather than thrown: every+    /// operation on this store already degrades to a logged warning when the+    /// filesystem refuses, and a session store that cannot be created is not a+    /// reason to fail document restoration.+    init(directory: URL, using fileManager: FileManager = .default) {+        self.directory = directory+        Self.createDirectory(directory, using: fileManager)+    } -    /// Resolves the UnsavedSessions directory, falling back to+    /// Resolves the app's UnsavedSessions directory, falling back to     /// a temporary directory if Application Support is unavailable.     ///     /// Extracted as an internal function so the fallback logic@@ -63,18 +99,26 @@ enum SessionFileManager {             base = fileManager.temporaryDirectory         }         let dir = base.appendingPathComponent("UnsavedSessions")+        createDirectory(dir, using: fileManager)+        return dir+    }++    /// Creates `directory` if it does not exist, logging any failure.+    private static func createDirectory(+        _ directory: URL,+        using fileManager: FileManager+    ) {         do {             try fileManager.createDirectory(-                at: dir,+                at: directory,                 withIntermediateDirectories: true             )         } catch {             // Privacy qualifiers per specs/operational-hardening/decision_log.md (Decision 10).             logger.warning(-                "Failed to create UnsavedSessions directory at \(dir.path, privacy: .public): \(String(describing: error), privacy: .public)"+                "Failed to create UnsavedSessions directory at \(directory.path, privacy: .public): \(String(describing: error), privacy: .public)"             )         }-        return dir     }      /// Writes content to file for a session.@@ -85,7 +129,7 @@ enum SessionFileManager {     /// - Parameters:     ///   - content: The markdown content to persist.     ///   - sessionID: The unique identifier for the session.-    static func writeContent(_ content: String, for sessionID: UUID) {+    func writeContent(_ content: String, for sessionID: UUID) {         let url = directory.appendingPathComponent("\(sessionID.uuidString).md")         do {             try content.write(to: url, atomically: true, encoding: .utf8)@@ -109,7 +153,7 @@ enum SessionFileManager {     /// - Parameter sessionID: The unique identifier for the session.     /// - Returns: The stored content, or nil if no file exists, it exceeds the     ///   document size limit, or it is not valid UTF-8.-    static func readContent(for sessionID: UUID) -> String? {+    func readContent(for sessionID: UUID) -> String? {         let url = directory.appendingPathComponent("\(sessionID.uuidString).md")         do {             let data = try BoundedFileRead.read(@@ -146,7 +190,7 @@ enum SessionFileManager {     /// - Parameters:     ///   - sessionID: The unique identifier for the session.     ///   - fileManager: Defaulted; the parameter exists so tests can inject a stub.-    static func deleteContent(+    func deleteContent(         for sessionID: UUID,         using fileManager: FileManager = .default     ) {@@ -174,7 +218,7 @@ enum SessionFileManager {     ///     /// The 7-day threshold provides a reasonable window for users to     /// restore their session while preventing permanent storage bloat.-    static func cleanupOldSessions(+    func cleanupOldSessions(         using fileManager: FileManager = .default     ) {         let files: [URL]
prism/Services/StatePersistence.swift Modified +32 / -9
diff --git a/prism/Services/StatePersistence.swift b/prism/Services/StatePersistence.swiftindex 0989f36b..facad157 100644--- a/prism/Services/StatePersistence.swift+++ b/prism/Services/StatePersistence.swift@@ -33,12 +33,20 @@ enum StatePersistence {     /// - Parameters:     ///   - state: The session state to persist, or nil to clear the storage.     ///   - storage: A binding to SceneStorage data for storing metadata.+    ///   - sessionFiles: The session content store to write to. Deliberately+    ///     has no default: the store is a real dependency, and defaulting it to+    ///     `.shared` is how a test comes to write into the user's live+    ///     unsaved-session directory without saying so (T-2275).     ///     /// Requirement 7.1: Persist unsaved content on background.-    static func save(_ state: PersistedSessionState?, to storage: Binding<Data>) {+    static func save(+        _ state: PersistedSessionState?,+        to storage: Binding<Data>,+        sessionFiles: SessionFileManager+    ) {         if let state = state {             // Write content to file (handles large content)-            SessionFileManager.writeContent(state.content, for: state.sessionID)+            sessionFiles.writeContent(state.content, for: state.sessionID)              // Store only lightweight metadata in SceneStorage             let metadata = PersistedSessionMetadata(@@ -60,11 +68,16 @@ enum StatePersistence {     /// file. If either the metadata is invalid or the content file is missing,     /// nil is returned.     ///-    /// - Parameter storage: The SceneStorage data containing session metadata.+    /// - Parameters:+    ///   - storage: The SceneStorage data containing session metadata.+    ///   - sessionFiles: The session content store to read from.     /// - Returns: The reconstructed session state, or nil if restoration fails.     ///     /// Requirement 7.2: Restore on scene restoration.-    static func load(from storage: Data) -> PersistedSessionState? {+    static func load(+        from storage: Data,+        sessionFiles: SessionFileManager+    ) -> PersistedSessionState? {         // Check for empty data first         guard !storage.isEmpty else { return nil } @@ -75,7 +88,7 @@ enum StatePersistence {         ) else { return nil }          // Read content from file-        guard let content = SessionFileManager.readContent(for: metadata.sessionID) else {+        guard let content = sessionFiles.readContent(for: metadata.sessionID) else {             return nil         } @@ -97,12 +110,17 @@ enum StatePersistence {     ///   - storage: A binding to SceneStorage data to clear.     ///   - sessionID: The session ID whose content file should be deleted,     ///                or nil if no file deletion is needed.+    ///   - sessionFiles: The session content store to delete from.     ///     /// Requirement 7.4: Clear when user returns to WelcomeView.-    static func clear(_ storage: Binding<Data>, sessionID: UUID?) {+    static func clear(+        _ storage: Binding<Data>,+        sessionID: UUID?,+        sessionFiles: SessionFileManager+    ) {         storage.wrappedValue = Data()         if let id = sessionID {-            SessionFileManager.deleteContent(for: id)+            sessionFiles.deleteContent(for: id)         }     } @@ -157,8 +175,13 @@ enum StatePersistence {     /// - Parameters:     ///   - storage: A binding to SceneStorage data.     ///   - sessionID: The session whose persisted state should be cleared.-    static func clear(_ storage: Binding<Data>, ownedBy sessionID: UUID) {-        SessionFileManager.deleteContent(for: sessionID)+    ///   - sessionFiles: The session content store to delete from.+    static func clear(+        _ storage: Binding<Data>,+        ownedBy sessionID: UUID,+        sessionFiles: SessionFileManager+    ) {+        sessionFiles.deleteContent(for: sessionID)          guard let metadata = try? JSONDecoder().decode(             PersistedSessionMetadata.self,
prism/ViewModels/DocumentFlowCoordinator.swift Modified +34 / -12
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex 1b9084d0..c2a4777a 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -660,8 +660,12 @@ final class DocumentFlowCoordinator {         currentSession = nil         navigationPath = NavigationPath() -        if let binding = documentServices?.persistedSessionData {-            StatePersistence.clear(binding, sessionID: sessionID)+        if let services = documentServices {+            StatePersistence.clear(+                services.persistedSessionData,+                sessionID: sessionID,+                sessionFiles: services.sessionFiles+            )         }          if let cache = imageServices?.diagramCache {@@ -707,8 +711,12 @@ final class DocumentFlowCoordinator {             systemColorSchemeObserver?.refresh()         case .inactive, .background:             if let state = currentSession?.toPersistableState(),-               let binding = documentServices?.persistedSessionData {-                StatePersistence.save(state, to: binding)+               let services = documentServices {+                StatePersistence.save(+                    state,+                    to: services.persistedSessionData,+                    sessionFiles: services.sessionFiles+                )             }             currentSession?.persistScrollPosition()         @unknown default:@@ -718,10 +726,16 @@ final class DocumentFlowCoordinator {      /// Restores session from persisted state.     func restorePersistedSession() {-        SessionFileManager.cleanupOldSessions()--        guard let data = documentServices?.persistedSessionData.wrappedValue else { return }-        if let state = StatePersistence.load(from: data) {+        // Returning early skips `cleanupOldSessions()`, which is safe only+        // because the single production caller (`prismApp.onAppear`) always+        // runs `configureCoordinators()` first, so `documentServices` is nil+        // only for the bare coordinators that tests build. A second call+        // site must keep that ordering or it silently skips the cleanup.+        guard let services = documentServices else { return }+        services.sessionFiles.cleanupOldSessions()++        let data = services.persistedSessionData.wrappedValue+        if let state = StatePersistence.load(from: data, sessionFiles: services.sessionFiles) {             // clearPersisted: false — we are hydrating from persisted state,             // so there is nothing to clear before installing the session.             activateSession(DocumentSession(persisted: state), clearPersisted: false)@@ -838,8 +852,12 @@ final class DocumentFlowCoordinator {      /// Clears persisted state for the current session.     private func clearPersistedState() {-        guard let binding = documentServices?.persistedSessionData else { return }-        StatePersistence.clear(binding, sessionID: currentSession?.id)+        guard let services = documentServices else { return }+        StatePersistence.clear(+            services.persistedSessionData,+            sessionID: currentSession?.id,+            sessionFiles: services.sessionFiles+        )     }      /// Clears persisted state for one named session, leaving any other@@ -848,8 +866,12 @@ final class DocumentFlowCoordinator {     /// Used by the save-finalisation paths, which are the only ones that can     /// run for a session other than the installed one (T-2213).     private func clearPersistedState(ownedBy session: DocumentSession) {-        guard let binding = documentServices?.persistedSessionData else { return }-        StatePersistence.clear(binding, ownedBy: session.id)+        guard let services = documentServices else { return }+        StatePersistence.clear(+            services.persistedSessionData,+            ownedBy: session.id,+            sessionFiles: services.sessionFiles+        )     }      /// Creates security-scoped bookmark data for a URL.
prism/prismApp.swift Modified +2 / -1
diff --git a/prism/prismApp.swift b/prism/prismApp.swiftindex 2963b9dd..c8b791ad 100644--- a/prism/prismApp.swift+++ b/prism/prismApp.swift@@ -493,7 +493,8 @@ struct MainContentView: View {         let documentServices = DocumentServices(             recentFilesManager: recentFilesManager,             persistedSessionData: $persistedSessionData,-            bundledDocumentState: bundledDocumentState+            bundledDocumentState: bundledDocumentState,+            sessionFiles: .shared         )         flowCoordinator.configure(             documentServices: documentServices,
prismTests/ConsecutiveSaveAsTests.swift Modified +4 / -1
diff --git a/prismTests/ConsecutiveSaveAsTests.swift b/prismTests/ConsecutiveSaveAsTests.swiftindex b2ea4390..02783d78 100644--- a/prismTests/ConsecutiveSaveAsTests.swift+++ b/prismTests/ConsecutiveSaveAsTests.swift@@ -542,10 +542,13 @@ struct ConsecutiveSaveAsTests {         let defaults = try #require(UserDefaults(suiteName: suiteName))         defaults.removePersistentDomain(forName: suiteName)         let recentFiles = RecentFilesManager(storage: defaults)+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }         flow.setDocumentServicesForTests(DocumentServices(             recentFilesManager: recentFiles,             persistedSessionData: .constant(Data()),-            bundledDocumentState: BundledDocumentState(defaults: defaults)+            bundledDocumentState: BundledDocumentState(defaults: defaults),+            sessionFiles: sessionFiles         ))          let directory = FileManager.default.temporaryDirectory
prismTests/DocumentFlowCoordinatorRecentFileTests.swift Modified +17 / -7
diff --git a/prismTests/DocumentFlowCoordinatorRecentFileTests.swift b/prismTests/DocumentFlowCoordinatorRecentFileTests.swiftindex 1e3b3f0e..b06d3d2f 100644--- a/prismTests/DocumentFlowCoordinatorRecentFileTests.swift+++ b/prismTests/DocumentFlowCoordinatorRecentFileTests.swift@@ -43,24 +43,31 @@ struct DocumentFlowCoordinatorRecentFileTests {      /// Creates a coordinator with an isolated RecentFilesManager so the     /// bookmark open path (which requires documentServices) is reachable.-    private func makeCoordinator() -> DocumentFlowCoordinator {+    ///+    /// Returns the throwaway session store alongside the coordinator so the+    /// caller can `defer { sessionFiles.tearDown() }`; `.temporary()` creates+    /// its directory eagerly, so an uncaptured store leaks one per test run.+    private func makeCoordinator() -> (flow: DocumentFlowCoordinator, sessionFiles: SessionFileManager) {         let flow = DocumentFlowCoordinator()         let suiteName = UUID().uuidString         let defaults = UserDefaults(suiteName: suiteName)!         defaults.removePersistentDomain(forName: suiteName)+        let sessionFiles = SessionFileManager.temporary()         flow.setDocumentServicesForTests(DocumentServices(             recentFilesManager: RecentFilesManager(storage: defaults),             persistedSessionData: .constant(Data()),-            bundledDocumentState: BundledDocumentState(defaults: defaults)+            bundledDocumentState: BundledDocumentState(defaults: defaults),+            sessionFiles: sessionFiles         ))-        return flow+        return (flow, sessionFiles)     }      // MARK: - T-1630 regression      @Test("bookmark recent with unsaved clipboard session shows confirmation instead of opening")     func bookmarkRecentWithUnsavedSessionShowsConfirmation() {-        let flow = makeCoordinator()+        let (flow, sessionFiles) = makeCoordinator()+        defer { sessionFiles.tearDown() }         let clipboardSession = DocumentSession(clipboardContent: "# Unsaved clipboard content")         flow.currentSession = clipboardSession         let entry = makeBookmarkEntry()@@ -81,7 +88,8 @@ struct DocumentFlowCoordinatorRecentFileTests {      @Test("discard proceeds with the deferred bookmark open")     func discardExecutesPendingBookmarkOpen() {-        let flow = makeCoordinator()+        let (flow, sessionFiles) = makeCoordinator()+        defer { sessionFiles.tearDown() }         flow.currentSession = DocumentSession(clipboardContent: "# Unsaved clipboard content")          flow.openRecentFile(makeBookmarkEntry())@@ -98,7 +106,8 @@ struct DocumentFlowCoordinatorRecentFileTests {      @Test("save completion resumes the deferred bookmark open")     func saveCompletionExecutesPendingBookmarkOpen() throws {-        let flow = makeCoordinator()+        let (flow, sessionFiles) = makeCoordinator()+        defer { sessionFiles.tearDown() }         let session = DocumentSession(clipboardContent: "# Unsaved clipboard content")         flow.currentSession = session @@ -131,7 +140,8 @@ struct DocumentFlowCoordinatorRecentFileTests {      @Test("bookmark recent without an unsaved session opens directly")     func bookmarkRecentWithoutUnsavedSessionOpensDirectly() {-        let flow = makeCoordinator()+        let (flow, sessionFiles) = makeCoordinator()+        defer { sessionFiles.tearDown() }         // No current session: nothing unsaved to protect.          flow.openRecentFile(makeBookmarkEntry())
prismTests/ImageMemoryPipelineTests.swift Modified +14 / -8
diff --git a/prismTests/ImageMemoryPipelineTests.swift b/prismTests/ImageMemoryPipelineTests.swiftindex e5ba4b71..c4136827 100644--- a/prismTests/ImageMemoryPipelineTests.swift+++ b/prismTests/ImageMemoryPipelineTests.swift@@ -309,29 +309,35 @@ struct SessionFileManagerBoundedReadTests {     /// content the document limit governs on the way in.     @Test("A session file within the document limit round-trips")     func withinLimitRoundTrips() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()-        defer { SessionFileManager.deleteContent(for: sessionID) }-        SessionFileManager.writeContent("# Restored", for: sessionID)-        #expect(SessionFileManager.readContent(for: sessionID) == "# Restored")+        sessionFiles.writeContent("# Restored", for: sessionID)+        #expect(sessionFiles.readContent(for: sessionID) == "# Restored")     }      @Test("A session file past the document limit is refused, not buffered")     func pastLimitIsRefused() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")-        defer { try? FileManager.default.removeItem(at: url) }+        let url = sessionFiles.fileURL(for: sessionID)         try Data().write(to: url)         let handle = try FileHandle(forWritingTo: url)         try handle.truncate(atOffset: UInt64(MarkdownDocument.maxFileSize + 1))         try handle.close() -        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      @Test("A session that was never written reads as nil")     func missingSessionReadsNil() {-        #expect(SessionFileManager.readContent(for: UUID()) == nil)+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }++        #expect(sessionFiles.readContent(for: UUID()) == nil)     } } 
prismTests/LateSaveFinalisationTests.swift Modified +14 / -18
diff --git a/prismTests/LateSaveFinalisationTests.swift b/prismTests/LateSaveFinalisationTests.swiftindex 7b81560c..4e6a85d7 100644--- a/prismTests/LateSaveFinalisationTests.swift+++ b/prismTests/LateSaveFinalisationTests.swift@@ -79,14 +79,16 @@ struct LateSaveFinalisationTests {     private final class Fixture {         let coordinator = DocumentFlowCoordinator()         let recentFiles: RecentFilesManager+        /// This fixture's own unsaved-session store. Before T-2275 the+        /// coordinator wrote clipboard bodies straight into the user's live+        /// Application Support directory.+        let sessionFiles = SessionFileManager.temporary()         let directory: URL         let suiteName: String         private let defaults: UserDefaults         /// Backing storage for the persisted-session binding. A live box, not         /// `.constant`: the whole question is who is allowed to clear it.         private var persisted = Data()-        /// Session ids whose content files this fixture created.-        private var trackedSessionIDs: [UUID] = []          /// The scene-storage binding the coordinator writes through.         private var binding: Binding<Data> {@@ -105,7 +107,8 @@ struct LateSaveFinalisationTests {             coordinator.setDocumentServicesForTests(DocumentServices(                 recentFilesManager: recentFiles,                 persistedSessionData: binding,-                bundledDocumentState: BundledDocumentState(defaults: defaults)+                bundledDocumentState: BundledDocumentState(defaults: defaults),+                sessionFiles: sessionFiles             ))         } @@ -126,7 +129,7 @@ struct LateSaveFinalisationTests {         func persistRestorePoint(for session: DocumentSession) throws {             let state = try #require(session.toPersistableState(),                                      "only a clipboard session has a restore point")-            StatePersistence.save(state, to: binding)+            StatePersistence.save(state, to: binding, sessionFiles: sessionFiles)         }          /// Installs a replacement clipboard document the way the app does:@@ -139,10 +142,10 @@ struct LateSaveFinalisationTests {         ///   share an id.         @discardableResult         func installReplacementDocument(sessionID: UUID = UUID(), content: String) -> DocumentSession? {-            trackedSessionIDs.append(sessionID)             StatePersistence.save(                 PersistedSessionState(sessionID: sessionID, content: content, scrollPositionID: ""),-                to: binding+                to: binding,+                sessionFiles: sessionFiles             )             coordinator.restorePersistedSession()             return coordinator.currentSession@@ -150,7 +153,7 @@ struct LateSaveFinalisationTests {          /// The restore point currently in the scene binding, if any.         var restorePoint: PersistedSessionState? {-            StatePersistence.load(from: persisted)+            StatePersistence.load(from: persisted, sessionFiles: sessionFiles)         }          /// Runs one Save As attempt: the exporter callback, then the notes work.@@ -171,14 +174,8 @@ struct LateSaveFinalisationTests {             )         } -        func track(_ session: DocumentSession) {-            trackedSessionIDs.append(session.id)-        }-         func tearDown() {-            for id in trackedSessionIDs {-                SessionFileManager.deleteContent(for: id)-            }+            sessionFiles.tearDown()             defaults.removePersistentDomain(forName: suiteName)             try? FileManager.default.removeItem(at: directory)         }@@ -204,7 +201,6 @@ struct LateSaveFinalisationTests {                 sessionID: session.id             )         }-        fixture.track(session)         fixture.coordinator.currentSession = session         return (session, manager)     }@@ -241,7 +237,7 @@ struct LateSaveFinalisationTests {         // The saving document was backgrounded while it was still pasted, so it         // has a restore point of its own to lose.         try fixture.persistRestorePoint(for: session)-        #expect(SessionFileManager.readContent(for: session.id) != nil)+        #expect(fixture.sessionFiles.readContent(for: session.id) != nil)         let destination = try fixture.destination("alpha.md")          await save(session, to: destination, manager: manager, store: store, in: fixture) {@@ -257,7 +253,7 @@ struct LateSaveFinalisationTests {         #expect(restorePoint.content == "# Replacement")         // The saving document's own restore point is still the one that goes:         // it is a file document now. Meaningful only because it had one.-        #expect(SessionFileManager.readContent(for: session.id) == nil)+        #expect(fixture.sessionFiles.readContent(for: session.id) == nil)     }      @Test("a late completion labels its recents entry with its own document's title")@@ -437,7 +433,7 @@ struct LateSaveFinalisationTests {          #expect(session.source == .file(url: first))         #expect(fixture.coordinator.migrationError == nil)-        #expect(SessionFileManager.readContent(for: session.id) == nil)+        #expect(fixture.sessionFiles.readContent(for: session.id) == nil)         #expect(fixture.restorePoint?.content == "# Replacement")     } }
prismTests/SessionFileManagerTests.swift Modified +129 / -114
diff --git a/prismTests/SessionFileManagerTests.swift b/prismTests/SessionFileManagerTests.swiftindex 9ebbe055..40763035 100644--- a/prismTests/SessionFileManagerTests.swift+++ b/prismTests/SessionFileManagerTests.swift@@ -20,40 +20,49 @@ import Foundation /// Requirements covered: /// - 7.1: Persist content when app moves to background /// - 7.2: Restore on scene restoration-@Suite(.serialized)+@Suite struct SessionFileManagerTests {      // MARK: - Directory -    @Test("Directory is in Application Support")-    func directoryIsInApplicationSupport() {-        let directory = SessionFileManager.directory-        guard let appSupport = FileManager.default.urls(-            for: .applicationSupportDirectory,-            in: .userDomainMask-        ).first else {-            Issue.record("Application Support directory not available in test environment")-            return-        }--        #expect(directory.path(percentEncoded: false).hasPrefix(appSupport.path(percentEncoded: false)))+    @Test("Shared store is rooted at the resolved Application Support directory")+    func sharedStoreUsesResolvedDirectory() {+        // The one place a test is entitled to name the production store, and it+        // only reads: without this, `shared` could be re-pointed anywhere and+        // every other suite — now isolated by construction — would still pass.+        // prism-session-store-exempt: asserts where the production store lives; reads only+        #expect(SessionFileManager.shared.directory == SessionFileManager.resolveDirectory())     }      @Test("Directory path contains UnsavedSessions")     func directoryContainsUnsavedSessions() {-        let directory = SessionFileManager.directory+        let directory = SessionFileManager.resolveDirectory()         #expect(directory.lastPathComponent == "UnsavedSessions")     }      @Test("Directory is created if it doesn't exist")     func directoryIsCreated() {-        let directory = SessionFileManager.directory+        let directory = SessionFileManager.resolveDirectory()         var isDirectory: ObjCBool = false         let exists = FileManager.default.fileExists(atPath: directory.path(percentEncoded: false), isDirectory: &isDirectory)         #expect(exists)         #expect(isDirectory.boolValue)     } +    @Test("A store creates its directory on construction")+    func storeCreatesItsDirectory() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }++        var isDirectory: ObjCBool = false+        let exists = FileManager.default.fileExists(+            atPath: sessionFiles.directory.path(percentEncoded: false),+            isDirectory: &isDirectory+        )+        #expect(exists)+        #expect(isDirectory.boolValue)+    }+     @Test("resolveDirectory falls back to temporary directory when Application Support unavailable")     func resolveDirectoryFallsBackToTemporary() throws {         // Regression test for T-385: force unwrap crash when Application Support unavailable.@@ -90,117 +99,117 @@ struct SessionFileManagerTests {      @Test("writeContent creates file in directory")     func writeContentCreatesFile() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "# Test Content\n\nThis is test markdown." -        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let expectedURL = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let expectedURL = sessionFiles.fileURL(for: sessionID)          #expect(FileManager.default.fileExists(atPath: expectedURL.path(percentEncoded: false)))--        // Cleanup-        try? FileManager.default.removeItem(at: expectedURL)     }      @Test("writeContent stores content with .md extension")     func writeContentUsesMdExtension() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "# Test" -        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let expectedURL = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let expectedURL = sessionFiles.fileURL(for: sessionID)          #expect(expectedURL.pathExtension == "md")         #expect(FileManager.default.fileExists(atPath: expectedURL.path(percentEncoded: false)))--        // Cleanup-        try? FileManager.default.removeItem(at: expectedURL)     }      @Test("writeContent overwrites existing file")     func writeContentOverwritesExisting() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID() -        SessionFileManager.writeContent("Original content", for: sessionID)-        SessionFileManager.writeContent("Updated content", for: sessionID)+        sessionFiles.writeContent("Original content", for: sessionID)+        sessionFiles.writeContent("Updated content", for: sessionID) -        let result = SessionFileManager.readContent(for: sessionID)+        let result = sessionFiles.readContent(for: sessionID)         #expect(result == "Updated content")--        // Cleanup-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")-        try? FileManager.default.removeItem(at: url)     }      // MARK: - Read Content      @Test("readContent returns saved content")     func readContentReturnsSavedContent() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "# Hello World\n\nThis is a test document with **bold** text." -        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let result = SessionFileManager.readContent(for: sessionID)+        let result = sessionFiles.readContent(for: sessionID)         #expect(result == content)--        // Cleanup-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")-        try? FileManager.default.removeItem(at: url)     }      @Test("readContent returns nil for non-existent session")     func readContentReturnsNilForNonExistent() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let nonExistentID = UUID()-        let result = SessionFileManager.readContent(for: nonExistentID)+        let result = sessionFiles.readContent(for: nonExistentID)         #expect(result == nil)     }      @Test("readContent preserves unicode content")     func readContentPreservesUnicode() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "# Unicode Test 🎉\n\nEmoji: 😀🚀✨\nChinese: 中文\nArabic: العربية" -        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let result = SessionFileManager.readContent(for: sessionID)+        let result = sessionFiles.readContent(for: sessionID)         #expect(result == content)--        // Cleanup-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")-        try? FileManager.default.removeItem(at: url)     }      // MARK: - Delete Content      @Test("deleteContent removes file")     func deleteContentRemovesFile() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID() -        SessionFileManager.writeContent("Content to delete", for: sessionID)+        sessionFiles.writeContent("Content to delete", for: sessionID) -        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         #expect(FileManager.default.fileExists(atPath: url.path(percentEncoded: false))) -        SessionFileManager.deleteContent(for: sessionID)+        sessionFiles.deleteContent(for: sessionID)          #expect(!FileManager.default.fileExists(atPath: url.path(percentEncoded: false)))     }      @Test("deleteContent handles non-existent file gracefully")     func deleteContentHandlesNonExistent() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let nonExistentID = UUID()          // Should not throw or crash-        SessionFileManager.deleteContent(for: nonExistentID)+        sessionFiles.deleteContent(for: nonExistentID)          // No assertion needed - success is not crashing     }@@ -209,14 +218,16 @@ struct SessionFileManagerTests {      @Test("cleanupOldSessions removes files not modified in over 7 days")     func cleanupRemovesOldFiles() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "Old content"          // Write a file-        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         let filePath = url.path(percentEncoded: false)          // Set modification date to 8 days ago@@ -227,7 +238,7 @@ struct SessionFileManagerTests {         )          // Run cleanup-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // File should be deleted         #expect(!FileManager.default.fileExists(atPath: filePath))@@ -235,35 +246,36 @@ struct SessionFileManagerTests {      @Test("cleanupOldSessions keeps files newer than 7 days")     func cleanupKeepsNewFiles() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = "New content"          // Write a file (will have current timestamp)-        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         let filePath = url.path(percentEncoded: false)          // Run cleanup-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // File should still exist         #expect(FileManager.default.fileExists(atPath: filePath))--        // Cleanup-        try? FileManager.default.removeItem(at: url)     }      @Test("cleanupOldSessions respects exactly 7-day boundary by modification date")     func cleanupRespectsSevenDayBoundary() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let recentID = UUID()         let oldID = UUID()          // Create a file modified 6 days ago (should be kept)-        SessionFileManager.writeContent("Recent", for: recentID)-        let recentURL = SessionFileManager.directory-            .appendingPathComponent("\(recentID.uuidString).md")+        sessionFiles.writeContent("Recent", for: recentID)+        let recentURL = sessionFiles.fileURL(for: recentID)         let recentPath = recentURL.path(percentEncoded: false)         let sixDaysAgo = Date().addingTimeInterval(-6 * 24 * 60 * 60)         try FileManager.default.setAttributes(@@ -272,9 +284,8 @@ struct SessionFileManagerTests {         )          // Create a file modified 8 days ago (should be deleted)-        SessionFileManager.writeContent("Old", for: oldID)-        let oldURL = SessionFileManager.directory-            .appendingPathComponent("\(oldID.uuidString).md")+        sessionFiles.writeContent("Old", for: oldID)+        let oldURL = sessionFiles.fileURL(for: oldID)         let oldPath = oldURL.path(percentEncoded: false)         let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60)         try FileManager.default.setAttributes(@@ -283,27 +294,26 @@ struct SessionFileManagerTests {         )          // Run cleanup-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // Recent file should remain, old file should be deleted         #expect(FileManager.default.fileExists(atPath: recentPath))         #expect(!FileManager.default.fileExists(atPath: oldPath))--        // Cleanup-        try? FileManager.default.removeItem(at: recentURL)     }      @Test("cleanupOldSessions keeps old-created file that was recently modified")     func cleanupKeepsRecentlyModifiedOldFile() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Regression test for T-289: cleanup should use modification date, not creation date.         // A session file created >7 days ago but recently modified (actively used) must be kept.         let sessionID = UUID()         let content = "Active session content" -        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID) -        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         let filePath = url.path(percentEncoded: false)          // Set creation date to 10 days ago (old), but modification date stays current@@ -314,28 +324,27 @@ struct SessionFileManagerTests {         )          // Run cleanup-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // File should still exist because it was recently modified         #expect(FileManager.default.fileExists(atPath: filePath))          // Content should be intact-        let restored = SessionFileManager.readContent(for: sessionID)+        let restored = sessionFiles.readContent(for: sessionID)         #expect(restored == content)--        // Cleanup-        try? FileManager.default.removeItem(at: url)     }      @Test("cleanupOldSessions removes file with old modification date")     func cleanupRemovesOldModificationDateFile() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Complement to T-289 regression test: files with old modification date should be deleted.         let sessionID = UUID() -        SessionFileManager.writeContent("Abandoned content", for: sessionID)+        sessionFiles.writeContent("Abandoned content", for: sessionID) -        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         let filePath = url.path(percentEncoded: false)          // Set both creation and modification date to 10 days ago@@ -346,13 +355,10 @@ struct SessionFileManagerTests {         )          // Run cleanup-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // File should be deleted because it hasn't been modified recently         #expect(!FileManager.default.fileExists(atPath: filePath))--        // Cleanup (in case assertion fails and file still exists)-        try? FileManager.default.removeItem(at: url)     }      // MARK: - Round Trip@@ -361,68 +367,82 @@ struct SessionFileManagerTests {      @Test("readContent returns nil for non-existent session without throwing")     func readContentNonExistentReturnsNil() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.3: file-not-found is the suppressed-log path.         let nonExistentID = UUID()-        let result = SessionFileManager.readContent(for: nonExistentID)+        let result = sessionFiles.readContent(for: nonExistentID)         #expect(result == nil)     }      @Test("deleteContent for non-existent session completes without throwing")     func deleteContentNonExistentNoThrow() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.5: file-not-found is the suppressed-log path.-        SessionFileManager.deleteContent(for: UUID())+        sessionFiles.deleteContent(for: UUID())         // Success is not crashing.     }      @Test("deleteContent against throwing FileManager stub does not crash")     func deleteContentWithThrowingFileManagerNoCrash() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.5 (non-file-not-found path): stub FileManager throws from removeItem.         // The injection point on deleteContent exists for this scenario.         let stub = ThrowingRemoveFileManager(error: CocoaError(.fileWriteNoPermission))-        SessionFileManager.deleteContent(for: UUID(), using: stub)+        sessionFiles.deleteContent(for: UUID(), using: stub)         // Success is not crashing.     }      @Test("readContent returns nil for malformed UTF-8 file without throwing")     func readContentMalformedUTF8ReturnsNil() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.4: non-file-not-found error path. Writing invalid UTF-8 bytes         // makes String(contentsOf:encoding:) throw `.fileReadInapplicableStringEncoding`,         // which is not a file-not-found error and exercises the warning path.         let sessionID = UUID()-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let url = sessionFiles.fileURL(for: sessionID)         let invalidUTF8 = Data([0xFF, 0xFE, 0xFD])         try invalidUTF8.write(to: url) -        let result = SessionFileManager.readContent(for: sessionID)+        let result = sessionFiles.readContent(for: sessionID)         #expect(result == nil)--        // Cleanup-        try? FileManager.default.removeItem(at: url)     }      @Test("cleanupOldSessions returns without crash when enumeration throws")     func cleanupOldSessionsHandlesEnumerateFailure() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.7: stub FileManager throws from contentsOfDirectory(at:...).         let stub = ThrowingFileManager(             contentsOfDirectoryError: CocoaError(.fileReadNoPermission)         )-        SessionFileManager.cleanupOldSessions(using: stub)+        sessionFiles.cleanupOldSessions(using: stub)         // Success is not crashing.     }      @Test("cleanupOldSessions continues iteration when one file's attributes fail")     func cleanupOldSessionsContinuesAfterAttributeFailure() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // §1.8: stub returns two files; attributesOfItem throws for one specific path,         // and the other should still be age-evaluated. We verify the survivor by         // observing that removeItem is called for the readable old file.-        let directory = SessionFileManager.directory+        let directory = sessionFiles.directory         let failingFile = directory.appendingPathComponent("failing-attrs.md")         let readableID = UUID()          // Create the readable file with a modification date older than 7 days         // so it will be selected for removal.-        SessionFileManager.writeContent("expired", for: readableID)+        sessionFiles.writeContent("expired", for: readableID)         let readableFile = directory.appendingPathComponent("\(readableID.uuidString).md")         let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60)         try FileManager.default.setAttributes(@@ -435,19 +455,19 @@ struct SessionFileManagerTests {             failingPath: failingFile.path         ) -        SessionFileManager.cleanupOldSessions(using: stub)+        sessionFiles.cleanupOldSessions(using: stub)          // The readable old file's age was evaluated, so removeItem was called for it.         #expect(stub.removedURLs.contains(readableFile))         // The failing-attribute file was skipped, not removed.         #expect(!stub.removedURLs.contains(failingFile))--        // Cleanup-        try? FileManager.default.removeItem(at: readableFile)     }      @Test("Content survives write/read round trip")     func contentRoundTrip() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let sessionID = UUID()         let content = """         # Complex Document@@ -464,15 +484,10 @@ struct SessionFileManagerTests {         > Blockquotes too!         """ -        SessionFileManager.writeContent(content, for: sessionID)-        let result = SessionFileManager.readContent(for: sessionID)+        sessionFiles.writeContent(content, for: sessionID)+        let result = sessionFiles.readContent(for: sessionID)          #expect(result == content)--        // Cleanup-        let url = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")-        try? FileManager.default.removeItem(at: url)     } } 
prismTests/SessionStoreIsolationTests.swift Added +236 / -0
diff --git a/prismTests/SessionStoreIsolationTests.swift b/prismTests/SessionStoreIsolationTests.swiftnew file mode 100644index 00000000..2cb9534b--- /dev/null+++ b/prismTests/SessionStoreIsolationTests.swift@@ -0,0 +1,236 @@+//+//  SessionStoreIsolationTests.swift+//  prismTests+//+//  T-2275: no test may touch the user's live unsaved-session store.+//++import Foundation+import Testing+@testable import prism++/// Proves the session store is per-owner, and mechanically keeps it that way.+///+/// ## What went wrong+///+/// `SessionFileManager` was a static-only type over one directory in the app's+/// Application Support container, so every test that persisted a session wrote+/// into the same place the app keeps the user's recoverable unsaved documents.+/// `StatePersistenceIntegrationTests` wanted a clean slate around each of its+/// tests and had only one way to spell it: enumerate that directory and delete+/// everything in it, before and after. That did two things at once —+///+/// - **It deleted the user's data.** Under the test host the directory is the+///   real container's, so running the suite discarded any unsaved document+///   waiting to be restored.+/// - **It failed unrelated suites.** Swift Testing runs suites concurrently.+///   `StatePersistenceTests` and `SessionFileManagerTests` were writing session+///   files into the same directory at the same time, and the blanket delete took+///   theirs too. That is the duration-bearing "load returned nil" in+///   `saveOverwritesExistingState` that passed on a focused rerun.+///+/// Marking the suites `.serialized` was never a fix — it does not serialise+/// *across* suites, which is where the collision was. They no longer carry it.+///+/// ## What replaces it+///+/// A store is a value over an injected directory, so "delete everything in my+/// store" reaches nothing anybody else can see. `SessionFileManager.temporary()`+/// hands out a directory named by a fresh UUID; `StatePersistence` takes the+/// store as an argument with **no default**, so a caller cannot fall back to+/// the production one by omission; and `DocumentServices` carries it, so the+/// one production spelling of the shared store is `PrismApp`'s.+@Suite("Session store isolation")+struct SessionStoreIsolationTests {++    // MARK: - Isolation++    @Test("Two temporary stores never share a directory")+    func temporaryStoresAreDistinct() {+        let first = SessionFileManager.temporary()+        let second = SessionFileManager.temporary()+        defer {+            first.tearDown()+            second.tearDown()+        }++        #expect(first.directory != second.directory)++        // The same session id in two stores is two independent files.+        let sessionID = UUID()+        first.writeContent("first", for: sessionID)+        second.writeContent("second", for: sessionID)+        #expect(first.readContent(for: sessionID) == "first")+        #expect(second.readContent(for: sessionID) == "second")+    }++    @Test("A temporary store is never the production store")+    func temporaryStoreIsNotTheSharedStore() {+        let store = SessionFileManager.temporary()+        defer { store.tearDown() }++        // prism-session-store-exempt: the assertion is that a test store is NOT this one+        #expect(store.directory != SessionFileManager.shared.directory)+    }++    // MARK: - The concurrent regression++    /// Runs the integration suite's blanket cleanup against one store while+    /// another store save/loads, the way two Swift Testing suites run.+    ///+    /// Against the old static store this reproduces T-2275 directly: both halves+    /// resolve to the same directory, the cleanup empties it mid-flight, and a+    /// read comes back nil for content written a moment earlier. Against a+    /// per-owner store the two halves cannot name the same file.+    ///+    /// Two details are load-bearing. The work runs in `Task.detached` rather+    /// than `async let`: this target defaults to MainActor isolation, so an+    /// inline child task inherits the main actor and the two halves would take+    /// turns instead of overlapping — the test would pass without ever having+    /// been concurrent. And the reader asserts on every round rather than once+    /// at the end, because a single post-hoc check passes just as well on a+    /// store that was emptied and refilled in between.+    @Test("A blanket cleanup in one store cannot delete another store's content")+    func concurrentCleanupCannotReachAnotherStore() async {+        let reader = SessionFileManager.temporary()+        let cleaner = SessionFileManager.temporary()+        defer {+            reader.tearDown()+            cleaner.tearDown()+        }++        // The cleaner's store starts with content of its own, so "delete+        // everything here" is doing real work rather than iterating nothing.+        for _ in 0..<20 {+            cleaner.writeContent("# Cleaner", for: UUID())+        }++        let cleanups = Task.detached {+            for _ in 0..<100 {+                // Exactly the shape `StatePersistenceIntegrationTests` used to+                // run against the production directory.+                let files = try? FileManager.default.contentsOfDirectory(+                    at: cleaner.directory, includingPropertiesForKeys: nil+                )+                for file in files ?? [] {+                    try? FileManager.default.removeItem(at: file)+                }+                cleaner.writeContent("# Cleaner", for: UUID())+            }+        }++        let reads = Task.detached { () -> Int in+            var survived = 0+            for round in 0..<100 {+                let sessionID = UUID()+                let content = "# Round \(round)"+                reader.writeContent(content, for: sessionID)+                if reader.readContent(for: sessionID) == content {+                    survived += 1+                }+                reader.deleteContent(for: sessionID)+            }+            return survived+        }++        await cleanups.value+        let survived = await reads.value+        #expect(survived == 100, "a concurrent cleanup reached another store's content")+    }++    // MARK: - The guard++    /// The banned spelling, and the exemption token, each assembled from two+    /// halves.+    ///+    /// Not a flourish: this is the first scan in the codebase that reads the+    /// tree it lives in, so writing either constant out whole would make this+    /// file trip its own rule and leave a permanently stale marker beside the+    /// rule that produced it.+    private static let sharedStoreToken = "SessionFileManager" + ".shared"+    private static let markerToken = "prism-session-store" + "-exempt:"++    /// A file under `prismTests/` that must exist for the scan to have found+    /// the right source tree.+    private static let sentinel = "Support/IsolatedSessionStore.swift"++    private static var rules: [ProductionSourceScan.Rule] {+        [+            .init(+                name: "test reaches the production session store",+                tokens: [sharedStoreToken]+            )+        ]+    }++    private static func scanTests() throws -> [ProductionSourceScan.ScannedLine] {+        try ProductionSourceScan.scanProduction(+            sentinel: sentinel,+            directoryName: "prismTests",+            markerToken: markerToken,+            rules: rules+        )+    }++    /// The mechanical half of the fix.+    ///+    /// The injected directory makes isolation *possible*; nothing about it makes+    /// a future test take it — the old code was, after all, written by someone+    /// who had no other option. The shared store is the one spelling that+    /// reaches the user's directory, so a test that names it must say why, and+    /// the only defensible why is an assertion that reads and never writes.+    ///+    /// What this cannot see is a store reached indirectly: a helper that returns+    /// the shared store, or a production type given a shared-store default+    /// again. `StatePersistence` and `DocumentServices` taking the store with no+    /// default is what closes that path, and the compiler enforces it rather+    /// than this scan.+    @Test("No test reaches the production unsaved-session store")+    func testsDoNotUseTheSharedStore() throws {+        let findings = ProductionSourceScan.violations(in: try Self.scanTests())++        #expect(+            findings.isEmpty,+            """+            A test names \(Self.sharedStoreToken), which is the user's live \+            unsaved-session directory. Use SessionFileManager.temporary() \+            instead, or add `// \(Self.markerToken) <reason>` on the line \+            (or directly above it) if the use genuinely only reads.++            \(findings.map(\.description).joined(separator: "\n"))+            """+        )+    }++    @Test("No stale session-store exemption markers")+    func noStaleExemptionMarkers() throws {+        let stale = ProductionSourceScan.staleMarkers(in: try Self.scanTests())++        #expect(+            stale.isEmpty,+            """+            A \(Self.markerToken) marker no longer covers a flagged line, or \+            carries no reason. Remove it.++            \(stale.map(\.description).joined(separator: "\n"))+            """+        )+    }++    /// A scan that cannot find its tree reports no findings and passes, which is+    /// the failure mode the Makefile's zero-tests-executed guard exists for one+    /// level up. Pin that it actually read something, and that it read this file.+    @Test("The scan actually reads the test tree")+    func scanReachesTheTestTree() throws {+        let lines = try Self.scanTests()+        #expect(!lines.isEmpty, "the prismTests source tree was not found")+        #expect(+            lines.contains { $0.file == "SessionStoreIsolationTests.swift" },+            "the scan did not reach this file"+        )+        #expect(+            lines.contains { $0.file == "SessionFileManagerTests.swift" && $0.rule != nil },+            "the scan did not flag the one known read-only use of the shared store"+        )+    }+}
prismTests/StatePersistenceIntegrationTests.swift Modified +61 / -68
diff --git a/prismTests/StatePersistenceIntegrationTests.swift b/prismTests/StatePersistenceIntegrationTests.swiftindex 2a28f4f7..7c7533ef 100644--- a/prismTests/StatePersistenceIntegrationTests.swift+++ b/prismTests/StatePersistenceIntegrationTests.swift@@ -17,20 +17,15 @@ import Testing /// - Content restored when scene restores /// - Scroll position preserved /// - Persistence cleared on explicit close-@Suite("State Persistence Integration", .serialized)+///+/// Each test owns a `SessionFileManager.temporary()` store, so the "clean slate+/// before, clean slate after" this suite wants costs a directory removal that+/// nothing else can see. It used to be spelled as enumerating and emptying the+/// one production store — deleting the user's recoverable unsaved documents,+/// and racing every concurrently-running sibling suite (T-2275).+@Suite("State Persistence Integration") struct StatePersistenceIntegrationTests { -    /// Helper to clean up session files-    private func cleanupSessionFiles() {-        let files = try? FileManager.default.contentsOfDirectory(-            at: SessionFileManager.directory,-            includingPropertiesForKeys: nil-        )-        for file in files ?? [] {-            try? FileManager.default.removeItem(at: file)-        }-    }-     // MARK: - Content Persistence Tests      /// Tests that session content is persisted to file.@@ -38,19 +33,18 @@ struct StatePersistenceIntegrationTests {     /// Requirement 7.1: Persist content when app moves to background.     @Test("content persisted to file")     func contentPersistedToFile() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let sessionID = UUID()         let content = "# Test Content\n\nWith multiple paragraphs."          // When-        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID)          // Then-        let retrieved = SessionFileManager.readContent(for: sessionID)+        let retrieved = sessionFiles.readContent(for: sessionID)         #expect(retrieved == content)     } @@ -59,9 +53,8 @@ struct StatePersistenceIntegrationTests {     /// Requirement 7.1-7.2: Persist on background, restore on restoration.     @Test("full state persistence round trip")     func fullStatePersistenceRoundTrip() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let originalState = PersistedSessionState(@@ -78,10 +71,10 @@ struct StatePersistenceIntegrationTests {         )          // When - save-        StatePersistence.save(originalState, to: storageBinding)+        StatePersistence.save(originalState, to: storageBinding, sessionFiles: sessionFiles)          // Then - restore-        let restoredState = StatePersistence.load(from: storageData)+        let restoredState = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)          #expect(restoredState != nil)         #expect(restoredState?.sessionID == originalState.sessionID)@@ -94,9 +87,8 @@ struct StatePersistenceIntegrationTests {     /// Requirement 7.3: Persist scroll position along with content.     @Test("scroll position preserved")     func scrollPositionPreserved() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let sessionID = UUID()@@ -114,8 +106,8 @@ struct StatePersistenceIntegrationTests {         )          // When-        StatePersistence.save(state, to: storageBinding)-        let restored = StatePersistence.load(from: storageData)+        StatePersistence.save(state, to: storageBinding, sessionFiles: sessionFiles)+        let restored = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)          // Then         #expect(restored?.scrollPositionID == scrollPosition)@@ -126,9 +118,8 @@ struct StatePersistenceIntegrationTests {     /// Requirement 7.4: Clear persisted content when user explicitly closes.     @Test("persistence cleared on close")     func persistenceClearedOnClose() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let sessionID = UUID()@@ -145,14 +136,14 @@ struct StatePersistenceIntegrationTests {         )          // When - save then clear-        StatePersistence.save(state, to: storageBinding)+        StatePersistence.save(state, to: storageBinding, sessionFiles: sessionFiles)         #expect(!storageData.isEmpty) // Verify save worked -        StatePersistence.clear(storageBinding, sessionID: sessionID)+        StatePersistence.clear(storageBinding, sessionID: sessionID, sessionFiles: sessionFiles)          // Then         #expect(storageData.isEmpty)-        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      // MARK: - Session Restoration Tests@@ -208,43 +199,39 @@ struct StatePersistenceIntegrationTests {      // MARK: - SessionFileManager Tests -    /// Tests that session file is created in correct directory.+    /// Tests that session file is created in the store's directory.     @Test("session file created in correct directory")     func sessionFileCreatedInCorrectDirectory() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let sessionID = UUID()         let content = "# Test"          // When-        SessionFileManager.writeContent(content, for: sessionID)+        sessionFiles.writeContent(content, for: sessionID)          // Then-        let expectedPath = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let expectedPath = sessionFiles.fileURL(for: sessionID)         #expect(FileManager.default.fileExists(atPath: expectedPath.path))     }      /// Tests that session file is deleted correctly.     @Test("session file deleted")     func sessionFileDeleted() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given         let sessionID = UUID()-        SessionFileManager.writeContent("# Test", for: sessionID)+        sessionFiles.writeContent("# Test", for: sessionID) -        let filePath = SessionFileManager.directory-            .appendingPathComponent("\(sessionID.uuidString).md")+        let filePath = sessionFiles.fileURL(for: sessionID)         #expect(FileManager.default.fileExists(atPath: filePath.path))          // When-        SessionFileManager.deleteContent(for: sessionID)+        sessionFiles.deleteContent(for: sessionID)          // Then         #expect(!FileManager.default.fileExists(atPath: filePath.path))@@ -253,40 +240,37 @@ struct StatePersistenceIntegrationTests {     /// Tests that reading non-existent session returns nil.     @Test("reading non-existent session returns nil")     func readingNonExistentSessionReturnsNil() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Given         let nonExistentID = UUID()          // When-        let content = SessionFileManager.readContent(for: nonExistentID)+        let content = sessionFiles.readContent(for: nonExistentID)          // Then         #expect(content == nil)     } -    /// Tests that cleanup removes old session files.-    @Test("cleanup removes old session files")-    func cleanupRemovesOldSessionFiles() throws {-        // Setup-        cleanupSessionFiles()-        defer { cleanupSessionFiles() }+    /// Tests that cleanup leaves a just-written session file alone.+    @Test("cleanup keeps recent session files")+    func cleanupKeepsRecentSessionFiles() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }          // Given-        let oldSessionID = UUID()-        let oldFilePath = SessionFileManager.directory-            .appendingPathComponent("\(oldSessionID.uuidString).md")+        let sessionID = UUID()+        let filePath = sessionFiles.fileURL(for: sessionID) -        // Create an old file (we can't actually set creation date, so this tests the mechanism)-        SessionFileManager.writeContent("# Old Content", for: oldSessionID)-        #expect(FileManager.default.fileExists(atPath: oldFilePath.path))+        sessionFiles.writeContent("# Recent Content", for: sessionID)+        #expect(FileManager.default.fileExists(atPath: filePath.path))          // When - cleanup (won't remove recent files, but verifies method runs without error)-        SessionFileManager.cleanupOldSessions()+        sessionFiles.cleanupOldSessions()          // Then - recent file should still exist (within 7 days)-        #expect(FileManager.default.fileExists(atPath: oldFilePath.path))--        // Cleanup-        SessionFileManager.deleteContent(for: oldSessionID)+        #expect(FileManager.default.fileExists(atPath: filePath.path))     }      // MARK: - Empty/Invalid State Tests@@ -294,11 +278,14 @@ struct StatePersistenceIntegrationTests {     /// Tests that loading from empty storage returns nil.     @Test("loading from empty storage returns nil")     func loadingFromEmptyStorageReturnsNil() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Given         let emptyData = Data()          // When-        let state = StatePersistence.load(from: emptyData)+        let state = StatePersistence.load(from: emptyData, sessionFiles: sessionFiles)          // Then         #expect(state == nil)@@ -307,11 +294,14 @@ struct StatePersistenceIntegrationTests {     /// Tests that loading from invalid data returns nil.     @Test("loading from invalid data returns nil")     func loadingFromInvalidDataReturnsNil() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Given         let invalidData = Data("not valid json".utf8)          // When-        let state = StatePersistence.load(from: invalidData)+        let state = StatePersistence.load(from: invalidData, sessionFiles: sessionFiles)          // Then         #expect(state == nil)@@ -320,6 +310,9 @@ struct StatePersistenceIntegrationTests {     /// Tests that saving nil clears storage.     @Test("saving nil clears storage")     func savingNilClearsStorage() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Given         var storageData = Data("existing data".utf8)         let storageBinding = Binding(@@ -328,7 +321,7 @@ struct StatePersistenceIntegrationTests {         )          // When-        StatePersistence.save(nil, to: storageBinding)+        StatePersistence.save(nil, to: storageBinding, sessionFiles: sessionFiles)          // Then         #expect(storageData.isEmpty)
prismTests/StatePersistenceTests.swift Modified +96 / -56
diff --git a/prismTests/StatePersistenceTests.swift b/prismTests/StatePersistenceTests.swiftindex d0f9bd5f..896f4ca8 100644--- a/prismTests/StatePersistenceTests.swift+++ b/prismTests/StatePersistenceTests.swift@@ -23,13 +23,16 @@ import Foundation /// - 7.2: Restore on scene restoration /// - 7.4: Clear when user returns to WelcomeView /// - 7.5: Scene-scoped storage for multi-window iPad support-@Suite(.serialized)+@Suite struct StatePersistenceTests {      // MARK: - Save      @Test("save stores metadata to binding and writes content file")     func saveStoresMetadataAndContent() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -45,34 +48,37 @@ struct StatePersistenceTests {             scrollPositionID: scrollPositionID         ) -        StatePersistence.save(state, to: binding)+        StatePersistence.save(state, to: binding, sessionFiles: sessionFiles)          // Verify content was written to file-        let savedContent = SessionFileManager.readContent(for: sessionID)+        let savedContent = sessionFiles.readContent(for: sessionID)         #expect(savedContent == content)          // Verify metadata was stored in binding         #expect(!storageData.isEmpty)--        // Cleanup-        SessionFileManager.deleteContent(for: sessionID)     }      @Test("save with nil state clears binding data")     func saveNilClearsBinding() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data([0x01, 0x02, 0x03]) // Some data         let binding = Binding<Data>(             get: { storageData },             set: { storageData = $0 }         ) -        StatePersistence.save(nil, to: binding)+        StatePersistence.save(nil, to: binding, sessionFiles: sessionFiles)          #expect(storageData.isEmpty)     }      @Test("save overwrites existing state for same session")     func saveOverwritesExistingState() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -85,28 +91,28 @@ struct StatePersistenceTests {             content: "Original content",             scrollPositionID: "block-1"         )-        StatePersistence.save(originalState, to: binding)+        StatePersistence.save(originalState, to: binding, sessionFiles: sessionFiles)          let updatedState = PersistedSessionState(             sessionID: sessionID,             content: "Updated content",             scrollPositionID: "block-2"         )-        StatePersistence.save(updatedState, to: binding)+        StatePersistence.save(updatedState, to: binding, sessionFiles: sessionFiles)          // Load and verify the updated state-        let loadedState = StatePersistence.load(from: storageData)+        let loadedState = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)         #expect(loadedState?.content == "Updated content")         #expect(loadedState?.scrollPositionID == "block-2")--        // Cleanup-        SessionFileManager.deleteContent(for: sessionID)     }      // MARK: - Load      @Test("load retrieves metadata and reads content file")     func loadRetrievesState() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -122,35 +128,41 @@ struct StatePersistenceTests {             scrollPositionID: scrollPositionID         ) -        StatePersistence.save(state, to: binding)+        StatePersistence.save(state, to: binding, sessionFiles: sessionFiles) -        let loadedState = StatePersistence.load(from: storageData)+        let loadedState = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)          #expect(loadedState != nil)         #expect(loadedState?.sessionID == sessionID)         #expect(loadedState?.content == content)         #expect(loadedState?.scrollPositionID == scrollPositionID)--        // Cleanup-        SessionFileManager.deleteContent(for: sessionID)     }      @Test("load returns nil for empty data")     func loadReturnsNilForEmptyData() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let emptyData = Data()-        let result = StatePersistence.load(from: emptyData)+        let result = StatePersistence.load(from: emptyData, sessionFiles: sessionFiles)         #expect(result == nil)     }      @Test("load returns nil for invalid data")     func loadReturnsNilForInvalidData() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let invalidData = Data([0xFF, 0xFE, 0x00, 0x01]) // Not valid JSON-        let result = StatePersistence.load(from: invalidData)+        let result = StatePersistence.load(from: invalidData, sessionFiles: sessionFiles)         #expect(result == nil)     }      @Test("load returns nil when content file is missing")     func loadReturnsNilWhenContentFileMissing() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         // Create valid metadata but no content file         let sessionID = UUID()         let metadata = PersistedSessionMetadata(@@ -163,7 +175,7 @@ struct StatePersistenceTests {          // Don't write the content file -        let result = StatePersistence.load(from: encodedMetadata)+        let result = StatePersistence.load(from: encodedMetadata, sessionFiles: sessionFiles)         #expect(result == nil)     } @@ -171,6 +183,9 @@ struct StatePersistenceTests {      @Test("clear removes both metadata and content file")     func clearRemovesMetadataAndContent() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -183,35 +198,41 @@ struct StatePersistenceTests {             content: "Content to clear",             scrollPositionID: "block-999"         )-        StatePersistence.save(state, to: binding)+        StatePersistence.save(state, to: binding, sessionFiles: sessionFiles)          // Verify saved         #expect(!storageData.isEmpty)-        #expect(SessionFileManager.readContent(for: sessionID) != nil)+        #expect(sessionFiles.readContent(for: sessionID) != nil)          // Clear-        StatePersistence.clear(binding, sessionID: sessionID)+        StatePersistence.clear(binding, sessionID: sessionID, sessionFiles: sessionFiles)          // Verify cleared         #expect(storageData.isEmpty)-        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      @Test("clear with nil sessionID only clears metadata")     func clearWithNilSessionIDOnlyClearsMetadata() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data([0x01, 0x02, 0x03])         let binding = Binding<Data>(             get: { storageData },             set: { storageData = $0 }         ) -        StatePersistence.clear(binding, sessionID: nil)+        StatePersistence.clear(binding, sessionID: nil, sessionFiles: sessionFiles)          #expect(storageData.isEmpty)     }      @Test("clear handles non-existent content file gracefully")     func clearHandlesNonExistentContentFile() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data([0x01, 0x02, 0x03])         let binding = Binding<Data>(             get: { storageData },@@ -221,7 +242,7 @@ struct StatePersistenceTests {         let nonExistentID = UUID()          // Should not crash-        StatePersistence.clear(binding, sessionID: nonExistentID)+        StatePersistence.clear(binding, sessionID: nonExistentID, sessionFiles: sessionFiles)          #expect(storageData.isEmpty)     }@@ -237,23 +258,30 @@ struct StatePersistenceTests {      @Test("clear(ownedBy:) removes its own content file and its own metadata")     func clearOwnedByRemovesItsOwnSlot() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })          let sessionID = UUID()         StatePersistence.save(             PersistedSessionState(sessionID: sessionID, content: "Owned", scrollPositionID: "block-1"),-            to: binding+            to: binding,+            sessionFiles: sessionFiles         ) -        StatePersistence.clear(binding, ownedBy: sessionID)+        StatePersistence.clear(binding, ownedBy: sessionID, sessionFiles: sessionFiles)          #expect(storageData.isEmpty)-        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      @Test("clear(ownedBy:) leaves another session's metadata intact")     func clearOwnedByLeavesAnotherSessionsSlotIntact() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 }) @@ -261,36 +289,39 @@ struct StatePersistenceTests {         let other = UUID()         StatePersistence.save(             PersistedSessionState(sessionID: owner, content: "Owner", scrollPositionID: "block-1"),-            to: binding+            to: binding,+            sessionFiles: sessionFiles         )         // The second save re-points the shared binding at `other` while both         // content files exist — the state a late finalisation lands in.         StatePersistence.save(             PersistedSessionState(sessionID: other, content: "Other", scrollPositionID: "block-2"),-            to: binding+            to: binding,+            sessionFiles: sessionFiles         ) -        StatePersistence.clear(binding, ownedBy: owner)+        StatePersistence.clear(binding, ownedBy: owner, sessionFiles: sessionFiles)          // The owner's own file goes; the slot and file that belong to the         // other session are not the owner's to clear.-        #expect(SessionFileManager.readContent(for: owner) == nil)-        let remaining = StatePersistence.load(from: storageData)+        #expect(sessionFiles.readContent(for: owner) == nil)+        let remaining = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)         #expect(remaining?.sessionID == other)         #expect(remaining?.content == "Other")--        SessionFileManager.deleteContent(for: other)     }      @Test("clear(ownedBy:) still removes the content file when the slot is empty")     func clearOwnedByHandlesAnEmptySlot() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })          let sessionID = UUID()-        SessionFileManager.writeContent("Orphaned", for: sessionID)+        sessionFiles.writeContent("Orphaned", for: sessionID) -        StatePersistence.clear(binding, ownedBy: sessionID)+        StatePersistence.clear(binding, ownedBy: sessionID, sessionFiles: sessionFiles)          // Only the content-file assertion discriminates here. `storageData`         // starts empty, so asserting it is STILL empty passes whether the@@ -302,29 +333,35 @@ struct StatePersistenceTests {         // something a wrongly-unconditional wipe would destroy. Do not         // re-add an `isEmpty` check here thinking it covers that; it reads         // like coverage and is not (T-2213 pre-push).-        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      @Test("clear(ownedBy:) leaves undecodable slot data alone")     func clearOwnedByLeavesUndecodableDataAlone() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data([0x01, 0x02, 0x03])         let binding = Binding<Data>(get: { storageData }, set: { storageData = $0 })          let sessionID = UUID()-        SessionFileManager.writeContent("Owned", for: sessionID)+        sessionFiles.writeContent("Owned", for: sessionID) -        StatePersistence.clear(binding, ownedBy: sessionID)+        StatePersistence.clear(binding, ownedBy: sessionID, sessionFiles: sessionFiles)          // Data that names no session names no owner either, so there is         // nothing here this session is entitled to wipe.         #expect(storageData == Data([0x01, 0x02, 0x03]))-        #expect(SessionFileManager.readContent(for: sessionID) == nil)+        #expect(sessionFiles.readContent(for: sessionID) == nil)     }      // MARK: - Round Trip      @Test("Round-trip preserves all fields")     func roundTripPreservesAllFields() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -353,21 +390,21 @@ struct StatePersistenceTests {             scrollPositionID: scrollPositionID         ) -        StatePersistence.save(originalState, to: binding)-        let loadedState = StatePersistence.load(from: storageData)+        StatePersistence.save(originalState, to: binding, sessionFiles: sessionFiles)+        let loadedState = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)          #expect(loadedState != nil)         #expect(loadedState?.sessionID == sessionID)         #expect(loadedState?.content == content)         #expect(loadedState?.scrollPositionID == scrollPositionID)         #expect(loadedState?.savedAt != nil)--        // Cleanup-        SessionFileManager.deleteContent(for: sessionID)     }      @Test("Multiple save/load cycles maintain consistency")     func multipleSaveLoadCycles() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         var storageData = Data()         let binding = Binding<Data>(             get: { storageData },@@ -381,10 +418,10 @@ struct StatePersistenceTests {             content: "Content 1",             scrollPositionID: "pos-1"         )-        StatePersistence.save(state1, to: binding)-        let loaded1 = StatePersistence.load(from: storageData)+        StatePersistence.save(state1, to: binding, sessionFiles: sessionFiles)+        let loaded1 = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)         #expect(loaded1?.content == "Content 1")-        StatePersistence.clear(binding, sessionID: session1ID)+        StatePersistence.clear(binding, sessionID: session1ID, sessionFiles: sessionFiles)          // Cycle 2         let session2ID = UUID()@@ -393,18 +430,18 @@ struct StatePersistenceTests {             content: "Content 2",             scrollPositionID: "pos-2"         )-        StatePersistence.save(state2, to: binding)-        let loaded2 = StatePersistence.load(from: storageData)+        StatePersistence.save(state2, to: binding, sessionFiles: sessionFiles)+        let loaded2 = StatePersistence.load(from: storageData, sessionFiles: sessionFiles)         #expect(loaded2?.content == "Content 2")--        // Cleanup-        SessionFileManager.deleteContent(for: session2ID)     }      // MARK: - PersistedSessionState      @Test("PersistedSessionState initializer sets savedAt to current date")     func persistedSessionStateSetsCurrentDate() {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let beforeDate = Date()         let state = PersistedSessionState(             sessionID: UUID(),@@ -421,6 +458,9 @@ struct StatePersistenceTests {      @Test("PersistedSessionMetadata is Codable")     func persistedSessionMetadataIsCodable() throws {+        let sessionFiles = SessionFileManager.temporary()+        defer { sessionFiles.tearDown() }+         let original = PersistedSessionMetadata(             sessionID: UUID(),             scrollPositionID: "block-123",
prismTests/Support/IsolatedSessionStore.swift Added +55 / -0
diff --git a/prismTests/Support/IsolatedSessionStore.swift b/prismTests/Support/IsolatedSessionStore.swiftnew file mode 100644index 00000000..4e87da2c--- /dev/null+++ b/prismTests/Support/IsolatedSessionStore.swift@@ -0,0 +1,55 @@+//+//  IsolatedSessionStore.swift+//  prismTests+//+//  Per-test session stores (T-2275).+//++import Foundation+@testable import prism++/// Test-only construction of `SessionFileManager` stores that no other test —+/// and no user — shares.+///+/// ## Why this exists+///+/// `SessionFileManager` used to be a static-only type over a single directory+/// in the app's Application Support container. Every test that persisted a+/// session wrote there, so "start from a clean slate" could only be spelled as+/// "delete everything in the one real directory" — which is what+/// `StatePersistenceIntegrationTests` did, before and after most of its tests.+/// That destroyed the user's recoverable unsaved documents on any machine the+/// suite ran on, and, because Swift Testing runs suites concurrently, it also+/// deleted files that `StatePersistenceTests` and `SessionFileManagerTests`+/// were part-way through using.+///+/// A store over a directory of its own removes both problems at once: there is+/// nothing shared to race over, and a blanket cleanup can only reach files the+/// same test wrote. It is the reason the three persistence suites no longer+/// need `.serialized`.+extension SessionFileManager {+    /// A store over a fresh temporary directory that nothing else refers to.+    ///+    /// The directory name is a UUID, so two calls — including two calls running+    /// concurrently in different tests — never collide.+    static func temporary() -> SessionFileManager {+        let directory = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-test-sessions", isDirectory: true)+            .appendingPathComponent(UUID().uuidString, isDirectory: true)+        return SessionFileManager(directory: directory)+    }++    /// Removes this store's directory and everything under it.+    ///+    /// Only ever call this on a store from ``temporary()``. On+    /// `SessionFileManager.shared` it is the T-2275 bug itself.+    func tearDown() {+        try? FileManager.default.removeItem(at: directory)+    }++    /// The on-disk URL this store would use for `sessionID`, for tests that+    /// assert on file existence rather than on read-back content.+    func fileURL(for sessionID: UUID) -> URL {+        directory.appendingPathComponent("\(sessionID.uuidString).md")+    }+}
prismTests/Support/ProductionSourceScan.swift Modified +22 / -9
diff --git a/prismTests/Support/ProductionSourceScan.swift b/prismTests/Support/ProductionSourceScan.swiftindex ccd78926..b5fd7d62 100644--- a/prismTests/Support/ProductionSourceScan.swift+++ b/prismTests/Support/ProductionSourceScan.swift@@ -330,14 +330,23 @@ enum ProductionSourceScan {      // MARK: - Source location -    /// The `prism/` production source directory, found by walking up from this-    /// file and confirmed by `sentinel` (a path relative to `prism/` that must-    /// exist). Compile-time path, so it resolves on the machine that built the-    /// test bundle — which is also the machine that runs it.-    static func productionSourceRoot(sentinel: String, from filePath: String = #filePath) -> URL? {+    /// A source directory beside the repository root, found by walking up from+    /// this file and confirmed by `sentinel` (a path relative to that directory+    /// which must exist). Compile-time path, so it resolves on the machine that+    /// built the test bundle — which is also the machine that runs it.+    ///+    /// `directoryName` defaults to the `prism/` production tree. The T-2275+    /// scan points it at `prismTests/` instead: the rule it enforces — no test+    /// may reach the user's live unsaved-session store — is a rule *about test+    /// source*, and nothing else here cares which tree it is reading.+    static func productionSourceRoot(+        sentinel: String,+        directoryName: String = "prism",+        from filePath: String = #filePath+    ) -> URL? {         var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent()         for _ in 0..<6 {-            let candidate = directory.appendingPathComponent("prism", isDirectory: true)+            let candidate = directory.appendingPathComponent(directoryName, isDirectory: true)             var isDirectory: ObjCBool = false             if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory),                isDirectory.boolValue,@@ -363,11 +372,15 @@ enum ProductionSourceScan {     /// Locates the source root and scans it, failing the calling test when the     /// tree cannot be found or looks implausibly small.     static func scanProduction(-        sentinel: String, skipping: Set<String> = [], markerToken: String, rules: [Rule]+        sentinel: String,+        directoryName: String = "prism",+        skipping: Set<String> = [],+        markerToken: String,+        rules: [Rule]     ) throws -> [ScannedLine] {         let root = try #require(-            productionSourceRoot(sentinel: sentinel),-            "Could not locate the prism/ source tree from #filePath; the scan cannot run."+            productionSourceRoot(sentinel: sentinel, directoryName: directoryName),+            "Could not locate the \(directoryName)/ source tree from #filePath; the scan cannot run."         )         let files = try #require(swiftFiles(under: root))         #expect(files.count > 50, "Source scan found suspiciously few files (\(files.count))")
specs/bugfixes/state-persistence-test-isolation/report.md Added +237 / -0
diff --git a/specs/bugfixes/state-persistence-test-isolation/report.md b/specs/bugfixes/state-persistence-test-isolation/report.mdnew file mode 100644index 00000000..894ab1af--- /dev/null+++ b/specs/bugfixes/state-persistence-test-isolation/report.md@@ -0,0 +1,237 @@+# Bugfix Report: State Persistence Test Isolation++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-2275++## Description of the Issue++`StatePersistenceIntegrationTests.cleanupSessionFiles()` enumerated+`SessionFileManager.directory` and deleted every entry in it, before each test+and again in a `defer`. That directory was not a test fixture: it was the app's+real `Application Support/UnsavedSessions/` folder, where Prism keeps the body+of every pasted document you have not saved yet so it can be restored on the+next launch.++Two distinct harms followed from the same line.++1. **Data loss.** Running the unit test suite on a machine that had used Prism+   discarded the user's recoverable unsaved documents.+2. **Cross-suite races.** Swift Testing runs suites concurrently. Every+   persistence suite wrote into that one directory, so the blanket delete+   removed files that `StatePersistenceTests` and `SessionFileManagerTests` were+   using at that moment. `@Suite(.serialized)` did not help: it serialises tests+   *within* a suite, never across suites, and across suites is where the+   collision was.++**Reproduction steps:**++1. Paste a document into Prism and background the app, so a restore point is+   written into `Application Support/UnsavedSessions/`.+2. Run `make test-quick`.+3. Observe the file is gone, and that reopening Prism no longer offers the+   pasted document.+4. Separately, run the full suite repeatedly and observe intermittent+   duration-bearing failures in unrelated persistence suites.++**Impact:** High. Silent user data loss on any developer machine, plus a source+of intermittent, misattributed test failures that cost investigation time each+time it surfaced.++## Investigation Summary++- **Symptoms examined:** The 2026-08-25 full `make test-quick` run at `5b6cf922`+  reported a failure in `StatePersistenceTests.saveOverwritesExistingState` —+  `StatePersistence.load` returned nil for state saved a moment earlier. It had+  a real 0.29s duration and two explicit nil expectations, so it was not+  crash-cascade fallout, and a focused rerun of that one test passed.+- **Code inspected:** `prismTests/StatePersistenceIntegrationTests.swift`,+  `prismTests/StatePersistenceTests.swift`,+  `prismTests/SessionFileManagerTests.swift`,+  `prism/Services/SessionFileManager.swift`,+  `prism/Services/StatePersistence.swift`,+  `prism/ViewModels/DocumentFlowCoordinator.swift`.+- **Hypotheses ruled out:** A deterministic defect in `StatePersistence` (a+  focused rerun passes); an atomic-write ordering problem in `writeContent` (the+  write is atomic and the failing read is unrelated to it); test-host crash+  cascade (the failure carried a duration and named assertions).+- **Confirmed:** the failing test's content file could only vanish between save+  and load if something else deleted it, and exactly one thing in the suite+  deletes files it did not write.++## Discovered Root Cause++`SessionFileManager` was a caseless `enum` whose entire API was `static`, over a+single `static let directory` resolved from Application Support. **There was+therefore exactly one session store in the process, and no way to ask for+another.**++**Defect type:** Missing seam (untestable global), surfacing as data loss and as+a concurrency race.++**Why it occurred:** A test that wants a clean slate has to be able to say+"empty *my* store". With one process-wide store, the only sentence available was+"empty *the* store", and that sentence is indistinguishable from "delete the+user's data and everybody else's fixtures". The test author had no other option;+the defect is in the type's shape, not in the test that had to work around it.++**Contributing factors:**++- Under the test host, Application Support resolves to the real app container's,+  so the test directory and the production directory are the same directory.+- `@Suite(.serialized)` on all three suites read like isolation and was not. It+  suppressed the intra-suite half of the collision, which made the residual+  failures rare enough to look like flakes.++## Resolution for the Issue++**Changes made:**++- `prism/Services/SessionFileManager.swift` — `enum` -> `struct` with a stored+  `let directory: URL` and an `init(directory:using:)` that creates it. The+  static methods became instance methods. `static let shared` is the app's store;+  `resolveDirectory(using:)` is unchanged and still names where it lives.+- `prism/Services/StatePersistence.swift` — `save`, `load`, and both `clear`+  overloads take `sessionFiles: SessionFileManager` **with no default**. A+  default would make the production store reachable by omission, which is the+  precise failure this fix exists to remove.+- `prism/Services/DocumentServices.swift` — carries `sessionFiles`, also with no+  default. The two halves of clipboard persistence (the scene binding for the+  metadata, the store for the bodies) now travel together.+- `prism/ViewModels/DocumentFlowCoordinator.swift` — reads the store off+  `documentServices` at all five call sites. `restorePersistedSession()` now+  returns early when services are unconfigured, rather than running+  `cleanupOldSessions()` against a store it has not been given.+- `prism/prismApp.swift` — the one production spelling of `.shared`.+- `prismTests/Support/IsolatedSessionStore.swift` (new) —+  `SessionFileManager.temporary()` / `tearDown()` / `fileURL(for:)`.+- The five test files that touched session files now take a per-test store. The+  three persistence suites dropped `.serialized`, which was only ever there+  because they shared a directory.+- `prismTests/Support/ProductionSourceScan.swift` — `productionSourceRoot` and+  `scanProduction` take a `directoryName`, defaulting to `prism`, so a scan can+  read `prismTests/`.++**Approach rationale:** An injected directory is the only thing that makes+"clean up everything in my store" a safe sentence to write. Two stores over two+directories share no mutable state at all, so isolation is by construction and+needs no serialisation — which is why the suites could drop `.serialized`+rather than gain more of it. `SessionFileManager` is `Sendable` (one `let URL`),+so a store can be handed to concurrent work directly.++**Alternatives considered:**++- **A `@TaskLocal` directory override, bound by a Swift Testing scoping trait.**+  Far less churn, and per-task isolation is genuine. Rejected because the+  isolation would then depend on task inheritance: production code reached+  through `Task.detached`, or a future call from a context that is not a child+  of the test's task, would silently see the production store again. The+  guarantee would hold by convention rather than by construction, and the whole+  point here is that convention already failed once.+- **A mutable `static var directoryOverride`.** Rejected outright: shared+  mutable global state cannot isolate concurrently-running suites, which is half+  the bug.+- **Keeping the static API and defaulting the store to `.shared`.** Rejected+  because a defaulted dependency is reachable by omission, and "a test forgot to+  pass a store" is exactly the mistake that must be impossible rather than+  merely discouraged.+- **Fixing only `StatePersistenceIntegrationTests`** (delete just the files it+  wrote). Rejected: it stops the data loss but leaves every persistence test+  writing into the user's directory, and leaves the next author with the same+  missing seam.++## Regression Test++**Test file:** `prismTests/SessionStoreIsolationTests.swift`++Four guards, in two layers.++**Behavioural — `concurrentCleanupCannotReachAnotherStore`:** runs the+integration suite's blanket cleanup against one store while another store+save/loads, the way two Swift Testing suites run. Two details are load-bearing.+The work runs in `Task.detached`, not `async let`: this target defaults to+MainActor isolation, so an inline child task inherits the main actor and the two+halves would take turns instead of overlapping — the test would pass without ever+having been concurrent. And the reader asserts on every round, not once at the+end, because a single post-hoc check passes just as well on a store that was+emptied and refilled in between.++**Mechanical — `testsDoNotUseTheSharedStore` / `noStaleExemptionMarkers` /+`scanReachesTheTestTree`:** a `ProductionSourceScan` over `prismTests/` that+fails when a test names the shared store without a+`// prism-session-store-exempt: <reason>` marker. The injected directory makes+isolation *possible*; nothing about it makes a future test take it. There is one+live marker in the tree, on+`SessionFileManagerTests.sharedStoreUsesResolvedDirectory`, which reads and never+writes.++Both layers were verified red before being accepted: pointing the two stores at+one directory failed `concurrentCleanupCannotReachAnotherStore` in 0.35s, and+removing the single exemption marker failed `testsDoNotUseTheSharedStore`.++**Run command:**++```bash+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug \+  -testPlan prism -only-test-configuration "en (base)" \+  -only-testing:prismTests/SessionStoreIsolationTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/SessionFileManager.swift` | Static-only `enum` to `struct` with an injected `directory`; `shared` is the app's store |+| `prism/Services/StatePersistence.swift` | All four entry points take `sessionFiles`, no default |+| `prism/Services/DocumentServices.swift` | Carries `sessionFiles`, no default |+| `prism/ViewModels/DocumentFlowCoordinator.swift` | Reads the store off `documentServices` at five call sites |+| `prism/prismApp.swift` | The one production `.shared` |+| `prismTests/Support/IsolatedSessionStore.swift` | New: `temporary()`, `tearDown()`, `fileURL(for:)` |+| `prismTests/Support/ProductionSourceScan.swift` | `directoryName` parameter so a scan can read `prismTests/` |+| `prismTests/SessionStoreIsolationTests.swift` | New: the concurrent regression and the scan guard |+| `prismTests/StatePersistenceIntegrationTests.swift` | Per-test store; blanket production-directory cleanup removed; `.serialized` dropped |+| `prismTests/StatePersistenceTests.swift` | Per-test store; `.serialized` dropped |+| `prismTests/SessionFileManagerTests.swift` | Per-test store; `.serialized` dropped; directory tests expressed via `resolveDirectory()` |+| `prismTests/ImageMemoryPipelineTests.swift` | Bounded-read session suite takes a per-test store |+| `prismTests/LateSaveFinalisationTests.swift` | Fixture owns a store; per-id tracking replaced by `tearDown()` |+| `prismTests/ConsecutiveSaveAsTests.swift`, `prismTests/DocumentFlowCoordinatorRecentFileTests.swift` | `DocumentServices` gains a temporary store |++## Verification++**Automated:**++- [x] Regression tests pass, and were confirmed to fail without the fix+- [x] `make build-macos` — Build Succeeded, zero warnings+- [x] `make lint` — 0 violations in 565 files+- [x] All 10 touched/related suites pass targeted (198 tests, `TEST SUCCEEDED`)+- [x] `make test-quick` — the 111 failures in the full run are all live-WebKit+      suites timing out at 22-56s under concurrent load from other agents on this+      machine; a sample (`WebSearchParityTests`, `WebScrollabilityReportingTests`,+      `RawSourceViewModelTests`) passes in isolation. No persistence suite failed.++**Manual verification:**++- Confirmed the production `UnsavedSessions` directory is untouched by a test+  run: the only remaining reference to it from tests is the read-only assertion+  in `sharedStoreUsesResolvedDirectory`, and the scan fails on any other.++## Prevention++- **A process-wide store with no injection point is a latent data-loss bug**, not+  merely an untestable one. The tell is a test that has to clean up by+  enumerating a directory it does not own.+- **Prefer no default over a convenient one for a dependency that names a real+  location.** A defaulted store is reachable by omission; a required one is not.+- **`.serialized` is not isolation.** It serialises within a suite. If two suites+  share state, it buys nothing and hides how often the collision happens.+- The scan over `prismTests/` is the first of its kind in this codebase — the+  existing chokepoint scans read `prism/`. `ProductionSourceScan` now takes the+  tree as a parameter, so the next test-source rule is cheap to add.++## Related++- T-2275 (this ticket)+- T-2213 — `clear(_:ownedBy:)`, whose tests are among those isolated here+- T-2132 / T-1867 — `BoundedFileRead` in `SessionFileManager.readContent`+- `specs/operational-hardening/decision_log.md` (Decisions 6, 10)

Things to double-check

Scan cannot see indirect reaches.

A helper returning the shared store or a future defaulted parameter is invisible to the scan; only the no-default signatures close that path. Keep them default-free.

Follow-up commit 514d599d.

Reviewed only as part of the merged diff: 5 lines of comment in the coordinator, tearDown captures in two test files. No behaviour change.

Full test-quick not run here.

Lint and the six touched suites were run (30/30). The author's report notes the full run's 111 failures were live-WebKit timeouts under machine load; unrelated to persistence. CI is billing-blocked.