feat(hindsight): strict context search and supersession provenance #61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-18-context"
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
Issue #18 makes semantic context retrieval safe to rely on. Hindsight is semantic and bounded:
it can rank an outdated rationale first, it knows nothing about what the calendar says now, and
a recall for one item can silently return a decision that belongs to another. Life therefore has
to own the rules around it:
tags: ["life-item:<sha256>"]withtags_match: "all_strict", which excludes untagged andforeign-tagged memories. An empty result is a valid answer, and widening to a bank-wide query
would leak unrelated private decisions across tasks.
the live calendar reports
COMPLETEDmust not be answered as "postponed" because an embeddingstill ranks that memory highly.
forget the old one. The journal's own facts say which event supersedes which, so the recalled
memory can be labelled rather than presented as the current preference.
SQLite, never from a result set.
Changes
Adds
POST /v1/contextandPOST /v1/context/search(life context add/life context search)on top of the #16 adapter and the #17 outbox — no second queue, no second table, no second worker.
src/hindsight/recall.rs— the typed recall request/response.RecallScopeis part of therequest type: an item-scoped query cannot be built without its tag and is fixed to
all_strict,and a bank-wide read is a separate, explicit constructor. There is no expression that starts
item-scoped and ends bank-wide.
src/hindsight/provenance.rs— the local supersession ledger, read fromlife_events(
correction_target/provenance_json.supersedes_context_id), resolved to the end of thesupersession chain and applied to each recalled memory as
is_stale+superseded_by, plus thejournal counts (
context_events,undelivered_context_events). No migration and no new table:the ledger is the append-only journal.
src/hindsight/why.rs— live-calendar authority. Live state leads the answer; every recollectionis labelled historical, and one that asserts a state contradicting the live
STATUS(over ashort, documented, deterministic vocabulary) is flagged and quoted as historical context.
src/hindsight/context.rs— the parts captures and context-add must not diverge on: the derivedsource-event identity, the retain body (item tag vs. stable context tag) and the single
transaction that commits the fact together with its queued delivery.
src/api/captures.rsnowuses these, so the two routes share one implementation rather than two copies (net −37 lines
there).
src/api/context.rs— the two routes. Add resolves an item-linked context against the livecalendar to derive
life-item:<sha256>(refusing rather than inventing a tag), records the factand enqueues the delivery in one transaction, and reports the delivery state the durable row
actually holds. Search resolves the item live for the tag and for the state it reports, sends
one recall in one scope, labels the hits against the ledger, reads counts from the journal, and
returns the live read never from
item_projection.src/hindsight/client.rs—HindsightClient::recall, one request, no follow-up.src/cli.rs—life context add --input - [--operation-id KEY](write, operation key required)and
life context search [--input - | --query TEXT [--item-id ID]](read, no key), with terminalrendering that puts the live state first.
Degradation is explicit in both directions. Hindsight disabled or unreachable leaves the
deterministic parts working under
context_unavailable, recorded-but-undelivered context iscontext_pendingfrom the journal's rows, superseded recollections carrycontext_stale, and nocontext failure can alter a calendar result.
Note on the recall route
docs/technical/hindsight.md's endpoint table lists recall asPOST /v1/default/banks/{bank_id}/recall.The reviewed OpenAPI 0.10.1 that same page pins compatibility to serves it at
/v1/default/banks/{bank_id}/memories/recall(its retain and operations rows match the table exactly,so the table's recall row looks like the odd one out). This change uses the pinned deployment's route,
memories/recall, so a real deployment answers rather than 404s.Verification
cargo test— 26 targets, all green (480 passed, 0 failed, exit 0; up from 448).cargo clippy --all-targets -- -D warningsand
cargo fmt --checkclean. The rebase ontoorigin/main(which now carries #34'ssrc/api/mod.rschanges) auto-merged cleanly; the merged router registers both sides and the suite was re-run on the
rebased tree.
tests/context_tests.rs(14 tests) drives the real router over TCP against a mock Hindsight thatrecords every request and implements
all_strictfaithfully, and a mock Radicale that serves thelive calendar, with the daemon's SQLite file read back. Per criterion:
{"query":"Why was an outline chosen first?","tags":["life-item:c84962…"],"tags_match":"all_strict","budget":"mid","max_tokens":1024,"trace":false}.With an item-tagged, a foreign-tagged and an untagged memory present, the item query returns only
the item's own memory.
returns
hits: []and issues exactly one recall request, whose body carries the tag andall_strict. A separate explicit bank-wide query finds all three memories, and its captured bodycarries no
tags/tags_matchfield at all.STATUS:COMPLETEDand a recollection saying the outline waspostponed, the answer reports
live.authority = "live_calendar",live.item.status = "COMPLETED",a statement beginning
Item … is COMPLETED (observed live in CalDAV at …)followed byHistorical context from evt_123 indicated a deferral (…), and the hit flaggedcontradicts_live_state: true. The test also asserts one liveGETagainst Radicale and zeroitem_projectionrows, so the state came from the live read and not the cached projection.supersedes_context_id= C1's source event) are recorded through theroute; the older recalled memory comes back
is_stale: true,superseded_by: <C2 event id>, with acontext_stalewarning naming the superseding event, while C2 is not stale.recall_max_tokens = 256the captured body carriesmax_tokens: 256and the response reportsrecall_max_tokens: 256; with one memory recalled andtwo journal context events, the response reports
journal.context_events: 2(nothits.len()).A supplied
temporal_windowis asserted on the request as a preference.partialwithcontext_unavailable,empty hits (never a silent "there is no context"), and the live
COMPLETEDstate, whileGET /v1/items/{id}beside it returns the unaltered calendar result with no warnings. AHindsight-disabled deployment reports
context_unavailableand dispatches nothing, and a contextadd still records the fact and the durable outbox row.
source_event_idmatches thelife_eventsrow(
kind: context_captured,collection_id/uidset, provenance containing/v1/context); theoutbox row is
pending, addressed tolife-event:<event_id>, and its stored retain body carriestags: ["life-item:<sha256>"],metadata.source_event_id,update_mode: replaceandobservation_scopes: combined. Replaying the operation records nothing new. The queued delivery isthen dispatched by the existing
OutboxWorker(one pass →completed, one retain bodycaptured with the item tag) and the item-scoped search finds the delivered memory.
life context add --input - --operation-id KEY --jsonexits 6 withcontext_delivery: "pending"; an item-scopedlife context search --input - --jsonexits 6 withthe live state, the labelled hit and
context_pending; a bank-wide search with nothing pendingexits 0 with
status: "ok"; an unrecordable payload exits 2 withinvalid_input.Related Issues
Closes #18
Coordinator review — APPROVED
Verified
85d024fon a tree based on currentmain(1c35748, so #34's lock and drain are present). Independent verification:cargo test480 passed / 0 failed (14 new context integration tests plus unit tests);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.What I checked beyond the summary, and why each matters:
TagsMatchhas only theall_strictvariant representable,RecallQuery'stags/tags_matchare private and set only by theitem_scoped/bank_wideconstructors, andRecallScopeis part of the query — so there is no code path that starts item-scoped and ends bank-wide. The test additionally asserts the captured body carries the tag plusall_strictand that exactly one recall request was issued.the_contextual_why_prefers_live_state_over_a_contradicting_recollectionassertscaldav.live_reads() == 1and that theitem_projectionrow count is0— that is the evidence that the answer came from CalDAV rather than a cached projection, which is what the criterion actually requires. The contradiction is resolved in favour of live state with the recalled claim labelled.SupersessionLedgerreadslife_events.provenance_json.supersedes_context_idby SQL and walks the chain to its end, so a correction-of-a-correction shows the newest rationale.superseding(). It is capped byMAX_SUPERSESSION_HOPSwith a comment naming the cycle case explicitly and a defined fallback (report the last event reached, and the memory is superseded either way). That is the right handling for input the journal cannot currently forbid.Noneledger means the journal could not be read, is disclosed separately, and a hit is never marked fresh on the strength of a check that did not happen — the same honesty rule I asked for in the item-get path.commit_operation_result_with_delivery+NewContextDelivery::hindsight),captures.rsnow shares those helpers (net −37 lines, no duplication), and no second queue, table or worker appears.Cargo.tomluntouched, so the deferred authors chore stays pending — it lands with #35, which mandates thesubtlecrate.Merging. This completes Phase 3.
feat(hindsight): strict context search and supersession provenance (#18)to feat(hindsight): strict context search and supersession provenance