Persistence: Drift/SQLite
All data is stored locally in SQLite via Drift. There is no remote database. Rationale:- Offline-first by default - no network required, ever
- Typed query DSL with compile-time safety
- Codegen for table classes and query results
- First-class migration support with tested upgrade paths
- Mature ecosystem and good Flutter integration
- No cross-device sync (intentional - user data stays on their device)
- Full-text search requires FTS5 extension (not needed now)
- Large event logs may require pagination over time
Migration strategy
Drift uses an integerschemaVersion on the database class. Migrations are defined as a sequence of from/to steps in MigrationStrategy.onUpgrade.
Each migration must have a test that creates an in-memory database at the old schema version, seeds it with representative data, runs the migration, and asserts the resulting schema and data are correct. Never ship a migration without a passing test.
Tables
Dart field names are camelCase. Drift maps them to snake_case column names in SQLite automatically (e.g.dueDatetime → due_datetime).
Tasks
Stores the definition of a task - its cost parameters and scheduling rules.manaCost is not stored - it is computed at read time from relativeCost, durationMinutes, personalCoefficient, and axisFactor. See Task costing and budget for the derivation.
| Field | Type | Notes |
|---|---|---|
id | TEXT (UUID) | Primary key |
title | TEXT | User-provided label |
details | TEXT? | Further (optional) information on this task. Used as fallback when an occurrence has no per-instance details. |
relativeCost | REAL | Effort relative to this user’s baseline; canonical replacement for historical netMet. |
durationMinutes | INTEGER | User-provided or template default |
bodyWeight | REAL (0-1) | Physical vs cognitive split (body component) |
mindWeight | REAL (0-1) | Physical vs cognitive split (mind component) |
type | TEXT | template or custom |
templateRef | TEXT? | Compendium ID if based on a template; null for fully custom |
isEssential | BOOLEAN | If true, task remains visible even when over pool. |
timingType | TEXT | none, deadline, scheduled |
recurrenceRule | TEXT? | RFC 5545 RRule string. Nullable for non-recurring tasks. |
recoveryWeight | REAL | Default 1.0. Multiplier capturing post-exertional cost. Range [0.8, 2.0]. Deviates from 1.0 only after RECOVERY_MIN_OBSERVATIONS. |
recoveryObservationCount | INTEGER | Default 0. Number of check-in deltas attributed to this task. |
createdAt | DateTime | Required |
TaskOccurrences
Canonical per-instance schedule/state for a task. One task has many occurrences. For recurring tasks, due slots are generated fromrecurrenceRule at query time, and persisted rows represent concrete instance state/overrides (completion, deferral, cancellation, details override).
| Field | Type | Notes |
|---|---|---|
id | INTEGER | Primary key (auto-increment local surrogate ID) |
taskId | TEXT (UUID) | FK to Tasks |
dueDatetime | DateTime | Canonical due date/time for this instance |
details | TEXT? | Per-occurrence detail override. If null, UI falls back to parent task’s details |
deferredUntil | DateTime? | Nullable |
completedAt | DateTime? | Nullable |
createdAt | DateTime | Required |
CheckIns
One row per daily check-in.| Field | Type | Notes |
|---|---|---|
id | TEXT (UUID) | Primary key |
body | INTEGER (1-5) | Physical axis |
mind | INTEGER (1-5) | Cognitive axis |
mood | INTEGER (1-5) | Emotional axis |
checkInTier | TEXT | full, quick, momentum, none |
checkInDate | DateTime | Local calendar day identity, normalized to local midnight |
recordedAt | DateTime | Required |
dayType | TEXT | normal, packed, blocked, unusual |
Events
Append-only audit log. Events are never deleted. See the Event log section below. An index on(entityId, entityType, occurredAt) is required. All engine queries filter by entity - without this index, replaying history for a given task or check-in degrades linearly with total event count.
Lightweight persistence: shared_preferences
Settings and feature flags are stored viashared_preferences, not in SQLite.
Why not SQLite:
- SQLite is encrypted and wiped on nuclear reset. Settings and flags have no relation to user health data and must not be caught in that wipe.
shared_preferencesmaps directly toNSUserDefaults(iOS) andSharedPreferences(Android) - appropriate for small, non-sensitive key-value data.
What lives here
| Key | Type | Notes |
|---|---|---|
onboarding_complete | bool | Survives wipe - prevents re-running onboarding unintentionally |
notification_enabled | bool | User preference |
notification_time | string | HH:mm, user preference |
theme | string | system, light, dark |
ff_* | string | Feature flag values, prefixed ff_ |
What survives a nuclear reset
| Storage | Wiped on reset |
|---|---|
| SQLite database | Yes |
| Encryption key (keystore) | Yes |
shared_preferences | No |
Event log
TheEvents table is an append-only record of everything that has happened. It is the source of truth for all derived state.
| Field | Type | Notes |
|---|---|---|
id | TEXT (UUID) | Primary key |
type | EventType enum | Stored as camelCase string via Drift’s textEnum |
entityId | TEXT? | ID of the entity this event concerns (task definition, instance, check-in) |
entityType | TEXT? | taskDefinition, taskOccurrence, checkIn, system |
occurredAt | DateTime | Must be monotonically non-decreasing within a session |
payload | TEXT (JSON) | Fields vary by event type; unknown keys ignored on read |
Typed payload
type is a Dart enum (EventType). Each value has a corresponding sealed payload class. Callers switch exhaustively on type to deserialize payload into the correct typed object - the compiler enforces all cases are handled.
Current domain event read/write model types:
EventRecordfor read queriesEventWritefor append operationsEventPayloadfor typed payload deserialization/serialization
Domain).
Event types
| Type | Payload fields | Description |
|---|---|---|
taskCreated | taskId, title, relativeCost, durationMinutes, bodyWeight, mindWeight, type, timingType, isEssential | New task added |
taskEdited | taskId, changes: {} | Field-level diff only |
taskRemoved | taskId | Task deleted |
taskOccurrenceCreated | occurrenceId, taskId, dueDatetime | New occurrence row created |
taskOccurrenceCompleted | occurrenceId, manaCostAtCompletion | Occurrence marked done; completion snapshot includes manaCostAtCompletion for deterministic replay |
taskOccurrenceUncompleted | occurrenceId | Completion reversed |
taskOccurrenceDeferred | occurrenceId, deferredUntil | Occurrence snoozed |
taskOccurrenceRemoved | occurrenceId | Occurrence removed |
taskCostCalibrated | taskId, previousRelativeCost, newRelativeCost, statedCost, axisFactorAtCalibration | User corrected perceived cost after first completion |
taskRecoveryWeightUpdated | taskId, previousWeight, newWeight, observationCount | Recovery EWMA moved the stored weight |
checkInRecorded | body, mind, mood, dayType, checkInTier | Morning check-in |
dayTypeSet | date, dayType, opportunityWeight | End-of-day day type. Current default mapping: normal=1.0, packed=0.25, blocked=0.0, unusual=0.0. |
pemDetected | triggerMagnitude, recentMagnitude, confidenceBefore | PEM event fired |
pemResolved | resolutionReason | stateRecovered, crossover, or escapeValve |
- Events are never updated or deleted
occurredAtmust be monotonically non-decreasing within a sessionpayloadis JSON; unknown keys are ignored on read (forward compatibility)