feat(jev): reqwest adapter and wire primitive validation #54

Merged
yuris merged 1 commit from feat/issue-19-jev into main 2026-09-22 22:50:37 +00:00
Owner

Motivation

Life delegates advisory semantic evaluation (reference matching, priority alignment, consequence scoring, context-retention triage) to TypeSafe/Jev System One, an external typed probabilistic reasoner. Before any question family can be asked, the adapter that talks to that provider has to exist and has to be impossible to fool: provider output is untrusted data, the model is pinned, and a provider outage must never change a calendar result.

This PR implements the adapter and its validation only — the client, the typed wire primitives, the primitive validators, the failure taxonomy and the retry policy. Routes and question runners that consume a judgment (#20 reference resolution, #21 advisory integration) own their own wiring and are deliberately out of scope.

Changes

src/jev/primitives.rs (new)

  • Typed wire model: JevRequest (state, model, questions), JevQuestion tagged choice / score / noul with NouLCriteria ("true" / "false" rubrics), JevResponse (model, answers, usage), JevUsage.
  • Three strict typed answers (ChoiceAnswer, ScoreAnswer, NouLAnswer), each #[serde(deny_unknown_fields)]. Answers arrive as RawAnswer (the raw JSON value) and are validated against the question that asked for them, so serde cannot silently drop a field a primitive does not define: noul carrying an invented confidence is refused, not ignored. The wire spelling stays noul; a harness bool shape is malformed.
  • Validation with the approved tolerances (1e-6):
    • choice: tag is choice; probabilities cover exactly the offered candidates and sum to 1.0; the selected id is an offered candidate and is the highest-probability one (ties inside the tolerance accepted); finite values and confidence.
    • score: tag is score; legend equals the requested criteria; probabilities.len() == legend.len(); they sum to 1.0; the reported score equals the legend-weighted level index Σ i·pᵢ (fractional indices supported); finite confidence.
    • noul: tag is noul; no confidence field; numeric probability within [0.0, 1.0].
    • Whole responses: returned model must equal the approved version, exactly the expected question ids must be answered (missing / unrequested ids are refused), and the answer primitive must match the question.
  • Request-side limits (validate_questions): non-empty question set and ids, non-empty instructions, 2–10 levels for score, and at most 20 candidates for choice (Life's cap, far below the provider's 255).
  • Error taxonomy: JevValidationError (every variant reports judgment_invalid) and JevRequestError (local, reports invalid_input), each with a payload-free reason() label for diagnostics.

src/jev/client.rs (new)

  • JevClient: one reusable pooled reqwest client over rustls with bounded connect/request timeouts and redirects refused (Policy::none()), so the bearer token can never follow a redirect to another origin.
  • Configuration comes from the existing [jev] section via JevClient::from_config (bearer token read from bearer_token_file with read_secret_file); JevClient::new accepts already-resolved parts for callers holding resolve_secrets. POSTs to v1/systemone joined onto [jev].base_url.
  • Pinned model: APPROVED_MODEL = "jev-1.13.0". execute re-asserts it before dispatch, so an unapproved or moving alias can neither be sent upstream nor accepted in a response — it fails visibly as judgment_invalid with nothing dispatched. (The config layer already rejects jev-latest when [jev] is enabled; this is defence in depth for hand-built sections.)
  • Failure taxonomy: transport, auth, rate-limit and timeout problems are judgment_unavailable; malformed, oversized, wrong-model, missing/extra-answer and drifting payloads are judgment_invalid; local refusals are invalid_input / configuration_invalid. JevError::warning() gives callers the disclosed degradation, and a refused judgment never becomes a success.
  • Retries: bounded (three attempts at most, read-only) for 429/529 only, honouring Retry-After in either documented form (delay-seconds or HTTP-date), falling back to a bounded backoff when absent, never sleeping past the request deadline, and never changing model, questions or state between attempts.
  • Privacy: logs carry the pinned model, the code-owned question ids, a SHA-256 fingerprint of the exact request bytes, and reason labels — never state, never a response body. Validation and unavailability are logged by code + reason so provider text (which could echo state) cannot reach a log line, and JevClient's Debug redacts the token.

Other

  • src/lib.rs: registers pub mod jev;.
  • No new dependency: reqwest, serde, serde_json, thiserror, chrono, ring and zeroize already cover this, so Cargo.toml (including the deferred authors chore) is untouched.
  • Tests: tests/jev_fixtures.rs (34 tests, no network) and tests/jev_client_tests.rs (24 tests against an in-process axum provider over TCP). The adapter is not wired into AppState/serve, and no calendar, journal, history or existing route code is touched.

Notes for reviewers / deviations

  • validate_jev_response(expected_model, questions, response) -> Result<(), JevValidationError> is implemented with the signature the issue specifies. validated_answers returns the typed Judgment the same validation produces, and JevClient::execute returns that Judgment rather than a raw answer map, so a consumer cannot re-read an answer that validation rejected.
  • The 20-candidate cap is enforced as 20 total choice criteria entries (a 21st is refused), matching the issue's acceptance criterion "rejects candidate lists exceeding 20 items". docs/technical/jev.md can also be read as "20 candidates plus a fixed no_match" (i.e. 21 keys); #20 should confirm which reading it needs before it starts offering 20 candidates plus no_match.
  • No live provider was contacted. The pinned model's deployed availability remains unverified, exactly as docs/technical/jev.md records.

Verification

  • nix develop --command cargo test — all suites green (35 unit tests plus every integration suite; new suites: 34 fixtures + 24 adapter tests).
  • cargo clippy --all-targets -- -D warnings — clean. cargo fmt --check — clean.
  • Scenario I08 (fixtures): the documented request example serializes to the documented JSON exactly; the documented response example validates and reports noul = 0.82; valid choice, score (integer and fractional) and noul (including 0.0 and 1.0) fixtures validate; validated answers round-trip back to the wire shape, preserving the Score answer's legend.
  • Scenario I06 (drift → judgment_invalid): jev-latest and jev-1.12.0; choice probabilities summing to 0.98; a selected candidate that is not the most probable; a candidate id outside the offered set; probability keys that add, drop or rename a candidate; a score that disagrees with the legend-weighted mean; a legend that is not the requested criteria; a probability-per-level count mismatch; noul carrying confidence: 0.85; noul spelled as a boolean; noul outside [0,1]; missing answers; unrequested answer ids; an answer carrying the wrong primitive. Ties and sums inside the 1e-6 tolerance are accepted.
  • Scenario I07 (unavailability → judgment_unavailable): 401/403, 500 and an unexpected status; connection refused; a slow provider against a 1 s deadline (bounded). 429/529 are retried at most twice: Retry-After: 1 is honoured (asserted via elapsed time) and the retry succeeds; an absent Retry-After gets a bounded backoff; exhausted retries report rate_limited after exactly three requests; a Retry-After beyond the deadline (delay-seconds and HTTP-date forms) returns immediately without waiting. Every attempt carries the pinned model — asserted from the captured request bodies.
  • Local refusals: a 21-candidate Choice and an unapproved configured model both dispatch zero requests and return invalid_input / judgment_invalid respectively.
  • Privacy: a process-wide log capture over an accepted judgment, a provider outage and a refused payload asserts the private state marker never appears in the logs while the request fingerprint does. Verified the assertion has teeth by temporarily logging state (the test fails as expected) and reverting.
  • Untrusted text: instruction-like state is sent verbatim, changes no adapter behaviour (the alias it asks for is still refused), and candidate ids are compared literally rather than interpreted.
  • Decoupling: with the judgment backend down, the daemon's /health/live route still answers 200 while the adapter reports judgment_unavailable.

Closes #19

## Motivation Life delegates advisory semantic evaluation (reference matching, priority alignment, consequence scoring, context-retention triage) to TypeSafe/Jev System One, an external typed probabilistic reasoner. Before any question family can be asked, the adapter that talks to that provider has to exist and has to be impossible to fool: provider output is untrusted data, the model is pinned, and a provider outage must never change a calendar result. This PR implements the adapter and its validation only — the client, the typed wire primitives, the primitive validators, the failure taxonomy and the retry policy. Routes and question runners that consume a judgment (#20 reference resolution, #21 advisory integration) own their own wiring and are deliberately out of scope. ## Changes ### `src/jev/primitives.rs` (new) - Typed wire model: `JevRequest` (`state`, `model`, `questions`), `JevQuestion` tagged `choice` / `score` / `noul` with `NouLCriteria` (`"true"` / `"false"` rubrics), `JevResponse` (`model`, `answers`, `usage`), `JevUsage`. - Three strict typed answers (`ChoiceAnswer`, `ScoreAnswer`, `NouLAnswer`), each `#[serde(deny_unknown_fields)]`. Answers arrive as `RawAnswer` (the raw JSON value) and are validated against *the question that asked for them*, so serde cannot silently drop a field a primitive does not define: `noul` carrying an invented `confidence` is refused, not ignored. The wire spelling stays `noul`; a harness `bool` shape is malformed. - Validation with the approved tolerances (`1e-6`): - `choice`: tag is `choice`; probabilities cover exactly the offered candidates and sum to 1.0; the selected id is an offered candidate and is the highest-probability one (ties inside the tolerance accepted); finite values and `confidence`. - `score`: tag is `score`; `legend` equals the requested criteria; `probabilities.len() == legend.len()`; they sum to 1.0; the reported `score` equals the legend-weighted level index `Σ i·pᵢ` (fractional indices supported); finite `confidence`. - `noul`: tag is `noul`; no `confidence` field; numeric probability within `[0.0, 1.0]`. - Whole responses: returned model must equal the approved version, exactly the expected question ids must be answered (missing / unrequested ids are refused), and the answer primitive must match the question. - Request-side limits (`validate_questions`): non-empty question set and ids, non-empty instructions, 2–10 levels for `score`, and at most 20 candidates for `choice` (Life's cap, far below the provider's 255). - Error taxonomy: `JevValidationError` (every variant reports `judgment_invalid`) and `JevRequestError` (local, reports `invalid_input`), each with a payload-free `reason()` label for diagnostics. ### `src/jev/client.rs` (new) - `JevClient`: one reusable pooled `reqwest` client over rustls with bounded connect/request timeouts and redirects refused (`Policy::none()`), so the bearer token can never follow a redirect to another origin. - Configuration comes from the existing `[jev]` section via `JevClient::from_config` (bearer token read from `bearer_token_file` with `read_secret_file`); `JevClient::new` accepts already-resolved parts for callers holding `resolve_secrets`. POSTs to `v1/systemone` joined onto `[jev].base_url`. - Pinned model: `APPROVED_MODEL = "jev-1.13.0"`. `execute` re-asserts it before dispatch, so an unapproved or moving alias can neither be sent upstream nor accepted in a response — it fails visibly as `judgment_invalid` with nothing dispatched. (The config layer already rejects `jev-latest` when `[jev]` is enabled; this is defence in depth for hand-built sections.) - Failure taxonomy: transport, auth, rate-limit and timeout problems are `judgment_unavailable`; malformed, oversized, wrong-model, missing/extra-answer and drifting payloads are `judgment_invalid`; local refusals are `invalid_input` / `configuration_invalid`. `JevError::warning()` gives callers the disclosed degradation, and a refused judgment never becomes a success. - Retries: bounded (three attempts at most, read-only) for 429/529 only, honouring `Retry-After` in either documented form (delay-seconds or HTTP-date), falling back to a bounded backoff when absent, never sleeping past the request deadline, and never changing model, questions or state between attempts. - Privacy: logs carry the pinned model, the code-owned question ids, a SHA-256 fingerprint of the exact request bytes, and reason labels — never `state`, never a response body. Validation and unavailability are logged by code + reason so provider text (which could echo state) cannot reach a log line, and `JevClient`'s `Debug` redacts the token. ### Other - `src/lib.rs`: registers `pub mod jev;`. - No new dependency: `reqwest`, `serde`, `serde_json`, `thiserror`, `chrono`, `ring` and `zeroize` already cover this, so `Cargo.toml` (including the deferred `authors` chore) is untouched. - Tests: `tests/jev_fixtures.rs` (34 tests, no network) and `tests/jev_client_tests.rs` (24 tests against an in-process axum provider over TCP). The adapter is not wired into `AppState`/`serve`, and no calendar, journal, history or existing route code is touched. ### Notes for reviewers / deviations - `validate_jev_response(expected_model, questions, response) -> Result<(), JevValidationError>` is implemented with the signature the issue specifies. `validated_answers` returns the typed `Judgment` the same validation produces, and `JevClient::execute` returns that `Judgment` rather than a raw answer map, so a consumer cannot re-read an answer that validation rejected. - The 20-candidate cap is enforced as 20 total `choice` criteria entries (a 21st is refused), matching the issue's acceptance criterion "rejects candidate lists exceeding 20 items". `docs/technical/jev.md` can also be read as "20 candidates plus a fixed `no_match`" (i.e. 21 keys); #20 should confirm which reading it needs before it starts offering 20 candidates plus `no_match`. - No live provider was contacted. The pinned model's deployed availability remains unverified, exactly as `docs/technical/jev.md` records. ## Verification - `nix develop --command cargo test` — all suites green (35 unit tests plus every integration suite; new suites: 34 fixtures + 24 adapter tests). - `cargo clippy --all-targets -- -D warnings` — clean. `cargo fmt --check` — clean. - Scenario I08 (fixtures): the documented request example serializes to the documented JSON exactly; the documented response example validates and reports `noul = 0.82`; valid `choice`, `score` (integer and fractional) and `noul` (including 0.0 and 1.0) fixtures validate; validated answers round-trip back to the wire shape, preserving the Score answer's `legend`. - Scenario I06 (drift → `judgment_invalid`): `jev-latest` and `jev-1.12.0`; choice probabilities summing to 0.98; a selected candidate that is not the most probable; a candidate id outside the offered set; probability keys that add, drop or rename a candidate; a score that disagrees with the legend-weighted mean; a legend that is not the requested criteria; a probability-per-level count mismatch; `noul` carrying `confidence: 0.85`; `noul` spelled as a boolean; `noul` outside `[0,1]`; missing answers; unrequested answer ids; an answer carrying the wrong primitive. Ties and sums inside the `1e-6` tolerance are accepted. - Scenario I07 (unavailability → `judgment_unavailable`): 401/403, 500 and an unexpected status; connection refused; a slow provider against a 1 s deadline (bounded). 429/529 are retried at most twice: `Retry-After: 1` is honoured (asserted via elapsed time) and the retry succeeds; an absent `Retry-After` gets a bounded backoff; exhausted retries report `rate_limited` after exactly three requests; a `Retry-After` beyond the deadline (delay-seconds and HTTP-date forms) returns immediately without waiting. Every attempt carries the pinned model — asserted from the captured request bodies. - Local refusals: a 21-candidate Choice and an unapproved configured model both dispatch zero requests and return `invalid_input` / `judgment_invalid` respectively. - Privacy: a process-wide log capture over an accepted judgment, a provider outage and a refused payload asserts the private state marker never appears in the logs while the request fingerprint does. Verified the assertion has teeth by temporarily logging `state` (the test fails as expected) and reverting. - Untrusted text: instruction-like `state` is sent verbatim, changes no adapter behaviour (the alias it asks for is still refused), and candidate ids are compared literally rather than interpreted. - Decoupling: with the judgment backend down, the daemon's `/health/live` route still answers `200` while the adapter reports `judgment_unavailable`. ## Related Issues Closes #19
Author
Owner

Coordinator review — APPROVED

Verified 7d70cac myself (read the adapter and the tests, ran the suite).

What I checked beyond the summary:

  • Retry policy is genuinely bounded by the request deadline, not just by attempt count. request() computes deadline = Instant::now() + self.timeout, retries only 429/529, and when the advertised Retry-After would land past the deadline it stops with a distinct RetryAfterBeyondDeadline instead of sleeping past it. parse_retry_after handles both wire forms — delay-seconds and an RFC 2822 HTTP-date — and an unusable value falls back to bounded backoff rather than a hammer loop.
  • noul is validated as its own shape: unit-interval only, no confidence field, and a boolean spelling is malformed — the exact trap the issue warns about (copying a harness bool result).
  • Score/choice tolerances are exercised on both sides of the boundary, including 1.0000005 accepted and 0.98 rejected, a tie within tolerance accepted, and selected-choice-must-be-highest.
  • Privacy is proven, not asserted in prose: private_state_text_never_reaches_a_log_line installs a MakeWriter that captures what the adapter actually logs, guards against a vacuous pass with !logs.is_empty(), and instead of logging private text the adapter emits a SHA-256 fingerprint of the exact request bytes for correlation. That is the right trade.
  • Instruction-like state does not move the adapter: instruction_like_state_text_does_not_change_adapter_behaviour feeds text telling it to switch to jev-latest and exfiltrate a token, and the request still carries the pinned model.
  • No swallowed errors: the only ok() in the adapter is the documented Retry-After unusable-value fallback.

Independent verification on 7d70cac: cargo test 310 passed / 0 failed (58 new: 34 fixture + 24 client); cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean. The diff touches only src/lib.rs (+1 module) plus the new src/jev/* and its two suites — no calendar, history, API or CLI changes, so this cannot have disturbed a sibling.

Cargo.toml untouched, so the deferred authors chore stays pending.

Merging.

## Coordinator review — APPROVED Verified `7d70cac` myself (read the adapter and the tests, ran the suite). What I checked beyond the summary: - **Retry policy is genuinely bounded by the request deadline, not just by attempt count.** `request()` computes `deadline = Instant::now() + self.timeout`, retries only 429/529, and when the advertised `Retry-After` would land past the deadline it stops with a distinct `RetryAfterBeyondDeadline` instead of sleeping past it. `parse_retry_after` handles **both** wire forms — delay-seconds and an RFC 2822 HTTP-date — and an unusable value falls back to bounded backoff rather than a hammer loop. - **`noul` is validated as its own shape**: unit-interval only, no `confidence` field, and a boolean spelling is malformed — the exact trap the issue warns about (copying a harness `bool` result). - **Score/choice tolerances are exercised on both sides of the boundary**, including 1.0000005 accepted and 0.98 rejected, a tie within tolerance accepted, and selected-choice-must-be-highest. - **Privacy is proven, not asserted in prose**: `private_state_text_never_reaches_a_log_line` installs a `MakeWriter` that captures what the adapter actually logs, guards against a vacuous pass with `!logs.is_empty()`, and instead of logging private text the adapter emits a SHA-256 fingerprint of the exact request bytes for correlation. That is the right trade. - **Instruction-like state does not move the adapter**: `instruction_like_state_text_does_not_change_adapter_behaviour` feeds text telling it to switch to `jev-latest` and exfiltrate a token, and the request still carries the pinned model. - No swallowed errors: the only `ok()` in the adapter is the documented `Retry-After` unusable-value fallback. Independent verification on `7d70cac`: `cargo test` **310 passed / 0 failed** (58 new: 34 fixture + 24 client); `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. The diff touches only `src/lib.rs` (+1 module) plus the new `src/jev/*` and its two suites — no calendar, history, API or CLI changes, so this cannot have disturbed a sibling. `Cargo.toml` untouched, so the deferred authors chore stays pending. Merging.
yuris merged commit ade394a98f into main 2026-09-22 22:50:37 +00:00
yuris changed title from feat(jev): reqwest adapter and wire primitive validation (#19) to feat(jev): reqwest adapter and wire primitive validation 2026-09-23 05:12:06 +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!54
No description provided.