Skip to content

SLICE-3.3: SQLite Persistence + Schema Versioning

Goal

Replace the file-backed JSON run-history store with a SQLite-backed implementation using Dapper (not EF Core), introduce a schema-versioning + migrations infrastructure that future Phase 3 slices (3.1 rich defect model, 3.4 identity + audit) can extend, and add alarm history persistence that is currently lost across process restarts. The existing run-history.json is migrated into SQLite on first startup with a one-time import path. The slice's load-bearing measurement is that opening the app with 10 000 historical runs loads the most recent page of history in < 200 ms, and that the existing per-slice rate metrics (frames, telemetry, encoder, GC, working-set drift) reproduce within their established bounds against the new store.

This is the first Phase 3 slice opened under the 2026-05-07 strategy doc. It deliberately starts here because SQLite persistence under sustained run cycles is the most likely Phase 2 trigger candidate — its row block will surface whether AppStateStore.Update's allocation share or lock-wait time changes materially under real persistence pressure, which is what SLICE-2.0 is being instrumented to detect.

Why This Slice

The current JsonRunHistoryStore (SLICE-002) writes the entire history file on every SaveAsync call: load all → upsert by RunId → serialize all → atomic-rename. At 10 000 runs that's O(N) writes for every terminal run summary. The Phase 1 captures show 5 109 runs in 8 hours under Soak8h, which means the JSON path would have been writing increasingly-large files for the entire duration. The fact that this didn't surface as a measurement problem reflects the test profile, not the implementation — a deployed inspection tool running for weeks would hit the wall.

Independent of throughput, the JSON file is one of the things phase-1-capabilities-and-limits.md §4.1 names as not-real-world. Real wafer inspection tools persist run history to databases (sometimes SQLite, sometimes vendor-specific), with schema migrations as a first-class concern. Moving to SQLite + Dapper:

  1. Replaces the O(N)-write-per-save path with a single INSERT OR REPLACE per run.
  2. Introduces schema versioning so Phase 3.1 (defect model), Phase 3.4 (identity + audit), and any future schema growth land cleanly without the "rewrite the whole file" pattern the JSON store needs.
  3. Adds alarm history persistence, which is currently in-memory only — alarms raised and cleared during a run are visible in AppState.ActiveAlarms while the process runs but lost on restart. Real production tools need an audit trail.
  4. Surfaces real persistence-side load shapes (lock-wait under concurrent reads + writes, journal mode trade-offs, transaction batching) that the JSON path can't model.

Dapper, not EF Core. Operator preference and project fit. Dapper is a thin micro-ORM — direct SQL with parameter binding and POCO mapping, no LINQ-to-SQL translation, no change tracking, no DbContext lifecycle. The persistence layer here is small (two tables, simple queries, no joins to traverse) and benefits more from explicit SQL than from an entity graph. Dapper also keeps the diff small: ~5 query strings + 2 mapper helpers replace what would be a DbContext + entity classes + OnModelCreating configuration.

Requirements Coverage

In Scope

