feat(api): work-block outcome capture as user-reported facts #72
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-28-outcomes"
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
Calendar systems routinely conflate the passage of time with human action: a scheduled focus block expires, and the block is assumed attended — or worse, its task is marked finished. Life forbids that inference (
docs/technical/domain-model.md#time-and-work-outcomes, scenario W13): an ended interval proves neither attendance nor completion,VEVENThas noCOMPLETEDstatus to write (RFC 5545 definesTENTATIVE,CONFIRMED,CANCELLEDand nothing else), and real outcomes exist only as explicit user-reported timeline facts.POST /v1/captures(#17) already performedkind: contextand mixedtask|event+context, and explicitly refusedkind: outcomeas not-yet-implemented. This change implements that refusal: the capture family now records work-block outcomes (attended,completed,missed,partial,unknown) asevidence_class = 'user_report'facts, with no calendar write of any kind and no effect on a linked task's lifecycle.Changes
src/domain/timeline/outcome.rs(new): the domain fact.WorkBlockOutcome(closed vocabulary, unsupported values refused rather than coerced), the documented capture payload (item_id,outcome, optionaloccurred_at,actual_minutes,qualifies_career_floor,reason),OutcomeFact(builds the append-onlylife_eventsrow),OutcomeLinkageand the read-back view. An unknown actual start, an unknown duration and an end the report does not establish all staynull;ended_atis arithmetic on a reported start plus a reported duration and is filled from nothing else. The instant shape and theuser_reportvocabulary are the ones the interruption fact (#26) already established.src/api/outcome.rs(new): the route-level part. Validates the report before anything is read or written, resolves the reported item's live identity by reading (collection/UID/occurrence, plus the task a block links withRELATED-TOwhen that task is live in the block's own collection), and commits oneoperationsrow plus onelife_eventsappend throughJournalService::record_fact_operation(#26). A link that cannot be verified is disclosed asevent_linkage_unverifiedand recorded with every coordinate null — never guessed.src/api/captures.rs:kind: outcomeis now implemented and dispatches to the above;task|eventcaptures accept an optionaloutcomepart whose operation identity is derived (<key>:outcome) so one step of a capture is not confused with the capture itself. Every part is validated before the first is dispatched, the parts compose without erasing one another, and a composed answer is never better than its parts (a partial/unconfirmed calendar part keeps its status and warnings). Acontextmember besidekind: outcomeis refused rather than ignored: an outcome's explanation is its payload'sreason.src/cli.rs:life capture --input -renders a recorded outcome as the fact it appended (outcome, times, minutes, evidence class, fact id, link), with an explicitunverifiedrendering when the link was not resolved.src/api/mod.rs: the identity-refusal failure is extracted (not_this_identity) so a caller can report it inside its own answer.tests/outcome_tests.rs(new) andtests/outbox_tests.rs: the #17 fixture that pinnedkind: outcomeas refused is narrowed tocorrection(#29), which is still refused.Idempotency follows the documented contract: the same
Idempotency-Keywith the same report replays the recorded fact and appends nothing; the same key with a changed report isidempotency_conflict(409). Re-capturing the same outcome for the same item under a new key is a new report: a user who reports an outcome twice reported it twice, at two different moments, and deduplicating by content would erase the second report — the operation key is the report's identity, as it is for the interruption fact.Verification
nix develop --command cargo test— all suites green on the rebased tree (f953b2d+56ec097), including the new 12-testoutcome_testsbinary.nix develop --command cargo clippy --all-targets -- -D warningsandcargo fmt --check— clean.tests/outcome_tests.rsdrives the real path (in-process daemon + mock CalDAV over TCP + the real CLI binary) and asserts the acceptance criteria, in particular the no-mutation evidence:evidence_class = 'user_report'(asserted!= 'confirmed_service'), the verified link and the envelope'sevent_id;done,failed,COMPLETED,skipped,missed,"") isinvalid_input/exit 2 with zero CalDAV requests, zero facts and zero operations — both through the route and throughlife capture;missedleaves theVEVENTand the linkedVTODObyte-identical (compared exactly against the fixture),STATUS:NEEDS-ACTIONintact, with every request aREPORTorGET—PUT/DELETE/PROPPATCHcounts are 0 and no dispatched body ever containsCOMPLETED;item_projectionrow;null,actual_minutes: 0records observed zero effort, and the block's scheduledDTENDnever leaks into the fact;GET /v1/timelinewith its evidence class, both times, linkage anditem_id; an unknown actual time counts as unknown and is excluded by an occurrence-time window;state: "replayed",changed: false, no second row; a changed report 409s; a new key is a new report;task applied,context pending,outcome recorded), a refused outcome part dispatches nothing, and an unconfirmed calendar write does not erase the recorded outcome part.Documentation
The design record (
docs/technical/*) lives outside this repository (it is excluded from the working tree), so no documentation files are changed here. Two non-error warning identifiers are introduced by the #28 specification's failure path:event_linkage_unverified(a link that could not be verified) andoutcome_unavailable(an outcome part withheld because the calendar part of the same capture failed).Related Issues
Closes #28
Coordinator review — APPROVED
Verified
b72f1femyself:cargo test681 passed / 0 failed (12 new outcome tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary — this issue is mostly about not doing things, so the evidence quality matters more than usual:
life_eventsrows, an empty timeline view, and zero CalDAV requests — the daemon did not look at the block at all. That is the strongest available form of "an elapsed interval proves nothing", and it closes the exact failure mode W13 names.occurred_at/ended_at/actual_minutesnull and the test asserts the block's scheduledDTENDnever appears inafter_json; separately,actual_minutes: 0is an explicit zero (ended_at == occurred_at) rather than being confused with "unknown".ended_atis only start + reported duration.reads_only()holds,PUT/DELETE/PROPPATCHare all zero, every dispatched request body is asserted free ofCOMPLETED, and noitem_projectionrow is written. "No calendar mutation" is a property of the whole path, not just of a status field.completedfor the task andmissedfor the block that links it leaves the VTODO byte-identical withSTATUS:NEEDS-ACTIONintact and noPERCENT-COMPLETE, so the #12 lifecycle command remains the only route to completion.Attendedis documented as "the user was there, and the block did not finish its work", keeping attendance distinct from completion; an unsupported value is refused with a message naming all five accepted values; and a date-onlyoccurred_atis refused rather than resolved, preserving the date/instant distinction the rest of the service maintains.#17refusal fixture was narrowed tocorrectionrather than deleted, so the remaining unimplemented kind is still covered — andcorrectionis exactly what #29 will implement.Merging.
#29(append-only corrections) is now the last slice of the review chain.feat(api): work-block outcome capture as user-reported facts (#28)to feat(api): work-block outcome capture as user-reported facts