Skip to content

V3 architecture research: choose the durable state model and storage engine #89

Description

@Loufe

Parent: #33. Prompted by the first-class History work in #79 and the live/replay mismatch in #81.

Status

This is an open architecture investigation, not a decision and not an implementation issue.

The goal is to find the best durable-state architecture for CRFty's actual domain and expected lifetime, even if that means superseding accepted ADRs or replacing substantial pre-release work. Do not optimize for preserving the current journal merely because it already exists, and do not treat SQLite/Turso as selected merely because they currently look promising.

When this investigation concludes, record the decision in one or more ADRs and update the affected issues. Until then, ADR-004 and ADR-009 remain the accepted description of the implemented system.

Why this reopened

ADR-004 chose an append-only NDJSON journal because:

  • the durable state fit in memory;
  • the reducer already owned the single transaction boundary;
  • History and Statistics were understood as projections of operational file records, verdicts, runs, and outputs;
  • inspectable/redactable plaintext was considered valuable;
  • avoiding database schema migration machinery was considered a maintenance advantage.

#79 exposed a false premise. Historical statistical evidence is not current operational state:

  • it must survive deletion, movement, replacement, and loss of the source;
  • it must not be reached through a current standing verdict;
  • it grows independently and needs filtered/paginated queries;
  • terminal completion and the corresponding observation must land atomically;
  • privacy scrub must clear the readable History path while preserving its hash and all statistical evidence.

As the journal has been fleshed out, it has also acquired framing, exact schema-version rejection, semantic replay validation, torn-tail recovery, corruption acknowledgement, compaction policy, Windows replacement retries, and a large crash-injection suite. That is real storage-engine responsibility, not merely serialization.

The current format has JOURNAL_SCHEMA_VERSION = 16 and refuses every other version before decoding its payload. That detects incompatibility but does not preserve user state across a released schema change. Avoiding explicit migrations has not eliminated schema evolution; it has left the update path undefined.

Problem statement

Choose how V3 should represent, transact, recover, query, evolve, inspect, and scrub:

  1. Operational durable state
    • queue items and ordering;
    • path bindings and current file observations;
    • analyses and standing operational decisions;
    • conversion runs and phase spans;
    • the output transaction ledger needed for crash recovery;
    • durable ID allocation.
  2. Historical statistical observations
    • append-only operation facts used by History, Statistics, and Estimation;
    • independent of current files, verdicts, runs, and outputs;
    • native and translated observations sharing one model;
    • potentially large and request-driven rather than mirrored to TypeScript.
  3. Settings
    • small and human-editable today;
    • a different lifecycle from conversion/recovery state;
    • still subject to evolution across released application versions.
  4. Ephemeral backend state
    • active process handles, cancellation, telemetry, tool availability, session coordination, analysis generations, channels, and subscribers;
    • not durable merely because an embedded database exists.

The selected design must minimize lifetime correctness and maintenance burden, not only initial implementation size.

Invariants the solution must enforce

