feat(api): task lifecycle transitions with replays and progress tracking #50

Merged
yuris merged 1 commit from feat/issue-12-lifecycle into main 2026-09-22 22:17:05 +00:00
Owner

Motivation

Tasks in life-service do not have free-form status strings: VTODO STATUS is exactly NEEDS-ACTION, IN-PROCESS, COMPLETED or CANCELLED, and every move between them is an explicit, governed operation (docs/technical/domain-model.md#task-axes). Until now there was no way to move a task between those states at all — life task complete|reopen|cancel fell through to not_implemented, and there was no route behind them.

This adds the three lifecycle transitions as first-class operations with their own state machine, mandatory caller version evidence, idempotent replays, no-op suppression and journaled outcomes, per scenario W09 of docs/technical/verification.md (issue #012).

Changes

src/domain/lifecycle.rs (new) — the state machine, kept separate from any transport concern:

  • TaskStatus is exactly the four RFC 5545 values. A missing STATUS reads as NEEDS-ACTION (RFC 5545 §3.8.1.11 gives VTODO that default; the domain model's one forbidden inference — reading absence as completed — is not made). Any other token is refused as calendar_unsupported rather than coerced.
  • LifecycleAction fixes each transition's source set, target state and fact kind: complete NEEDS-ACTION|IN-PROCESS → COMPLETED, reopen COMPLETED|CANCELLED → NEEDS-ACTION, cancel open → CANCELLED.
  • plan() returns NoOp when the task already satisfies the target, an invalid-combination error otherwise, and for a real transition the exact lifecycle property edits: complete writes STATUS:COMPLETED, COMPLETED (the caller's occurred_at, else the instant of the action being dispatched) and PERCENT-COMPLETE:100; reopen writes STATUS:NEEDS-ACTION, removes COMPLETED and resets PERCENT-COMPLETE:0; cancel writes STATUS:CANCELLED and removes any completion timestamp without touching progress. Deadline, strength and every other member are never in the edit set.
  • occurred_at is refused on reopen/cancel, where it has no meaning; an unknown payload member (an occurrence scope, say) is refused rather than silently dropped.

src/api/lifecycle.rs (new) — POST /v1/tasks/{id}/complete|reopen|cancel, sharing the #11 two-phase boundary:

  1. Idempotency-Key and the caller's quoted strong If-Match are required; missing/empty/weak/*/unquoted/multi-tag evidence is refused with 400 invalid_input / exit 2 and zero backend requests and no journal row.
  2. The raw resource is resolved live and read; a non-VTODO item, an uninterpretable STATUS, an occurrence (RECURRENCE-ID set) or an invalid combination is refused before anything is written (calendar_unsupported / 422 invalid_input).
  3. A no-op is recorded applied with changed:false: no remote PUT, no life_events row, and a same-key retry replays it.
  4. Otherwise the prepared intent is committed before dispatch, the PUT carries If-Match: <caller ETag>, and a 412 is recorded failed and reported as etag_conflict (412 / exit 4) — never retried with a refreshed ETag.
  5. On success one transaction moves the operation to applied, appends task_completed / task_reopened / task_cancelled (evidence_class='confirmed_service', with before_json/after_json) and upserts the projection. An unconfirmed outcome is needs_reconciliation + partial (exit 6) and is never announced as completed.

The representation written is the live bytes with only those properties rewritten via calendar::preservation — alarms, unknown properties and their parameters, VTIMEZONE and unrelated overrides survive. Cancellation updates the resource; it never DELETEs it.

src/api/mod.rs, src/api/update.rs, src/cli.rs, src/client.rs — the three routes and thin handlers, required_if_match opened to the crate so the preflight is literally the #11 one, client::post_conditional_envelope (the POST sibling of patch_envelope), and the three CLI verbs sharing one implementation that refuses unusable --etag locally before transmitting.

Verification

  • nix develop --command cargo test — 217 passed, 0 failed (27 of them new in tests/lifecycle_tests.rs).
  • nix develop --command cargo clippy --all-targets -- -D warnings — clean.
  • nix develop --command cargo fmt --check — clean.

tests/lifecycle_tests.rs runs against a mock CalDAV backend over TCP and asserts on the requests the daemon actually emitted and the journal rows it actually wrote: the full transition matrix (valid and invalid, with zero PUTs and zero operations on refusal), the exact bytes PUT (STATUS, COMPLETED, PERCENT-COMPLETE, and preservation of DUE, CATEGORIES:life-active, VALARM, X-CUSTOM-CLIENT-FLAG;X-FOO="bar", VTIMEZONE, the unrelated override), the recorded life_events kind/evidence/before/after, no-op changed:false with 0 PUT and 1 event plus its same-key replay, replay with 1 PUT total, stale-ETag 412 with operations.state='failed' and no retry, occurrence and uninterpretable-status refusal, unconfirmed → partial/exit 6 with no completion fact, and the CLI exit codes/envelopes for all three verbs.

Closes #12

## Motivation Tasks in `life-service` do not have free-form status strings: VTODO `STATUS` is exactly `NEEDS-ACTION`, `IN-PROCESS`, `COMPLETED` or `CANCELLED`, and every move between them is an explicit, governed operation (`docs/technical/domain-model.md#task-axes`). Until now there was no way to move a task between those states at all — `life task complete|reopen|cancel` fell through to `not_implemented`, and there was no route behind them. This adds the three lifecycle transitions as first-class operations with their own state machine, mandatory caller version evidence, idempotent replays, no-op suppression and journaled outcomes, per scenario W09 of `docs/technical/verification.md` (issue #012). ## Changes **`src/domain/lifecycle.rs` (new)** — the state machine, kept separate from any transport concern: - `TaskStatus` is exactly the four RFC 5545 values. A missing `STATUS` reads as `NEEDS-ACTION` (RFC 5545 §3.8.1.11 gives VTODO that default; the domain model's one forbidden inference — reading absence as completed — is not made). Any other token is refused as `calendar_unsupported` rather than coerced. - `LifecycleAction` fixes each transition's source set, target state and fact kind: complete `NEEDS-ACTION|IN-PROCESS → COMPLETED`, reopen `COMPLETED|CANCELLED → NEEDS-ACTION`, cancel open → `CANCELLED`. - `plan()` returns `NoOp` when the task already satisfies the target, an invalid-combination error otherwise, and for a real transition the exact lifecycle property edits: complete writes `STATUS:COMPLETED`, `COMPLETED` (the caller's `occurred_at`, else the instant of the action being dispatched) and `PERCENT-COMPLETE:100`; reopen writes `STATUS:NEEDS-ACTION`, removes `COMPLETED` and resets `PERCENT-COMPLETE:0`; cancel writes `STATUS:CANCELLED` and removes any completion timestamp without touching progress. Deadline, strength and every other member are never in the edit set. - `occurred_at` is refused on reopen/cancel, where it has no meaning; an unknown payload member (an occurrence `scope`, say) is refused rather than silently dropped. **`src/api/lifecycle.rs` (new)** — `POST /v1/tasks/{id}/complete|reopen|cancel`, sharing the #11 two-phase boundary: 1. `Idempotency-Key` and the caller's quoted strong `If-Match` are required; missing/empty/weak/`*`/unquoted/multi-tag evidence is refused with `400 invalid_input` / exit 2 and **zero** backend requests and no journal row. 2. The raw resource is resolved live and read; a non-VTODO item, an uninterpretable `STATUS`, an occurrence (`RECURRENCE-ID` set) or an invalid combination is refused before anything is written (`calendar_unsupported` / `422 invalid_input`). 3. A no-op is recorded `applied` with `changed:false`: no remote `PUT`, no `life_events` row, and a same-key retry replays it. 4. Otherwise the prepared intent is committed before dispatch, the `PUT` carries `If-Match: <caller ETag>`, and a `412` is recorded `failed` and reported as `etag_conflict` (412 / exit 4) — never retried with a refreshed ETag. 5. On success one transaction moves the operation to `applied`, appends `task_completed` / `task_reopened` / `task_cancelled` (`evidence_class='confirmed_service'`, with `before_json`/`after_json`) and upserts the projection. An unconfirmed outcome is `needs_reconciliation` + `partial` (exit 6) and is never announced as completed. The representation written is the live bytes with only those properties rewritten via `calendar::preservation` — alarms, unknown properties and their parameters, `VTIMEZONE` and unrelated overrides survive. Cancellation updates the resource; it never `DELETE`s it. **`src/api/mod.rs`, `src/api/update.rs`, `src/cli.rs`, `src/client.rs`** — the three routes and thin handlers, `required_if_match` opened to the crate so the preflight is literally the #11 one, `client::post_conditional_envelope` (the POST sibling of `patch_envelope`), and the three CLI verbs sharing one implementation that refuses unusable `--etag` locally before transmitting. ## Verification - `nix develop --command cargo test` — **217 passed, 0 failed** (27 of them new in `tests/lifecycle_tests.rs`). - `nix develop --command cargo clippy --all-targets -- -D warnings` — clean. - `nix develop --command cargo fmt --check` — clean. `tests/lifecycle_tests.rs` runs against a mock CalDAV backend over TCP and asserts on the requests the daemon actually emitted and the journal rows it actually wrote: the full transition matrix (valid and invalid, with zero `PUT`s and zero operations on refusal), the exact bytes `PUT` (`STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, and preservation of `DUE`, `CATEGORIES:life-active`, `VALARM`, `X-CUSTOM-CLIENT-FLAG;X-FOO="bar"`, `VTIMEZONE`, the unrelated override), the recorded `life_events` kind/evidence/before/after, no-op `changed:false` with 0 `PUT` and 1 event plus its same-key replay, replay with 1 `PUT` total, stale-ETag 412 with `operations.state='failed'` and no retry, occurrence and uninterpretable-status refusal, unconfirmed → `partial`/exit 6 with no completion fact, and the CLI exit codes/envelopes for all three verbs. ## Related Issues Closes #12
Complete, reopen and cancel become explicit operations instead of free-form
status patches: each has a fixed source set and a fixed target state, refuses
every other combination before dispatch, and records what it did.

- POST /v1/tasks/{id}/complete|reopen|cancel (write scope) and the matching
  `life task complete|reopen|cancel` verbs. All three reuse the #11 preflight
  verbatim: a missing, empty, weak, unquoted, wildcard or multi-tag ETag is
  refused with 400 invalid_input / exit 2 and zero backend requests.
- src/domain/lifecycle.rs decides what a transition means: complete writes
  STATUS:COMPLETED, COMPLETED (the caller's outcome instant, else the action
  instant) and PERCENT-COMPLETE:100; reopen writes STATUS:NEEDS-ACTION, removes
  COMPLETED and resets PERCENT-COMPLETE:0 while preserving DUE and strength;
  cancel writes STATUS:CANCELLED and clears any completion timestamp without
  deleting the resource or touching progress.
- The written representation is the live raw resource with only those
  properties rewritten (calendar::preservation), so alarms, unknown properties,
  VTIMEZONE and unrelated overrides survive. Occurrence-scoped transitions are
  refused as calendar_unsupported rather than guessed.
- A task already in the target state is a recorded no-op: applied with
  changed:false, no remote PUT, and no second lifecycle fact. Same-key replays
  return the recorded outcome without a second write; a stale baseline is
  recorded failed and reported as etag_conflict (412 / exit 4); an unconfirmed
  remote outcome is needs_reconciliation + partial (exit 6), never completed.
- tests/lifecycle_tests.rs covers the transition matrix, the bytes PUT, the
  journal facts, no-op/replay/conflict/uncertainty behaviour and the CLI
  surface over a mock CalDAV backend.
yuris force-pushed feat/issue-12-lifecycle from 85f2417b5d to 3e3a595a95 2026-09-22 22:13:46 +00:00 Compare
Author
Owner

Coordinator review — CHANGES REQUESTED (one item)

Verified 85f2417 myself: cargo test 217 passed / 0 failed incl. lifecycle_tests (27), clippy 0 errors, fmt clean.

Genuinely good: the #11 preflight is reused unchanged (required_if_match made pub(crate), no logic change) and 13 tests assert request counts around it; plan() keeps the edit vocabulary in one place and emits only STATUS/COMPLETED/PERCENT-COMPLETE, so DUE and strength survive via the preservation patcher; complete records the action instant when no explicit occurred_at is given; cancel removes the completion timestamp instead of deleting the resource; no-op transitions persist applied + changed:false with 0 PUT and 0 life_events; replay returns the recorded outcome with 1 PUT total.

Change needed: reopen on an already-open task.

Issue #12's acceptance criteria require: "Attempting an invalid state transition (such as completing an already cancelled task without reopening, or reopening a task that is already in NEEDS-ACTION or IN-PROCESS) returns validation error (HTTP 422, exit code 2)".

Current behaviour treats the two open states differently:

  • reopen from IN_PROCESS → 422 (listed in INVALID_TRANSITIONS, correct);
  • reopen from NEEDS-ACTION → recorded no-op, 200 changed:false (tests/lifecycle_tests.rs::reopening_an_open_task_is_a_recorded_no_op), because plan() short-circuits on current == action.target() before checking the verb's source set.

Both halves cannot be right: the same verb on two open states gives two different outcomes, and neither a caller nor an agent can predict which. reopen names its source set explicitly (COMPLETED/CANCELLED) — being already open is not a reopen, it is caller error, and silently reporting success can mask a caller who meant complete.

Fix: validate reopen's source set before the target-state short-circuit, so reopen is refused with 422 from both NEEDS-ACTION and IN-PROCESS (zero PUT, no journal row), while COMPLETED/CANCELLED still succeed. Keep the target-match no-op for complete on COMPLETED and cancel on CANCELLED — there the verb re-asserts the same intent, which is the case the No-Op Persistence criterion describes.

Tests: replace reopening_an_open_task_is_a_recorded_no_op with a case asserting reopen from NEEDS-ACTION → 422 invalid_input / exit 2, 0 PUTs, no operations row and no life_events; add reopen from IN_PROCESS to the same assertion if it is not already covered by INVALID_TRANSITIONS; keep the complete/cancel no-op tests unchanged. Note the asymmetry explicitly in a comment so the next reader knows it is deliberate.

Keep cargo test / clippy -D warnings / fmt --check green. Do not merge.

## Coordinator review — CHANGES REQUESTED (one item) Verified `85f2417` myself: `cargo test` **217 passed / 0 failed** incl. `lifecycle_tests` (27), clippy 0 errors, fmt clean. Genuinely good: the #11 preflight is reused unchanged (`required_if_match` made `pub(crate)`, no logic change) and 13 tests assert request counts around it; `plan()` keeps the edit vocabulary in one place and emits only `STATUS`/`COMPLETED`/`PERCENT-COMPLETE`, so `DUE` and strength survive via the preservation patcher; `complete` records the action instant when no explicit `occurred_at` is given; `cancel` removes the completion timestamp instead of deleting the resource; no-op transitions persist `applied` + `changed:false` with 0 PUT and 0 `life_events`; replay returns the recorded outcome with 1 PUT total. **Change needed: `reopen` on an already-open task.** Issue #12's acceptance criteria require: *"Attempting an invalid state transition (such as completing an already cancelled task without reopening, or **reopening a task that is already in `NEEDS-ACTION` or `IN-PROCESS`**) returns validation error (HTTP 422, exit code 2)"*. Current behaviour treats the two open states differently: - `reopen` from `IN_PROCESS` → 422 (listed in `INVALID_TRANSITIONS`, correct); - `reopen` from `NEEDS-ACTION` → recorded no-op, 200 `changed:false` (`tests/lifecycle_tests.rs::reopening_an_open_task_is_a_recorded_no_op`), because `plan()` short-circuits on `current == action.target()` before checking the verb's source set. Both halves cannot be right: the same verb on two open states gives two different outcomes, and neither a caller nor an agent can predict which. `reopen` names its source set explicitly (`COMPLETED`/`CANCELLED`) — being already open is not a reopen, it is caller error, and silently reporting success can mask a caller who meant `complete`. Fix: validate `reopen`'s source set **before** the target-state short-circuit, so `reopen` is refused with 422 from both `NEEDS-ACTION` and `IN-PROCESS` (zero PUT, no journal row), while `COMPLETED`/`CANCELLED` still succeed. Keep the target-match no-op for `complete` on `COMPLETED` and `cancel` on `CANCELLED` — there the verb re-asserts the same intent, which is the case the No-Op Persistence criterion describes. Tests: replace `reopening_an_open_task_is_a_recorded_no_op` with a case asserting `reopen` from `NEEDS-ACTION` → 422 `invalid_input` / exit 2, 0 PUTs, no `operations` row and no `life_events`; add `reopen` from `IN_PROCESS` to the same assertion if it is not already covered by `INVALID_TRANSITIONS`; keep the `complete`/`cancel` no-op tests unchanged. Note the asymmetry explicitly in a comment so the next reader knows it is deliberate. Keep `cargo test` / `clippy -D warnings` / `fmt --check` green. Do not merge.
yuris force-pushed feat/issue-12-lifecycle from 3e3a595a95 to 0655f60d5e 2026-09-22 22:16:15 +00:00 Compare
Author
Owner

Coordinator re-review — APPROVED

Verified 0655f60 myself:

  • plan() now gates the target-match short-circuit on LifecycleAction::reports_no_op_when_already_target(), which is false only for Reopen — so reopen is validated against its source set first and an open task is refused rather than answered with a no-op. The asymmetry is documented in the module doc and on the predicate, which is what I asked for.
  • complete@COMPLETED and cancel@CANCELLED still return the recorded no-op with changed:false; complete@CANCELLED and cancel@COMPLETED still refuse.
  • The replacement test drives both open states and asserts 422 / invalid_input / exit 2 with 0 PUTs, 0 operations rows and 0 life_events; the domain unit matrix is now an explicit 12-row spec table.

Independent verification on 0655f60: cargo test 217 passed / 0 failed incl. lifecycle_tests (27); cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean.

Merging.

## Coordinator re-review — APPROVED Verified `0655f60` myself: - `plan()` now gates the target-match short-circuit on `LifecycleAction::reports_no_op_when_already_target()`, which is `false` only for `Reopen` — so `reopen` is validated against its source set first and an open task is refused rather than answered with a no-op. The asymmetry is documented in the module doc and on the predicate, which is what I asked for. - `complete`@`COMPLETED` and `cancel`@`CANCELLED` still return the recorded no-op with `changed:false`; `complete`@`CANCELLED` and `cancel`@`COMPLETED` still refuse. - The replacement test drives both open states and asserts 422 / `invalid_input` / exit 2 with 0 PUTs, 0 `operations` rows and 0 `life_events`; the domain unit matrix is now an explicit 12-row spec table. Independent verification on `0655f60`: `cargo test` **217 passed / 0 failed** incl. `lifecycle_tests` (27); `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. Merging.
yuris merged commit 0081f59289 into main 2026-09-22 22:17:05 +00:00
yuris changed title from feat(api): task lifecycle transitions with replays and progress tracking (#12) to feat(api): task lifecycle transitions with replays and progress tracking 2026-09-23 05:12:07 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!50
No description provided.