Status: Living — updated when the rules below change. Decision record: ADR-0008. Closes #59 (E5). Companion doc to
ARCHITECTURE.md. Updated when the rules below change.
The codebase already has a workable hybrid model — it’s just not documented, and a few persistence rough edges create divergence risk. This doc names the layers, fixes the gaps.
Rules:
SolveState owns the live, in-progress solve. Only the SolveNotifier writes to it.puzzle_completions (immutable, append-only) is the authority for completion history — stats, streaks, personal bests.solve_sessions is the resumable session cache — one mutable row per puzzle, used by Archive and on session resume. It mirrors the latest in-memory state but is not the historical source of truth.PuzzleStatus ↔ CompletionType mapping is owned by SolveRepositoryImpl._statusFromDb (load) and SolveNotifier._deriveCompletionType (save). These two functions must stay inverses of each other.| Store | Mutability | Owner | Read by |
|---|---|---|---|
SolveState.status / usedCheck / usedReveal / checkCount / revealCount |
mutable (in-memory) | SolveNotifier |
SolveScreen, CompletionSheet |
solve_sessions row (one per puzzle) |
mutable (DB) | SolveRepositoryImpl.saveProgress / markComplete |
ArchiveRepositoryImpl, createOrResumeSession |
puzzle_completions row (one per completion) |
append-only (DB) | SolveRepositoryImpl.markComplete → PuzzleCompletionDao.recordCompletion |
StatsRepositoryImpl.getStats, StatsDao.getStreakDates |
Derived flags inside StatsData |
computed | StatsRepositoryImpl.getStats |
StatsScreen widgets |
PuzzleStatus (core/domain/models/enums.dart) — domain layer. Six values: unsolved, inProgress, solved, solvedWithHelp, solvedWithReveal, revealed.CompletionType (core/domain/models/enums.dart) — domain layer. Four values: clean, checked, hinted, revealed. Maps to puzzle_completions.completion_type and solve_sessions.completion_type.DB status string — 'not_started' |
'in_progress' |
'completed' |
'revealed'. Lossy: both solvedWithHelp and solvedWithReveal collapse to 'completed', recovered via completion_type. |
PuzzleStatus.solved ─┐
PuzzleStatus.solvedWithHelp ├─ DB status='completed', completion_type='clean'/'checked'/'hinted'
PuzzleStatus.solvedWithReveal ─┘
PuzzleStatus.revealed ── DB status='revealed', completion_type='revealed'
PuzzleStatus.inProgress ── DB status='in_progress', completion_type=null
The encoding is consistent but the naming is confusing: usedReveal=true in memory produces CompletionType.hinted in the DB (not revealed), because “hinted” means “they used a reveal action” while “revealed” means “they revealed the whole puzzle.” _statusFromDb knows this and round-trips correctly.
Today, in-memory and DB state can disagree in these windows:
Completion → DB write window. _checkCompletion sets state = AsyncData(completed) immediately, then calls _persistCompletion which fires markComplete unawaited (in _persistCompletion, solve_notifier.dart). If the process dies in this window, the in-memory completion is lost — the next resume sees the previous in-progress state.
Autosave debounce window (500 ms). Letter input mutates SolveState immediately, schedules a debounced save. A kill before the debounce fires loses up to 500 ms of input.
Reset-while-saving race. resetPuzzle overwrites the solve_sessions row with in_progress and clears completion fields. If a previous markComplete is still in flight, the reset might land first or second — the historical puzzle_completions row is safe (it’s a separate insert) but the solve_sessions snapshot could land in an unexpected intermediate state.
solve_sessions.completion_type redundancy. This column duplicates the puzzle_completions.completion_type for the latest completion. They can diverge if markComplete partially fails (one insert succeeds, the other doesn’t). Not observed today but the schema allows it.
usedCheck / usedReveal post-completion staleness. These flags are on solve_sessions and persist after completion. They’re never read by UI (verified by grep), but _deriveCompletionType re-derives the type from them on every persist. If a future feature reads these flags directly and the row was last written before the completion type was fixed up, they could mismatch.
The active SolveNotifier owns completion truth. DB is a derived cache written on completion + autosave.
Verdict: rejected. The notifier is per-puzzle and ephemeral. Stats/Archive read across all puzzles and need a persistent authority.
All consumers read from DB. The notifier subscribes to DB streams and renders.
SolveNotifier for marginal correctness gain; the existing in-memory model is appropriate for a live solve.Verdict: rejected. Trading UX latency for an abstraction that the codebase doesn’t need.
Each layer owns a specific slice of the truth. The rules from the TL;DR above are the rules.
Verdict: adopt.
All six tightenings landed in PR #82 (E5 follow-up):
markComplete kill-window backstop. _persistCompletion still fires markComplete unawaited, but SolveScreen.dispose now invokes flushPendingSave when status is terminal — so a normal screen tear-down persists the completion to solve_sessions even if the in-flight markComplete doesn’t return in time.
Round-trip test. solve_completion_roundtrip_test.dart drives SolveNotifier through every terminal status (solved / solvedWithHelp / solvedWithReveal / revealed) against a real SolveRepositoryImpl, disposes the container, resumes from the same in-memory DB. Locks the inverse-mapping invariant between _deriveCompletionType and _statusFromDb.
Reset-race test. A _GatedMarkCompleteRepository test spy lets markComplete’s inner DB writes complete but holds the outer Future open; the test then triggers resetPuzzle and asserts puzzle_completions retains the completion row (append-only) while solve_sessions.status ends in in_progress.
usedCheck / usedReveal documented as session-state-only. Doc comments added to solve_sessions_table.dart marking these columns as inputs to CompletionType derivation only — reading them post-completion to infer the type is a bug.
solve_sessions.completion_type duplication accepted. Option (a) adopted — the column stays as Archive’s hot-read convenience, with an explicit schema comment noting puzzle_completions remains the history authority.
ARCHITECTURE.md decision-log entry added. See “Completion data authority” in ARCHITECTURE.md → Recent Architectural Decisions.
solve_sessions and puzzle_completions serve different access patterns (latest-per-puzzle vs. all-time history) and the split is well-justified.