feat(jev): candidate reference resolution with deterministic first #58

Merged
yuris merged 1 commit from feat/issue-20-resolve into main 2026-09-22 23:28:59 +00:00
Owner

Motivation

Users and agents refer to tasks and events informally ("finish portfolio outline", "weekly sync"), but every safe operation needs an authoritative itm_... id and its current ETag. Issue #20 asks for that mapping to be deterministic first, to consult a semantic judgment only when the reference is genuinely ambiguous, to disclose when the candidate pool was narrowed, and to never let a model's confidence authorize anything.

The route is read scope, so it is also the place where the architectural boundary is easiest to state: resolution returns an identity, and nothing it returns can write.

Changes

  • src/domain/resolve.rs (new): the resolution policy as pure functions over a live candidate snapshot. exact_match resolves an item id, UID, or case-folded equal title only when exactly one live candidate matches (a shared title is ambiguous, not deterministic). select_candidates builds a bounded, deterministically ordered heuristic pool and reports narrowed(). Prompts are fixed text; the caller's reference is never echoed.
  • src/jev/questions/reference_match.rs (new): the code-owned reference_match_v1 Choice question — options are exactly the offered live item ids plus the fixed no_match, rubrics are fixed strings, and the candidate titles, statuses and the user's phrase travel only inside the untrusted state object. evaluate_reference_match reads a validated answer: a decisive selection yields that live id, no_match is abstention, and a tie at the maximum is ambiguous. The confidence field is deliberately not consulted.
  • src/api/resolve.rs (new): POST /v1/resolve. Reads the live inventory of the requested kind (open tasks; events that are not cancelled) across the selected collections, then: exact match → method: "deterministic" with zero Jev calls; otherwise the bounded pool goes to reference_match_v1 through the #19 adapter, whose validated_answers enforces exact question id, candidate membership and probability rules. Degradations are judgment_unavailable (disabled/unreachable) or judgment_invalid (refused payload), both HTTP 503 / exit 5 with the candidates still carried; the deterministic path keeps working through either.
  • src/api/mod.rs, src/domain/mod.rs, src/jev/mod.rs: registration, plus the route and handler.
  • src/cli.rs, src/client.rs: life resolve --text TEXT --kind task|event [--collection ID]... [--json], the documented --input - stdin alternative for sensitive text, a read-scope POST transport (no operation key), and judgment_unavailable / judgment_invalid mapped to exit 5.
  • src/api/agenda.rs: select_collections widened to pub(crate) so resolve reuses the same collection validation instead of a second convention.

Notes on two judgement calls:

  • The pool is capped at 19 live candidates plus no_match, because the wire contract caps a Choice question at 20 options in total (docs/technical/jev.md allows 20 candidates plus no_match; MAX_CHOICE_CANDIDATES counts options). Anything the cap excludes is disclosed as candidate_narrowed, naming how many matched.
  • Semantic resolution happens only when the validated selection is decisive — one offered live id strictly above every other option. An abstention, a tie, an empty pool or a failed judgment returns needs_clarification (HTTP 200 / exit 3) or the degraded error, always with the candidate choices and never with an invented id. No confidence threshold is applied anywhere, and life resolve has no write path at all: it issues live REPORT reads only.

Verification

nix develop --command cargo test — 430 passed, 0 failed (all suites; 23 new integration tests plus 14 new unit tests). cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean under nix develop.

tests/resolve_tests.rs drives the daemon over real HTTP against a mock Radicale and a mock System One, and drives the real CLI binary against a daemon serving the daemon's own router:

  • An exact title, UID or item id resolves with method: "deterministic" and asserts zero captured Jev requests; a title shared by two live items is not deterministic and does ask.
  • Two plausible matches ask Jev exactly once, the captured request is asserted to offer exactly the live ids plus no_match with fixed rubrics, the candidate titles appear only in state, and the resolved id and live ETag are the selected candidate's.
  • An answer naming an unoffered candidate, an answer for a different question id, a distribution that does not sum to one, and an answer from a moving alias are each judgment_invalid with no id invented and the candidates still carried.
  • Jev unavailable (503) and Jev disabled are judgment_unavailable; the exact-match request in the same test still resolves deterministically.
  • A tie and a no_match answer return needs_clarification with the canonical questions/candidates payload and judgment_provenance; an unmatched reference returns an empty candidate list and is asserted to make zero Jev calls (never widened).
  • 25 matching live items produce candidate_narrowed naming the count, a 20-option offer, and a resolved id that is one of the offered options.
  • An unreadable collection is disclosed as incomplete_calendar; all collections unreadable is dependency_unavailable, never an empty calendar. A cancelled event is not a candidate.
  • The CLI exits 0 for a resolved item, 3 for clarification, 5 when no semantic conclusion is obtained, and reads {text,kind} from stdin (asserted to arrive as untrusted state) instead of argv.
  • Resolution issues REPORT reads only: the mock asserts every request the route made.

Documentation

No doc changes: docs/technical/cli-api.md (POST /v1/resolve, read scope, Resolve payload row, --input -, clarification_required, candidate_narrowed, judgment_unavailable/judgment_invalid) and docs/technical/jev.md (#versioned-questions, the reference_match.v1 row, the deterministic-boundary rule) already specify this behaviour.

Closes #20

## Motivation Users and agents refer to tasks and events informally ("finish portfolio outline", "weekly sync"), but every safe operation needs an authoritative `itm_...` id and its current ETag. Issue #20 asks for that mapping to be *deterministic first*, to consult a semantic judgment only when the reference is genuinely ambiguous, to disclose when the candidate pool was narrowed, and to never let a model's confidence authorize anything. The route is read scope, so it is also the place where the architectural boundary is easiest to state: resolution returns an identity, and nothing it returns can write. ## Changes - **`src/domain/resolve.rs`** (new): the resolution policy as pure functions over a live candidate snapshot. `exact_match` resolves an item id, UID, or case-folded equal title only when exactly one live candidate matches (a shared title is ambiguous, not deterministic). `select_candidates` builds a bounded, deterministically ordered heuristic pool and reports `narrowed()`. Prompts are fixed text; the caller's reference is never echoed. - **`src/jev/questions/reference_match.rs`** (new): the code-owned `reference_match_v1` Choice question — options are exactly the offered live item ids plus the fixed `no_match`, rubrics are fixed strings, and the candidate titles, statuses and the user's phrase travel only inside the untrusted `state` object. `evaluate_reference_match` reads a validated answer: a decisive selection yields that live id, `no_match` is abstention, and a tie at the maximum is ambiguous. The `confidence` field is deliberately not consulted. - **`src/api/resolve.rs`** (new): `POST /v1/resolve`. Reads the live inventory of the requested kind (open tasks; events that are not cancelled) across the selected collections, then: exact match → `method: "deterministic"` with **zero** Jev calls; otherwise the bounded pool goes to `reference_match_v1` through the #19 adapter, whose `validated_answers` enforces exact question id, candidate membership and probability rules. Degradations are `judgment_unavailable` (disabled/unreachable) or `judgment_invalid` (refused payload), both HTTP 503 / exit 5 with the candidates still carried; the deterministic path keeps working through either. - **`src/api/mod.rs`**, **`src/domain/mod.rs`**, **`src/jev/mod.rs`**: registration, plus the route and handler. - **`src/cli.rs`**, **`src/client.rs`**: `life resolve --text TEXT --kind task|event [--collection ID]... [--json]`, the documented `--input -` stdin alternative for sensitive text, a read-scope POST transport (no operation key), and `judgment_unavailable` / `judgment_invalid` mapped to exit 5. - **`src/api/agenda.rs`**: `select_collections` widened to `pub(crate)` so resolve reuses the same collection validation instead of a second convention. Notes on two judgement calls: - The pool is capped at **19** live candidates plus `no_match`, because the wire contract caps a Choice question at 20 options in total (`docs/technical/jev.md` allows 20 candidates plus `no_match`; `MAX_CHOICE_CANDIDATES` counts options). Anything the cap excludes is disclosed as `candidate_narrowed`, naming how many matched. - Semantic resolution happens only when the validated selection is *decisive* — one offered live id strictly above every other option. An abstention, a tie, an empty pool or a failed judgment returns `needs_clarification` (HTTP 200 / exit 3) or the degraded error, always with the candidate choices and never with an invented id. No confidence threshold is applied anywhere, and `life resolve` has no write path at all: it issues live REPORT reads only. ## Verification `nix develop --command cargo test` — **430 passed, 0 failed** (all suites; 23 new integration tests plus 14 new unit tests). `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` are clean under `nix develop`. `tests/resolve_tests.rs` drives the daemon over real HTTP against a mock Radicale and a mock System One, and drives the real CLI binary against a daemon serving the daemon's own router: - An exact title, UID or item id resolves with `method: "deterministic"` and asserts **zero** captured Jev requests; a title shared by two live items is not deterministic and does ask. - Two plausible matches ask Jev exactly once, the captured request is asserted to offer exactly the live ids plus `no_match` with fixed rubrics, the candidate titles appear only in `state`, and the resolved id and live ETag are the selected candidate's. - An answer naming an unoffered candidate, an answer for a different question id, a distribution that does not sum to one, and an answer from a moving alias are each `judgment_invalid` with no id invented and the candidates still carried. - Jev unavailable (503) and Jev disabled are `judgment_unavailable`; the exact-match request in the same test still resolves deterministically. - A tie and a `no_match` answer return `needs_clarification` with the canonical questions/candidates payload and `judgment_provenance`; an unmatched reference returns an empty candidate list and is asserted to make **zero** Jev calls (never widened). - 25 matching live items produce `candidate_narrowed` naming the count, a 20-option offer, and a resolved id that is one of the offered options. - An unreadable collection is disclosed as `incomplete_calendar`; all collections unreadable is `dependency_unavailable`, never an empty calendar. A cancelled event is not a candidate. - The CLI exits 0 for a resolved item, 3 for clarification, 5 when no semantic conclusion is obtained, and reads `{text,kind}` from stdin (asserted to arrive as untrusted state) instead of argv. - Resolution issues REPORT reads only: the mock asserts every request the route made. ## Documentation No doc changes: `docs/technical/cli-api.md` (`POST /v1/resolve`, read scope, `Resolve` payload row, `--input -`, `clarification_required`, `candidate_narrowed`, `judgment_unavailable`/`judgment_invalid`) and `docs/technical/jev.md` (`#versioned-questions`, the `reference_match.v1` row, the deterministic-boundary rule) already specify this behaviour. ## Related Issues Closes #20
POST /v1/resolve and `life resolve` turn an informal reference into one live
item id and its current ETag, or say plainly that they cannot.

- Deterministic boundary first: an unambiguous exact item id, UID or title
  match over the live candidate snapshot resolves with method
  "deterministic" and performs no Jev call at all.
- Ambiguity asks reference_match.v1 (Choice over live ids plus the fixed
  no_match alternative) through the #19 adapter, and the answer is validated
  against exactly the question that was asked: candidate membership, exact
  question id, primitive and probability checks.
- The candidate pool is bounded at 19 live items plus no_match, one below the
  wire's 20-option Choice limit; anything the cap excludes is disclosed as
  candidate_narrowed rather than presented as exhaustive.
- An empty pool, a no_match answer or an unresolved tie returns
  needs_clarification (HTTP 200 / exit 3) with the candidate choices. No id is
  ever invented, and an unmatched reference is never widened into an all-items
  search.
- A disabled or unreachable Jev is judgment_unavailable, a refused payload is
  judgment_invalid, both HTTP 503 / exit 5 with the candidates still carried;
  the exact-match path keeps working through either.
- The route is read scope and writes nothing: it issues live REPORT reads
  only, so no confidence value can authorize a mutation, and the confidence is
  disclosed in judgment_provenance rather than compared to a threshold.
- `--input -` reads {text, kind, collection_ids?} from stdin so a private
  reference never has to appear in an argument list.
Author
Owner

Coordinator review — APPROVED

Verified c361a98 on a tree based on current main (d0713c0, so #21's five families are present and src/jev/questions/mod.rs carries both sides).

What I checked beyond the summary:

  • Deterministic-first is proven by call count, not by outcome. an_exact_title_uid_or_item_id_resolves_deterministically_with_zero_jev_calls asserts the mock captured zero provider requests across four exact references — the only form of evidence that actually distinguishes "did not need the model" from "asked and happened to agree". And a title shared by two live items correctly stops being deterministic and does ask.
  • The route is structurally read-only. I grepped the handler for write calls: the only matches for put|post_conditional|prepare_operation|commit_operation in src/api/resolve.rs are the substring inside invalid_input. There is no journal or calendar-write path to reach, which is stronger than a test asserting none was taken.
  • The candidate budget is enforced twice and derived rather than magic. MAX_CANDIDATE_POOL = MAX_CHOICE_CANDIDATES.saturating_sub(1) with the fixed no_match taking the last slot, and the adapter independently rejects >20 options (TooManyCandidates). MAX_CHOICE_CANDIDATES is documented in src/jev/primitives.rs as Life's cap ("far below the provider's 255-option maximum"), so treating it as the whole offered budget is the stricter reading and is coherent. Exclusions are disclosed as candidate_narrowed rather than passed off as exhaustive.
  • No invented identity and no threshold. Outcomes are a resolved item with its live ETag or needs_clarification (200 / exit 3) with the candidate choices; a tie at the maximum is treated as ambiguous; no_match yields an empty candidate list with a rephrase prompt, no provenance and zero Jev calls rather than a widened search.
  • Degradation stays in its lane: disabled/unreachable → judgment_unavailable, refused payload → judgment_invalid, a moving alias is never believed, and the exact-match path keeps resolving with zero Jev calls in the same test.

Independent verification on c361a98: cargo test 430 passed / 0 failed (37 new: 23 integration + 14 unit); cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean. The #19 adapter (src/jev/{client,primitives}.rs) is untouched, and the diff adds no dependency.

Merging. With this, Phase 3's judgment half is complete; #17 is the last in-flight item.

## Coordinator review — APPROVED Verified `c361a98` on a tree based on current `main` (`d0713c0`, so #21's five families are present and `src/jev/questions/mod.rs` carries both sides). What I checked beyond the summary: - **Deterministic-first is proven by call count, not by outcome.** `an_exact_title_uid_or_item_id_resolves_deterministically_with_zero_jev_calls` asserts the mock captured **zero** provider requests across four exact references — the only form of evidence that actually distinguishes "did not need the model" from "asked and happened to agree". And a title shared by two live items correctly stops being deterministic and does ask. - **The route is structurally read-only.** I grepped the handler for write calls: the only matches for `put|post_conditional|prepare_operation|commit_operation` in `src/api/resolve.rs` are the substring inside `invalid_input`. There is no journal or calendar-write path to reach, which is stronger than a test asserting none was taken. - **The candidate budget is enforced twice and derived rather than magic.** `MAX_CANDIDATE_POOL = MAX_CHOICE_CANDIDATES.saturating_sub(1)` with the fixed `no_match` taking the last slot, and the adapter independently rejects >20 options (`TooManyCandidates`). `MAX_CHOICE_CANDIDATES` is documented in `src/jev/primitives.rs` as *Life's* cap ("far below the provider's 255-option maximum"), so treating it as the whole offered budget is the stricter reading and is coherent. Exclusions are disclosed as `candidate_narrowed` rather than passed off as exhaustive. - **No invented identity and no threshold.** Outcomes are a resolved item with its live ETag or `needs_clarification` (200 / exit 3) with the candidate choices; a tie at the maximum is treated as ambiguous; `no_match` yields an empty candidate list with a rephrase prompt, no provenance and **zero** Jev calls rather than a widened search. - **Degradation stays in its lane:** disabled/unreachable → `judgment_unavailable`, refused payload → `judgment_invalid`, a moving alias is never believed, and the exact-match path keeps resolving with zero Jev calls in the same test. Independent verification on `c361a98`: `cargo test` **430 passed / 0 failed** (37 new: 23 integration + 14 unit); `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. The #19 adapter (`src/jev/{client,primitives}.rs`) is untouched, and the diff adds no dependency. Merging. With this, Phase 3's judgment half is complete; #17 is the last in-flight item.
yuris merged commit 1b621fd251 into main 2026-09-22 23:28:59 +00:00
yuris changed title from feat(jev): candidate reference resolution with deterministic first (#20) to feat(jev): candidate reference resolution with deterministic first 2026-09-23 05:12:04 +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!58
No description provided.