feat(api): planning proposal engine with capacity reserve #68
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-25-planning"
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
Life's planning workflow is the two-stage model
docs/technical/workflows.md#planning-and-interruptiondescribes: generation stores a reviewable proposal, and only an explicitly approved apply changes the calendar. Nothing implemented that first stage, solife plan --period today|weekwas 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:
Changes
Generation is a write that stores a proposal and touches nothing else: no
VEVENTorVTODOis created, patched or deleted, and applying the proposal stays #27's separately reviewed workflow.Capacity (
src/domain/policy/capacity.rs) — resolves[planning].working_windowsto 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 ownApiItem.consumes_capacityflag rather than re-derivingTRANSP/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 distinctX-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 isinvalid_input/422 carrying the documentedpolicy_review_requiredwarning that names the code to supply.Proposal (
src/domain/proposal.rs) — ordered atomic steps (update_taskrecordingplanned_start, thencreate_eventfor the block; each carrying its target, its payload and the livebaseline_etagit was computed against,nullfor 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 = 24is a named constant citing the documented default.Persistence (
src/history/proposals.rs) — onependingproposalsrow, plus theoperationsbookkeeping that makes the caller'sIdempotency-Keyidempotent, committed in a single local transaction and recordedapplied. 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 itpreparedwould 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.rsnow exposeslive_task_inventory(used by #24 and #25; #24's own warning wording and behaviour are unchanged) andnext::career_flooris 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_deferralscountstask_deferredevents whose evidence class establishes a deliberate act (user_report,confirmed_service); anobserved_externaldifference 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 insrc/api/mod.rs; #35'sauth::route_accessalready maps it to write scope) andlife plan --period today|week [--input -] [--json](src/cli.rs). The response is one standard envelope carrying the proposal (non-emptyproposal_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/plansreturnsdata.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) -> ProposalRecordreads the stored row (state,digest,created_at,expires_at,steps_json,policy_json,operation_id,approval_json);ProposalState::parseandStepAction::parsedecode 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_atstates expiry. Expiry bounds review availability only — it never bypasses the baseline check.update_task→{"planned_start": {"kind":"instant","value":…}};create_event→{"title", "start", "end", "related_task_id"}withbaseline_etag: null(asserting nonexistence) and anevt_…preallocated identity.steps_json+policy_json.policy_exceptions, refuse an expired proposal, and preflight every non-nullbaseline_etagbefore dispatch.Notes and deliberate decisions
life_events.kind = 'task_deferred'withafter_json.item_idnaming 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_overridestates 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.todayplan may intenddaily_outcome_maxoutcomes; aweekplan may intenddaily_outcome_max × 7, and anything the cap leaves out when the plan selects its own work is disclosed as unallocated rather than dropped.Verification
nix develop --command cargo test— 607 passed, 0 failed across every suite (up from 557), including #24'snext_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 reallifebinary against a daemon serving the daemon's own router. It covers, per acceptance criterion:exceed_daily_outcomes_capnamed, and the same request proceeding with the exception recorded — reason included when supplied and optional when not; two projects likewise;REPORTis ever issued andPUT/POST/DELETE/PATCH/PROPPATCH/MKCOLcounts are asserted to be zero, while the journal gains exactly oneproposalsrow and itsoperationsbookkeeping (life_events,item_projection,sync_state,outboxall unchanged) — plus a replay that writes nothing and issues noREPORT;state: pending,approval_json: null;observed_externaldifferences proven not to count;Related Issues
Refs #22, #23, #24, #26, #27, #35
Closes #25
Coordinator review — APPROVED
Verified
999e74don a tree based on currentmain(ec72708, so #36's backup/DR work is present and both sides survived the rebase). Independent verification:cargo test607 passed / 0 failed (21 new planning tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
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)withallocatable = available − reservedkeepsreserved + allocatable == remainingexact.basis: no_working_windowsand zero steps; an incomplete calendar givesstate: unknown,basis: incomplete_calendar, theincomplete_calendarwarning 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".capacity.rsreads onlyApiItem.consumes_capacity; the transparent/cancelled test adds both over the same two hours and assertsbusy_minutesandallocatableare unchanged.policy_json, with an omitted reason recorded as absent and an unknown exception code refused asinvalid_input.canonical_digestre-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.live_task_inventoryis now shared bynext.rs(semantics unchanged) instead of a second inventory read, and the CLI stub fixture moved fromplantoreplanbecauselife plannow exists — the correct fix rather than deleting the coverage.Nit, no change required:
canonical_digestends withserde_json::to_string(&canonical).unwrap_or_default(), so an impossible serialization failure would hash the empty string. It is unreachable for aValue(object keys are already strings), butexpectwith a reason would state that instead of implying a case exists.Merging.
#26(interruption replanning) and#27(approval + application) now build on this engine.feat(api): planning proposal engine with capacity reserve (#25)to feat(api): planning proposal engine with capacity reserve