forked from eva/focus-flow
docs(domain): define v1 lifecycle semantics
This commit is contained in:
parent
1855cc0fc6
commit
656ba0634b
5 changed files with 286 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
126
test/domain_contracts_test.dart
Normal file
126
test/domain_contracts_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue