PoC: KB-backed OSA-scoped persistent memory (design)
Date: 2026-08-05 Status: PoC โ validates that the existing knowledge base can serve as the durable, cross-session fact store for a Specialist assignment (OSA).
Problemโ
Under ADR-045/046, a Hermes session is per-conversation: its AgentFS home
starts empty, so the native memory tool cannot persist facts across
conversations. The governed SpecialistMemory bridge (#5468) was removed by
the ADR-046 cleanup, leaving no cross-session memory path at all. Meanwhile
the platform already operates a governed, org/specialist-scoped knowledge
base with hybrid retrieval (Haystack/pgvector via the rag service) that
Hermes can reach through the MCP gateway โ but the KB has no per-assignment
scoping.
Decisionโ
Store assignment-level facts as lightweight KB entries and expose store/recall to the runtime through the existing MCP tool gateway. Hermes decides when to store and when to recall (ADR-046 ยง3: MCP supplies organization capabilities; no per-turn injection, no Humanwork gates).
Data model โ no migrationโ
A fact is an ordinary org_documents row + rag ingestion, created through
HaystackIngestionService.upload() with a synthetic in-memory markdown file
(same pattern as ingestTeamMarkdown and the golden-answer sync):
source: "agent_fact"(newOrgDocumentSourceliteral; the column is a plain varchar โ type-only change),specialistId= the assignment's Specialist,audience: "ai_internal"โ facts are never client-visible and never surface throughknowledge_search, which searchesaudiences: ["customer"],metadata.assignment_id = <osaId>โ the OSA scoping key; rides into the rag chunk meta viabuildIndexMetadata, so recall can filter on it,metadata.osa_memory = true, optionalmetadata.topic,- the stored bytes begin with a scope marker line
(
<!-- humanwork:osa-memory <osaId> -->), stripped from recalled content. The marker must be inside the bytes: rag rejects anycontent_hashthat is not sha256 of the exact buffer and derives the persistent doc id asorg:dataset:content_hash, so a content-only hash would let two assignments storing identical text collide on one doc id โ the second store would clobber the first assignment's metadata and hide the fact from its own recall (Greptile P1 on #5509), - deterministic
haystack_doc_id= sha256(marker + fact text) โ storing the same fact twice in the same assignment upserts in place (idempotent); the same text in another assignment gets its own document.
Toolsโ
Two registry entries (mirroring the precedent of the removed
memory_retain/memory_recall pair โ one accessClass per binding),
handler kb_osa_memory, integrationTypes: [], dispatched before the
credential gate like governed_knowledge:
kb_memory_storeโ actionstore(fact1..2000 chars, optionaltopicโค120). ResolvesAgentRun.osaIdโ assignment, ingests the fact, returns identifiers only ({stored, assignment_id, document_id}).kb_memory_recallโ actionrecall(query1..1000,limit1..20 default 8). RunsHaystackRetrievalService.searchwithaudiences: ["ai_internal"]and a newassignmentIdoption.
Retrieval scopingโ
HaystackRetrievalService.search gains an optional assignmentId filter
applied inside the org_documents ACL gate: only documents whose GOVERNING
row carries metadata.assignment_id === <osaId> are admitted, and fail-open
admission (no governing row) is disabled under the filter. The filter cannot
live on rag chunk meta: the retrieval response serializes meta through a
fixed allowlist (rag/pipelines/retrieval/pipeline_wrapper.py,
_serialize_document) that does not include assignment_id โ a chunk-meta
comparison never matches (the original PoC shipped that variant and recall
always returned empty). The assignment filter doubles as the "facts only"
filter โ other ai_internal documents carry no assignment_id metadata.
HaystackIngestionService.upload gains an optional audience attr; omitted
means today's behavior (column default customer) โ existing callers are
untouched.
Seed / grantsโ
Two entries appended to DEMO_RUNTIME_TOOL_SPECS in
demo-managed-runtime.ts; binding assets, release pointers, grants, and
assignment metadata derive automatically. Sessions established before a
release republish do not gain the tools (frozen session config, by design).
Alternatives consideredโ
- Restore the
specialist_memory-backedmemory_retain/memory_recallpair (deleted hours ago in the same cleanup): stronger governance (Expert review UI, recall audit, retraction) but does not answer the question this PoC exists to answer โ whether the KB can be the store. The two approaches share the gateway seam; a later decision can swap the backend without touching the tool surface. - Index-level assignment filter in the Python rag service: cleaner long-term, but touches a second deployable for a PoC; the chunk-meta post-filter proves the model without it.
- OSA-level AgentFS home: pure ADR-046, but requires solving concurrent session writes and loses governance; out of PoC scope.
Known PoC limitations (deliberate)โ
- Recall relevance depends on rag ranking; the chunk-meta filter discards
non-matching chunks after
top_k, so a very large KB could crowd facts out of the candidate set. Index-level filtering fixes this later. - No retraction/edit surface (a stored fact is evicted only by re-ingesting identical content or KB admin deletion).
- The keyword fallback path (
KbRetrievalService, Haystack paused) does not serve facts โ recall degrades to empty rather than unscoped. - No Expert review flow for facts (
status: "approved"on write).