feat(api): work-block outcome capture as user-reported facts #72

Merged
yuris merged 1 commit from feat/issue-28-outcomes into main 2026-09-23 03:25:21 +00:00
Owner

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, VEVENT has no COMPLETED status to write (RFC 5545 defines TENTATIVE, CONFIRMED, CANCELLED and nothing else), and real outcomes exist only as explicit user-reported timeline facts.

POST /v1/captures (#17) already performed kind: context and mixed task|event + context, and explicitly refused kind: outcome as not-yet-implemented. This change implements that refusal: the capture family now records work-block outcomes (attended, completed, missed, partial, unknown) as evidence_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, optional occurred_at, actual_minutes, qualifies_career_floor, reason), OutcomeFact (builds the append-only life_events row), OutcomeLinkage and the read-back view. An unknown actual start, an unknown duration and an end the report does not establish all stay null; ended_at is arithmetic on a reported start plus a reported duration and is filled from nothing else. The instant shape and the user_report vocabulary 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 with RELATED-TO when that task is live in the block's own collection), and commits one operations row plus one life_events append through JournalService::record_fact_operation (#26). A link that cannot be verified is disclosed as event_linkage_unverified and recorded with every coordinate null — never guessed.
  • src/api/captures.rs: kind: outcome is now implemented and dispatches to the above; task|event captures accept an optional outcome part 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). A context member beside kind: outcome is refused rather than ignored: an outcome's explanation is its payload's reason.
  • 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 explicit unverified rendering 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) and tests/outbox_tests.rs: the #17 fixture that pinned kind: outcome as refused is narrowed to correction (#29), which is still refused.

Idempotency follows the documented contract: the same Idempotency-Key with the same report replays the recorded fact and appends nothing; the same key with a changed report is idempotency_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-test outcome_tests binary.
  • nix develop --command cargo clippy --all-targets -- -D warnings and cargo fmt --check — clean.
  • tests/outcome_tests.rs drives 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:
    • every outcome value records with evidence_class = 'user_report' (asserted != 'confirmed_service'), the verified link and the envelope's event_id;
    • an unsupported value (done, failed, COMPLETED, skipped, missed , "") is invalid_input/exit 2 with zero CalDAV requests, zero facts and zero operations — both through the route and through life capture;
    • a block whose end has long passed creates no fact: the journal and the timeline read stay empty (W13), and nothing even reads the calendar;
    • missed leaves the VEVENT and the linked VTODO byte-identical (compared exactly against the fixture), STATUS:NEEDS-ACTION intact, with every request a REPORT or GET — PUT/DELETE/PROPPATCH counts are 0 and no dispatched body ever contains COMPLETED;
    • an outcome linked to a task (and one reported about the task) leaves the task's lifecycle untouched, with no item_projection row;
    • unknown actual time/end stay null, actual_minutes: 0 records observed zero effort, and the block's scheduled DTEND never leaks into the fact;
    • the fact is retrievable from GET /v1/timeline with its evidence class, both times, linkage and item_id; an unknown actual time counts as unknown and is excluded by an occurrence-time window;
    • same key + same report replays with state: "replayed", changed: false, no second row; a changed report 409s; a new key is a new report;
    • a mixed capture reports all three parts independently (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) and outcome_unavailable (an outcome part withheld because the calendar part of the same capture failed).

Closes #28

## 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, `VEVENT` has no `COMPLETED` status to write (RFC 5545 defines `TENTATIVE`, `CONFIRMED`, `CANCELLED` and nothing else), and real outcomes exist only as explicit user-reported timeline facts. `POST /v1/captures` (#17) already performed `kind: context` and mixed `task|event` + `context`, and explicitly refused `kind: outcome` as not-yet-implemented. This change implements that refusal: the capture family now records work-block outcomes (`attended`, `completed`, `missed`, `partial`, `unknown`) as `evidence_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`, optional `occurred_at`, `actual_minutes`, `qualifies_career_floor`, `reason`), `OutcomeFact` (builds the append-only `life_events` row), `OutcomeLinkage` and the read-back view. An unknown actual start, an unknown duration and an end the report does not establish all stay `null`; `ended_at` is arithmetic on a reported start plus a reported duration and is filled from nothing else. The instant shape and the `user_report` vocabulary 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 with `RELATED-TO` when that task is live in the block's own collection), and commits one `operations` row plus one `life_events` append through `JournalService::record_fact_operation` (#26). A link that cannot be verified is disclosed as `event_linkage_unverified` and recorded with every coordinate null — never guessed. - **`src/api/captures.rs`**: `kind: outcome` is now implemented and dispatches to the above; `task|event` captures accept an optional `outcome` part 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). A `context` member beside `kind: outcome` is refused rather than ignored: an outcome's explanation is its payload's `reason`. - **`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 explicit `unverified` rendering 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) and **`tests/outbox_tests.rs`**: the #17 fixture that pinned `kind: outcome` as refused is narrowed to `correction` (#29), which is still refused. Idempotency follows the documented contract: the same `Idempotency-Key` with the same report replays the recorded fact and appends nothing; the same key with a changed report is `idempotency_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-test `outcome_tests` binary. - `nix develop --command cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` — clean. - `tests/outcome_tests.rs` drives 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: - every outcome value records with `evidence_class = 'user_report'` (asserted `!= 'confirmed_service'`), the verified link and the envelope's `event_id`; - an unsupported value (`done`, `failed`, `COMPLETED`, `skipped`, `missed `, `""`) is `invalid_input`/exit 2 with **zero CalDAV requests**, zero facts and zero operations — both through the route and through `life capture`; - a block whose end has long passed creates **no** fact: the journal and the timeline read stay empty (W13), and nothing even reads the calendar; - `missed` leaves the `VEVENT` **and** the linked `VTODO` byte-identical (compared exactly against the fixture), `STATUS:NEEDS-ACTION` intact, with every request a `REPORT` or `GET` — `PUT`/`DELETE`/`PROPPATCH` counts are 0 and no dispatched body ever contains `COMPLETED`; - an outcome linked to a task (and one reported *about* the task) leaves the task's lifecycle untouched, with no `item_projection` row; - unknown actual time/end stay `null`, `actual_minutes: 0` records observed zero effort, and the block's scheduled `DTEND` never leaks into the fact; - the fact is retrievable from `GET /v1/timeline` with its evidence class, both times, linkage and `item_id`; an unknown actual time counts as unknown and is excluded by an occurrence-time window; - same key + same report replays with `state: "replayed"`, `changed: false`, no second row; a changed report 409s; a new key is a new report; - a mixed capture reports all three parts independently (`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) and `outcome_unavailable` (an outcome part withheld because the calendar part of the same capture failed). ## Related Issues Closes #28
Author
Owner

Coordinator review — APPROVED

Verified b72f1fe myself: cargo test 681 passed / 0 failed (12 new outcome tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary — this issue is mostly about not doing things, so the evidence quality matters more than usual:

  • Nothing is inferred from the clock, and the test proves it by the absence of even a read. An ended block with no report yields zero life_events rows, 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.
  • The scheduled end can never masquerade as an actual end. Both time assertions are the kind that catch a real bug: an unknown actual time leaves occurred_at/ended_at/actual_minutes null and the test asserts the block's scheduled DTEND never appears in after_json; separately, actual_minutes: 0 is an explicit zero (ended_at == occurred_at) rather than being confused with "unknown". ended_at is only start + reported duration.
  • Non-mutation is asserted at the byte level for both components — the VEVENT and the linked VTODO come back byte-identical, reads_only() holds, PUT/DELETE/PROPPATCH are all zero, every dispatched request body is asserted free of COMPLETED, and no item_projection row is written. "No calendar mutation" is a property of the whole path, not just of a status field.
  • Task completion stays a separate act: capturing completed for the task and missed for the block that links it leaves the VTODO byte-identical with STATUS:NEEDS-ACTION intact and no PERCENT-COMPLETE, so the #12 lifecycle command remains the only route to completion.
  • The vocabularies are precise rather than convenient: Attended is 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-only occurred_at is refused rather than resolved, preserving the date/instant distinction the rest of the service maintains.
  • The #17 refusal fixture was narrowed to correction rather than deleted, so the remaining unimplemented kind is still covered — and correction is exactly what #29 will implement.

Merging. #29 (append-only corrections) is now the last slice of the review chain.

## Coordinator review — APPROVED Verified `b72f1fe` myself: `cargo test` **681 passed / 0 failed** (12 new outcome tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary — this issue is mostly about *not* doing things, so the evidence quality matters more than usual: - **Nothing is inferred from the clock, and the test proves it by the absence of even a read.** An ended block with no report yields zero `life_events` rows, 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. - **The scheduled end can never masquerade as an actual end.** Both time assertions are the kind that catch a real bug: an unknown actual time leaves `occurred_at`/`ended_at`/`actual_minutes` null *and* the test asserts the block's scheduled `DTEND` never appears in `after_json`; separately, `actual_minutes: 0` is an explicit zero (`ended_at == occurred_at`) rather than being confused with "unknown". `ended_at` is only start + reported duration. - **Non-mutation is asserted at the byte level for both components** — the VEVENT *and* the linked VTODO come back byte-identical, `reads_only()` holds, `PUT`/`DELETE`/`PROPPATCH` are all zero, every dispatched request body is asserted free of `COMPLETED`, and no `item_projection` row is written. "No calendar mutation" is a property of the whole path, not just of a status field. - **Task completion stays a separate act:** capturing `completed` for the task and `missed` for the block that links it leaves the VTODO byte-identical with `STATUS:NEEDS-ACTION` intact and no `PERCENT-COMPLETE`, so the #12 lifecycle command remains the only route to completion. - **The vocabularies are precise rather than convenient:** `Attended` is 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-only `occurred_at` is *refused* rather than resolved, preserving the date/instant distinction the rest of the service maintains. - The `#17` refusal fixture was narrowed to `correction` rather than deleted, so the remaining unimplemented kind is still covered — and `correction` is exactly what #29 will implement. Merging. `#29` (append-only corrections) is now the last slice of the review chain.
yuris merged commit 23fb2365ac into main 2026-09-23 03:25:21 +00:00
yuris changed title from feat(api): work-block outcome capture as user-reported facts (#28) to feat(api): work-block outcome capture as user-reported facts 2026-09-23 05:11:59 +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!72
No description provided.