Skip to main content

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" (new OrgDocumentSource literal; 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 through knowledge_search, which searches audiences: ["customer"],
  • metadata.assignment_id = <osaId> โ€” the OSA scoping key; rides into the rag chunk meta via buildIndexMetadata, so recall can filter on it,
  • metadata.osa_memory = true, optional metadata.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 any content_hash that is not sha256 of the exact buffer and derives the persistent doc id as org: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 โ€” action store (fact 1..2000 chars, optional topic โ‰ค120). Resolves AgentRun.osaId โ†’ assignment, ingests the fact, returns identifiers only ({stored, assignment_id, document_id}).
  • kb_memory_recall โ€” action recall (query 1..1000, limit 1..20 default 8). Runs HaystackRetrievalService.search with audiences: ["ai_internal"] and a new assignmentId option.

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-backed memory_retain/memory_recall pair (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).