feat(hindsight): reqwest client and deterministic document identity #55
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-16-hindsight"
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 incorporates Hindsight as its semantic context backend, but nothing in the codebase talks to it yet. This lands the direct Rust adapter for one configured bank together with the two identities that make retained memory trustworthy:
It also makes the delivery vocabulary honest before anything depends on it: an accepted retain is not completed ingestion, an unconfirmed delivery is not a delivered one, and an authentication or schema mismatch stops delivery with an operator-visible error instead of being retried away. Hindsight remains optional throughout — it owns semantic context and never current calendar or task state.
Changes
src/hindsight/identity.rs— deterministic derivation:document_id = "life-event:<event_id>",item_tag = "life-item:" + lowercase hex SHA-256(canonical UTF-8 JSON array[collection_id,uid]),and the stablelife-contexttag. The canonical form is produced by the JSON serializer, so no caller formatting can change the digest. Unit tests pin independently computed vectors (printf '%s' '["personal","fictional-demo-uid"]' | shasum -a 256), order sensitivity, whitespace significance and non-collapse.src/hindsight/models.rs— typed upstream wire format and the delivery-state model.RetainItemhas no constructor that accepts a caller-supplied document id, update mode or observation scope: the document id is derived from the source event andupdate_mode/observation_scopesare single-variant enums (replace/combined), so a retry can only ever replace the same document.ContentTimestampdistinguishes a real occurrence time fromunset, so ingestion time is never presented as occurrence time. Metadata is string-valued and held in aBTreeMap(notHashMap) so the same event always serializes to the same bytes. Responses expose upstream memory/operation ids separately, andContextDeliveryserializes as the documented result literal ("context_delivery":"not_requested"), never leaking upstream ids into that field.src/hindsight/client.rs— pooledreqwest+rustls client, one per bank, credential read from the mounted[hindsight].authorization_fileviaread_secret_file, bounded timeouts, bounded response size,Debugredaction, and redirects refused outright so anAuthorizationheader can never be forwarded to another origin.verify_compatibility()proves the deployed authentication scheme and operations route before retains are enabled; authentication, authorization, absence and schema failures halt delivery and stay operator-visible, while an outage stays transient and leaves deliveryretry_wait.retry_delay()is the bounded backoff that honors a serverRetry-After.pending/accepted/completed/retry_wait/needs_reconciliation/failed(plusnot_requested), matching the timeline outbox vocabulary. A lost acceptance response is reported asneeds_reconciliation, never as accepted or completed.confirm_retain()hands the upstream operation identity to the caller before the first poll is dispatched, and refuses to poll at all if the caller cannot record it.tests/hindsight_client_tests.rs— 23 integration tests against an in-process mock Hindsight (and, for outage independence, the daemon plus a mock Radicale), including exact captured request bodies across repeated retains.ring::digest, already used bysrc/history/fingerprint.rs, and the secret is held inzeroize::Zeroizinglikesrc/calendar/client.rs.Cargo.tomlis untouched (so theauthorschore is not bundled here).Left for #17 by design: the durable outbox row, the background polling worker and retry scheduling. The
outboxtable and its state vocabulary already exist in the baseline schema; this issue builds the adapter, identity, wire types, delivery-state model and the direct client a caller uses.Deliberate deviations from the issue's illustrative sketch
secrecy::SecretString→zeroize::Zeroizing<String>, matchingread_secret_file/CalDavClient(no new dependency).sha2::Sha256→ring::digest::SHA256, matchingsrc/history/fingerprint.rs(no new dependency).HashMap<String,String>metadata →BTreeMap, so serialization is deterministic and repeated retains produce identical bytes.Unauthenticated/Forbidden) because the documented CLI vocabulary distinguishesunauthenticatedfromforbidden.HindsightError::code()is named to match the mergedJevError::code()insrc/jev/client.rs.Verification
nix develop --command cargo test— all suites green (44 lib unit tests, 23 newhindsight_client_tests, and every pre-existing suite unchanged):cargo clippy --all-targets -- -D warningsandcargo fmt --checkare clean.What the new tests observe (all against a real mock HTTP server, never a live bank):
evt_demo(the second with revised content) senddocument_id: "life-event:evt_demo",update_mode: "replace"andobservation_scopes: "combined"in both captured bodies; the mock holds exactly one memory for that document, holding the revision; re-sending identical content produces byte-identical bytes (no random id, no "now" timestamp).recurrence_kind/recurrence_keymetadata; context-only records carrylife-context, the source event id and nouidkey.docs/technical/hindsight.md(with the placeholder tag replaced by the real derivation), and unknown content is sent as"unset".acceptedand is not completed; pollingpending,in_progress,completedandfailedmaps to accepted/accepted/completed/failed; only upstream reporting completion yields a completed delivery.needs_reconciliation.POST /v1/tasksstill returns 201okwith the created item, twice; the context side reportsretry_waitwith acontext_pendingwarning. A disabled backend is refused at construction and reported as an explicitfailed/context_unavailable, never as pending or completed.Related Issues
Closes #16
Coordinator review — APPROVED
Verified
383dabamyself. Notably, I re-derived the identity vectors independently rather than trusting the test's own comment:which matches the hard-coded
PERSONAL_DEMO_TAG, and likewise for970038bf…and the reversed-order96bf4ab0…. The derivation itself is right for a stronger reason than the vectors:derive_item_tagbuilds the canonical form withserde_json::to_string(&[collection_id, uid])rather than string concatenation, so no caller's formatting can move the digest — and the order-sensitivity vector proves[a,b]≠[b,a].What I checked beyond the summary:
i03_accepted_and_completed_stay_distinct_milestoneswalks pending → in_progress → completed → failed, andan_unrecognised_operation_status_is_not_a_completion_claimrefuses to treat an unknown status as done. Nothing claims exactly-once consolidation.i04_a_hindsight_outage_never_changes_a_calendar_resultruns the real in-process daemon against a mock Radicale with Hindsight down, which is the one claim that would have been easy — and wrong — to take on faith.verify_compatibilityprobes a reserved operation id (404 = credential accepted and route present); 401/403/404/schema failures set a halt so a later retain is refused with zero POSTs, while a transient outage deliberately does not halt (an outage is not a compatibility halt) — the distinction the spec asks for.a_redirect_is_refused_without_forwarding_the_credential— the credential-forwarding boundary is enforced in the transport, not just documented.Independent verification on
383daba:cargo test342 passed / 0 failed (23 new Hindsight tests);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean. The rebase ontoade394akept bothpub mod hindsight;andpub mod jev;. No new dependency;Cargo.tomluntouched, so the deferred authors chore stays pending.Correctly scoped: the outbox table, polling worker and retry scheduling are left to #17, and wiring
verify_compatibilityinto startup belongs with #17/#18.Merging.
feat(hindsight): reqwest client and deterministic document identity (#16)to feat(hindsight): reqwest client and deterministic document identity