Database file

  • SQLite database at %LocalAppData%/LcnWaferInspection/inspection.db (configurable via SqlitePersistenceOptions.DatabasePath).
  • Connection string: Data Source=<path>;Mode=ReadWriteCreate;Foreign Keys=True;Cache=Shared.
  • Journal mode: WAL (Write-Ahead Logging) for better concurrent reader/writer behavior. Set on first connection.
  • The directory is created on first connection if missing (matches JsonRunHistoryStore's behavior).

Schema versioning

A schema_version table tracks applied migrations:

sql
CREATE TABLE schema_version (
  version    INTEGER PRIMARY KEY,
  applied_at TEXT NOT NULL          -- ISO 8601
);

A MigrationRunner (in Infrastructure.Data.Migrations) loads embedded .sql resources from Migrations/M{NNN}_{description}.sql, queries MAX(version) from schema_version, applies any missing migrations in order inside a single transaction per migration, and inserts the version row on success. Failure rolls back the migration's transaction; the runner aborts the host startup.

Initial migration M001_initial_schema.sql creates schema_version, run_summaries, and alarm_history. Future slices add M002_*.sql, M003_*.sql, etc. — never editing prior migrations.

Tables (initial migration M001)

sql
CREATE TABLE run_summaries (
  run_id                  TEXT    PRIMARY KEY,            -- Guid as canonical 36-char string
  recipe_name             TEXT    NOT NULL,
  started_at_utc          TEXT    NOT NULL,               -- ISO 8601 with offset
  ended_at_utc            TEXT    NOT NULL,
  terminal_status         TEXT    NOT NULL,               -- enum string: Completed/Stopped/Aborted/Faulted
  defect_count            INTEGER NOT NULL,
  defects_minor           INTEGER NOT NULL,
  defects_major           INTEGER NOT NULL,
  defects_critical        INTEGER NOT NULL,
  completed_scan_points   INTEGER NOT NULL,
  total_scan_points       INTEGER NOT NULL,
  simulator_profile_name  TEXT,                            -- nullable
  major_alarms_json       TEXT    NOT NULL DEFAULT '[]'   -- JSON array of strings
);
CREATE INDEX idx_run_summaries_started_at_utc ON run_summaries(started_at_utc DESC);

CREATE TABLE alarm_history (
  id                      INTEGER PRIMARY KEY AUTOINCREMENT,
  alarm_code              TEXT    NOT NULL,
  severity                TEXT    NOT NULL,               -- enum string: Critical/Major/Minor/Info
  message                 TEXT    NOT NULL,
  raised_at_utc           TEXT    NOT NULL,
  cleared_at_utc          TEXT,                            -- nullable while active
  acknowledged_at_utc     TEXT,                            -- nullable
  run_id                  TEXT                             -- nullable; correlates to run_summaries.run_id
);
CREATE INDEX idx_alarm_history_raised_at_utc ON alarm_history(raised_at_utc DESC);
CREATE INDEX idx_alarm_history_run_id ON alarm_history(run_id);

major_alarms_json is a JSON array of strings rather than a separate table because (a) the existing RunSummary.MajorAlarms shape is IReadOnlyList<string>, (b) the data is read together and never queried by individual alarm string, (c) keeping it inline avoids a join on the hot path. SQLite's JSON1 extension is available if future queries need to filter on it.

IRunHistoryStore interface evolution

The current interface has two methods (LoadAsync returning everything; SaveAsync upserts). After this slice:

csharp
public interface IRunHistoryStore
{
    /// <summary>
    /// Returns the most recent N run summaries, ordered newest first.
    /// Used by HistoryHydrationService at startup; does not load all history into memory.
    /// </summary>
    Task<IReadOnlyList<RunSummary>> LoadRecentAsync(int count = 50, CancellationToken ct = default);

    /// <summary>
    /// Returns a page of run summaries, ordered newest first, for explicit pagination.
    /// Used by UI surfaces that need older history beyond the recent window.
    /// </summary>
    Task<IReadOnlyList<RunSummary>> LoadPageAsync(int skip, int take, CancellationToken ct = default);

    /// <summary>Total count of persisted run summaries.</summary>
    Task<long> CountAsync(CancellationToken ct = default);

    /// <summary>Persists a terminal run summary. Upserts by RunId.</summary>
    Task SaveAsync(RunSummary summary, CancellationToken ct = default);

    /// <summary>Loads a single summary by RunId, or null if not present.</summary>
    Task<RunSummary?> GetAsync(Guid runId, CancellationToken ct = default);
}

The LoadAsync() overload that returned everything is removed. The only production caller (HistoryHydrationService) is updated to call LoadRecentAsync(50) + CountAsync().

IAlarmHistoryStore (new)

csharp
public interface IAlarmHistoryStore
{
    /// <summary>Persists a newly raised alarm. Returns the assigned database id.</summary>
    Task<long> SaveAsync(Alarm alarm, DateTimeOffset raisedAt, Guid? runId, CancellationToken ct = default);

    /// <summary>Marks the most recent open occurrence of alarmCode as cleared.</summary>
    Task MarkClearedAsync(string alarmCode, DateTimeOffset clearedAt, CancellationToken ct = default);

    /// <summary>Marks the most recent open occurrence of alarmCode as acknowledged.</summary>
    Task MarkAcknowledgedAsync(string alarmCode, DateTimeOffset acknowledgedAt, CancellationToken ct = default);

    /// <summary>Returns the most recent N alarm-history entries, newest first.</summary>
    Task<IReadOnlyList<AlarmHistoryEntry>> LoadRecentAsync(int count = 100, CancellationToken ct = default);

    /// <summary>Total count of persisted alarm-history entries.</summary>
    Task<long> CountAsync(CancellationToken ct = default);
}

public record AlarmHistoryEntry(
    long Id,
    string AlarmCode,
    AlarmSeverity Severity,
    string Message,
    DateTimeOffset RaisedAtUtc,
    DateTimeOffset? ClearedAtUtc,
    DateTimeOffset? AcknowledgedAtUtc,
    Guid? RunId);

MarkClearedAsync and MarkAcknowledgedAsync find the most recent row matching alarm_code with cleared_at_utc IS NULL (or acknowledged_at_utc IS NULL) and update that single row. If no open occurrence exists, the call is a no-op (logged at Debug level, not an error — alarm-clear races can produce double-clear calls).

WorkflowService integration

OnFaultInjected, OnFaultCleared, and AcknowledgeFault gain calls to the alarm-history store:

csharp
private void OnFaultInjected(FaultInjectedEventArgs args)
{
    // ... existing AppState update ...
    _ = _alarmHistoryStore.SaveAsync(alarm, DateTimeOffset.UtcNow,
        _store.Current.ActiveRun?.RunId);
}

private void OnFaultCleared(string alarmCode)
{
    // ... existing AppState update ...
    _ = _alarmHistoryStore.MarkClearedAsync(alarmCode, DateTimeOffset.UtcNow);
}

public void AcknowledgeFault(string alarmCode)
{
    // ... existing AppState update ...
    _ = _alarmHistoryStore.MarkAcknowledgedAsync(alarmCode, DateTimeOffset.UtcNow);
}

The fire-and-forget pattern (no await in the event-handler path) is intentional: alarm history persistence is not on the critical path for state transitions. If the SQLite write fails, the in-memory ActiveAlarms is still correct. An exception inside the persistence call is logged but does not propagate to the workflow handler — the same swallow-and-log pattern AlarmBursterService uses.

AppState evolution

AppState.RunHistory keeps its shape (IReadOnlyList<RunSummary>) but its semantic narrows: it is now "the most recent 50 summaries" (the value LoadRecentAsync returned at startup), not "all history." A new field is added:

csharp
public long TotalRunCount { get; init; }

Updated by HistoryHydrationService at startup via _historyStore.CountAsync() and by WorkflowService.RunLoopAsync.finally when a new summary is saved (incremented atomically through the standard _store.Update reducer).

One-time import from JSON

SqliteRunHistoryStore's constructor (or a separate JsonImportService running before HistoryHydrationService) checks:

  1. Is inspection.db present? If yes → skip import.
  2. Is run-history.json present? If no → skip import.
  3. Read all summaries from the JSON file, batch-insert into run_summaries, rename the JSON file to run-history.json.imported-<timestamp> so the import doesn't repeat.
  4. Log Info "Imported N run summaries from legacy JSON store."

The import runs after the migration runner (so the schema exists) and before HistoryHydrationService (so the loaded recent-50 includes imported rows).

If the JSON file is corrupted, the import is skipped (logged Warning) and the SQLite database remains empty — same fail-soft behavior JsonRunHistoryStore.LoadAsync had.

Configuration

csharp
public sealed class SqlitePersistenceOptions
{
    public const string SectionName = "Persistence:Sqlite";
    public string DatabasePath { get; set; } = string.Empty;
    public string JournalMode { get; set; } = "WAL";  // valid: WAL / DELETE / TRUNCATE / MEMORY
}

Defaults set in InfrastructureServiceCollectionExtensions.AddInfrastructure:

csharp
services.AddOptions<SqlitePersistenceOptions>().Configure(o =>
{
    o.DatabasePath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "LcnWaferInspection",
        "inspection.db");
    o.JournalMode = "WAL";
});

The existing RunHistoryStoreOptions (JSON file path) is kept for the import path's source location, then the migration tool renames the JSON file out of the way. A subsequent slice can remove RunHistoryStoreOptions entirely once enough deployments have migrated.

Packages

Add to Directory.Packages.props:

xml
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />

Both reference the latest stable versions at slice-open time. The Microsoft.Data.Sqlite minor must align with the .NET TFM (currently net10.0). Dapper is targeted-TFM-agnostic.

Measurement scenario and row

A new file docs/reviews/phase-3-measurements.md is created (mirrors phase-1-measurements.md structure: Conventions / Fixed metric set / Phase 3 rows). One row block tagged slice-3-3-sqlite-persistence.

A new runbook section §5.2 — SLICE-3.3 SQLite persistence capture (where §5.1 is the SLICE-2.0 store-allocation scenario from Phase 2) describes the procedure: 30-min MultiTag profile capture under the new SQLite store, with the database pre-populated to 10 000 rows so the persistence path is exercised at scale.

The row block extends the standard 22-26-metric set with three persistence-specific metrics:

MetricHow extracted
runs.persisted (count)Sum of new rows in run_summaries over the capture window
alarms.persisted (count)Sum of new rows in alarm_history over the capture window
recent-history-load p95 (ms)p95 of LoadRecentAsync(50) wall-clock at startup, measured by an in-process counter or a one-off log line + extraction

MeasurementExtraction.psm1 gains Get-RunsPersistedCount, Get-AlarmsPersistedCount, and Get-RecentHistoryLoadP95 helpers (the first two are simple SQLite queries; the last reads the startup log).

Out of Scope

  • Per-run TagSample snapshot persistence — the original roadmap §5 named it; SLICE-3.3 explicitly defers to SLICE-3.1 (rich defect model) or a follow-up. The reason: per-run TagSample storage is a non-trivial schema design (snapshot frequency, compression, retention), and conflating it with the run-history migration risks both being done badly.
  • Defect persistence (rich shape: Id, FrameId, BoundingBox, Classification, Confidence, ImageRef) — SLICE-3.1's whole point. SLICE-3.3 ships the per-run defect counts (already on RunSummary) and leaves the defect records for 3.1.
  • Concurrent-writer scenarios — multiple WPF processes writing to the same SQLite file. The single-instance mutex from SLICE-006 prevents this in production; tests don't simulate it.
  • Backup / restore tooling — copy inspection.db is the entire backup procedure. Versioned backups, encryption-at-rest, and remote replication are deferred to Phase 4 (real-deployment concerns).
  • Schema rollback — migrations are forward-only. Reverting to an earlier schema requires restoring a backup.
  • EF Core or any other ORM — explicitly Dapper; this slice is the precedent.
  • InspectionResult (per-frame defect detection result) persistence — the workflow currently passes InspectionResult through the pipeline service into ActiveRun.DefectCount; the result itself is in-memory only. 3.1 lifts this.
  • Removing RunHistoryStoreOptions — kept as the import path's source; removal is a follow-up after enough deployments have migrated.
  • UI changesAppState.RunHistory keeps its shape so the existing UI binding works. The new TotalRunCount field is wired but not yet displayed; UI consumption is a Phase 3.1 / 3.2 concern.

Runtime Behavior

Startup sequence

host startup


