feat(api): interruption fact logging and schedule replanning #69

Merged
yuris merged 1 commit from feat/issue-26-replan into main 2026-09-23 02:19:47 +00:00
Owner

Motivation

Real execution is interrupted. A production incident, an impromptu meeting or an
urgent obligation takes time out of a schedule that was already planned, and a
service that answers that badly does one of two things: it leaves the blocks in
the past as if they happened, or it silently moves them on the remote calendar.
Life separates the two halves structurally — a timeline fact about what
happened, and a reviewable proposal about what to do next — and #26 implements
the interruption workflow (docs/technical/workflows.md#planning-and-interruption,
scenario W08): "Record user-reported interruption separately; compute available
remainder; review changed commitments"
, with its failure path "Recording may
succeed while proposal generation fails; report both, do not erase fact or claim
calendar changed"
.

Changes

The fact is recorded first, and independently (src/domain/timeline/interruption.rs,
src/history/journal.rs)

  • POST /v1/replans takes {interruption: {item_ids, occurred_at?, ended_at?, reason?}, remaining_intended_outcomes?, policy_exceptions?} and appends one life_events
    row with kind = "interruption_logged" and evidence_class = "user_report" —
    never confirmed_service — carrying the affected item ids, the actual start the
    user reported, a nullable end, and the reason when supplied.
  • An omitted occurred_at is recorded as null beside the recorded_at the
    report was received at, and an unknown end stays unknown: neither is inferred
    from "now", from the requested window or from the moment of the report. A
    date-only value is refused rather than resolved to a midnight instant.
  • JournalService::record_fact_operation writes the operations row (applied,
    since a local-only fact has no second network phase) and appends the fact in one
    local transaction, with the replay/conflict semantics every other operation key
    has. The reasoning mirrors src/history/proposals.rs: recording it prepared
    would make startup recovery treat a local fact as an in-flight remote write.

Generation follows, and cannot erase the fact (src/api/replans.rs,
src/history/proposals.rs)

  • Generation runs after the fact is durable. When it fails — the calendar cannot
    be read, the capacity is unknown, a named outcome does not resolve, a policy cap
    has no explicit exception — the answer is status: partial (HTTP 200, CLI exit
    6) with the recorded interruption in data, proposal: null, and the failure in
    error. The calendar is never claimed to have changed.
  • record_generation_outcome stores the outcome in a second transaction: the
    proposal row when one was generated, or the failure when none was — and the
    operation's recorded answer either way, so a replay reports what the first
    attempt actually answered. If a process dies between the two writes, a replay
    says so (operation_unknown) instead of inventing a proposal.

Replanning is planning from a later instant (src/api/plans.rs,
src/domain/policy/capacity.rs)

  • #25's planner is reused rather than restated: generate now takes a
    Generation (the window to observe, the named outcomes, the exceptions) and both
    routes call it. The remaining window is the remainder of the anchor's local day
    (remaining_day), which resolve_working_windows already clamps to the anchor —
    so the same working windows, the same 40% reserve on what remains, the same caps
    and the same ordered-step builder apply. A past end does not move the anchor
    backwards; an unknown end anchors at the report and is disclosed as
    unknown_estimate with anchor.basis = "reported_now".
  • The proposal is new: its own id, its own canonical digest, pending, 24-hour
    expiry. The previous proposal's row is neither read nor mutated.

Zero calendar writes — the route holds no writing CalDAV client. One
operations row, one life_events fact and one proposals row are the whole
effect; item_projection, sync_state and outbox stay untouched. The calendar
changes only when #27 applies a reviewed proposal.

Timeline queryability (src/api/timeline.rs, src/history/timeline.rs)

  • Implements the documented GET /v1/timeline read (life timeline =
    GET /v1/timeline, read): the last seven local days by recorded_at by default,
    with from/to, item_id, evidence_class, occurred_from/to, limit and
    cursor. A fact's recording time and its nullable actual time are kept apart,
    and every unknown member is serialized as null rather than omitted, so an
    unknown end is visible as unknown rather than absent. The view reports how many
    facts carry no actual time, which is what tells a quiet period from an
    unobserved one.

