feat(api): planning proposal engine with capacity reserve #68

Merged
yuris merged 1 commit from feat/issue-25-planning into main 2026-09-23 01:37:08 +00:00
Owner

Motivation

Life's planning workflow is the two-stage model docs/technical/workflows.md#planning-and-interruption describes: generation stores a reviewable proposal, and only an explicitly approved apply changes the calendar. Nothing implemented that first stage, so life plan --period today|week was still the #2 scaffold stub and no capacity arithmetic, policy cap or proposal artifact existed.

The workflow also states four rules that a naive scheduler gets wrong, and each is the reason a piece of this change exists:

  • Reserve 40% of declared available capacity unscheduled, computed from configured working windows minus opaque busy time, and applied to the capacity remaining after busy time — not to the gross window.
  • No working windows, or incomplete busy data, means unknown capacity — never zero and never an empty day.
  • At most three intended outcomes per day and one active discretionary project, with an exception that is explicit, scoped and recorded rather than silently applied.
  • An explicit review after two confirmed deferrals, which decides nothing itself.

Changes

Generation is a write that stores a proposal and touches nothing else: no VEVENT or VTODO is created, patched or deleted, and applying the proposal stays #27's separately reviewed workflow.

Capacity (src/domain/policy/capacity.rs) — resolves [planning].working_windows to concrete UTC intervals for the period (earlier DST occurrence, so a window never silently starts an hour late), reads busy time from the agenda's own ApiItem.consumes_capacity flag rather than re-deriving TRANSP/STATUS, clamps and merges overlapping busy intervals, and returns the arithmetic as data: gross, busy, remaining, declared available, reserved and allocatable. The reserve is integer arithmetic scaled from the configured fraction (ceil, so it never under-protects) and always sums back with the allocatable figure. A fraction outside [0, 1] is refused, not clamped.

Policy (src/domain/policy/planning.rs) — the outcome cap (the daily cap applied to each local day the period spans), the project cap over the distinct X-LIFE-PROJECT-IDs the plan targets, and the deferral review prompt. An exception lifts exactly the cap it names and its optional reason is recorded; an unknown exception code is refused. Exceeding a cap without its exception is invalid_input/422 carrying the documented policy_review_required warning that names the code to supply.

Proposal (src/domain/proposal.rs) — ordered atomic steps (update_task recording planned_start, then create_event for the block; each carrying its target, its payload and the live baseline_etag it was computed against, null for a creation), and a canonical SHA-256 digest over the ordered steps, their baselines and the policy exceptions, computed through one shared hashing helper (history::fingerprint::canonical_digest) so it is not a serde-ordered blob. PROPOSAL_TTL_HOURS = 24 is a named constant citing the documented default.

Persistence (src/history/proposals.rs) — one pending proposals row, plus the operations bookkeeping that makes the caller's Idempotency-Key idempotent, committed in a single local transaction and recorded applied. It is deliberately not two-phase: a calendar mutation is two-phase because its second phase is a network write that may not have landed, whereas generation has no such phase — recording it prepared would make startup recovery try to reconcile a proposal against the calendar. A replay answers from the recorded outcome and reads no calendar at all.

Work selection — the plan reuses #22's filter and #23's ranking, so a plan cannot disagree with a recommendation: src/api/tasks.rs now exposes live_task_inventory (used by #24 and #25; #24's own warning wording and behaviour are unchanged) and next::career_floor is shared. Momentary energy is not a planning input — the documented plan payload has none — so the filter's energy rule is evaluated at the highest level, which excludes nothing, rather than at an invented level that would silently drop work the caller could do on another day.

Deferrals (src/history/timeline.rs) — confirmed_deferrals counts task_deferred events whose evidence class establishes a deliberate act (user_report, confirmed_service); an observed_external difference is excluded by the query, which is what the policy says. A repeated deferral raises the prompt (smaller scope, queue/someday, retain or cancel) and leaves the item unallocated and otherwise untouched.