1. AddInfrastructure()
   - registers SqlitePersistenceOptions with default DatabasePath
   - registers MigrationRunner as IHostedService (priority: very high)
   - registers JsonImportService as IHostedService (priority: high, after MigrationRunner)
   - registers SqliteRunHistoryStore as IRunHistoryStore (singleton)
   - registers SqliteAlarmHistoryStore as IAlarmHistoryStore (singleton)
   - HistoryHydrationService unchanged (consumer of IRunHistoryStore)

2. MigrationRunner.StartAsync
   - opens connection, sets PRAGMA journal_mode=WAL
   - reads MAX(version) FROM schema_version (or 0 if table absent)
   - applies M001_initial_schema.sql (and any later Mxxx) inside per-migration transaction
   - on failure: logs Critical, throws → host startup aborts

3. JsonImportService.StartAsync
   - if inspection.db exists AND has any rows in run_summaries → no-op (already imported)
   - else if run-history.json exists → batch-insert all summaries, rename JSON to *.imported-<ts>
   - logs Info "Imported N run summaries"

4. HistoryHydrationService.StartAsync
   - LoadRecentAsync(50) → AppState.RunHistory
   - CountAsync() → AppState.TotalRunCount
   - sets LastRunSummary if any summaries exist

5. Rest of hosted services (SimulatedTagSource, FramePipelineService, etc.) start

Run-end persistence flow (changed)

WorkflowService.RunLoopAsync.finally:

   build RunSummary
   call _historyStore.SaveAsync(summary)        ◀── now writes to SQLite, single-row INSERT OR REPLACE
   call _store.Update(s => s with {
       RunHistory = ([summary] + s.RunHistory).Take(50).ToList(),  // sliding window
       TotalRunCount = s.TotalRunCount + 1,
       LastRunSummary = summary
   })

The "load all → modify → write all" pattern of JsonRunHistoryStore is replaced by a single INSERT OR REPLACE. The in-memory RunHistory list is kept as a sliding window of the most-recent 50 (a simple bounded list — the older entries are still in SQLite, accessible via LoadPageAsync).

Alarm-history flow (new)

fault injected → WorkflowService.OnFaultInjected:
   - existing: AppState update (Faulted state, ActiveAlarms list)
   - NEW: _alarmHistoryStore.SaveAsync(alarm, DateTimeOffset.UtcNow, runId)
         (fire-and-forget; logged on failure; does not block the handler)

fault cleared → WorkflowService.OnFaultCleared:
   - existing: AppState update (alarm.IsActive = false)
   - NEW: _alarmHistoryStore.MarkClearedAsync(alarmCode, DateTimeOffset.UtcNow)

operator acks → WorkflowService.AcknowledgeFault:
   - existing: AppState update (alarm.IsAcknowledged = true)
   - NEW: _alarmHistoryStore.MarkAcknowledgedAsync(alarmCode, DateTimeOffset.UtcNow)

The AlarmBursterService (SLICE-1.4) automatically exercises all three paths under ChaosMonkey load — useful for the row-block evidence.

Pagination access pattern

HistoryHydrationService at startup:
   recent = await _historyStore.LoadRecentAsync(50);
   total = await _historyStore.CountAsync();
   _store.Update(s => s with {
       RunHistory = recent,
       TotalRunCount = total,
       LastRunSummary = recent.FirstOrDefault() ?? s.LastRunSummary
   });

UI surfaces wanting older history (Phase 3.1 / 3.2) call _historyStore.LoadPageAsync(skip, take) directly. AppState.RunHistory deliberately does NOT grow as the UI pages — the canonical store holds the recent window only; pages are loaded on demand.

Connection management

