feat(api): task lifecycle transitions with replays and progress tracking #50
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-12-lifecycle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Tasks in
life-servicedo not have free-form status strings: VTODOSTATUSis exactlyNEEDS-ACTION,IN-PROCESS,COMPLETEDorCANCELLED, 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|cancelfell through tonot_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:TaskStatusis exactly the four RFC 5545 values. A missingSTATUSreads asNEEDS-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 ascalendar_unsupportedrather than coerced.LifecycleActionfixes each transition's source set, target state and fact kind: completeNEEDS-ACTION|IN-PROCESS → COMPLETED, reopenCOMPLETED|CANCELLED → NEEDS-ACTION, cancel open →CANCELLED.plan()returnsNoOpwhen the task already satisfies the target, an invalid-combination error otherwise, and for a real transition the exact lifecycle property edits: complete writesSTATUS:COMPLETED,COMPLETED(the caller'soccurred_at, else the instant of the action being dispatched) andPERCENT-COMPLETE:100; reopen writesSTATUS:NEEDS-ACTION, removesCOMPLETEDand resetsPERCENT-COMPLETE:0; cancel writesSTATUS:CANCELLEDand removes any completion timestamp without touching progress. Deadline, strength and every other member are never in the edit set.occurred_atis refused on reopen/cancel, where it has no meaning; an unknown payload member (an occurrencescope, 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:Idempotency-Keyand the caller's quoted strongIf-Matchare required; missing/empty/weak/*/unquoted/multi-tag evidence is refused with400 invalid_input/ exit 2 and zero backend requests and no journal row.STATUS, an occurrence (RECURRENCE-IDset) or an invalid combination is refused before anything is written (calendar_unsupported/422 invalid_input).appliedwithchanged:false: no remotePUT, nolife_eventsrow, and a same-key retry replays it.PUTcarriesIf-Match: <caller ETag>, and a412is recordedfailedand reported asetag_conflict(412 / exit 4) — never retried with a refreshed ETag.applied, appendstask_completed/task_reopened/task_cancelled(evidence_class='confirmed_service', withbefore_json/after_json) and upserts the projection. An unconfirmed outcome isneeds_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,VTIMEZONEand unrelated overrides survive. Cancellation updates the resource; it neverDELETEs it.src/api/mod.rs,src/api/update.rs,src/cli.rs,src/client.rs— the three routes and thin handlers,required_if_matchopened to the crate so the preflight is literally the #11 one,client::post_conditional_envelope(the POST sibling ofpatch_envelope), and the three CLI verbs sharing one implementation that refuses unusable--etaglocally before transmitting.Verification
nix develop --command cargo test— 217 passed, 0 failed (27 of them new intests/lifecycle_tests.rs).nix develop --command cargo clippy --all-targets -- -D warnings— clean.nix develop --command cargo fmt --check— clean.tests/lifecycle_tests.rsruns 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 zeroPUTs and zero operations on refusal), the exact bytesPUT(STATUS,COMPLETED,PERCENT-COMPLETE, and preservation ofDUE,CATEGORIES:life-active,VALARM,X-CUSTOM-CLIENT-FLAG;X-FOO="bar",VTIMEZONE, the unrelated override), the recordedlife_eventskind/evidence/before/after, no-opchanged:falsewith 0PUTand 1 event plus its same-key replay, replay with 1PUTtotal, stale-ETag 412 withoperations.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.85f2417b5dto3e3a595a95Coordinator review — CHANGES REQUESTED (one item)
Verified
85f2417myself:cargo test217 passed / 0 failed incl.lifecycle_tests(27), clippy 0 errors, fmt clean.Genuinely good: the #11 preflight is reused unchanged (
required_if_matchmadepub(crate), no logic change) and 13 tests assert request counts around it;plan()keeps the edit vocabulary in one place and emits onlySTATUS/COMPLETED/PERCENT-COMPLETE, soDUEand strength survive via the preservation patcher;completerecords the action instant when no explicitoccurred_atis given;cancelremoves the completion timestamp instead of deleting the resource; no-op transitions persistapplied+changed:falsewith 0 PUT and 0life_events; replay returns the recorded outcome with 1 PUT total.Change needed:
reopenon 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-ACTIONorIN-PROCESS) returns validation error (HTTP 422, exit code 2)".Current behaviour treats the two open states differently:
reopenfromIN_PROCESS→ 422 (listed inINVALID_TRANSITIONS, correct);reopenfromNEEDS-ACTION→ recorded no-op, 200changed:false(tests/lifecycle_tests.rs::reopening_an_open_task_is_a_recorded_no_op), becauseplan()short-circuits oncurrent == 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.
reopennames 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 meantcomplete.Fix: validate
reopen's source set before the target-state short-circuit, soreopenis refused with 422 from bothNEEDS-ACTIONandIN-PROCESS(zero PUT, no journal row), whileCOMPLETED/CANCELLEDstill succeed. Keep the target-match no-op forcompleteonCOMPLETEDandcancelonCANCELLED— 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_opwith a case assertingreopenfromNEEDS-ACTION→ 422invalid_input/ exit 2, 0 PUTs, nooperationsrow and nolife_events; addreopenfromIN_PROCESSto the same assertion if it is not already covered byINVALID_TRANSITIONS; keep thecomplete/cancelno-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 --checkgreen. Do not merge.3e3a595a95to0655f60d5eCoordinator re-review — APPROVED
Verified
0655f60myself:plan()now gates the target-match short-circuit onLifecycleAction::reports_no_op_when_already_target(), which isfalseonly forReopen— soreopenis 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@COMPLETEDandcancel@CANCELLEDstill return the recorded no-op withchanged:false;complete@CANCELLEDandcancel@COMPLETEDstill refuse.invalid_input/ exit 2 with 0 PUTs, 0operationsrows and 0life_events; the domain unit matrix is now an explicit 12-row spec table.Independent verification on
0655f60:cargo test217 passed / 0 failed incl.lifecycle_tests(27);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.Merging.
feat(api): task lifecycle transitions with replays and progress tracking (#12)to feat(api): task lifecycle transitions with replays and progress tracking