docs(domain): define v1 lifecycle semantics

This commit is contained in:
Ashley Venn 2026-06-24 16:37:32 -07:00
parent 1855cc0fc6
commit 656ba0634b
5 changed files with 286 additions and 1 deletions

View file

@ -0,0 +1,128 @@
# V1 ADR 001: Lifecycle, Metadata, and Reminder Semantics
Status: Accepted during Block 11.2
Date: 2026-06-24
## Context
The human product specification lists `planned`, `completed`, `missed`,
`cancelled`, `no longer relevant`, `backlog`, `skipped`, and `pushed` under task
status. The current backend model exposes durable `TaskStatus` values for
lifecycle state and uses statistics/scheduling results for movement history.
V1 needs one durable meaning per field before later chunks add stronger
validation, activity records, application use cases, and persistence codecs.
## Decision
V1 durable task lifecycle states are:
- `planned`: queued or placed work that has not started.
- `active`: work currently in progress.
- `completed`: work finished by the user.
- `missed`: work that did not happen in its intended time and remains as
missed history.
- `cancelled`: work that was expected but intentionally will not happen.
- `noLongerRelevant`: work dismissed because circumstances changed.
- `backlog`: unscheduled work kept for later planning.
`pushed` is not a durable task lifecycle state. It is an activity/movement event
recorded through scheduling changes, Block 13/14 activity records, and counters such
as `manuallyPushedCount`, `autoPushedCount`, `movedToBacklogCount`, and
`restoredFromBacklogCount`.
`skipped during burnout` is not a V1 lifecycle state. V1 may keep compatible
statistics and schema fields such as `skippedDuringBurnoutCount`, but the
overwhelm shield and burnout catch-up workflow remain V2 work.
## Task Type Semantics
| Task type | V1 lifecycle behavior |
|---|---|
| Flexible | `planned` and `active` tasks may be moved by scheduling operations. `completed` records completion. `missed` means the intended slot was not used; recovery actions may reschedule it as `planned` or move it to `backlog`. `cancelled` and `noLongerRelevant` remove it from active planning. |
| Critical | Required visible commitment. Automatic flexible scheduling must not move it. If missed, V1 records a missed event and places the task in `backlog` so it remains actionable. The backlog status is the durable current state; missed history is recorded in activity/stat metadata. |
| Inflexible | Required visible time-bound commitment. Automatic flexible scheduling must not move it. If missed, it remains `missed` in its original planned interval as historical context. |
| Locked | Scheduling constraint, not a normal task card. Locked time should normally be represented by locked-block models and overlay read models. If imported as a task-shaped item, it should not receive normal flexible or required task actions. |
| Surprise | Unplanned completed work logged after the fact. It is created as `completed`. If it has an interval, that interval is actual occupied time and later same-day scheduling must not ignore it. |
| Free Slot | Protected intentional rest. It blocks automatic flexible placement and normal flexible reminder directives. Explicit critical or inflexible commitments may conflict with it, but automatic scheduling must not silently move the free slot. |
## Time and Metadata Fields
The following meanings are reserved for V1:
- Planned placement: `scheduledStart` and `scheduledEnd` represent where work or
a visible commitment is planned on the schedule.
- Actual work time: actual start/end must be represented by a distinct actual
interval, added in Block 12/13. It must not be inferred from `updatedAt`.
- Completion time: completion timestamp must be represented by a distinct
completion field, added in Block 13. It must not be inferred from `updatedAt`.
- Backlog-entry time: backlog age must use a distinct backlog-entered timestamp
once added in Blocks 13/15. The current starter model uses `createdAt` only as
a documented temporary baseline for staleness markers.
- Last modification time: `updatedAt` means only "last domain-level change". It
is not a proxy for backlog entry, completion, actual work, or missed time.
Current surprise-task logging stores the known surprise interval in the existing
schedule interval fields so the baseline scheduler can repair overlaps. Block 12
must split actual occupancy from planned placement and preserve surprise
occupancy for later same-day operations.
## Project Defaults, Learned Suggestions, and Task Overrides
These are separate concepts:
- System defaults are hard-coded fallback values such as inbox project,
medium priority, `RewardLevel.notSet`, and gentle reminders.
- Configured project defaults are user/project settings stored on
`ProjectProfile`, such as default priority, reward, difficulty, duration, and
reminder profile.
- Learned suggestions are non-blocking recommendations with provenance and
confidence. They never silently overwrite configured project defaults.
- Task overrides are explicit task-level choices that win over project defaults
for that task.
Effective value resolution for future configurable fields is:
1. Explicit task override.
2. Configured project default.
3. System default.
Learned suggestions may be shown as suggested values at creation or cleanup
time, but they do not become effective values unless the user accepts them as a
task override or configured project default.
## Reminder Policy Boundary
Reminder delivery through operating-system, background, or notification services
is outside the pure Dart core.
The backend/core may compute reminder policy inputs and directives:
- task-level reminder override, added in Block 13;
- configured project reminder profile;
- effective reminder profile resolver;
- free-slot suppression directive for normal flexible reminders;
- clear conflict/interrupt directives for critical and inflexible commitments.
Effective reminder profile resolution is:
1. Task reminder override, if present.
2. Project configured reminder profile.
3. System default `ReminderProfile.gentle`.
Free-slot suppression is not a reminder profile. It is a scheduling/rest policy
directive that can suppress normal flexible reminders during protected rest.
## Consequences
- Later chunks must not add `pushed` or `skipped` to `TaskStatus` to make the
human status list symmetrical.
- Movement, restore, skip, and push operations need idempotent activity/stat
records in Block 13/14.
- Block 12 must treat actual surprise occupancy as occupied time independent of
planned placement.
- Blocks 13 and 15 must add explicit metadata and document mappings instead of
overloading `updatedAt`.
- V1 keeps the V2 burnout vocabulary compatible in stats/schema only and does
not implement shield or catch-up workflows.