SqliteRunHistoryStore and SqliteAlarmHistoryStore each construct a fresh SqliteConnection per call (Dapper's standard pattern with connection pooling enabled by Microsoft.Data.Sqlite). No long-lived connection is held — the pool reuses underlying handles.

WAL mode is set on first connection by the MigrationRunner's opening transaction; subsequent connections inherit it from the database file.

Acceptance Criteria

This slice is satisfied only if all of the following are true:

  1. Dapper 2.1+ and Microsoft.Data.Sqlite 10.0+ are added to Directory.Packages.props (no Version= attribute on individual .csproj files; centralized version management per Phase 0 conventions).
  2. Infrastructure/Data/Migrations/M001_initial_schema.sql exists as an embedded resource and creates the schema_version, run_summaries, and alarm_history tables with the indexes specified in "In Scope". Loading the migration via the MigrationRunner produces the exact schema (verified by a test that queries sqlite_master post-migration).
  3. MigrationRunner (Infrastructure.Data.Migrations.MigrationRunner) is registered as an IHostedService that runs before HistoryHydrationService. On a fresh database it applies M001; on a DB at version N it applies M(N+1) onward. Each migration runs in a single transaction; failure rolls back and aborts host startup with a Critical log line.
  4. IRunHistoryStore evolved per "In Scope": LoadRecentAsync(int count = 50), LoadPageAsync(int skip, int take), CountAsync(), GetAsync(Guid), SaveAsync(RunSummary). The previous LoadAsync() (returning everything) is removed; all callsites updated.
  5. SqliteRunHistoryStore (Dapper) implements IRunHistoryStore. SaveAsync is INSERT OR REPLACE INTO run_summaries (...) VALUES (...). LoadRecentAsync and LoadPageAsync are SELECT ... ORDER BY started_at_utc DESC LIMIT @take OFFSET @skip. CountAsync is SELECT COUNT(*) FROM run_summaries. GetAsync is SELECT ... WHERE run_id = @runId.
  6. IAlarmHistoryStore exists with the shape in "In Scope". SqliteAlarmHistoryStore implements it. MarkClearedAsync / MarkAcknowledgedAsync find the most recent open row by alarm_code and (cleared|acknowledged)_at_utc IS NULL, then update; if no row matches, the call is a Debug-logged no-op.
  7. WorkflowService calls the alarm-history store from OnFaultInjected, OnFaultCleared, and AcknowledgeFault per "Runtime Behavior". The calls are fire-and-forget (_ = ...) and do not block the workflow handlers. Exceptions inside the persistence call are caught and logged at Warning, never propagated.
  8. JsonImportService is registered as an IHostedService running between MigrationRunner and HistoryHydrationService. On first SQLite-mode startup with a non-empty run-history.json, it imports every summary (preserving order), then renames the JSON file to run-history.json.imported-<yyyy-MM-ddTHH-mm-ssZ> so the import doesn't repeat. Idempotent: re-running on a populated SQLite DB is a no-op.
  9. HistoryHydrationService updated: LoadRecentAsync(50) + CountAsync() instead of LoadAsync(). AppState.TotalRunCount populated. AppState.RunHistory is the recent-50 window.
  10. AppState gains long TotalRunCount (immutable record property). WorkflowService.RunLoopAsync.finally updates it (TotalRunCount + 1 per terminal summary persisted) inside the same _store.Update reducer that updates RunHistory and LastRunSummary.
  11. JsonRunHistoryStore is removed from the codebase (file deleted, DI registration replaced with SqliteRunHistoryStore). The RunHistoryStoreOptions class stays for the import-source path; remove in a follow-up slice once enough deployments have migrated.
  12. 10 000-row pagination benchmark. A test (SqliteRunHistoryStorePerformanceTests.LoadRecent50_AfterPopulating10K_CompletesUnder200Ms) populates a temp database with 10 000 synthetic RunSummary rows, then asserts LoadRecentAsync(50) completes in < 200 ms wall-clock. The test uses Stopwatch and is run under Release configuration. If the test fails, investigate the index (idx_run_summaries_started_at_utc) — without it, the LIMIT/OFFSET path scans the whole table.
  13. Reproducibility of prior Phase 1 rows. Running Capture-Measurements.ps1 -Profile MultiTag -DurationSeconds 1800 on the SQLite branch produces a row whose first 22 metrics (frames / tags / encoder / GC / CPU) are within ±10% of slice-1-1-multi-tag-telemetry. The persistence change must not perturb the data plane materially.
  14. A new file docs/reviews/phase-3-measurements.md exists with the same conventions as phase-1-measurements.md. One row block tagged slice-3-3-sqlite-persistence covers the standard metrics plus three new persistence-specific rows: runs.persisted (count), alarms.persisted (count), recent-history-load p95 (ms). The CSV is committed at docs/captures/slice-3-3-sqlite-persistence-<date>.csv.
  15. MeasurementExtraction.psm1 gains Get-RunsPersistedCount, Get-AlarmsPersistedCount, Get-RecentHistoryLoadP95. ConvertTo-MeasurementRow emits the three new metrics when present, "—" otherwise. Pester tests cover each helper.
  16. docs/runbook/capturing-measurements.md gains a §5.2 entry (Phase 3 captures, after the §5.1 placeholder for SLICE-2.0). Procedure: pre-populate inspection.db with 10 000 synthetic rows, run the standard MultiTag 30-min capture, extract row, append.
  17. The full existing test suite still passes (Phase 1 + Phase 2.0 tests reproduce against the SQLite-backed history). New tests: migration runner applies M001 cleanly; one-time import path works idempotently and handles missing/corrupted JSON; SQLite roundtrip for RunSummary and AlarmHistoryEntry; MarkClearedAsync / MarkAcknowledgedAsync no-op on missing rows; pagination perf (criterion 12); WorkflowService integration test verifies fault inject → SQLite row → fault clear → row updated.

Verification Notes

  • WAL mode on test databases. Tests using a temp .db file should explicitly PRAGMA journal_mode = WAL on first connection; otherwise tests may interact with leftover WAL/SHM files from prior test runs. Consider cleanup in test teardown (File.Delete(dbPath); File.Delete(dbPath + "-wal"); File.Delete(dbPath + "-shm")).
  • Microsoft.Data.Sqlite connection-string Cache=Shared. Required for in-memory test databases (Data Source=:memory:) to persist across pooled connections. For file-based test DBs, omit it (file pooling works without).
  • Time precision. SQLite stores TEXT for timestamps; DateTimeOffset round-trips through ISO 8601 with millisecond precision. If a future test depends on sub-millisecond precision (alarm raise → clear in < 1 ms), the precision is lost. Real wafer inspection alarm raises and clears are ms-or-coarser, so this is fine for current scope.
  • Concurrent reads + writes. WAL mode allows readers to proceed without blocking writers. The fire-and-forget alarm-history calls run on the thread pool; the LoadRecentAsync on the UI startup path runs concurrently. Verified by an integration test that issues 100 concurrent SaveAsync calls and asserts no exception + count matches.
  • Schema-version forward-migration test. Stub a future M002 (a SQL file added under Migrations/ that adds a no-op column). Run migrations on a fresh DB; assert MAX(version) = 2. Run again on the populated DB; assert it's still 2 (no double-apply).
  • JSON import corruption handling. A malformed run-history.json in the user's %LocalAppData% should NOT abort startup — the import is best-effort. The original JSON is renamed with a .malformed-<ts> suffix so the user can inspect it; SQLite history starts empty.
  • Existing Capture-Measurements.ps1 doesn't currently invoke any DB pre-population. The Pass 3 capture procedure documents the pre-population step as manual SQL (or a one-off helper script) so the operator can prepare the 10K-row state before the capture window starts.
  • Phase 2 trigger watch. This slice's row block is one of the candidate triggers for Phase 2.x slices (per the 2026-05-07 strategy doc). When the row lands, compare its cpu-usage avg, working-set growth, and alloc-rate avg columns against slice-2-0-store-profiling; if SQLite persistence pressure visibly elevates AppStateStore.Update's alloc share, that's the SLICE-2.1 / 2.2 trigger. Document the observation in the row's Notes section.

Docs-first project memory for AI-assisted implementation.