feat(jev): reqwest adapter and wire primitive validation #54
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-19-jev"
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 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)JevRequest(state,model,questions),JevQuestiontaggedchoice/score/noulwithNouLCriteria("true"/"false"rubrics),JevResponse(model,answers,usage),JevUsage.ChoiceAnswer,ScoreAnswer,NouLAnswer), each#[serde(deny_unknown_fields)]. Answers arrive asRawAnswer(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:noulcarrying an inventedconfidenceis refused, not ignored. The wire spelling staysnoul; a harnessboolshape is malformed.1e-6):choice: tag ischoice; 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 andconfidence.score: tag isscore;legendequals the requested criteria;probabilities.len() == legend.len(); they sum to 1.0; the reportedscoreequals the legend-weighted level indexΣ i·pᵢ(fractional indices supported); finiteconfidence.noul: tag isnoul; noconfidencefield; numeric probability within[0.0, 1.0].validate_questions): non-empty question set and ids, non-empty instructions, 2–10 levels forscore, and at most 20 candidates forchoice(Life's cap, far below the provider's 255).JevValidationError(every variant reportsjudgment_invalid) andJevRequestError(local, reportsinvalid_input), each with a payload-freereason()label for diagnostics.src/jev/client.rs(new)JevClient: one reusable pooledreqwestclient over rustls with bounded connect/request timeouts and redirects refused (Policy::none()), so the bearer token can never follow a redirect to another origin.[jev]section viaJevClient::from_config(bearer token read frombearer_token_filewithread_secret_file);JevClient::newaccepts already-resolved parts for callers holdingresolve_secrets. POSTs tov1/systemonejoined onto[jev].base_url.APPROVED_MODEL = "jev-1.13.0".executere-asserts it before dispatch, so an unapproved or moving alias can neither be sent upstream nor accepted in a response — it fails visibly asjudgment_invalidwith nothing dispatched. (The config layer already rejectsjev-latestwhen[jev]is enabled; this is defence in depth for hand-built sections.)judgment_unavailable; malformed, oversized, wrong-model, missing/extra-answer and drifting payloads arejudgment_invalid; local refusals areinvalid_input/configuration_invalid.JevError::warning()gives callers the disclosed degradation, and a refused judgment never becomes a success.Retry-Afterin 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.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, andJevClient'sDebugredacts the token.Other
src/lib.rs: registerspub mod jev;.reqwest,serde,serde_json,thiserror,chrono,ringandzeroizealready cover this, soCargo.toml(including the deferredauthorschore) is untouched.tests/jev_fixtures.rs(34 tests, no network) andtests/jev_client_tests.rs(24 tests against an in-process axum provider over TCP). The adapter is not wired intoAppState/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_answersreturns the typedJudgmentthe same validation produces, andJevClient::executereturns thatJudgmentrather than a raw answer map, so a consumer cannot re-read an answer that validation rejected.choicecriteria entries (a 21st is refused), matching the issue's acceptance criterion "rejects candidate lists exceeding 20 items".docs/technical/jev.mdcan also be read as "20 candidates plus a fixedno_match" (i.e. 21 keys); #20 should confirm which reading it needs before it starts offering 20 candidates plusno_match.docs/technical/jev.mdrecords.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.noul = 0.82; validchoice,score(integer and fractional) andnoul(including 0.0 and 1.0) fixtures validate; validated answers round-trip back to the wire shape, preserving the Score answer'slegend.judgment_invalid):jev-latestandjev-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;noulcarryingconfidence: 0.85;noulspelled as a boolean;nouloutside[0,1]; missing answers; unrequested answer ids; an answer carrying the wrong primitive. Ties and sums inside the1e-6tolerance are accepted.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: 1is honoured (asserted via elapsed time) and the retry succeeds; an absentRetry-Aftergets a bounded backoff; exhausted retries reportrate_limitedafter exactly three requests; aRetry-Afterbeyond the deadline (delay-seconds and HTTP-date forms) returns immediately without waiting. Every attempt carries the pinned model — asserted from the captured request bodies.invalid_input/judgment_invalidrespectively.state(the test fails as expected) and reverting.stateis sent verbatim, changes no adapter behaviour (the alias it asks for is still refused), and candidate ids are compared literally rather than interpreted./health/liveroute still answers200while the adapter reportsjudgment_unavailable.Related Issues
Closes #19
Coordinator review — APPROVED
Verified
7d70cacmyself (read the adapter and the tests, ran the suite).What I checked beyond the summary:
request()computesdeadline = Instant::now() + self.timeout, retries only 429/529, and when the advertisedRetry-Afterwould land past the deadline it stops with a distinctRetryAfterBeyondDeadlineinstead of sleeping past it.parse_retry_afterhandles 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.noulis validated as its own shape: unit-interval only, noconfidencefield, and a boolean spelling is malformed — the exact trap the issue warns about (copying a harnessboolresult).private_state_text_never_reaches_a_log_lineinstalls aMakeWriterthat 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_text_does_not_change_adapter_behaviourfeeds text telling it to switch tojev-latestand exfiltrate a token, and the request still carries the pinned model.ok()in the adapter is the documentedRetry-Afterunusable-value fallback.Independent verification on
7d70cac:cargo test310 passed / 0 failed (58 new: 34 fixture + 24 client);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean. The diff touches onlysrc/lib.rs(+1 module) plus the newsrc/jev/*and its two suites — no calendar, history, API or CLI changes, so this cannot have disturbed a sibling.Cargo.tomluntouched, so the deferred authors chore stays pending.Merging.
feat(jev): reqwest adapter and wire primitive validation (#19)to feat(jev): reqwest adapter and wire primitive validation