View file

@ -85,6 +85,24 @@ BREAKPOINT: Stop here. Confirm `high` mode before resolving domain semantics.
Recommended Codex level: high Recommended Codex level: high
Status: Complete on 2026-06-24.
Documentation outputs:
- Added `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md` to define durable
V1 lifecycle statuses, movement/activity semantics, task-type behavior,
planned versus actual time fields, project default precedence, and reminder
policy boundaries.
- Updated `Human Documentation/Starter Architecture Notes.md` to resolve the
human-spec `pushed`/`skipped` status ambiguity without changing product
intent.
Test outputs:
- Added `test/domain_contracts_test.dart` to lock the durable status list,
assert push remains movement metadata, keep burnout skip as stats-only
compatibility, and cover critical versus inflexible missed semantics.
Tasks: Tasks:
- Add an architecture decision record that defines the durable task lifecycle - Add an architecture decision record that defines the durable task lifecycle

View file

@ -63,7 +63,7 @@ Status values:
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. |
| MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. |
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. | | MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. |
| MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` currently excludes both. | Existing tests assert current durable statuses indirectly. | contradictory | Chunk 11.2 must document `pushed` as an event and `skipped during burnout` as V2-compatible future activity/stat data. | | MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. |
## Intentionally deferred human-spec items ## Intentionally deferred human-spec items

View file

@ -50,6 +50,19 @@ all direct children can complete the parent, and explicitly completing a parent
can force-complete its direct children. Scheduling still treats child ownership can force-complete its direct children. Scheduling still treats child ownership
as metadata and does not block placement based on parent/child state. as metadata and does not block placement based on parent/child state.
V1 treats task lifecycle status and movement history as separate concepts.
Durable task statuses are planned, active, completed, missed, cancelled, no
longer relevant, and backlog. Pushes, restores from backlog, and burnout skips
are activity/stat metadata rather than durable statuses. This preserves the
product language from the design spec while keeping scheduling state
deterministic and testable. The detailed backend decision is recorded in
`Codex Documentation/Current Software Plan/V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`.
`updatedAt` means last domain-level modification only. It should not be used as
a proxy for completion time, actual work time, missed time, or backlog-entry
time. Blocks 12, 13, and 15 add those fields explicitly where the product needs
them.
## Persistence direction ## Persistence direction
MongoDB is the committed persistence target. The V1 scheduling core should still MongoDB is the committed persistence target. The V1 scheduling core should still

View file

@ -0,0 +1,126 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('V1 lifecycle contracts', () {
final now = DateTime(2026, 6, 24, 9);
const engine = SchedulingEngine();
test('durable task statuses exclude movement and V2 burnout labels', () {
expect(TaskStatus.values, const [
TaskStatus.planned,
TaskStatus.active,
TaskStatus.completed,
TaskStatus.missed,
TaskStatus.cancelled,
TaskStatus.noLongerRelevant,
TaskStatus.backlog,
]);
expect(TaskStatus.values.map((status) => status.name),
isNot(contains('pushed')));
expect(
TaskStatus.values.map((status) => status.name),
isNot(contains('skipped')),
);
});
test('manual push preserves durable status and records movement metadata',
() {
final task = Task(
id: 'flexible-1',
title: 'Clean coffee maker',
projectId: 'home',
type: TaskType.flexible,
status: TaskStatus.planned,
durationMinutes: 15,
scheduledStart: DateTime(2026, 6, 24, 9),
scheduledEnd: DateTime(2026, 6, 24, 9, 15),
createdAt: now,
updatedAt: now,
);
final result = engine.pushFlexibleTaskToNextAvailableSlot(
input: SchedulingInput(
tasks: [task],
window: SchedulingWindow(
start: DateTime(2026, 6, 24, 9),
end: DateTime(2026, 6, 24, 10),
),
),
taskId: task.id,
updatedAt: now.add(const Duration(minutes: 1)),
);
final pushed = result.tasks.single;
expect(pushed.status, TaskStatus.planned);
expect(pushed.stats.manuallyPushedCount, 1);
expect(result.changes.single.taskId, task.id);
expect(result.notices.single.type, SchedulingNoticeType.moved);
});
test('burnout skip remains statistics-compatible without a status', () {
final task = Task.quickCapture(
id: 'task-1',
title: 'Fold laundry',
createdAt: now,
).copyWith(
status: TaskStatus.planned,
stats: const TaskStatistics().incrementSkippedDuringBurnout(),
);
expect(task.status, TaskStatus.planned);
expect(task.stats.skippedDuringBurnoutCount, 1);
expect(TaskStatus.values.map((status) => status.name),
isNot(contains('skipped')));
});
test(
'critical missed moves to backlog while inflexible missed stays in place',
() {
final critical = _scheduledRequiredTask(
id: 'critical-1',
type: TaskType.critical,
now: now,
);
final inflexible = _scheduledRequiredTask(
id: 'inflexible-1',
type: TaskType.inflexible,
now: now,
);
final missedCritical = engine.markMissed(critical, updatedAt: now);
final missedInflexible = engine.markMissed(inflexible, updatedAt: now);
expect(missedCritical.status, TaskStatus.backlog);
expect(missedCritical.scheduledStart, isNull);
expect(missedCritical.scheduledEnd, isNull);
expect(missedCritical.stats.missedCount, 1);
expect(missedCritical.stats.movedToBacklogCount, 1);
expect(missedInflexible.status, TaskStatus.missed);
expect(missedInflexible.scheduledStart, inflexible.scheduledStart);
expect(missedInflexible.scheduledEnd, inflexible.scheduledEnd);
expect(missedInflexible.stats.missedCount, 1);
expect(missedInflexible.stats.movedToBacklogCount, 0);
});
});
}
Task _scheduledRequiredTask({
required String id,
required TaskType type,
required DateTime now,
}) {
return Task(
id: id,
title: 'Required task',
projectId: 'home',
type: type,
status: TaskStatus.planned,
durationMinutes: 30,
scheduledStart: DateTime(2026, 6, 24, 9),
scheduledEnd: DateTime(2026, 6, 24, 9, 30),
createdAt: now,
updatedAt: now,
);
}