Surface — POST /v1/plans (src/api/plans.rs, route registered in src/api/mod.rs; #35's auth::route_access already maps it to write scope) and life plan --period today|week [--input -] [--json] (src/cli.rs). The response is one standard envelope carrying the proposal (non-empty proposal_id, digest, ordered steps), the stated capacity arithmetic, the per-collection live coverage the decision used, the unallocated outcomes with their reasons, the review prompts and the applied exceptions.

Typed API #27 consumes

  • POST /v1/plans returns data.proposal.{proposal_id, schema_version, state, digest, created_at, expires_at, steps[]}; each step is {step_id, action: create_event|update_task, item_id, collection_id, baseline_etag, payload, reason}.
  • life::history::get_proposal(&DatabaseWorker, id) -> ProposalRecord reads the stored row (state, digest, created_at, expires_at, steps_json, policy_json, operation_id, approval_json); ProposalState::parse and StepAction::parse decode the stored vocabulary.
  • life::domain::proposal::proposal_digest(steps, exceptions) recomputes the digest from the published steps and exceptions, which is what approval verification needs; ProposalRecord::is_expired_at states expiry. Expiry bounds review availability only — it never bypasses the baseline check.
  • Step payloads: update_task → {"planned_start": {"kind":"instant","value":…}}; create_event → {"title", "start", "end", "related_task_id"} with baseline_etag: null (asserting nonexistence) and an evt_… preallocated identity.
  • #27 must verify the digest over steps_json + policy_json.policy_exceptions, refuse an expired proposal, and preflight every non-null baseline_etag before dispatch.

Notes and deliberate decisions

  • The deferral fact is owned by the defer workflow, which is not in this issue: planning reads life_events.kind = 'task_deferred' with after_json.item_id naming the canonical item id. That writer (and the "since its last review" boundary, which needs a review event that does not exist yet) is follow-up work; the count is therefore over all confirmed deferrals for the item.
  • capacity_override states the caller's own available capacity, and is the only way to plan a period this deployment declares no working window for. It cannot rescue an incompletely observed calendar: that is the one case where no confident allocation happens whatever the caller states.
  • The outcome cap is the daily cap applied to each local day the period spans, because the engine does not assign an outcome to a day before it allocates one. A today plan may intend daily_outcome_max outcomes; a week plan may intend daily_outcome_max × 7, and anything the cap leaves out when the plan selects its own work is disclosed as unallocated rather than dropped.
  • No new dependency.

Verification

  • nix develop --command cargo test — 607 passed, 0 failed across every suite (up from 557), including #24's next_command_tests, #22/#23's filter and ranking suites, and the journal, lifecycle and backup/DR suites.
  • cargo clippy --all-targets -- -D warnings — clean. cargo fmt --check — clean.
  • tests/planning_tests.rs (21 tests) drives the real path: mock CalDAV over TCP, the daemon's own configuration, its own SQLite worker, and the real life binary against a daemon serving the daemon's own router. It covers, per acceptance criterion:
    • the stated arithmetic on a fixture with known events — 480m gross, 120m busy, 360m remaining, 144m reserved (exactly 40%), 216m allocatable, with the block placed at 11:00–12:30 in the first free slot;
    • transparent and cancelled events proven capacity-free by assertion on the response, not by re-deriving the rule;
    • unknown capacity for no working windows and for an incompletely observed collection, each disclosing its warning, claiming no figure and allocating nothing;
    • four outcomes in a day refused with exceed_daily_outcomes_cap named, and the same request proceeding with the exception recorded — reason included when supplied and optional when not; two projects likewise;
    • zero calendar writes: only REPORT is ever issued and PUT/POST/DELETE/PATCH/PROPPATCH/MKCOL counts are asserted to be zero, while the journal gains exactly one proposals row and its operations bookkeeping (life_events, item_projection, sync_state, outbox all unchanged) — plus a replay that writes nothing and issues no REPORT;
    • the digest recomputed from the published steps and exceptions, a 24-hour expiry, state: pending, approval_json: null;
    • the prompt after two confirmed deferrals with the four options, the item left unallocated, and two observed_external differences proven not to count;
    • the CLI printing one envelope and exiting 0, rendering the arithmetic, and exiting 2 for a missing or unknown period without reading the calendar.

Refs #22, #23, #24, #26, #27, #35

Closes #25

## Motivation Life's planning workflow is the two-stage model `docs/technical/workflows.md#planning-and-interruption` describes: generation stores a reviewable proposal, and only an explicitly approved apply changes the calendar. Nothing implemented that first stage, so `life plan --period today|week` was still the #2 scaffold stub and no capacity arithmetic, policy cap or proposal artifact existed. The workflow also states four rules that a naive scheduler gets wrong, and each is the reason a piece of this change exists: * **Reserve 40% of declared available capacity unscheduled**, computed from configured working windows minus opaque busy time, and applied to the capacity *remaining after* busy time — not to the gross window. * **No working windows, or incomplete busy data, means unknown capacity** — never zero and never an empty day. * **At most three intended outcomes per day and one active discretionary project**, with an exception that is explicit, scoped and recorded rather than silently applied. * **An explicit review after two confirmed deferrals**, which decides nothing itself. ## Changes Generation is a write that stores a proposal and touches nothing else: no `VEVENT` or `VTODO` is created, patched or deleted, and applying the proposal stays #27's separately reviewed workflow. **Capacity (`src/domain/policy/capacity.rs`)** — resolves `[planning].working_windows` to concrete UTC intervals for the period (earlier DST occurrence, so a window never silently starts an hour late), reads busy time from the agenda's own `ApiItem.consumes_capacity` flag rather than re-deriving `TRANSP`/`STATUS`, clamps and merges overlapping busy intervals, and returns the arithmetic as data: gross, busy, remaining, declared available, reserved and allocatable. The reserve is integer arithmetic scaled from the configured fraction (`ceil`, so it never under-protects) and always sums back with the allocatable figure. A fraction outside `[0, 1]` is refused, not clamped. **Policy (`src/domain/policy/planning.rs`)** — the outcome cap (the daily cap applied to each local day the period spans), the project cap over the distinct `X-LIFE-PROJECT-ID`s the plan targets, and the deferral review prompt. An exception lifts exactly the cap it names and its optional reason is recorded; an unknown exception code is refused. Exceeding a cap without its exception is `invalid_input`/422 carrying the documented `policy_review_required` warning that names the code to supply. **Proposal (`src/domain/proposal.rs`)** — ordered atomic steps (`update_task` recording `planned_start`, then `create_event` for the block; each carrying its target, its payload and the live `baseline_etag` it was computed against, `null` for a creation), and a canonical SHA-256 digest over the ordered steps, their baselines and the policy exceptions, computed through one shared hashing helper (`history::fingerprint::canonical_digest`) so it is not a serde-ordered blob. `PROPOSAL_TTL_HOURS = 24` is a named constant citing the documented default. **Persistence (`src/history/proposals.rs`)** — one `pending` `proposals` row, plus the `operations` bookkeeping that makes the caller's `Idempotency-Key` idempotent, committed in a **single local transaction** and recorded `applied`. It is deliberately not two-phase: a calendar mutation is two-phase because its second phase is a network write that may not have landed, whereas generation has no such phase — recording it `prepared` would make startup recovery try to reconcile a proposal against the calendar. A replay answers from the recorded outcome and reads no calendar at all. **Work selection** — the plan reuses #22's filter and #23's ranking, so a plan cannot disagree with a recommendation: `src/api/tasks.rs` now exposes `live_task_inventory` (used by #24 and #25; #24's own warning wording and behaviour are unchanged) and `next::career_floor` is shared. Momentary energy is not a planning input — the documented plan payload has none — so the filter's energy rule is evaluated at the highest level, which excludes nothing, rather than at an invented level that would silently drop work the caller could do on another day. **Deferrals (`src/history/timeline.rs`)** — `confirmed_deferrals` counts `task_deferred` events whose evidence class establishes a deliberate act (`user_report`, `confirmed_service`); an `observed_external` difference is excluded by the query, which is what the policy says. A repeated deferral raises the prompt (smaller scope, queue/someday, retain or cancel) and leaves the item **unallocated and otherwise untouched**. **Surface** — `POST /v1/plans` (`src/api/plans.rs`, route registered in `src/api/mod.rs`; #35's `auth::route_access` already maps it to write scope) and `life plan --period today|week [--input -] [--json]` (`src/cli.rs`). The response is one standard envelope carrying the proposal (non-empty `proposal_id`, `digest`, ordered steps), the stated capacity arithmetic, the per-collection live coverage the decision used, the unallocated outcomes with their reasons, the review prompts and the applied exceptions. ### Typed API #27 consumes * `POST /v1/plans` returns `data.proposal.{proposal_id, schema_version, state, digest, created_at, expires_at, steps[]}`; each step is `{step_id, action: create_event|update_task, item_id, collection_id, baseline_etag, payload, reason}`. * `life::history::get_proposal(&DatabaseWorker, id) -> ProposalRecord` reads the stored row (`state`, `digest`, `created_at`, `expires_at`, `steps_json`, `policy_json`, `operation_id`, `approval_json`); `ProposalState::parse` and `StepAction::parse` decode the stored vocabulary. * `life::domain::proposal::proposal_digest(steps, exceptions)` recomputes the digest from the published steps and exceptions, which is what approval verification needs; `ProposalRecord::is_expired_at` states expiry. Expiry bounds review availability only — it never bypasses the baseline check. * Step payloads: `update_task` → `{"planned_start": {"kind":"instant","value":…}}`; `create_event` → `{"title", "start", "end", "related_task_id"}` with `baseline_etag: null` (asserting nonexistence) and an `evt_…` preallocated identity. * **#27 must** verify the digest over `steps_json` + `policy_json.policy_exceptions`, refuse an expired proposal, and preflight every non-null `baseline_etag` before dispatch. ### Notes and deliberate decisions * The deferral fact is owned by the defer workflow, which is not in this issue: planning *reads* `life_events.kind = 'task_deferred'` with `after_json.item_id` naming the canonical item id. That writer (and the "since its last review" boundary, which needs a review event that does not exist yet) is follow-up work; the count is therefore over all confirmed deferrals for the item. * `capacity_override` states the caller's own available capacity, and is the only way to plan a period this deployment declares no working window for. It cannot rescue an incompletely observed calendar: that is the one case where no confident allocation happens whatever the caller states. * The outcome cap is the daily cap applied to each local day the period spans, because the engine does not assign an outcome to a day before it allocates one. A `today` plan may intend `daily_outcome_max` outcomes; a `week` plan may intend `daily_outcome_max × 7`, and anything the cap leaves out when the plan selects its own work is disclosed as unallocated rather than dropped. * No new dependency. ## Verification * `nix develop --command cargo test` — **607 passed, 0 failed** across every suite (up from 557), including #24's `next_command_tests`, #22/#23's filter and ranking suites, and the journal, lifecycle and backup/DR suites. * `cargo clippy --all-targets -- -D warnings` — clean. `cargo fmt --check` — clean. * `tests/planning_tests.rs` (21 tests) drives the real path: mock CalDAV over TCP, the daemon's own configuration, its own SQLite worker, and the real `life` binary against a daemon serving the daemon's own router. It covers, per acceptance criterion: * the stated arithmetic on a fixture with known events — 480m gross, 120m busy, 360m remaining, **144m reserved (exactly 40%)**, 216m allocatable, with the block placed at 11:00–12:30 in the first free slot; * transparent and cancelled events proven capacity-free by assertion on the response, not by re-deriving the rule; * unknown capacity for no working windows and for an incompletely observed collection, each disclosing its warning, claiming no figure and allocating nothing; * four outcomes in a day refused with `exceed_daily_outcomes_cap` named, and the same request proceeding with the exception recorded — reason included when supplied and optional when not; two projects likewise; * **zero calendar writes**: only `REPORT` is ever issued and `PUT`/`POST`/`DELETE`/`PATCH`/`PROPPATCH`/`MKCOL` counts are asserted to be zero, while the journal gains exactly one `proposals` row and its `operations` bookkeeping (`life_events`, `item_projection`, `sync_state`, `outbox` all unchanged) — plus a replay that writes nothing and issues no `REPORT`; * the digest recomputed from the published steps and exceptions, a 24-hour expiry, `state: pending`, `approval_json: null`; * the prompt after two confirmed deferrals with the four options, the item left unallocated, and two `observed_external` differences proven not to count; * the CLI printing one envelope and exiting 0, rendering the arithmetic, and exiting 2 for a missing or unknown period without reading the calendar. ## Related Issues Refs #22, #23, #24, #26, #27, #35 Closes #25
Author
Owner

Coordinator review — APPROVED

Verified 999e74d on a tree based on current main (ec72708, so #36's backup/DR work is present and both sides survived the rebase). Independent verification: cargo test 607 passed / 0 failed (21 new planning tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • The reserve is computed on the right quantity, and the test proves the distinction rather than restating it. Fixture: 480 gross, 120 busy, 360 remaining, 144 reserved, 216 allocatable. The test asserts reserved != 192 — i.e. it explicitly rules out the tempting-but-wrong "40% of gross", which is the failure mode this criterion exists to prevent. ceil(available × fraction) with allocatable = available − reserved keeps reserved + allocatable == remaining exact.
  • Unknown capacity is null, never zero. With no configured windows every capacity field is null with basis: no_working_windows and zero steps; an incomplete calendar gives state: unknown, basis: incomplete_calendar, the incomplete_calendar warning naming the unavailable collection, and zero steps. Representing unknown as null rather than 0 is what stops a downstream reader from treating "we don't know" as "there is nothing".
  • Busy semantics are consumed, not re-derived. capacity.rs reads only ApiItem.consumes_capacity; the transparent/cancelled test adds both over the same two hours and asserts busy_minutes and allocatable are unchanged.
  • Caps are enforced with recorded, reviewable exceptions: four outcomes and a second active project both 422 with the specific code, and with an exception the proposal is created with the exception recorded in the response and in the stored policy_json, with an omitted reason recorded as absent and an unknown exception code refused as invalid_input.
  • The digest is canonical, not incidental: canonical_digest re-canonicalizes the JSON (sorted keys) before hashing, so the proposal digest is reproducible rather than dependent on serialization order — which is what #27 needs to verify approval.
  • Reuse held: live_task_inventory is now shared by next.rs (semantics unchanged) instead of a second inventory read, and the CLI stub fixture moved from plan to replan because life plan now exists — the correct fix rather than deleting the coverage.

Nit, no change required: canonical_digest ends with serde_json::to_string(&canonical).unwrap_or_default(), so an impossible serialization failure would hash the empty string. It is unreachable for a Value (object keys are already strings), but expect with a reason would state that instead of implying a case exists.

Merging. #26 (interruption replanning) and #27 (approval + application) now build on this engine.

## Coordinator review — APPROVED Verified `999e74d` on a tree based on current `main` (`ec72708`, so #36's backup/DR work is present and both sides survived the rebase). Independent verification: `cargo test` **607 passed / 0 failed** (21 new planning tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **The reserve is computed on the right quantity, and the test proves the distinction rather than restating it.** Fixture: 480 gross, 120 busy, 360 remaining, 144 reserved, 216 allocatable. The test asserts `reserved != 192` — i.e. it explicitly rules out the tempting-but-wrong "40% of gross", which is the failure mode this criterion exists to prevent. `ceil(available × fraction)` with `allocatable = available − reserved` keeps `reserved + allocatable == remaining` exact. - **Unknown capacity is *null*, never zero.** With no configured windows every capacity field is null with `basis: no_working_windows` and zero steps; an incomplete calendar gives `state: unknown`, `basis: incomplete_calendar`, the `incomplete_calendar` warning naming the unavailable collection, and zero steps. Representing unknown as null rather than 0 is what stops a downstream reader from treating "we don't know" as "there is nothing". - **Busy semantics are consumed, not re-derived.** `capacity.rs` reads only `ApiItem.consumes_capacity`; the transparent/cancelled test adds both over the same two hours and asserts `busy_minutes` and `allocatable` are unchanged. - **Caps are enforced with recorded, reviewable exceptions:** four outcomes and a second active project both 422 with the specific code, and with an exception the proposal is created with the exception recorded in the response *and* in the stored `policy_json`, with an omitted reason recorded as absent and an unknown exception code refused as `invalid_input`. - **The digest is canonical, not incidental:** `canonical_digest` re-canonicalizes the JSON (sorted keys) before hashing, so the proposal digest is reproducible rather than dependent on serialization order — which is what #27 needs to verify approval. - Reuse held: `live_task_inventory` is now shared by `next.rs` (semantics unchanged) instead of a second inventory read, and the CLI stub fixture moved from `plan` to `replan` because `life plan` now exists — the correct fix rather than deleting the coverage. Nit, no change required: `canonical_digest` ends with `serde_json::to_string(&canonical).unwrap_or_default()`, so an impossible serialization failure would hash the empty string. It is unreachable for a `Value` (object keys are already strings), but `expect` with a reason would state that instead of implying a case exists. Merging. `#26` (interruption replanning) and `#27` (approval + application) now build on this engine.
yuris merged commit 8e6f79b611 into main 2026-09-23 01:37:08 +00:00
yuris changed title from feat(api): planning proposal engine with capacity reserve (#25) to feat(api): planning proposal engine with capacity reserve 2026-09-23 05:12:01 +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!68
No description provided.