feat(api): append-only timeline fact corrections #73

Merged
yuris merged 1 commit from feat/issue-29-corrections into main 2026-09-23 03:58:58 +00:00
Owner

Motivation

Human reports are fallible: a duration gets misreported, an outcome is wrong, an interruption is logged against the wrong block. The journal is append-only, so a correction must never be a history rewrite — it has to be a new fact that names the one it corrects, while the original stays readable. POST /v1/captures already accepted kind: correction and refused it as not implemented (#28 left that refusal as the fixture), life timeline was an unimplemented command, and view=corrected was refused. This implements the correction workflow (docs/technical/workflows.md#lifecycle-and-review, scenario W14) together with its refusals (scenario R13): an unknown target, a cyclic chain, a cross-kind correction, and any attempt to rewrite a confirmed transport fact.

Changes

  • src/domain/timeline/correction.rs (new): the correction vocabulary, pure — the payload, the target's evidence-class and kind validation, the replacement vocabulary per fact kind, the appended life_events row (correction_target, before_json, after_json, evidence_class: user_report), and the bounded chain resolution (MAX_CORRECTION_HOPS = 16, the discipline #18 established for supersession).
  • src/api/correction.rs (new) and src/api/captures.rs: the correction part of the capture family. It reads the target from the journal, refuses an unknown target, a confirmed_service/observed_external target, a cross-kind correction and an unresolvable chain as invalid_input with the reason stated, appends one row through JournalService::record_fact_operation, and replays idempotently. It makes no calendar request of any kind.
  • src/history/timeline.rs: correction_target on the timeline projection, correction_edges/correction_target reads, and timeline_corrected — the folded read over the same query, one entry per chain, with the original carried, the chain listed and the correction that supplied each value named.
  • src/api/timeline.rs and src/api/mod.rs: view parsed with the rest of the filters (raw|corrected), the folded read wired in, the view reported in the response, and the pagination cursor fingerprint bound to the view.
  • src/cli.rs: life timeline implemented (--from, --to, --occurred-from, --occurred-to, --item-id, --evidence-class, --limit, --cursor, --corrected, --view), with a human-readable table that marks correction linkage and folded provenance.
  • skills/life/references/commands.md: life timeline moved from the unimplemented table to reads, the capture kinds and the correction payload documented, and the stale "outcome/correction are refused" claim removed.
  • Tests: new tests/correction_tests.rs (10 integration tests over a mock CalDAV, the real router and the real binary) and 13 unit tests in the domain module; tests/outbox_tests.rs's now-obsolete correction refusal fixture removed (no capture kind is refused any more); tests/replan_tests.rs's corrected-view assertion moved to the implemented behaviour.

Design decisions worth a reviewer's eye:

  • Omission preserves, null clears — the documented patch semantics. A replacement restates only what changed, so correcting a duration cannot silently erase the reported time or the link.
  • A replacement is written in the target's vocabulary: only the assertion members of the fact's kind, validated by that kind's domain type (done is refused, not recorded as an outcome); the link members (item_id, related_task_id, linkage) and the derived ended_at are refused, because a correction corrects the assertion and the link the fact was recorded with stands.
  • A correction is always user_report. Its evidence member is the user's own words, never a claim of service evidence — a payload cannot promote an assertion to confirmed_service.
  • One standing assertion per chain, resolved from the original in recorded sequence order; a correction of a superseded sibling does not resurrect it, and every event of one chain resolves to the same answer.
  • A chain that does not resolve within 16 links is refused on write (invalid_input, bound stated) and the folded read refuses to fold rather than spinning or inventing a value.

Verification

nix develop --command cargo test — 704 tests across 37 suites, all passing (was 681); cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

Per acceptance criterion, all observed through the real path (in-process daemon, mock CalDAV, real CLI):

  • Correction appends a linked row, original untouched — tests/correction_tests.rs::a_correction_appends_a_linked_row_and_leaves_the_corrected_one_untouched compares the corrected row's stored bytes (every column, raw JSON included) and its sequence before and after, and exercises the triggers directly: a raw UPDATE and a raw DELETE on life_events both abort with append-only events, leaving the row unchanged.
  • Target validation — an unknown target (and a blank one) is invalid_input (HTTP 400, CLI exit 2) with nothing appended; a cyclic chain is refused with the depth bound stated (16 links), asserted by planting a loop directly in SQLite (the service cannot produce one) and checking both that the correction is refused and that the folded read refuses to fold it.
  • No laundering of evidence classes — a real confirmed_service fact (a task created and acknowledged by the mock backend) cannot be corrected: the refusal says the recorded fact stays as recorded and where the disagreement belongs, the original row and the row count are untouched, no projection changed, and the user can record their disagreement as a separate fact beside it.
  • Zero Radicale mutation — after a correction and a correction of that correction, the mock's request log is unchanged (not one request of any kind), the calendar resource bytes are identical, and item_projection, sync_state and outbox row counts are unchanged.
  • Two views over one log — the raw view returns both the original and the correction with its correction_target; the corrected view folds to one entry that carries the original's identity and assertion, the chain, corrected_by and correction_chain_length.
  • Correction of a correction — a two-correction chain resolves to the newest, with both hops visible and the value each supplied.
  • Idempotency — the same key and payload replays (state: replayed, no second fact or operation); the same key with a changed payload is idempotency_conflict (HTTP 409) with no row appended; a new key is a new correction and stands.
  • Retrievability — the correction is read back from the timeline with its evidence_class: user_report, recorded_at, target linkage and reason.
  • CLI — life capture --input - records a correction (exit 0, evt_cor_…), life timeline --view raw shows every fact, life timeline --corrected shows the folded value with its provenance, and a contradictory or unknown view is exit 2 before any request.

Documentation

skills/life/references/commands.md is updated in the same commit (the skill package is validated against the real CLI by tests/skill_package_tests.rs), including a correction payload example. The normative design documents were unchanged by this work.

Closes #29

## Motivation Human reports are fallible: a duration gets misreported, an outcome is wrong, an interruption is logged against the wrong block. The journal is append-only, so a correction must never be a history rewrite — it has to be a new fact that names the one it corrects, while the original stays readable. `POST /v1/captures` already accepted `kind: correction` and refused it as not implemented (#28 left that refusal as the fixture), `life timeline` was an unimplemented command, and `view=corrected` was refused. This implements the correction workflow (`docs/technical/workflows.md#lifecycle-and-review`, scenario W14) together with its refusals (scenario R13): an unknown target, a cyclic chain, a cross-kind correction, and any attempt to rewrite a confirmed transport fact. ## Changes - `src/domain/timeline/correction.rs` (new): the correction vocabulary, pure — the payload, the target's evidence-class and kind validation, the replacement vocabulary per fact kind, the appended `life_events` row (`correction_target`, `before_json`, `after_json`, `evidence_class: user_report`), and the bounded chain resolution (`MAX_CORRECTION_HOPS = 16`, the discipline #18 established for supersession). - `src/api/correction.rs` (new) and `src/api/captures.rs`: the correction part of the capture family. It reads the target from the journal, refuses an unknown target, a `confirmed_service`/`observed_external` target, a cross-kind correction and an unresolvable chain as `invalid_input` with the reason stated, appends one row through `JournalService::record_fact_operation`, and replays idempotently. It makes no calendar request of any kind. - `src/history/timeline.rs`: `correction_target` on the timeline projection, `correction_edges`/`correction_target` reads, and `timeline_corrected` — the folded read over the same query, one entry per chain, with the original carried, the chain listed and the correction that supplied each value named. - `src/api/timeline.rs` and `src/api/mod.rs`: `view` parsed with the rest of the filters (`raw`|`corrected`), the folded read wired in, the view reported in the response, and the pagination cursor fingerprint bound to the view. - `src/cli.rs`: `life timeline` implemented (`--from`, `--to`, `--occurred-from`, `--occurred-to`, `--item-id`, `--evidence-class`, `--limit`, `--cursor`, `--corrected`, `--view`), with a human-readable table that marks correction linkage and folded provenance. - `skills/life/references/commands.md`: `life timeline` moved from the unimplemented table to reads, the capture kinds and the correction payload documented, and the stale "outcome/correction are refused" claim removed. - Tests: new `tests/correction_tests.rs` (10 integration tests over a mock CalDAV, the real router and the real binary) and 13 unit tests in the domain module; `tests/outbox_tests.rs`'s now-obsolete correction refusal fixture removed (no capture kind is refused any more); `tests/replan_tests.rs`'s corrected-view assertion moved to the implemented behaviour. Design decisions worth a reviewer's eye: - **Omission preserves, null clears** — the documented patch semantics. A replacement restates only what changed, so correcting a duration cannot silently erase the reported time or the link. - **A replacement is written in the target's vocabulary**: only the assertion members of the fact's kind, validated by that kind's domain type (`done` is refused, not recorded as an outcome); the link members (`item_id`, `related_task_id`, `linkage`) and the derived `ended_at` are refused, because a correction corrects the assertion and the link the fact was recorded with stands. - **A correction is always `user_report`.** Its `evidence` member is the user's own words, never a claim of service evidence — a payload cannot promote an assertion to `confirmed_service`. - **One standing assertion per chain**, resolved from the original in recorded sequence order; a correction of a superseded sibling does not resurrect it, and every event of one chain resolves to the same answer. - **A chain that does not resolve within 16 links** is refused on write (`invalid_input`, bound stated) and the folded read refuses to fold rather than spinning or inventing a value. ## Verification `nix develop --command cargo test` — 704 tests across 37 suites, all passing (was 681); `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean. Per acceptance criterion, all observed through the real path (in-process daemon, mock CalDAV, real CLI): - **Correction appends a linked row, original untouched** — `tests/correction_tests.rs::a_correction_appends_a_linked_row_and_leaves_the_corrected_one_untouched` compares the corrected row's stored bytes (every column, raw JSON included) and its sequence before and after, and exercises the triggers directly: a raw `UPDATE` and a raw `DELETE` on `life_events` both abort with `append-only events`, leaving the row unchanged. - **Target validation** — an unknown target (and a blank one) is `invalid_input` (HTTP 400, CLI exit 2) with nothing appended; a cyclic chain is refused with the depth bound stated (`16 links`), asserted by planting a loop directly in SQLite (the service cannot produce one) and checking both that the correction is refused and that the folded read refuses to fold it. - **No laundering of evidence classes** — a real `confirmed_service` fact (a task created and acknowledged by the mock backend) cannot be corrected: the refusal says the recorded fact stays as recorded and where the disagreement belongs, the original row and the row count are untouched, no projection changed, and the user can record their disagreement as a separate fact beside it. - **Zero Radicale mutation** — after a correction and a correction of that correction, the mock's request log is unchanged (not one request of any kind), the calendar resource bytes are identical, and `item_projection`, `sync_state` and `outbox` row counts are unchanged. - **Two views over one log** — the raw view returns both the original and the correction with its `correction_target`; the corrected view folds to one entry that carries the original's identity and assertion, the chain, `corrected_by` and `correction_chain_length`. - **Correction of a correction** — a two-correction chain resolves to the newest, with both hops visible and the value each supplied. - **Idempotency** — the same key and payload replays (`state: replayed`, no second fact or operation); the same key with a changed payload is `idempotency_conflict` (HTTP 409) with no row appended; a new key is a new correction and stands. - **Retrievability** — the correction is read back from the timeline with its `evidence_class: user_report`, `recorded_at`, target linkage and reason. - **CLI** — `life capture --input -` records a correction (exit 0, `evt_cor_…`), `life timeline --view raw` shows every fact, `life timeline --corrected` shows the folded value with its provenance, and a contradictory or unknown view is exit 2 before any request. ## Documentation `skills/life/references/commands.md` is updated in the same commit (the skill package is validated against the real CLI by `tests/skill_package_tests.rs`), including a correction payload example. The normative design documents were unchanged by this work. ## Related Issues Closes #29
yuris force-pushed feat/issue-29-corrections from 3d7ee54fd2 to 756c796f6f 2026-09-23 03:55:15 +00:00 Compare
Author
Owner

Coordinator review — APPROVED

Verified 756c796 myself: cargo test 704 passed / 0 failed (23 new correction tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • The append-only property is asserted, not assumed. a_correction_appends_a_linked_row_and_leaves_the_corrected_one_untouched checks the original row survives alongside the new one — and the schema's life_events_no_update/life_events_no_delete triggers from #4 still stand behind it, so "we chose not to update" is not the only defence.
  • Evidence classes cannot be laundered, which is the subtlest requirement here: a_confirmed_service_fact_cannot_be_rewritten_by_a_user_assertion proves a recorded service fact cannot be overwritten by a later user assertion. That is the boundary that keeps confirmed_service meaning something, and it is enforced by vocabulary (CONFIRMED_SERVICE in the domain) rather than by convention.
  • The cycle bound is explicit and matches the precedent: MAX_CORRECTION_HOPS = 16 with the failure message naming the bound, and tests that construct a chain longer than the bound — the same bounded-chain discipline #18 uses for supersession, applied to a graph that is now user-shapeable.
  • Two views over one log, with the original never lost: the corrected view folds to the newest assertion while the raw view still shows both facts, and a chain of corrections resolves to the newest with the intermediate links visible — so provenance survives the fold rather than being traded away for readability.
  • The vocabulary is closed: a replacement outside the correctable facts is refused, so a correction cannot smuggle in a new kind of claim.

Supporting suites that pinned the older behaviour (tests/outbox_tests.rs, tests/replan_tests.rs) and the skill's CLI reference were updated in the same commit, so the #17 refusal fixture now reflects that only kind: correction remained unimplemented — and that is no longer true.

Merging. #30 (weekly review analytics) is the last Phase 4 slice.

## Coordinator review — APPROVED Verified `756c796` myself: `cargo test` **704 passed / 0 failed** (23 new correction tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **The append-only property is asserted, not assumed.** `a_correction_appends_a_linked_row_and_leaves_the_corrected_one_untouched` checks the original row survives alongside the new one — and the schema's `life_events_no_update`/`life_events_no_delete` triggers from #4 still stand behind it, so "we chose not to update" is not the only defence. - **Evidence classes cannot be laundered**, which is the subtlest requirement here: `a_confirmed_service_fact_cannot_be_rewritten_by_a_user_assertion` proves a recorded service fact cannot be overwritten by a later user assertion. That is the boundary that keeps `confirmed_service` meaning something, and it is enforced by vocabulary (`CONFIRMED_SERVICE` in the domain) rather than by convention. - **The cycle bound is explicit and matches the precedent:** `MAX_CORRECTION_HOPS = 16` with the failure message naming the bound, and tests that construct a chain longer than the bound — the same bounded-chain discipline #18 uses for supersession, applied to a graph that is now user-shapeable. - **Two views over one log, with the original never lost:** the corrected view folds to the newest assertion while the raw view still shows both facts, and a chain of corrections resolves to the newest with the intermediate links visible — so provenance survives the fold rather than being traded away for readability. - The vocabulary is closed: a replacement outside the correctable facts is refused, so a correction cannot smuggle in a new kind of claim. Supporting suites that pinned the older behaviour (`tests/outbox_tests.rs`, `tests/replan_tests.rs`) and the skill's CLI reference were updated in the same commit, so the `#17` refusal fixture now reflects that only `kind: correction` remained unimplemented — and that is no longer true. Merging. `#30` (weekly review analytics) is the last Phase 4 slice.
yuris merged commit ba9e2994e1 into main 2026-09-23 03:58:58 +00:00
yuris changed title from feat(api): append-only timeline fact corrections (#29) to feat(api): append-only timeline fact corrections 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!73
No description provided.