Transaction and publication

  • One engine-owned writer remains the authority.
  • A command's durable changes are all-or-nothing.
  • A reportable terminal completion and its HistoryRecord are one atomic transaction.
  • Failed/stopped/non-reportable outcomes create no statistical observation.
  • Effects, replies, and UI-visible state never outrun durable commit.
  • Restart restores every committed transition and no uncommitted transition.
  • Reconnect obtains current truth without replaying frontend-owned business logic (V3 post-release: replace durable deltas and the TS fold with snapshot push #78).

Model boundaries

  • History has no referential-integrity dependency on current file records, paths, verdicts, outputs, queue items, or runs.
  • Deleting current operational state cannot cascade into History.
  • Runs remain execution/recovery evidence; History remains reporting/estimation evidence.
  • Derived aggregates are not authoritative persisted facts unless measurement proves materialization necessary.
  • Business transition rules remain in pure Rust domain code, not duplicated in SQL triggers, stored procedures, frontend folds, and replay validators.

Evolution

  • Released user data survives normal forward application updates, including users who skip releases.
  • The current application code operates on one current schema; old application/domain APIs do not remain live as compatibility shims.
  • A database/file produced by a newer application is refused clearly rather than misread.
  • Pre-3.0 development migrations may be squashed; released migrations or equivalent transforms cannot be silently discarded.
  • The project must resolve the conflict between preserving persisted user data and the current blanket “no migration helpers” rule. A likely policy boundary is: zero compatibility for source/runtime APIs, forward migration for released persisted data.
  • V2 interpretation stays exclusively in the disposable Python translator. V2-to-V3 translation is not a runtime schema migration.

Privacy and path fidelity

  • Privacy-on recording persists no readable History path.
  • Statistical facts are append-only, but the source locator has one explicit permitted mutation: irreversible raw-path → anonymous identifier redaction.
  • Scrub clears the optional readable path from current History records. It makes no physical-erasure promise about storage remnants.
  • Scrub does not alter History IDs, measurements, outcomes, Statistics, or Estimation.
  • Operational recovery paths remain outside “Scrub History.”
  • Native non-Unicode paths round-trip without lossy display conversion.
  • Cross-platform database copying has defined behavior for platform-native path encodings.

Operability

  • A second process cannot become a competing writer.
  • Corruption, an unsupported future schema, and an abnormal prior shutdown remain distinguishable.
  • The recovery/degraded UX is explicitly designed; replacing the journal must not quietly delete the existing corruption contract.
  • The durable format is inspectable with ordinary maintained tools or has a first-class export path. “Plaintext” alone is not sufficient if the coherent current state is only obtainable by replay.
  • No server, account, network connection, replication service, or online backup is required.

Serious candidate families

The investigation should compare these as architectures, not just crates.

A. Current typed NDJSON journal plus snapshot compaction

Strengths:

  • Naturally matches reducer-emitted transitions.
  • One append/fsync before publication is easy to state.
  • Text is directly visible.
  • Current implementation and extensive failure tests already exist.

Questions/costs:

  • Every historical delta and snapshot shape becomes a durable compatibility surface.
  • Replay validation currently duplicates live transition validation (V3: reducer and journal replay validate different transition relations #81).
  • Compaction is a custom storage protocol; History scrub is an ordinary logical record transformation.
  • Querying growing History requires a new index/store or full replay/load.
  • Human-readable individual events are not the same as a human-readable current model.
  • A released schema-update mechanism remains undefined.

Research whether a deliberately stable event schema with upcasters/copy-and-transform can make this simpler than current-state migration. Do not assume event sourcing is justified merely because the reducer emits deltas.

B. Canonical whole-state snapshot file

Examples: one atomic JSON/CBOR snapshot rewritten after each durable batch, possibly now viable once History leaves the operational snapshot.

Strengths:

  • Very small mechanism.
  • One current representation rather than an event lifetime.
  • Easy offline inspection if JSON.

Questions/costs:

  • Rewriting and fsyncing the whole file.
  • Serialized Rust/domain shape still requires schema evolution.
  • A separate History store breaks atomic terminal + History commit unless a reconciliation/outbox protocol is added.
  • Putting History back into the snapshot loses pagination and bounded startup.

C. Multiple dedicated plaintext files

Examples: operational snapshot, append-only History, settings, output-recovery ledger.

Strengths:

  • Clear human-facing separation.
  • Each artifact may have a format optimized for its lifecycle.

Questions/costs:

  • Cross-file atomicity is not provided by the filesystem.
  • Terminal completion, output settlement, and History require a transaction manifest, idempotent reconciliation, or a coordinator journal.
  • The design risks rebuilding a database transaction manager from files.
  • Crash recovery spans several artifacts; History scrub must still affect only readable History path fields.

D. SQLite-compatible relational current-state database

Possible engines/bindings include bundled SQLite through rusqlite and the native-Rust Turso Database.

Strengths:

Questions/costs:

  • Rust/domain-to-row mapping code.
  • Schema migrations become an explicit permanent responsibility after release.
  • Need to decide how much to normalize and where nullable variant columns or child tables are clearer.
  • SQLite engine/binding selection has packaging, sync/async, maturity, and maintenance tradeoffs.
  • WAL lifecycle, checkpointing, and Windows file behavior must be verified against the exact chosen engine.

E. Hybrid SQLite-compatible database

Examples:

  • relational history_records plus one serialized operational snapshot row;
  • relational aggregate-root tables with serialized leaf payloads;
  • current-state tables plus an internal durable change log/outbox.

Strengths:

  • Atomicity and History queries without immediately mapping every operational value to columns.
  • May preserve the current pure reducer with less initial persistence code.

Questions/costs:

  • Opaque JSON/CBOR payloads can merely hide the same serde/schema migration cliff.
  • A whole-state row gives up incremental update and constraint benefits.
  • A change log can accidentally retain the journal's replay/versioning burden.
  • Need a principled boundary: real columns for identity/lifecycle/query/integrity, serialized leaves only where their internal structure is truly storage-opaque.

F. Embedded transactional key/value store

Candidates worth evaluating include pure-Rust redb and LMDB through heed.

Strengths:

  • Embedded ACID transactions and single-writer semantics.
  • Natural keyed aggregate storage.
  • redb is pure Rust and crash-safe by default.
  • Could fit a reducer returning complete aggregate replacements.

Questions/costs:

  • No relational query language.
  • CRFty owns secondary indexes, joins, estimator cohorts, pagination order, and integrity constraints.
  • Values still require a durable encoding and migration strategy.
  • Inspectability and cross-language tooling are weaker than SQLite.
  • Engine file-format/API evolution and repair behavior must be assessed, not inferred from “ACID.”

G. Event-sourced database with rebuildable projections

Persist stable domain events as the enduring record and rebuild current/read tables.

Strengths:

  • Audit/replay and rebuildable projections.
  • Read schemas can change without rewriting the event record.

Questions/costs:

  • Event schemas themselves evolve and must remain interpretable or be transformed.
  • Snapshots, projection versioning, duplicate validation paths, and replay tooling remain.
  • CRFty needs immediate consistency and has one local writer; it does not need distributed consumers or historical reconstruction of every queue edit.
  • History observations are naturally append-only, but that does not imply the entire operational model should be event sourced.

H. Analytical, client/server, or OS-specific databases

Include as control candidates and reject only with explicit reasons:

  • DuckDB is embedded SQL but optimized for bulk analytical work rather than many small operational transactions.
  • PostgreSQL/MySQL add deployment and process administration with no CRFty requirement that justifies them.
  • OS-native stores lose Windows/Linux portability.
  • Frontend IndexedDB cannot own output-recovery state when the webview disconnects or restarts.

State ownership alternatives

Storage engine and runtime ownership are separate decisions.

Long-lived in-memory durable mirror

The current driver owns a full AppState, mutates it through the reducer, persists changes, then publishes.

  • Minimal domain refactor.
  • Fast reads.
  • Requires a structural guarantee that the mirror and database never diverge.
  • Persistence failure needs candidate-state rollback or fatal restart.

Database-authoritative, transaction-local domain objects

The driver owns one connection. For each command/batch:

begin transaction
  -> load required typed records
  -> pure transition
  -> persist complete resulting records
commit
  -> publish current operational snapshot/reply/effects
  • No long-lived duplicate durable cache.
  • The embedded engine's page cache supplies the actual cache.
  • Closely resembles a Unit of Work.
  • Requires splitting the monolithic reducer or initially loading a broader transaction working set.

Database-authoritative with a measured read cache

Start transaction-local and add caches only for demonstrated hot paths. Cache invalidation remains structurally simple if the driver is the only writer, but it should not be introduced without a measurement.

The investigation must also state why backend ephemeral process/session state remains in memory even if durable state becomes database-authoritative.

Engine-specific questions

Bundled SQLite / rusqlite

  • Fit with the current synchronous dedicated driver.
  • Bundled C SQLite packaging on Windows and Linux.
  • Transaction and migration APIs.
  • Integrity checks and WAL/rollback-journal behavior.
  • Dependency/API stability and binary-size impact.

Turso Database

  • Use local embedded mode only; cloud/network features are irrelevant.
  • Pin and test the exact Rust crate rather than assuming SQLite checklist equivalence.
  • Native async integration cost inside the otherwise synchronous driver.
  • WAL-only behavior and WAL growth/checkpoint policy (wal_autocheckpoint is currently absent).
  • Same-connection statement lifecycle: every write statement must be fully consumed/dropped before the next write.
  • Transaction completion, explicit WAL checkpointing, and SQLite-file interoperability through the Rust API.
  • Transactional DDL/migration behavior.
  • Windows file close/checkpoint/replacement behavior.
  • The compatibility guarantee allows returning to SQLite format, but the Rust API and operational behavior are still separate maintenance considerations.

redb / LMDB-class stores

  • Exact durability mode and fsync contract.
  • Typed value encoding and evolution.
  • Range/pagination/index implementation burden.
  • Compaction behavior and logical History scrub updates.
  • Non-Unicode paths and inspection/export tooling.
  • Recent engine migration/correctness history and the project's tolerance for owning more query logic.

Required experiments

Use disposable spikes, not production abstractions.

1. Same-schema engine comparison

Implement a minimal representative schema/workload in bundled SQLite and Turso:

  • open and migrate;
  • add/reserve/prepare/run/settle;
  • atomically settle a reportable run and insert History;
  • delete a queue item while retaining run and History;
  • paginate/filter History;
  • compute representative estimator cohorts;
  • restart and recover.

Record code size, conceptual complexity, packaging friction, and failure behavior. Do not build a generic storage trait merely to make the spike look uniform.

2. Schema mapping walk-through

Draft enough schema to execute these cases:

  • replace-mode crash at every output-ledger boundary;
  • current file changes at a known path;
  • repeated conversion of the same source;
  • queue deletion and retry lineage;
  • identical/conflicting re-import of one HistoryId;
  • privacy-on recording and retroactive scrub;
  • statistics/estimation after the source disappears;
  • durable monotonic ID allocation.

Use this to compare fully relational, aggregate-row hybrid, and serialized-snapshot hybrid designs.

3. Evolution rehearsal

Create artificial released schemas 1, 2, and 3:

  • add a nullable fact;
  • rename or split a field;
  • add an enum outcome;
  • transform existing values;
  • skip directly from v1 to v3;
  • kill the process during migration;
  • open a future-version database;
  • preserve meaningful data, not only table shape.

Perform the equivalent exercise for any serialized/KV candidate. “No migration framework” is not a pass unless the candidate demonstrates a safer update story.

4. Durability/fault tests

At controlled kill points:

  • before commit;
  • during commit/fsync;
  • after commit but before publication;
  • during startup recovery;
  • during schema migration;
  • during checkpoint/compaction;
  • during the privacy scrub transaction.

Verify the user observes either the old state or complete new state, never a hybrid.

5. Privacy contract

Persist History records with readable paths, scrub, restart, and verify that every current History record has no readable path while its hash/anonymous identifier, ID, measurements, outcome, Statistics, and Estimation results are unchanged. Verify that a second scrub is a no-op and operational paths intentionally outside History remain usable.

This is the Python feature contract: a logical record transformation. Compaction, vacuuming, database replacement, forensic byte searching, and physical-storage erasure are not acceptance criteria.

6. Native path proof

Round-trip:

  • ordinary Unicode paths;
  • Windows unpaired surrogate units;
  • Unix non-UTF-8 bytes;
  • database copied to the other OS;
  • History export/import and anonymous records.

Define a stable application encoding rather than depending accidentally on Rust's current OsStr internal representation.

7. Realistic scale

Generate representative operational state plus 10,000 and 100,000 History observations. Measure:

  • startup/current snapshot;
  • terminal commit;
  • History page/filter;
  • Statistics;
  • estimator sample selection;
  • file/WAL growth;
  • compaction/vacuum;
  • memory.

Indexes, caching, materialized aggregates, and extra read connections require evidence from these measurements.

8. Human inspection and recovery

For each finalist, demonstrate:

  • inspect schema/current queue/runs/History with maintained off-the-shelf tools;
  • export History to a durable documented interchange format;
  • distinguish corruption from unsupported schema;
  • define the application's degraded/read-only/recovery behavior;
  • explain which sidecar files are part of a live database.

Preliminary evidence, not conclusions

These sources strongly justify SQLite as the baseline to beat. They do not by themselves choose fully relational versus hybrid mapping, the Rust engine/binding, or long-lived versus transaction-local state.

Settings must not be forgotten

Keeping config.json separate may still be correct because it is small, human-editable, and has no required atomic relationship with a conversion terminal transition. But it does not escape evolution:

  • adding, renaming, or removing settings affects old installed files;
  • the current strict deserializer can quarantine/reset an older shape;
  • additive defaults, an explicit config version/transform, or moving settings into the database each have different maintenance and usability costs.

The final architecture must state the settings update policy rather than treating database migration as the only compatibility problem.

Affected issues and ADRs

Decision outputs

Before closing this investigation:

Closing this issue means the architecture decision is made and documented. It does not mean the resulting persistence rewrite is implemented.

Metadata

Metadata

Assignees

No one assigned

    Labels

    researchInvestigation/decision needed before implementation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions