13 KiB
V1 ADR 002: MongoDB Document Schema V1
Status: Accepted during Block 15.1
Date: 2026-06-25
Context
MongoDB is the committed persistence target for this project. Blocks 11-14 added the pure Dart domain model, repository boundaries, application unit of work, Today/Backlog read models, management commands, notice acknowledgement, and app-open rollover recovery.
Block 15 needs one versioned document contract before codecs, migrations, or a runtime MongoDB adapter are implemented. This ADR defines that contract without adding a database client, connection string, sync behavior, accounts, or background services.
Decision
V1 stores application data in MongoDB-friendly documents behind repository interfaces. The core scheduling logic remains independent from MongoDB APIs.
Every top-level document uses:
schemaVersion: integer,1for this contract._id: stable document id, unique within its collection.ownerId: authorization-neutral owner scope. This is a data boundary, not an account/auth implementation.revision: positive integer for mutable authoritative documents. Starts at1and increments on compare-and-set saves. Append-only documents userevision: 1.createdAt: UTC instant string.updatedAt: UTC instant string. Append-only documents set this equal tocreatedAt.
The adapter may use BSON dates internally later, but the core V1 codec contract uses the existing string conventions:
- Instant: UTC ISO-8601 string from
DateTime.toUtc().toIso8601String(). - Civil date:
YYYY-MM-DDstring with no timezone conversion. - Wall time:
HH:MMstring with no date or timezone conversion. - Time zone: IANA-style string chosen by the application boundary.
- Interval: embedded object
{start, end, label?}wherestartandendare UTC instant strings. - Optional fields: V1 writers include known optional fields with
nullwhen cleared. Decoders tolerate unknown extra fields and ignore them.
Collections
tasks
Authoritative task documents.
Required fields:
- common fields
titleprojectIdtypestatuspriorityrewarddifficultydurationMinutesscheduledStartscheduledEndactualStartactualEndcompletedAtparentTaskIdbacklogTagsreminderOverridestatsbacklogEnteredAtbacklogEnteredAtProvenance
backlogEnteredAt is nullable in V1. V0 migration may set it from createdAt
with provenance approximated_from_created_at; new V1 backlog transitions should
set provenance recorded.
Task documents do not embed history, children, scheduling changes, or notice
lists. Child ownership is represented by parentTaskId.
projects
Authoritative project configuration documents.
Required fields:
- common fields
namecolorKeydefaultPrioritydefaultRewarddefaultDifficultydefaultReminderProfiledefaultDurationMinutesarchivedAt
Archiving a project sets archivedAt and increments revision. It never deletes
or rewrites task history.
project_statistics
Project-level aggregate documents derived from internal activities.
Required fields:
- common fields
projectIdcompletedTaskCountdurationMinuteCountscompletionTimeBucketCountstotalPushesBeforeCompletioncompletedAfterPushCountrewardCountsdifficultyCountsreminderProfileCountsappliedActivityIds
appliedActivityIds is bounded by compaction policy in future adapter work. It
prevents double-application of completion activities.
locked_blocks
Authoritative recurring or one-off locked-time definitions.
Required fields:
- common fields
namestartTimeendTimedaterecurrencehiddenByDefaultprojectIdarchivedAt
Archiving a locked block sets archivedAt, increments revision, and stops base
occurrence expansion. It does not delete one-day overrides.
locked_overrides
Date-scoped locked-time override documents.
Required fields:
- common fields
lockedBlockIddatetypenamestartTimeendTimehiddenByDefaultprojectId
Override type is one of remove, replace, or add. Overrides are
append/update records, not edits to the recurring block.
task_activities
Append-only internal activity facts.
Required fields:
- common fields with
revision: 1 operationIdcodetaskIdprojectIdoccurredAtmetadata
Activities are internal application data. They are not a visible per-task history panel in V1.
owner_settings
One owner settings document per owner.
Required fields:
- common fields
timeZoneIddayStartdayEndcompactModeEnabledbacklogStaleness
Changing timeZoneId, dayStart, or dayEnd can reinterpret local dates. The
application layer must return typed warnings or run an explicit migration path;
codecs must not silently shift existing instants.
notice_acknowledgements
Owner-scoped consumed-notice records.
Required fields:
- common fields with
revision: 1 noticeIdacknowledgedAt
The unique key is (ownerId, noticeId). Acknowledgement hides a notice from
Today state without mutating the original operation/snapshot record.
operation_records
Exactly-once operation records.
Required fields:
- common fields with
revision: 1 operationIdoperationNamecommittedAt
The unique key is (ownerId, operationId). These records support application
idempotency and are not a replacement for task/activity facts.
scheduling_snapshots
Bounded diagnostics and pending-notice carrier documents.
Required fields:
- common fields with
revision: 1 operationNamesourceDatetargetDatewindowtaskslockedIntervalsrequiredVisibleIntervalsnoticeschangesoverlapsretentionExpiresAt
Full scheduling snapshots remain in V1 as bounded diagnostics and as the carrier for pending rollover notices. They are not the authoritative source of task state, project state, locked-time definitions, or activity/statistics facts.
Production adapters should keep snapshots compact:
- retain no more than 100 embedded tasks per snapshot;
- retain no more than 100 changes or overlaps per snapshot;
- retain no more than 20 notices per snapshot;
- omit or redact hidden locked names from
lockedIntervals; - set
retentionExpiresAtafter all notices are acknowledged or after the configured diagnostic retention window.
If an operation needs more diagnostic data than these bounds allow, it should
store the authoritative entity changes and a truncated snapshot with a typed
truncated: true marker.
Stable Codes
V1 documents must not depend on Dart enum source .name values. Codecs will use
explicit encode/decode tables. Unknown codes fail closed with typed mapping
errors.
Initial code values:
| Domain enum | Codes |
|---|---|
TaskType |
flexible, inflexible, critical, locked, surprise, free_slot |
TaskStatus |
planned, active, completed, missed, cancelled, no_longer_relevant, backlog |
PriorityLevel |
very_low, low, medium, high, very_high |
RewardLevel |
not_set, very_low, low, medium, high, very_high |
DifficultyLevel |
not_set, very_easy, easy, medium, hard, very_hard |
ReminderProfile |
silent, gentle, persistent, strict |
BacklogTag |
wishlist |
LockedWeekday |
monday, tuesday, wednesday, thursday, friday, saturday, sunday |
LockedBlockOverrideType |
remove, replace, add |
TaskActivityCode |
completed, missed, cancelled, no_longer_relevant, manually_pushed, automatically_pushed, moved_to_backlog, restored_from_backlog, activated |
SchedulingNoticeType |
info, moved, overlap, no_fit, overflow |
SchedulingIssueCode |
task_not_found, invalid_task_state, missing_duration, missing_scheduled_slot, non_positive_duration, no_available_slot, unfinished_tasks_could_not_fit, no_unfinished_flexible_tasks, duplicate_surprise_log |
SchedulingMovementCode |
backlog_task_inserted, flexible_task_moved_to_make_room, flexible_task_pushed_to_next_available_slot, flexible_task_moved_to_tomorrow, unfinished_flexible_tasks_rolled_over, flexible_task_moved_to_backlog, required_commitment_scheduled |
SchedulingConflictCode |
flexible_task_overlaps_blocked_time, surprise_task_overlaps_required_visible_time, required_commitment_overlaps_protected_free_slot |
ProjectCompletionTimeBucket |
overnight, morning, afternoon, evening, night |
Codec tables may add newer codes in later schema versions. They must not remap an existing code to a different semantic meaning.
Indexes And Uniqueness
Required index specifications are adapter-neutral in V1 and are created in Block 16.
| Collection | Index | Purpose |
|---|---|---|
| all collections | unique (_id) |
Stable document identity |
| all owner-scoped collections | (ownerId, _id) |
Owner boundary lookup |
tasks |
(ownerId, status, scheduledStart, scheduledEnd) |
Today/window and lifecycle queries |
tasks |
(ownerId, status, projectId) |
backlog/project filters |
tasks |
(ownerId, parentTaskId) |
child task lookup |
tasks |
(ownerId, status, backlogEnteredAt, createdAt) |
backlog candidate ordering |
projects |
(ownerId, archivedAt, name) |
active project pickers |
project_statistics |
unique (ownerId, projectId) |
aggregate lookup |
locked_blocks |
(ownerId, archivedAt, date) |
one-off locked expansion |
locked_blocks |
(ownerId, archivedAt, recurrence.weekdays) |
recurring locked expansion |
locked_overrides |
(ownerId, date, lockedBlockId, type) |
date-scoped override expansion |
task_activities |
unique (ownerId, _id) |
append-only activity identity |
task_activities |
(ownerId, operationId) |
command idempotency/debug lookup |
task_activities |
(ownerId, taskId, occurredAt) |
task activity loading |
task_activities |
(ownerId, projectId, code, occurredAt) |
project-stat aggregation |
owner_settings |
unique (ownerId) |
one settings document per owner |
notice_acknowledgements |
unique (ownerId, noticeId) |
notice consume idempotency |
operation_records |
unique (ownerId, operationId) |
exactly-once command boundary |
scheduling_snapshots |
unique (ownerId, sourceDate, operationName) for rollover-style operations |
source-day idempotency |
scheduling_snapshots |
(ownerId, window.start, window.end) |
Today pending notice lookup |
scheduling_snapshots |
(retentionExpiresAt) |
bounded diagnostic cleanup |
Partial indexes should prefer active records where applicable, such as
archivedAt: null for project and locked-block picker/expansion queries.
Archive, Delete, And Retention
Normal user-facing removal uses lifecycle state or archive fields:
- Tasks are not hard-deleted by automatic scheduling.
cancelled,no_longer_relevant, andbacklogpreserve history. - Projects use
archivedAt; taskprojectIdvalues remain unchanged. - Locked blocks use
archivedAt; overrides remain independent. - Scheduling snapshots are bounded diagnostics and may expire after retention rules once they are no longer carrying pending notices.
Hard delete is reserved for explicit adapter/admin operations outside the normal V1 app flow.
Operation records and task activities are internal operation data. V1 retains them long enough to preserve idempotency, statistics, and migration safety. Adapter-level pruning must not remove authoritative task/project/locked state or break exactly-once guarantees for active retry windows.
Privacy Boundaries
Documents must not contain credentials, database connection strings, OAuth tokens, sync tokens, or platform-notification secrets.
Hidden locked-time details are sensitive. They may live in the owner-scoped
locked_blocks and locked_overrides documents, but they should not be copied
into denormalized notices, public summaries, or unnecessary snapshot labels.
When snapshots need locked intervals for diagnostics, hidden labels should be
redacted or replaced with source IDs and hiddenByDefault: true.
Internal statistics and activity records are implementation data for future reports and scheduling correctness. They should remain behind repository/use-case boundaries and should not become a visible task-history UI in V1.
Consequences
- Chunk 15.2 must replace the current enum
.namemapping helpers with explicit stable code tables. - Chunk 15.2 must add codecs for every collection listed above, including owner-scoped notices and bounded scheduling snapshots.
- Chunk 15.3 must migrate V0 task documents to V1 with explicit provenance for approximated backlog entry time.
- Chunk 15.4 must expand repository contracts so Block 14 use cases can use
owner-scoped, indexed query methods instead of broad
findAll()loading. - Chunk 15.5 must turn the index table in this ADR into tested adapter-neutral index specifications.