feat(api): next action command with advisory provenance #66

Merged
yuris merged 1 commit from feat/issue-24-next into main 2026-09-23 00:53:28 +00:00
Owner

Motivation

life next is the primary entry point for a user deciding what to work on right now, and it is the surface where three easy mistakes become user-visible lies: presenting a model's opinion as a fact, dropping an attribute the item never stated, and letting a recommendation look like an action. #22 (policy filter) and #23 (lexicographic ranker) already own the deterministic constraints and the order; this change adds the missing user-facing surface — POST /v1/next and life next — and nothing else, so those invariants are published rather than re-implemented.

The documented contract (docs/technical/cli-api.md#command-to-route-table, #command-payloads-and-results; docs/technical/jev.md#next-task-policy steps 1–4; docs/technical/workflows.md; scenario W07) fixes the shape: read scope, at most three candidates with item_id, factual_reasons, semantic_reasons, unknowns and judgment provenance, zero calendar writes, and a choice that is never task completion.

Changes

  • src/api/next.rs (new) — POST /v1/next, a thin read over #22 + #23:
    • request validation: window is a positive duration (90m, 2h) bounded to 1 minute–24 hours (the bound is stated in the module and enforced with the reason), energy is one of the three levels that exist. A malformed request is invalid_input (HTTP 400 / exit 2) before any backend is contacted.
    • live read: the whole VTODO inventory across every configured collection (never an open-only projection — the lifecycle rule belongs to #22, so a completed task is excluded with its own diagnostic rather than silently missing), reusing tasks::todo_report rather than duplicating the read. An unread collection is disclosed as incomplete_calendar; all collections unread is dependency_unavailable (exit 5), never an empty calendar.
    • journal read: the weekday career floor from explicit recorded qualifying outcomes (history::timeline::day_outcomes), where an unreadable or absent journal is history_incomplete + CareerFloor::Unknown — an observation gap, never an unmet floor.
    • response data: basis (semantic/deterministic), considered (how many candidates the policy ordered, so the three-candidate bound is stated rather than hidden), candidates (documented fields; unknowns propagated verbatim from #22; judgment_provenance = pinned model + the versioned questions), and excluded (one stable reason code and sentence per dropped task, so an empty answer explains itself).
    • no policy is restated: the route builds the query, the context and the presentation only.
  • src/api/mod.rs — registers next and the read-scope next_handler (no Idempotency-Key; mirrors the existing /v1/resolve read-scope POST).
  • src/api/tasks.rs — todo_report becomes pub(crate) so the next-action read shares the one live VTODO read implementation.
  • src/cli.rs — life next --window <duration> --energy <low|medium|high> [--area <area>] [--json]: validates both flags locally (exit 2 without a round trip), posts through post_read_envelope (no operation key), prints exactly one envelope on stdout with --json, and otherwise renders a terminal view that marks [Fact], [Jev] and [UNKNOWN] apart, states the basis, and says plainly that choosing a candidate is not task completion.
  • tests/next_command_tests.rs (new) — 19 end-to-end tests over real HTTP: mock CalDAV + mock System One over TCP, the daemon's own router and SQLite worker, and the real binary for the CLI assertions.

Explicitly not in scope: planning/proposals (#25–#27), outcome capture (#28), auth (#35), and any change to #22/#23 semantics.

Verification

nix develop --command cargo test — 537 passed, 0 failed (was 514; +19 integration, +4 unit). cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.

Scenarios covered by tests/next_command_tests.rs:

  • W07 happy path: four eligible tasks return exactly three candidates in the documented order (overdue → inside window → later), considered: 4, every documented field present, and no factual reason is a score while every semantic reason is a model answer naming the model and question versions.
  • --window 90m excludes a known-120m task with window_exceeded, retains the unestimated task, and that task carries unknowns: [estimated_minutes, energy] with a factual reason that says fit is not claimed; the same task becomes eligible (and is claimed to fit) at --window 2h.
  • An observed non-qualifying journal outcome makes the weekday career floor unmet, and that appears as a factual reason — read from the journal, not inferred from an allocation.
  • Malformed window/energy ({}, 90, 0m, 25h, 1h30m, turbo, missing flags) → HTTP 400 / CLI exit 2, asserted with zero calendar requests and zero judgment dispatches.
  • Degradation: Jev disabled, unreachable, refusing (503), drifting (unapproved model) and slow (past its budget) each return basis: deterministic with judgment_unavailable/judgment_invalid and every candidate retained; a semantic run and a deterministic run are shown to state identical factual reasons for the same candidate.
  • Empty result: HTTP 200 with candidates: [], considered: 0 and the filter's exclusion diagnostics — never a 404, never an invented candidate.
  • Coverage: an unread collection is disclosed as incomplete_calendar (naming the collection) with candidates retained and no free-capacity claim; an unreachable calendar is dependency_unavailable / exit 5 with no data.
  • Read-only invariant on every path: no calendar method other than REPORT (explicit PUT/POST/DELETE/PATCH/PROPPATCH/MKCOL counts of 0), no journal row written, operations rows 0, no operation_id/operation_state in any answer, and the completed task is never offered.
  • CLI: exit 0 with exactly one envelope line on stdout (clean stderr), exit 2 for every malformed flag combination, exit 5 when the calendar is unreachable, and a human view carrying [Fact]/[UNKNOWN] markers, the degraded-basis note and the "not task completion" line with diagnostics on stderr.

Closes #24

## Motivation `life next` is the primary entry point for a user deciding what to work on right now, and it is the surface where three easy mistakes become user-visible lies: presenting a model's opinion as a fact, dropping an attribute the item never stated, and letting a recommendation look like an action. #22 (policy filter) and #23 (lexicographic ranker) already own the deterministic constraints and the order; this change adds the missing user-facing surface — `POST /v1/next` and `life next` — and nothing else, so those invariants are published rather than re-implemented. The documented contract (`docs/technical/cli-api.md#command-to-route-table`, `#command-payloads-and-results`; `docs/technical/jev.md#next-task-policy` steps 1–4; `docs/technical/workflows.md`; scenario W07) fixes the shape: read scope, at most three candidates with `item_id`, `factual_reasons`, `semantic_reasons`, `unknowns` and judgment provenance, zero calendar writes, and a choice that is never task completion. ## Changes - `src/api/next.rs` (new) — `POST /v1/next`, a thin read over #22 + #23: - request validation: `window` is a positive duration (`90m`, `2h`) bounded to 1 minute–24 hours (the bound is stated in the module and enforced with the reason), `energy` is one of the three levels that exist. A malformed request is `invalid_input` (HTTP 400 / exit 2) **before any backend is contacted**. - live read: the whole VTODO inventory across every configured collection (never an open-only projection — the lifecycle rule belongs to #22, so a completed task is excluded *with its own diagnostic* rather than silently missing), reusing `tasks::todo_report` rather than duplicating the read. An unread collection is disclosed as `incomplete_calendar`; all collections unread is `dependency_unavailable` (exit 5), never an empty calendar. - journal read: the weekday career floor from explicit recorded qualifying outcomes (`history::timeline::day_outcomes`), where an unreadable or absent journal is `history_incomplete` + `CareerFloor::Unknown` — an observation gap, never an unmet floor. - response `data`: `basis` (`semantic`/`deterministic`), `considered` (how many candidates the policy ordered, so the three-candidate bound is stated rather than hidden), `candidates` (documented fields; `unknowns` propagated verbatim from #22; `judgment_provenance` = pinned model + the versioned questions), and `excluded` (one stable reason code and sentence per dropped task, so an empty answer explains itself). - no policy is restated: the route builds the query, the context and the presentation only. - `src/api/mod.rs` — registers `next` and the read-scope `next_handler` (no `Idempotency-Key`; mirrors the existing `/v1/resolve` read-scope POST). - `src/api/tasks.rs` — `todo_report` becomes `pub(crate)` so the next-action read shares the one live VTODO read implementation. - `src/cli.rs` — `life next --window <duration> --energy <low|medium|high> [--area <area>] [--json]`: validates both flags locally (exit 2 without a round trip), posts through `post_read_envelope` (no operation key), prints exactly one envelope on stdout with `--json`, and otherwise renders a terminal view that marks `[Fact]`, `[Jev]` and `[UNKNOWN]` apart, states the basis, and says plainly that choosing a candidate is not task completion. - `tests/next_command_tests.rs` (new) — 19 end-to-end tests over real HTTP: mock CalDAV + mock System One over TCP, the daemon's own router and SQLite worker, and the real binary for the CLI assertions. Explicitly not in scope: planning/proposals (#25–#27), outcome capture (#28), auth (#35), and any change to #22/#23 semantics. ## Verification `nix develop --command cargo test` — **537 passed, 0 failed** (was 514; +19 integration, +4 unit). `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` are clean. Scenarios covered by `tests/next_command_tests.rs`: - W07 happy path: four eligible tasks return exactly three candidates in the documented order (overdue → inside window → later), `considered: 4`, every documented field present, and no factual reason is a score while every semantic reason is a model answer naming the model and question versions. - `--window 90m` excludes a known-120m task with `window_exceeded`, retains the unestimated task, and that task carries `unknowns: [estimated_minutes, energy]` with a factual reason that says fit is *not* claimed; the same task becomes eligible (and is claimed to fit) at `--window 2h`. - An observed non-qualifying journal outcome makes the weekday career floor unmet, and that appears as a factual reason — read from the journal, not inferred from an allocation. - Malformed window/energy (`{}`, `90`, `0m`, `25h`, `1h30m`, `turbo`, missing flags) → HTTP 400 / CLI exit 2, asserted with **zero** calendar requests and zero judgment dispatches. - Degradation: Jev disabled, unreachable, refusing (503), drifting (unapproved model) and slow (past its budget) each return `basis: deterministic` with `judgment_unavailable`/`judgment_invalid` and every candidate retained; a semantic run and a deterministic run are shown to state identical factual reasons for the same candidate. - Empty result: HTTP 200 with `candidates: []`, `considered: 0` and the filter's exclusion diagnostics — never a 404, never an invented candidate. - Coverage: an unread collection is disclosed as `incomplete_calendar` (naming the collection) with candidates retained and no free-capacity claim; an unreachable calendar is `dependency_unavailable` / exit 5 with no data. - Read-only invariant on every path: no calendar method other than `REPORT` (explicit `PUT`/`POST`/`DELETE`/`PATCH`/`PROPPATCH`/`MKCOL` counts of 0), no journal row written, `operations` rows 0, no `operation_id`/`operation_state` in any answer, and the completed task is never offered. - CLI: exit 0 with exactly one envelope line on stdout (clean stderr), exit 2 for every malformed flag combination, exit 5 when the calendar is unreachable, and a human view carrying `[Fact]`/`[UNKNOWN]` markers, the degraded-basis note and the "not task completion" line with diagnostics on stderr. ## Related Issues Closes #24
Add `POST /v1/next` and `life next --window <duration> --energy <level>
[--area <area>]`: at most three advisory next actions for the window and
energy a caller states, with factual reasons, semantic reasons, unknowns
and judgment provenance kept strictly apart.

The route is a thin read over the merged policy layers: #22's filter
decides who is eligible, #23's ranker orders the eligible set, and this
change adds only the request validation, the live read, the journal read
for the weekday career floor, and the presentation. It writes nothing —
no calendar method other than REPORT is issued and no journal row is
appended — and a choice is never task completion.

- window durations (`90m`, `2h`) are parsed and bounded to 1m–24h;
  malformed input is `invalid_input` (HTTP 400 / exit 2) before any
  backend is contacted
- unknowns are propagated verbatim from #22, so an unknown estimate is
  never described as fitting
- Jev disabled, unreachable, slow or refusing an answer yields
  `judgment_unavailable` / `judgment_invalid`, `basis: deterministic`
  and every candidate retained
- an empty candidate set is HTTP 200 with the filter's exclusion
  diagnostics, never a 404 and never an invented candidate
- an unread collection is disclosed as `incomplete_calendar`; an
  unreachable calendar is `dependency_unavailable` (exit 5)
- the human view marks facts, judgments and unknowns apart and says
  plainly that a choice is not completion; `--json` prints one envelope
  on stdout with diagnostics on stderr
Author
Owner

Coordinator review — APPROVED

Verified 9fe1eeb on a tree based on current main (772f0f2, so #35's auth layer and scope table are present and /v1/next is already mapped to read). Independent verification: cargo test 557 passed / 0 failed (19 new next-command tests), cargo clippy --all-targets -- -D warnings 0 errors, cargo fmt --check clean.

What I checked beyond the summary:

  • The route is structurally read-only, not merely tested as such. I grepped src/api/next.rs for every write primitive — put_new_resource, put_with_precondition, prepare_operation, commit_operation_result, lifecycle:: — and got zero matches. There is no write path to reach, which is stronger than an assertion that none was taken; your zero-write test then adds the empirical layer (every CalDAV request is a REPORT, all journal tables byte-identical before/after, operations == 0, and no answer carries an operation_id).
  • The response states its own narrowing instead of hiding it. considered vs the ≤3 returned candidates makes truncation visible, and excluded carries the single deciding reason per dropped task — so criterion 6's "empty is explained, never a 404 or an invented candidate" is satisfied by the shape itself rather than by a special case.
  • Unknown is carried, not resolved: unknowns is copied verbatim from #22 with the doc comment spelling out the rule ("an unknown estimate is not a fit and an unknown energy is not low"), and the test pair is the good kind — the same 120-minute task is excluded at 90m with a "not claimed to fit" reason and is claimed to fit at 2h. That asymmetry is what proves the claim is window-relative rather than hard-coded.
  • Separated reason kinds are enforced, not just present: no factual reason contains "score" and every semantic reason carries score and confidence; semantic_reasons is empty on the deterministic path with the reason stated ("a score that decided nothing is not a reason").
  • The window bound is explicit and tested (MIN_WINDOW_MINUTES = 1, MAX_WINDOW_MINUTES = 1440), with malformed input rejected as invalid_input/exit 2 across nine bodies and seven CLI flag combinations, all with zero calendar requests and zero judgment dispatches.
  • Reuse over duplication: todo_report was widened to pub(crate) so this reads through the single existing live VTODO read rather than adding a second one.

Merging. This closes the #22 → #23 → #24 chain: the deterministic filter, the proven lexicographic order, and the advisory command that separates facts from judgments.

## Coordinator review — APPROVED Verified `9fe1eeb` on a tree based on current `main` (`772f0f2`, so #35's auth layer and scope table are present and `/v1/next` is already mapped to `read`). Independent verification: `cargo test` **557 passed / 0 failed** (19 new next-command tests), `cargo clippy --all-targets -- -D warnings` 0 errors, `cargo fmt --check` clean. What I checked beyond the summary: - **The route is structurally read-only, not merely tested as such.** I grepped `src/api/next.rs` for every write primitive — `put_new_resource`, `put_with_precondition`, `prepare_operation`, `commit_operation_result`, `lifecycle::` — and got **zero** matches. There is no write path to reach, which is stronger than an assertion that none was taken; your zero-write test then adds the empirical layer (every CalDAV request is a `REPORT`, all journal tables byte-identical before/after, `operations == 0`, and no answer carries an `operation_id`). - **The response states its own narrowing instead of hiding it.** `considered` vs the ≤3 returned candidates makes truncation visible, and `excluded` carries the single deciding reason per dropped task — so criterion 6's "empty is explained, never a 404 or an invented candidate" is satisfied by the shape itself rather than by a special case. - **Unknown is carried, not resolved:** `unknowns` is copied verbatim from #22 with the doc comment spelling out the rule ("an unknown estimate is not a fit and an unknown energy is not low"), and the test pair is the good kind — the same 120-minute task is excluded at `90m` with a "not claimed to fit" reason and *is* claimed to fit at `2h`. That asymmetry is what proves the claim is window-relative rather than hard-coded. - **Separated reason kinds are enforced, not just present:** no factual reason contains "score" and every semantic reason carries score and confidence; `semantic_reasons` is empty on the deterministic path with the reason stated ("a score that decided nothing is not a reason"). - **The window bound is explicit and tested** (`MIN_WINDOW_MINUTES = 1`, `MAX_WINDOW_MINUTES = 1440`), with malformed input rejected as `invalid_input`/exit 2 across nine bodies and seven CLI flag combinations, all with zero calendar requests and zero judgment dispatches. - **Reuse over duplication:** `todo_report` was widened to `pub(crate)` so this reads through the single existing live VTODO read rather than adding a second one. Merging. This closes the #22 → #23 → #24 chain: the deterministic filter, the proven lexicographic order, and the advisory command that separates facts from judgments.
yuris merged commit 054efbb595 into main 2026-09-23 00:53:28 +00:00
yuris changed title from feat(api): next action command with advisory provenance (#24) to feat(api): next action command with advisory provenance 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!66
No description provided.