CLI — life replan --input - [--operation-id KEY] [--json] prints one
standard envelope and exits 0/2/4/5/6 per the contract. The payload is validated
before the daemon is contacted, so a malformed report is exit 2 without a round
trip. The tests/cli_tests.rs unimplemented-command fixture moved from replan to
review (#30), and the tests/api_tests.rs 501-stub fixture moved from
/v1/timeline to /v1/reviews, so the coverage survives.

Verification

  • nix develop --command cargo test — 633 passed, 0 failed (607 before this
    change; the new coverage is tests/replan_tests.rs with 17 integration tests plus
    8 unit tests in the interruption module).
  • nix develop --command cargo clippy --all-targets -- -D warnings — clean.
  • nix develop --command cargo fmt --check — clean.
  • tests/replan_tests.rs drives the real route and the real binary against a mock
    CalDAV server over TCP and the daemon's own SQLite worker, asserting:
    • the recorded row's kind, user_report evidence class, actor, source, affected
      ids, reason, dedupe key and operation linkage;
    • an unknown start/end recorded as null with the unknown_estimate warning and
      anchor.basis = "reported_now";
    • fact durability when generation fails (unreachable calendar →
      dependency_unavailable; unresolvable outcome → not_found): status: partial,
      the fact present and durable, zero proposal rows, and the recorded answer
      replayable;
    • the remaining arithmetic exactly — anchor 12:00, window 09:00–17:00, one opaque
      13:00–14:00 hour: gross 300, busy 60, remaining 240, reserved 96 (40%),
      allocatable 144 — plus the two ordered steps at 14:00–15:30 and the stored digest
      recomputed from the stored steps;
    • zero calendar writes: every request the daemon made was a REPORT, and the
      journal deltas are exactly +1 operations, +1 life_events, +1 proposals and
      0 everywhere else;
    • replay under the same key (no second fact, no second proposal, no calendar read)
      versus idempotency_conflict (409) for a changed payload;
    • the fact retrievable through GET /v1/timeline, with occurred_at distinct from
      recorded_at, unknown_time_count and a null end visible, view=corrected
      refused 501 and an unknown evidence_class refused 400;
    • CLI exit codes 0, 2, 4, 5 and 6.
  • Manual end-to-end smoke with the real binary, a real life serve daemon and a
    stand-in Radicale: life replan --input - --json exited 0 with gross 300 / busy 60 / remaining 240 / reserved 96 / allocatable 144, a pending proposal and its
    digest; the CalDAV log contained six REPORTs and no other method; the journal held
    one fact per report and one proposal per generated outcome. With the calendar
    unreachable, the same command exited 6 with status: partial, the interruption
    event id and proposal: null, while the fact was durable in life_events and
    GET /v1/timeline returned it.

Notes, and what #27 needs

  • For #27: the response carries everything an approval flow needs — the new
    proposal.proposal_id, its canonical digest, state: pending, expires_at and
    the ordered steps with their baseline_etags. Nothing here applies anything; the
    proposal becomes a commitment only when life proposal apply runs.
  • Deliberate deviations, stated rather than hidden:
    • Replanning reuses #25's StepAction vocabulary (update_task, create_event)
      instead of introducing update_event/cancel_event steps: the #25 builder and
      #27's preflight are built on that vocabulary, and a second one here would be a
      second planner with no apply semantics behind it.
    • The issue text suggests emitting incomplete_calendar when the end is unknown.
      Only unknown_estimate is emitted: incomplete_calendar means a collection
      could not be read, and emitting it for a known calendar would be a false
      statement. An unknown end still allocates nothing confidently when the calendar
      is unreadable, because that is the capacity rule, not a warning.
    • GET /v1/timeline reports no coverage array: the journal is a single local
      store, so a successful read is complete by construction and a failed read is an
      error rather than a partial view. view=corrected is refused with
      not_implemented (corrections are #29) rather than answered with raw facts under
      a corrected name. The life timeline CLI verb is left unimplemented, as #26's
      CLI scope is life replan.
    • The issue's illustrative data.proposal.steps[].action = "update_event" example
      is not the shape produced here, for the reason above.

Closes #26

## Motivation Real execution is interrupted. A production incident, an impromptu meeting or an urgent obligation takes time out of a schedule that was already planned, and a service that answers that badly does one of two things: it leaves the blocks in the past as if they happened, or it silently moves them on the remote calendar. Life separates the two halves structurally — a **timeline fact** about what happened, and a **reviewable proposal** about what to do next — and #26 implements the interruption workflow (`docs/technical/workflows.md#planning-and-interruption`, scenario W08): *"Record user-reported interruption separately; compute available remainder; review changed commitments"*, with its failure path *"Recording may succeed while proposal generation fails; report both, do not erase fact or claim calendar changed"*. ## Changes **The fact is recorded first, and independently** (`src/domain/timeline/interruption.rs`, `src/history/journal.rs`) - `POST /v1/replans` takes `{interruption: {item_ids, occurred_at?, ended_at?, reason?}, remaining_intended_outcomes?, policy_exceptions?}` and appends one `life_events` row with `kind = "interruption_logged"` and `evidence_class = "user_report"` — never `confirmed_service` — carrying the affected item ids, the actual start the user reported, a **nullable** end, and the reason when supplied. - An omitted `occurred_at` is recorded as `null` beside the `recorded_at` the report was received at, and an unknown end stays unknown: neither is inferred from "now", from the requested window or from the moment of the report. A date-only value is refused rather than resolved to a midnight instant. - `JournalService::record_fact_operation` writes the `operations` row (`applied`, since a local-only fact has no second network phase) and appends the fact in one local transaction, with the replay/conflict semantics every other operation key has. The reasoning mirrors `src/history/proposals.rs`: recording it `prepared` would make startup recovery treat a local fact as an in-flight remote write. **Generation follows, and cannot erase the fact** (`src/api/replans.rs`, `src/history/proposals.rs`) - Generation runs after the fact is durable. When it fails — the calendar cannot be read, the capacity is unknown, a named outcome does not resolve, a policy cap has no explicit exception — the answer is `status: partial` (HTTP 200, CLI exit 6) with the recorded interruption in `data`, `proposal: null`, and the failure in `error`. The calendar is never claimed to have changed. - `record_generation_outcome` stores the outcome in a second transaction: the proposal row when one was generated, or the failure when none was — and the operation's recorded answer either way, so a replay reports what the first attempt actually answered. If a process dies between the two writes, a replay says so (`operation_unknown`) instead of inventing a proposal. **Replanning is planning from a later instant** (`src/api/plans.rs`, `src/domain/policy/capacity.rs`) - #25's planner is reused rather than restated: `generate` now takes a `Generation` (the window to observe, the named outcomes, the exceptions) and both routes call it. The remaining window is the remainder of the anchor's local day (`remaining_day`), which `resolve_working_windows` already clamps to the anchor — so the same working windows, the same 40% reserve on what remains, the same caps and the same ordered-step builder apply. A past end does not move the anchor backwards; an unknown end anchors at the report and is disclosed as `unknown_estimate` with `anchor.basis = "reported_now"`. - The proposal is new: its own id, its own canonical digest, `pending`, 24-hour expiry. The previous proposal's row is neither read nor mutated. **Zero calendar writes** — the route holds no writing CalDAV client. One `operations` row, one `life_events` fact and one `proposals` row are the whole effect; `item_projection`, `sync_state` and `outbox` stay untouched. The calendar changes only when #27 applies a reviewed proposal. **Timeline queryability** (`src/api/timeline.rs`, `src/history/timeline.rs`) - Implements the documented `GET /v1/timeline` read (`life timeline` = `GET /v1/timeline`, read): the last seven local days by `recorded_at` by default, with `from`/`to`, `item_id`, `evidence_class`, `occurred_from`/`to`, `limit` and `cursor`. A fact's recording time and its nullable actual time are kept apart, and every unknown member is serialized as `null` rather than omitted, so an unknown end is visible as unknown rather than absent. The view reports how many facts carry no actual time, which is what tells a quiet period from an unobserved one. **CLI** — `life replan --input - [--operation-id KEY] [--json]` prints one standard envelope and exits 0/2/4/5/6 per the contract. The payload is validated before the daemon is contacted, so a malformed report is exit 2 without a round trip. The `tests/cli_tests.rs` unimplemented-command fixture moved from `replan` to `review` (#30), and the `tests/api_tests.rs` 501-stub fixture moved from `/v1/timeline` to `/v1/reviews`, so the coverage survives. ## Verification - `nix develop --command cargo test` — **633 passed, 0 failed** (607 before this change; the new coverage is `tests/replan_tests.rs` with 17 integration tests plus 8 unit tests in the interruption module). - `nix develop --command cargo clippy --all-targets -- -D warnings` — clean. - `nix develop --command cargo fmt --check` — clean. - `tests/replan_tests.rs` drives the real route and the real binary against a mock CalDAV server over TCP and the daemon's own SQLite worker, asserting: - the recorded row's kind, `user_report` evidence class, actor, source, affected ids, reason, dedupe key and operation linkage; - an unknown start/end recorded as `null` with the `unknown_estimate` warning and `anchor.basis = "reported_now"`; - **fact durability when generation fails** (unreachable calendar → `dependency_unavailable`; unresolvable outcome → `not_found`): `status: partial`, the fact present and durable, zero proposal rows, and the recorded answer replayable; - the remaining arithmetic exactly — anchor 12:00, window 09:00–17:00, one opaque 13:00–14:00 hour: gross 300, busy 60, remaining 240, reserved 96 (40%), allocatable 144 — plus the two ordered steps at 14:00–15:30 and the stored digest recomputed from the stored steps; - **zero calendar writes**: every request the daemon made was a `REPORT`, and the journal deltas are exactly `+1` operations, `+1` life_events, `+1` proposals and `0` everywhere else; - replay under the same key (no second fact, no second proposal, no calendar read) versus `idempotency_conflict` (409) for a changed payload; - the fact retrievable through `GET /v1/timeline`, with `occurred_at` distinct from `recorded_at`, `unknown_time_count` and a `null` end visible, `view=corrected` refused 501 and an unknown `evidence_class` refused 400; - CLI exit codes 0, 2, 4, 5 and 6. - Manual end-to-end smoke with the real binary, a real `life serve` daemon and a stand-in Radicale: `life replan --input - --json` exited 0 with `gross 300 / busy 60 / remaining 240 / reserved 96 / allocatable 144`, a `pending` proposal and its digest; the CalDAV log contained six `REPORT`s and no other method; the journal held one fact per report and one proposal per generated outcome. With the calendar unreachable, the same command exited 6 with `status: partial`, the interruption event id and `proposal: null`, while the fact was durable in `life_events` and `GET /v1/timeline` returned it. ## Notes, and what #27 needs - **For #27:** the response carries everything an approval flow needs — the new `proposal.proposal_id`, its canonical `digest`, `state: pending`, `expires_at` and the ordered steps with their `baseline_etag`s. Nothing here applies anything; the proposal becomes a commitment only when `life proposal apply` runs. - **Deliberate deviations, stated rather than hidden:** - Replanning reuses #25's `StepAction` vocabulary (`update_task`, `create_event`) instead of introducing `update_event`/`cancel_event` steps: the #25 builder and #27's preflight are built on that vocabulary, and a second one here would be a second planner with no apply semantics behind it. - The issue text suggests emitting `incomplete_calendar` when the end is unknown. Only `unknown_estimate` is emitted: `incomplete_calendar` means a collection could not be read, and emitting it for a known calendar would be a false statement. An unknown end still allocates nothing confidently when the calendar is unreadable, because that is the capacity rule, not a warning. - `GET /v1/timeline` reports no `coverage` array: the journal is a single local store, so a successful read is complete by construction and a failed read is an error rather than a partial view. `view=corrected` is refused with `not_implemented` (corrections are #29) rather than answered with raw facts under a corrected name. The `life timeline` CLI verb is left unimplemented, as #26's CLI scope is `life replan`. - The issue's illustrative `data.proposal.steps[].action = "update_event"` example is not the shape produced here, for the reason above. ## Related Issues Closes #26
`POST /v1/replans` and `life replan --input -` record a user-reported
interruption as an append-only `life_events` fact and replan the remaining work
around it, without touching the calendar.

The order of work is the contract:

* The interruption is appended first, in the same transaction as the operation
  that reports it, with `evidence_class = 'user_report'` (never
  `confirmed_service`), the affected item ids, the actual start when the user
  reported one and a nullable end that is never inferred from "now" or from the
  requested window.
* Generation follows, and its failure does not erase the fact: the answer reports
  the recorded interruption and the generation failure separately, as `partial`
  (exit 6), and never claims the calendar changed.
* Replanning is planning from a later starting instant with less remaining
  capacity. It reuses #25's capacity arithmetic, caps, selection and ordered-step
  builder rather than restating any of them, measuring the remainder from the
  interruption's end (or from the moment of the report, disclosed as
  `unknown_estimate`) to the end of the configured working window, with the same
  40% reserve applied to what remains.
* Zero calendar writes: one `pending` proposal row with its own id and canonical
  digest, and no `VEVENT` or `VTODO` is created, patched or deleted.

Also adds the documented `GET /v1/timeline` read, so a recorded interruption is
queryable as a timeline fact with its recording time and its nullable actual
times preserved (an unknown end is visible as unknown, not absent), and moves the
`tests/cli_tests.rs` and `tests/api_tests.rs` unimplemented fixtures to commands
and routes that are still unimplemented.
Author
Owner

Coordinator review — APPROVED

Verified 6ed44f4 myself: cargo test 633 passed / 0 failed (17 new replan tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • The fact is recorded through a reusable, correct primitive. journal::record_fact_operation commits the operations row and the life_events append in one transaction, with replay detection by fingerprint (a mismatched fingerprint on an existing operation id is an error rather than a silent overwrite). That is the right shape for #28/#29 to reuse, and it means "fact first" is a transaction boundary rather than a call ordering you have to trust.
  • Independence is tested with generation actually failing, twice, in different ways — an unreachable calendar and an unknown item named in remaining_intended_outcomes — and in both cases the fact survives with status: partial, the interruption event id present, proposal: null, calDAV.read_only(), and exact row deltas. The manual smoke confirms the same on a real daemon (exit 6, durable row, operations.state=applied, zero proposals). Reporting both outcomes separately is the criterion's whole point, and it is demonstrated rather than asserted.
  • The interruption is a user_report, and the tests assert it is not confirmed_service — the distinction that keeps an unverified claim from inheriting a service-confirmed one's evidential strength. An unknown end is stored as ended_at: null rather than being filled from "now".
  • Replanning genuinely reuses the #25 engine (plans::generate is now shared) with the anchor's remaining local day: gross 300, busy 60, remaining 240, reserved 96 (ceil, 40% of remaining), allocatable 144 — the same rule as #25, applied to a later instant, and the stored digest recomputes from the stored steps.
  • The previous proposal is left byte-identical while the replan stores its own row with a different id and digest — asserted directly, which is what stops "replan" from becoming "mutate the plan I already showed you".
  • Zero writes are proven twice over: read_only() (every request the daemon made begins with REPORT) plus per-table deltas, and the smoke run's CalDAV log uniq -c showing six REPORTs and nothing else.
  • The tests/cli_tests.rs unimplemented-command fixture was moved to a still-unimplemented verb rather than deleted, keeping that coverage alive as the CLI fills in.

Merging. #27 now needs only the new proposal id and digest plus preflight, which the PR states explicitly.

## Coordinator review — APPROVED Verified `6ed44f4` myself: `cargo test` **633 passed / 0 failed** (17 new replan tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **The fact is recorded through a reusable, correct primitive.** `journal::record_fact_operation` commits the `operations` row and the `life_events` append in one transaction, with replay detection by fingerprint (a mismatched fingerprint on an existing operation id is an error rather than a silent overwrite). That is the right shape for #28/#29 to reuse, and it means "fact first" is a transaction boundary rather than a call ordering you have to trust. - **Independence is tested with generation actually failing, twice, in different ways** — an unreachable calendar and an unknown item named in `remaining_intended_outcomes` — and in both cases the fact survives with `status: partial`, the interruption event id present, `proposal: null`, `calDAV.read_only()`, and exact row deltas. The manual smoke confirms the same on a real daemon (exit 6, durable row, `operations.state=applied`, zero proposals). Reporting both outcomes separately is the criterion's whole point, and it is demonstrated rather than asserted. - **The interruption is a `user_report`, and the tests assert it is not `confirmed_service`** — the distinction that keeps an unverified claim from inheriting a service-confirmed one's evidential strength. An unknown end is stored as `ended_at: null` rather than being filled from "now". - **Replanning genuinely reuses the #25 engine** (`plans::generate` is now shared) with the anchor's remaining local day: gross 300, busy 60, remaining 240, reserved 96 (`ceil`, 40% of remaining), allocatable 144 — the same rule as #25, applied to a later instant, and the stored digest recomputes from the stored steps. - **The previous proposal is left byte-identical** while the replan stores its own row with a different id and digest — asserted directly, which is what stops "replan" from becoming "mutate the plan I already showed you". - **Zero writes are proven twice over**: `read_only()` (every request the daemon made begins with `REPORT`) plus per-table deltas, and the smoke run's CalDAV log `uniq -c` showing six `REPORT`s and nothing else. - The `tests/cli_tests.rs` unimplemented-command fixture was **moved** to a still-unimplemented verb rather than deleted, keeping that coverage alive as the CLI fills in. Merging. `#27` now needs only the new proposal id and digest plus preflight, which the PR states explicitly.
yuris merged commit cc7e274ebb into main 2026-09-23 02:19:47 +00:00
yuris changed title from feat(api): interruption fact logging and schedule replanning (#26) to feat(api): interruption fact logging and schedule replanning 2026-09-23 05:12:00 +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!69
No description provided.