Self-Hosted Memory Layer Bake-Off — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up a one-week, throwaway, side-by-side evaluation of two self-hosted correctable memory layers for Claude Code — Candidate C (a Go explicit-store MCP over Qdrant) and Candidate B (Graphiti temporal knowledge graph over FalkorDB) — both fully local via Ollama-behind-LiteLLM, then measure them against pre-defined pass/fail criteria and tear them down cleanly.
Architecture: Both stacks deploy to a disposable mem-eval k3s namespace via two sibling ArgoCD Applications (mem-eval-c, mem-eval-b), each with its own resources-finalizer for clean cascade teardown. Both reach LLM/embedding capability only through the in-cluster LiteLLM gateway, which routes a wildcard ollama/* model family to the Mac-mini Ollama over the LAN; a LiteLLM virtual key whose model allowlist contains only ollama/* makes cloud egress structurally impossible. Claude Code attaches to each via streamable-HTTP MCP plus a SessionStart context-injection hook and a Stop capture-nudge hook. Memories are written agent-controlled (no firehose) except for a measured firehose baseline run against Candidate B only.
Tech Stack: Go 1.23 (modelcontextprotocol/go-sdk, qdrant/go-client), Qdrant, Graphiti (zepai/knowledge-graph-mcp, patched), FalkorDB, LiteLLM, Ollama (qwen2.5:14b extraction, nomic-embed-text 768d embeddings), k3s + ArgoCD + Kustomize, Traefik IngressRoute, cert-manager (vault-issuer), ExternalSecrets + Vault.
Spec: docs/engineering/specs/2026-05-30-selfhosted-memory-bakeoff-design.md · Bead: hl-73s
Conventions used throughout
Section titled “Conventions used throughout”- ArgoCD app:
argocd/cluster-app/templates/<name>.yaml(Application CR,project: core-services). - App manifests:
argocd/app-configs/<name>/withkustomization.yaml+ raw YAML. - Secrets:
ExternalSecret→ClusterSecretStorenamedvault,refreshInterval: 5m, Vault KV pathfzymgc-house/cluster/mem-eval. - Ingress: Traefik
IngressRouteonwebsecure, host<name>.fzymgc.house, annotationrouter-hosts.fzymgc.house/enabled: "true"; TLS via cert-managerCertificate(ClusterIssuervault-issuer). - Scope key (both stacks):
run:tier:repo, e.g.eval-2026-05:project:selfhosted-cluster, pluseval-firehose:project:<repo>for the baseline. Tier ∈ {project,global}. In C this is the Qdrant payload fieldscope; in B it is the Graphitigroup_id. - Provenance (both): every memory carries
repo,workspace,worktree_path,base_dir,source(user-said|agent-inferred),category,created_at. - Commit discipline: conventional commits, one logical change per commit, AI byline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. VCS perreferences/vcs-preamble.md(jj-first repo).
Phase 0 — Shared foundation
Section titled “Phase 0 — Shared foundation”Task 1: LiteLLM wildcard Ollama routes (chat + embeddings)
Section titled “Task 1: LiteLLM wildcard Ollama routes (chat + embeddings)”Files:
-
Modify:
argocd/app-configs/litellm/configmap.yaml(append tomodel_list) -
Step 1: Add the wildcard route and embedding route
In argocd/app-configs/litellm/configmap.yaml, under model_list:, append:
# --- mem-eval: local Ollama (Mac mini), no cloud egress --- - model_name: "ollama/*" litellm_params: model: "ollama_chat/*" api_base: "http://<MAC_MINI_LAN_IP>:11434" # replace with the Mac-mini LAN IP/hostname - model_name: "ollama/nomic-embed-text" litellm_params: model: "ollama/nomic-embed-text" api_base: "http://<MAC_MINI_LAN_IP>:11434"Rationale: LiteLLM supports provider-prefix wildcard routing (anthropic/*, groq/* are documented); ollama_chat/* uses the /api/chat path which is more reliable than /api/generate for tool-style prompts. The explicit embedding entry guarantees /v1/embeddings resolves even if the wildcard is treated as chat-only.
- Step 2: Commit
docs(litellm): add wildcard ollama/* route for mem-eval (hl-73s)- Step 3: Sync and verify the routes register (after merge to main)
Run:
kubectl -n litellm rollout status deploy/litellm --timeout=120skubectl -n litellm exec deploy/litellm -- \ curl -sS http://localhost:4000/v1/models -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ | grep -c 'ollama/'Expected: ≥ 1 (routes present). If 0, the wildcard did not register — check the configmap mounted and the pod restarted (reloader.stakater.com/auto: "true" handles this).
Task 2: Expose Mac-mini Ollama to the cluster and pull models
Section titled “Task 2: Expose Mac-mini Ollama to the cluster and pull models”Files: (host config — Mac mini; no repo files unless you manage it via the existing ansible/ tree)
- Step 1: Bind Ollama to the LAN
On the Mac mini, set the service env and restart:
launchctl setenv OLLAMA_HOST "0.0.0.0:11434"# Restart the Ollama app/service so it re-reads OLLAMA_HOST- Step 2: Restrict :11434 to the cluster egress source IPs
Add a packet-filter (pf) anchor allowing only the k3s node egress IP(s); default-deny otherwise. Record the exact node egress IP(s):
kubectl get nodes -o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}'Add a pf rule (illustrative): block in proto tcp to any port 11434 + pass in proto tcp from <NODE_IPS> to any port 11434.
- Step 3: Pull the eval models
ollama pull qwen2.5:14bollama pull nomic-embed-textExpected: both present in ollama list.
- Step 4: Verify reachability from inside the cluster
kubectl -n litellm exec deploy/litellm -- \ curl -sS http://<MAC_MINI_LAN_IP>:11434/api/tags | grep -c 'qwen2.5:14b'Expected: 1. If 0, fix firewall/binding before proceeding — every later task depends on this path.
Task 3: LiteLLM virtual key allowlisted to ollama/* (the egress guardrail)
Section titled “Task 3: LiteLLM virtual key allowlisted to ollama/* (the egress guardrail)”Files:
-
Create:
argocd/app-configs/mem-eval/external-secret.yaml(consumed in Task 11/15) -
Step 1: Mint the restricted key
kubectl -n litellm exec deploy/litellm -- curl -sS http://localhost:4000/key/generate \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \ -d '{"models": ["ollama/*"], "key_alias": "mem-eval-ollama", "metadata": {"purpose": "mem-eval bake-off; ollama-only"}}'Capture the returned key (sk-...).
- Step 2: Verify the guardrail rejects a cloud model (NEGATIVE test — load-bearing)
kubectl -n litellm exec deploy/litellm -- curl -sS -o /dev/null -w "%{http_code}\n" \ http://localhost:4000/v1/chat/completions \ -H "Authorization: Bearer <MEM_EVAL_KEY>" -H "Content-Type: application/json" \ -d '{"model": "or-claude-opus-4-7", "messages": [{"role":"user","content":"hi"}]}'Expected: 401 or 400 (rejected). If it returns 200, the guardrail is broken — STOP. Fallback: re-mint the key with the enumerated allowlist ["ollama/qwen2.5:14b","ollama/nomic-embed-text"] and re-test.
- Step 3: Verify the guardrail ALLOWS an ollama model (POSITIVE test)
kubectl -n litellm exec deploy/litellm -- curl -sS -o /dev/null -w "%{http_code}\n" \ http://localhost:4000/v1/chat/completions \ -H "Authorization: Bearer <MEM_EVAL_KEY>" -H "Content-Type: application/json" \ -d '{"model": "ollama/qwen2.5:14b", "messages": [{"role":"user","content":"reply with OK"}]}'Expected: 200. Ordering: this step requires Task 1 (wildcard route registered) and Task 2 (Ollama reachable) to be complete — otherwise a non-200 here reflects a missing route/host, not the key.
- Step 4: Store the key in Vault
vault kv put fzymgc-house/cluster/mem-eval litellm_ollama_key="<MEM_EVAL_KEY>"- Step 5: Write the shared ExternalSecret
argocd/app-configs/mem-eval/external-secret.yaml:
---apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: mem-eval-litellm namespace: mem-evalspec: refreshPolicy: Periodic refreshInterval: 5m secretStoreRef: name: vault kind: ClusterSecretStore target: name: mem-eval-litellm creationPolicy: Owner deletionPolicy: Delete data: - secretKey: litellm_ollama_key remoteRef: key: fzymgc-house/cluster/mem-eval property: litellm_ollama_key- Step 6: Commit
feat(mem-eval): add ollama-only LiteLLM key ExternalSecret (hl-73s)Phase 1 — Candidate C: Go explicit-store over Qdrant
Section titled “Phase 1 — Candidate C: Go explicit-store over Qdrant”Phase 1 and Phase 2 are independent and may be built in parallel. Phase 1 produces a static Go binary + image, a Qdrant deployment, and an ArgoCD app.
Task 4: Scaffold the Go module
Section titled “Task 4: Scaffold the Go module”Files:
- Create:
services/mem-eval-c/go.mod - Create:
services/mem-eval-c/main.go - Step 1: Initialise the module
mkdir -p services/mem-eval-c && cd services/mem-eval-cgo mod init github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-cgo get github.com/modelcontextprotocol/go-sdk/mcp@v1.4.0go get github.com/qdrant/go-client/qdrant@v1.18.2- Step 2: Minimal
main.gothat builds
services/mem-eval-c/main.go:
package main
import ( "log" "net/http" "os"
"github.com/modelcontextprotocol/go-sdk/mcp")
func main() { addr := envOr("MEM_LISTEN_ADDR", ":8080") server := mcp.NewServer(&mcp.Implementation{Name: "mem-eval-c", Version: "0.1.0"}, nil) registerTools(server) // defined in tools.go (Task 7)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) log.Printf("mem-eval-c listening on %s", addr) if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("server failed: %v", err) }}
func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def}- Step 3: Verify it compiles
Run: go build ./...
Expected: fails with undefined: registerTools (expected until Task 7) — confirms wiring. Comment out the registerTools call to get a clean go build checkpoint, then restore it in Task 7.
- Step 4: Commit
feat(mem-eval-c): scaffold Go MCP server module (hl-73s)Task 5: Embeddings client (LiteLLM /v1/embeddings)
Section titled “Task 5: Embeddings client (LiteLLM /v1/embeddings)”Files:
- Create:
services/mem-eval-c/internal/embed/embed.go - Test:
services/mem-eval-c/internal/embed/embed_test.go - Step 1: Write the failing test
internal/embed/embed_test.go:
package embed
import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing")
func TestEmbedReturnsVector(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer test-key" { t.Errorf("missing/wrong auth header: %q", got) } _ = json.NewEncoder(w).Encode(map[string]any{ "data": []map[string]any{{"embedding": []float32{0.1, 0.2, 0.3}}}, }) })) defer srv.Close()
c := New(srv.URL, "test-key", "ollama/nomic-embed-text") vec, err := c.Embed(context.Background(), "hello") if err != nil { t.Fatalf("Embed: %v", err) } if len(vec) != 3 || vec[0] != 0.1 { t.Fatalf("unexpected vector: %v", vec) }}- Step 2: Run it to verify it fails
Run: go test ./internal/embed/ -run TestEmbedReturnsVector -v
Expected: FAIL (undefined: New).
- Step 3: Implement
internal/embed/embed.go:
package embed
import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time")
type Client struct { baseURL string apiKey string model string http *http.Client}
func New(baseURL, apiKey, model string) *Client { return &Client{baseURL: baseURL, apiKey: apiKey, model: model, http: &http.Client{Timeout: 30 * time.Second}}}
type embedReq struct { Model string `json:"model"` Input string `json:"input"`}type embedResp struct { Data []struct { Embedding []float32 `json:"embedding"` } `json:"data"`}
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) { body, _ := json.Marshal(embedReq{Model: c.model, Input: text}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/embeddings", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.apiKey) resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("embeddings: status %d", resp.StatusCode) } var out embedResp if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } if len(out.Data) == 0 { return nil, fmt.Errorf("embeddings: empty data") } return out.Data[0].Embedding, nil}- Step 4: Run to verify it passes
Run: go test ./internal/embed/ -v
Expected: PASS.
- Step 5: Commit
feat(mem-eval-c): add LiteLLM embeddings client (hl-73s)Task 6: Qdrant store layer
Section titled “Task 6: Qdrant store layer”Files:
- Create:
services/mem-eval-c/internal/store/store.go - Test:
services/mem-eval-c/internal/store/store_test.go(integration; gated on a reachable Qdrant viaMEM_QDRANT_TEST_ADDR) - Step 1: Define the
Memorytype and store interface
internal/store/store.go:
package store
import ( "context" "time"
"github.com/qdrant/go-client/qdrant")
// Memory is the unit of storage. Fields map 1:1 to Qdrant payload keys.type Memory struct { ID string `json:"id"` Content string `json:"content"` Scope string `json:"scope"` // run:tier:repo, e.g. eval-2026-05:project:selfhosted-cluster Repo string `json:"repo"` Workspace string `json:"workspace"` Worktree string `json:"worktree_path"` BaseDir string `json:"base_dir"` Source string `json:"source"` // user-said | agent-inferred Category string `json:"category"` Tags []string `json:"tags"` CreatedAt time.Time `json:"created_at"`}
type Store struct { client *qdrant.Client collection string}
func New(c *qdrant.Client, collection string) *Store { return &Store{client: c, collection: collection} }
// EnsureCollection is idempotent: creates the collection at the given vector size if absent.func (s *Store) EnsureCollection(ctx context.Context, dim uint64) error { exists, err := s.client.CollectionExists(ctx, s.collection) if err != nil { return err } if exists { return nil } return s.client.CreateCollection(ctx, &qdrant.CreateCollection{ CollectionName: s.collection, VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{ Size: dim, Distance: qdrant.Distance_Cosine, }), })}- Step 2: Add Upsert/Search/Get/Delete/List with a failing integration test
internal/store/store_test.go:
package store
import ( "context" "net" "os" "strconv" "testing" "time"
"github.com/qdrant/go-client/qdrant")
func testStore(t *testing.T) *Store { addr := os.Getenv("MEM_QDRANT_TEST_ADDR") // host:port (gRPC 6334) if addr == "" { t.Skip("set MEM_QDRANT_TEST_ADDR to run store integration tests") } host, portStr, _ := net.SplitHostPort(addr) // addr = MEM_QDRANT_TEST_ADDR, e.g. 127.0.0.1:6334 port, _ := strconv.Atoi(portStr) c, err := qdrant.NewClient(&qdrant.Config{Host: host, Port: port}) if err != nil { t.Fatalf("client: %v", err) } s := New(c, "mem_eval_test") if err := s.EnsureCollection(context.Background(), 3); err != nil { t.Fatalf("ensure: %v", err) } return s}
func TestUpsertGetDeleteRoundtrip(t *testing.T) { s := testStore(t) ctx := context.Background() m := Memory{ID: "11111111-1111-1111-1111-111111111111", Content: "uses jj for VCS", Scope: "eval-test:project:selfhosted-cluster", Repo: "selfhosted-cluster", Source: "user-said", Category: "preference", CreatedAt: time.Now().UTC()} if err := s.Upsert(ctx, m, []float32{0.1, 0.2, 0.3}); err != nil { t.Fatalf("upsert: %v", err) } got, err := s.Get(ctx, m.ID) if err != nil || got.Content != m.Content { t.Fatalf("get: %v / %+v", err, got) } if err := s.Delete(ctx, m.ID); err != nil { t.Fatalf("delete: %v", err) } if _, err := s.Get(ctx, m.ID); err == nil { t.Fatalf("expected not-found after delete") }}- Step 3: Run to verify it fails
Run: go test ./internal/store/ -run TestUpsertGetDeleteRoundtrip -v
Expected: FAIL (undefined: (*Store).Upsert).
- Step 4: Implement Upsert/Search/Get/Delete/List/DeleteAll
Append to internal/store/store.go:
func payload(m Memory) map[string]any { return map[string]any{ "content": m.Content, "scope": m.Scope, "repo": m.Repo, "workspace": m.Workspace, "worktree_path": m.Worktree, "base_dir": m.BaseDir, "source": m.Source, "category": m.Category, "tags": m.Tags, "created_at": m.CreatedAt.Format(time.RFC3339), }}
func (s *Store) Upsert(ctx context.Context, m Memory, vec []float32) error { _, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{ CollectionName: s.collection, Wait: qdrant.PtrOf(true), Points: []*qdrant.PointStruct{{ Id: qdrant.NewID(m.ID), Vectors: qdrant.NewVectors(vec...), Payload: qdrant.NewValueMap(payload(m)), }}, }) return err}
func (s *Store) scopeFilter(scope string) *qdrant.Filter { return &qdrant.Filter{Must: []*qdrant.Condition{qdrant.NewMatch("scope", scope)}}}
func (s *Store) Search(ctx context.Context, scope string, vec []float32, k uint64) ([]Memory, error) { res, err := s.client.Query(ctx, &qdrant.QueryPoints{ CollectionName: s.collection, Query: qdrant.NewQuery(vec...), Filter: s.scopeFilter(scope), Limit: qdrant.PtrOf(k), WithPayload: qdrant.NewWithPayload(true), }) if err != nil { return nil, err } out := make([]Memory, 0, len(res)) for _, p := range res { out = append(out, fromPayload(p.Id.GetUuid(), p.Payload)) } return out, nil}
func (s *Store) Get(ctx context.Context, id string) (Memory, error) { pts, err := s.client.Get(ctx, &qdrant.GetPoints{ CollectionName: s.collection, Ids: []*qdrant.PointId{qdrant.NewID(id)}, WithPayload: qdrant.NewWithPayload(true), }) if err != nil { return Memory{}, err } if len(pts) == 0 { return Memory{}, fmt.Errorf("not found: %s", id) } return fromPayload(id, pts[0].Payload), nil}
func (s *Store) Delete(ctx context.Context, id string) error { _, err := s.client.Delete(ctx, &qdrant.DeletePoints{ CollectionName: s.collection, Wait: qdrant.PtrOf(true), Points: qdrant.NewPointsSelector(qdrant.NewID(id)), }) return err}
func (s *Store) DeleteAll(ctx context.Context, scope string) error { _, err := s.client.Delete(ctx, &qdrant.DeletePoints{ CollectionName: s.collection, Wait: qdrant.PtrOf(true), Points: qdrant.NewPointsSelectorFilter(s.scopeFilter(scope)), }) return err}Add the fmt import and a fromPayload decoder that reverses payload() (reads each key via p.GetStringValue() etc.; parse created_at with time.Parse(time.RFC3339, …)).
- Step 5: Run to verify it passes (with a local Qdrant)
docker run -d --rm -p 6334:6334 -p 6333:6333 qdrant/qdrant:v1.12.4MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test ./internal/store/ -vExpected: PASS.
- Step 6: Commit
feat(mem-eval-c): add Qdrant store layer with CRUD + scope filter (hl-73s)Task 7: MCP tools (store/search/get/update/delete/list/delete_all)
Section titled “Task 7: MCP tools (store/search/get/update/delete/list/delete_all)”Files:
- Create:
services/mem-eval-c/tools.go - Test:
services/mem-eval-c/tools_test.go - Step 1: Write a failing handler test for
store_memory
tools_test.go (uses a fake embedder + the integration store; gated on MEM_QDRANT_TEST_ADDR):
package main
import ( "context" "os" "testing"
"github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/store")
func TestStoreThenSearch(t *testing.T) { if os.Getenv("MEM_QDRANT_TEST_ADDR") == "" { t.Skip("integration") } d := newDeps(t) // builds store (dim 3) + a stub embedder returning a fixed 3-vector ctx := context.Background() id, err := d.storeMemory(ctx, storeArgs{Content: "prefers Go over Python", Scope: "s:project:r", Source: "user-said", Category: "preference"}) if err != nil || id == "" { t.Fatalf("store: %v id=%q", err, id) } hits, err := d.searchMemory(ctx, searchArgs{Query: "language preference", Scope: "s:project:r", K: 5}) if err != nil || len(hits) == 0 { t.Fatalf("search: %v hits=%d", err, len(hits)) } _ = store.Memory{} // ensure import used}- Step 2: Run to verify it fails
Run: MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test . -run TestStoreThenSearch -v
Expected: FAIL (undefined: newDeps).
- Step 3: Implement the tool layer
tools.go — typed args structs (so mcp.AddTool infers JSON Schema), a deps holder, the business methods, and registration:
package main
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/embed" "github.com/fzymgc-house/selfhosted-cluster/services/mem-eval-c/internal/store")
type deps struct { st *store.Store em interface { Embed(context.Context, string) ([]float32, error) }}
type storeArgs struct { Content string `json:"content" jsonschema:"the memory text to persist"` Scope string `json:"scope" jsonschema:"run:tier:repo, e.g. eval-2026-05:project:selfhosted-cluster"` Source string `json:"source" jsonschema:"user-said or agent-inferred"` Category string `json:"category" jsonschema:"decision|preference|convention|gotcha"` Tags []string `json:"tags,omitempty"` Repo string `json:"repo,omitempty"` Workspace string `json:"workspace,omitempty"` Worktree string `json:"worktree_path,omitempty"` BaseDir string `json:"base_dir,omitempty"`}type searchArgs struct { Query string `json:"query"` Scope string `json:"scope"` K uint64 `json:"k,omitempty"`}type idArgs struct { ID string `json:"id"`}type updateArgs struct { ID string `json:"id"` Content string `json:"content"`}type scopeArgs struct { Scope string `json:"scope"`}
func (d *deps) storeMemory(ctx context.Context, a storeArgs) (string, error) { vec, err := d.em.Embed(ctx, a.Content) if err != nil { return "", err } m := store.Memory{ ID: uuid.NewString(), Content: a.Content, Scope: a.Scope, Repo: a.Repo, Workspace: a.Workspace, Worktree: a.Worktree, BaseDir: a.BaseDir, Source: a.Source, Category: a.Category, Tags: a.Tags, CreatedAt: time.Now().UTC(), } return m.ID, d.st.Upsert(ctx, m, vec)}func (d *deps) searchMemory(ctx context.Context, a searchArgs) ([]store.Memory, error) { if a.K == 0 { a.K = 8 } vec, err := d.em.Embed(ctx, a.Query) if err != nil { return nil, err } return d.st.Search(ctx, a.Scope, vec, a.K)}func (d *deps) updateMemory(ctx context.Context, a updateArgs) error { cur, err := d.st.Get(ctx, a.ID) if err != nil { return err } cur.Content = a.Content vec, err := d.em.Embed(ctx, a.Content) if err != nil { return err } return d.st.Upsert(ctx, cur, vec) // same ID → in-place replace + re-embed}
func registerTools(server *mcp.Server) { d := buildDepsFromEnv() // store from MEM_QDRANT_ADDR; embedder from MEM_LITELLM_URL/KEY/MODEL mcp.AddTool(server, &mcp.Tool{Name: "store_memory", Description: "Persist a deliberate, well-formed memory. Do NOT store transient state, secrets, or timestamps."}, func(ctx context.Context, _ *mcp.CallToolRequest, a storeArgs) (*mcp.CallToolResult, any, error) { id, err := d.storeMemory(ctx, a) return textResult(fmt.Sprintf("stored %s", id)), map[string]string{"id": id}, err }) mcp.AddTool(server, &mcp.Tool{Name: "search_memory", Description: "Semantic search within a scope."}, func(ctx context.Context, _ *mcp.CallToolRequest, a searchArgs) (*mcp.CallToolResult, any, error) { hits, err := d.searchMemory(ctx, a) return textResult(fmt.Sprintf("%d hits", len(hits))), map[string]any{"memories": hits}, err }) mcp.AddTool(server, &mcp.Tool{Name: "get_memory", Description: "Fetch one memory by id."}, func(ctx context.Context, _ *mcp.CallToolRequest, a idArgs) (*mcp.CallToolResult, any, error) { m, err := d.st.Get(ctx, a.ID) return textResult(m.Content), m, err }) mcp.AddTool(server, &mcp.Tool{Name: "update_memory", Description: "Replace a memory's content in place (re-embeds)."}, func(ctx context.Context, _ *mcp.CallToolRequest, a updateArgs) (*mcp.CallToolResult, any, error) { err := d.updateMemory(ctx, a) return textResult("updated"), nil, err }) mcp.AddTool(server, &mcp.Tool{Name: "delete_memory", Description: "Delete one memory by id."}, func(ctx context.Context, _ *mcp.CallToolRequest, a idArgs) (*mcp.CallToolResult, any, error) { err := d.st.Delete(ctx, a.ID) return textResult("deleted"), nil, err }) mcp.AddTool(server, &mcp.Tool{Name: "delete_all", Description: "Delete every memory in a scope (teardown)."}, func(ctx context.Context, _ *mcp.CallToolRequest, a scopeArgs) (*mcp.CallToolResult, any, error) { err := d.st.DeleteAll(ctx, a.Scope) return textResult("scope cleared"), nil, err })}
func textResult(s string) *mcp.CallToolResult { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: s}}}}Implement buildDepsFromEnv() and the test helper newDeps(t) (which injects a stub embedder), plus add github.com/google/uuid to go.mod.
- Step 4: Run to verify it passes
Run: MEM_QDRANT_TEST_ADDR=127.0.0.1:6334 go test ./... -v
Expected: PASS across embed, store, and root package. Then go build ./... clean.
- Step 5: Commit
feat(mem-eval-c): add MCP tools store/search/get/update/delete/delete_all (hl-73s)Task 8: Container image
Section titled “Task 8: Container image”Files:
-
Create:
services/mem-eval-c/Dockerfile -
Step 1: Multi-stage distroless Dockerfile
services/mem-eval-c/Dockerfile:
FROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o /out/mem-eval-c .
FROM gcr.io/distroless/static-debian12:nonrootCOPY --from=build /out/mem-eval-c /mem-eval-cEXPOSE 8080ENTRYPOINT ["/mem-eval-c"]- Step 2: Build, smoke-test, push
cd services/mem-eval-cdocker build -t ghcr.io/fzymgc-house/mem-eval-c:0.1.0 .docker run --rm -e MEM_LISTEN_ADDR=:8080 -p 8080:8080 ghcr.io/fzymgc-house/mem-eval-c:0.1.0 &sleep 2 && curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8080/mcp # expect 400/406 (MCP needs proper headers) = server updocker push ghcr.io/fzymgc-house/mem-eval-c:0.1.0- Step 3: Commit
feat(mem-eval-c): add distroless Dockerfile (hl-73s)Task 9: Deploy Candidate C (Qdrant + Go server) via ArgoCD
Section titled “Task 9: Deploy Candidate C (Qdrant + Go server) via ArgoCD”Files:
- Create:
argocd/app-configs/mem-eval-c/kustomization.yaml - Create:
argocd/app-configs/mem-eval-c/qdrant.yaml(Deployment + Service, emptyDir — throwaway) - Create:
argocd/app-configs/mem-eval-c/deployment.yaml(Go server) - Create:
argocd/app-configs/mem-eval-c/service.yaml - Create:
argocd/app-configs/mem-eval-c/certificate.yaml - Create:
argocd/app-configs/mem-eval-c/ingress.yaml - Create:
argocd/cluster-app/templates/mem-eval-c.yaml(Application, with finalizer) - Step 1: Qdrant Deployment + Service (
qdrant.yaml)
---apiVersion: apps/v1kind: Deploymentmetadata: {name: qdrant, namespace: mem-eval, labels: {app.kubernetes.io/name: qdrant}}spec: replicas: 1 selector: {matchLabels: {app.kubernetes.io/name: qdrant}} template: metadata: {labels: {app.kubernetes.io/name: qdrant}} spec: containers: - name: qdrant image: qdrant/qdrant:v1.12.4 ports: [{containerPort: 6333, name: rest}, {containerPort: 6334, name: grpc}] volumeMounts: [{name: data, mountPath: /qdrant/storage}] resources: {requests: {memory: 512Mi, cpu: 250m}, limits: {memory: 2Gi, cpu: "1"}} volumes: [{name: data, emptyDir: {}}] # throwaway: emptyDir, lost on pod restart by design---apiVersion: v1kind: Servicemetadata: {name: qdrant, namespace: mem-eval}spec: selector: {app.kubernetes.io/name: qdrant} ports: [{name: rest, port: 6333, targetPort: rest}, {name: grpc, port: 6334, targetPort: grpc}]- Step 2: Go server Deployment (
deployment.yaml) — wires the LiteLLM key + ollama-only models
apiVersion: apps/v1kind: Deploymentmetadata: name: mem-eval-c namespace: mem-eval annotations: {reloader.stakater.com/auto: "true"}spec: replicas: 1 selector: {matchLabels: {app.kubernetes.io/name: mem-eval-c}} template: metadata: {labels: {app.kubernetes.io/name: mem-eval-c}} spec: containers: - name: mem-eval-c image: ghcr.io/fzymgc-house/mem-eval-c:0.1.0 ports: [{containerPort: 8080, name: http}] env: - {name: MEM_LISTEN_ADDR, value: ":8080"} - {name: MEM_QDRANT_ADDR, value: "qdrant.mem-eval.svc.cluster.local:6334"} - {name: MEM_QDRANT_COLLECTION, value: "mem_eval"} - {name: MEM_LITELLM_URL, value: "http://litellm.litellm.svc.cluster.local:4000"} - {name: MEM_EMBED_MODEL, value: "ollama/nomic-embed-text"} - {name: MEM_EMBED_DIM, value: "768"} - name: MEM_LITELLM_KEY valueFrom: {secretKeyRef: {name: mem-eval-litellm, key: litellm_ollama_key}} resources: {requests: {memory: 64Mi, cpu: 50m}, limits: {memory: 256Mi, cpu: 500m}}- Step 3: Service, Certificate, IngressRoute (
service.yaml,certificate.yaml,ingress.yaml)
Service → port 8080. Certificate mem-c-tls dnsNames mem-c.fzymgc.house + mem-c.k8s.fzymgc.house (issuer vault-issuer). IngressRoute host mem-c.fzymgc.house → service mem-eval-c:8080, tls.secretName: mem-c-tls, annotation router-hosts.fzymgc.house/enabled: "true". (Mirror argocd/app-configs/litellm/{service,certificate,ingress}.yaml exactly, substituting names/ports.)
- Step 4: kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: mem-evalresources: - ../mem-eval/external-secret.yaml # shared LiteLLM key (Task 3) - qdrant.yaml - deployment.yaml - service.yaml - certificate.yaml - ingress.yamlThe
../mem-eval/external-secret.yamlcross-directory reference is the repo’s first use of that kustomize pattern. Before committing, verify it renders:kubectl kustomize argocd/app-configs/mem-eval-c(expect the ExternalSecret + all resources, no error). Same check formem-eval-b.
- Step 5: ArgoCD Application with its own finalizer (
mem-eval-c.yaml)
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: mem-eval-c namespace: argocd finalizers: [resources-finalizer.argocd.argoproj.io] # per-Application; teardown cascadesspec: project: core-services sources: - repoURL: https://github.com/fzymgc-house/selfhosted-cluster targetRevision: HEAD path: argocd/app-configs/mem-eval-c destination: {server: https://kubernetes.default.svc, namespace: mem-eval} syncPolicy: automated: {prune: true, selfHeal: true} syncOptions: [ServerSideApply=true, CreateNamespace=true, RespectIgnoreDifferences=true] ignoreDifferences: - kind: ExternalSecret group: external-secrets.io jqPathExpressions: [.spec.data[].remoteRef.conversionStrategy, .spec.data[].remoteRef.decodingStrategy, .spec.data[].remoteRef.metadataPolicy]- Step 6: Commit
feat(mem-eval-c): deploy Qdrant + Go MCP server via ArgoCD (hl-73s)- Step 7: Verify after sync (post-merge)
argocd app sync mem-eval-c && argocd app wait mem-eval-c --health --timeout 180kubectl -n mem-eval rollout status deploy/mem-eval-c deploy/qdrant# store+search roundtrip through the live MCP endpoint (use an MCP client or curl the streamable endpoint)Expected: both Healthy; a store_memory then search_memory returns the stored item.
Phase 2 — Candidate B: Graphiti temporal graph over FalkorDB
Section titled “Phase 2 — Candidate B: Graphiti temporal graph over FalkorDB”Task 10: Build a patched Graphiti MCP image (local-Ollama fix)
Section titled “Task 10: Build a patched Graphiti MCP image (local-Ollama fix)”Files:
- Create:
services/graphiti-mcp/Dockerfile - Create:
services/graphiti-mcp/config.yaml
Why: Graphiti issue #1116 — the OpenAI-provider LLM factory ignores
api_urland hitsapi.openai.com(401). Verify whether the chosenzepai/knowledge-graph-mcptag already includes PR #1338 (base_urlpassed) and #1339 (OpenAIGenericClient). If yes, skip the patch and use the published image. If no, build from source with the patch.
- Step 1: Check whether the published image is already fixed
docker run --rm zepai/knowledge-graph-mcp:latest sh -c \ "grep -n 'base_url=config.providers.openai.api_url' src/services/factories.py" || echo "NEEDS PATCH"If the grep matches → the fix is present; record the digest and skip to Task 11 using zepai/knowledge-graph-mcp@<digest>. Else continue.
- Step 2: Dockerfile that applies the one-line fix
services/graphiti-mcp/Dockerfile:
FROM zepai/knowledge-graph-mcp:latest# Fix #1116: pass base_url to the OpenAI LLM factory so LLM calls honor api_url (LiteLLM→Ollama)RUN sed -i 's/model=config.model,/base_url=config.providers.openai.api_url,\n model=config.model,/' \ /app/mcp/src/services/factories.py# Verify the patch landed (fail the build if the anchor moved)RUN grep -q 'base_url=config.providers.openai.api_url' /app/mcp/src/services/factories.pyIf
sedanchors drift between versions, pin a specific upstream tag and adjust. The build-timegrepguard fails loudly rather than shipping an unpatched image.
- Step 3: config.yaml (env-expanded; LiteLLM + FalkorDB + Ollama)
services/graphiti-mcp/config.yaml:
server: {transport: "http", host: "0.0.0.0", port: 8000}llm: provider: "openai" model: "ollama/qwen2.5:14b" providers: openai: api_key: ${OPENAI_API_KEY} api_url: ${OPENAI_API_URL} # LiteLLM /v1embedder: provider: "openai" model: "ollama/nomic-embed-text" dimensions: 768 providers: openai: {api_key: ${OPENAI_API_KEY}, api_url: ${OPENAI_API_URL}}database: provider: "falkordb" providers: falkordb: {uri: ${FALKORDB_URI}, password: ${FALKORDB_PASSWORD:}, database: ${FALKORDB_DATABASE:mem_eval}}- Step 4: Build + push
docker build -t ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0 services/graphiti-mcpdocker push ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0- Step 5: Commit
feat(mem-eval-b): build patched Graphiti MCP image for local Ollama (hl-73s)Task 11: Deploy Candidate B (FalkorDB + Graphiti) via ArgoCD
Section titled “Task 11: Deploy Candidate B (FalkorDB + Graphiti) via ArgoCD”Files:
- Create:
argocd/app-configs/mem-eval-b/{kustomization,falkordb,deployment,configmap,service,certificate,ingress}.yaml - Create:
argocd/cluster-app/templates/mem-eval-b.yaml - Step 1: FalkorDB Deployment + Service (
falkordb.yaml)
falkordb/falkordb:latest, port 6379 (+ 3000 web UI), emptyDir storage (throwaway), Service falkordb:6379.
- Step 2: Graphiti config as ConfigMap + Deployment (
configmap.yaml,deployment.yaml)
ConfigMap holds config.yaml from Task 10 Step 3. Deployment uses ghcr.io/fzymgc-house/graphiti-mcp:patched-0.1.0, mounts the config at /app/mcp/config/config.yaml, env:
- {name: OPENAI_API_URL, value: "http://litellm.litellm.svc.cluster.local:4000/v1"} - name: OPENAI_API_KEY valueFrom: {secretKeyRef: {name: mem-eval-litellm, key: litellm_ollama_key}} - {name: OPENAI_BASE_URL, value: "http://litellm.litellm.svc.cluster.local:4000/v1"} # belt-and-suspenders for cross_encoder - {name: FALKORDB_URI, value: "redis://falkordb.mem-eval.svc.cluster.local:6379"} - {name: FALKORDB_DATABASE, value: "mem_eval"} - {name: SEMAPHORE_LIMIT, value: "3"} # keep low for local Ollama throughput - {name: GRAPHITI_TELEMETRY_ENABLED, value: "false"}Put reloader.stakater.com/auto: "true" on the Deployment’s metadata.annotations (exactly as in Task 9 Step 2) — not in the container spec.
-
Step 3: Service, Certificate, IngressRoute — host
mem-b.fzymgc.house→mem-eval-b:8000, path/mcp/. Mirror the litellm trio. -
Step 4: kustomization.yaml — resources:
../mem-eval/external-secret.yaml, falkordb, configmap, deployment, service, certificate, ingress. -
Step 5: ArgoCD Application
mem-eval-b— identical shape to Task 9 Step 5 (its OWNresources-finalizer),path: argocd/app-configs/mem-eval-b. -
Step 6: Commit
feat(mem-eval-b): deploy FalkorDB + patched Graphiti via ArgoCD (hl-73s)- Step 7: Verify the local-Ollama path end-to-end (post-merge)
argocd app sync mem-eval-b && argocd app wait mem-eval-b --health --timeout 240# add an episode, wait ~30s (async), then searchkubectl -n mem-eval logs deploy/mem-eval-b | grep -i 'api.openai.com' && echo "LEAK!" || echo "no cloud calls"Expected: an add_episode → (wait) → search_facts returns a fact; zero api.openai.com log lines (proves the #1116 patch works and egress is local). If you see 401 from api.openai.com, the patch did not apply — return to Task 10.
Phase 3 — Claude Code wiring
Section titled “Phase 3 — Claude Code wiring”Task 12: Register both MCP servers
Section titled “Task 12: Register both MCP servers”- Step 1: Add both (user scope)
claude mcp add --transport http --scope user mem-c https://mem-c.fzymgc.house/mcpclaude mcp add --transport http --scope user mem-b https://mem-b.fzymgc.house/mcp/Use
"type": "http"semantics (the CLI sets this) — noturl-type, which triggers an OAuth handshake that 404s for these servers.
- Step 2: Verify
claude mcp list | grep -E 'mem-(b|c)'Expected: both listed and reachable.
Task 13: SessionStart context-injection hook
Section titled “Task 13: SessionStart context-injection hook”Files:
- Create:
tools/mem-eval/hooks/session-start.sh - Modify:
~/.claude/settings.json(SessionStart hook registration — documented, not committed to repo) - Step 1: Write the hook (resolves scope, queries both stores, emits additionalContext)
tools/mem-eval/hooks/session-start.sh:
#!/usr/bin/env bashset -euo pipefailrepo="$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo unknown)")"scope="eval-2026-05:project:${repo}"# Query mem-c (fast) for recent + relevant; cap to keep context small.mem="$(curl -sS --max-time 5 "https://mem-c.fzymgc.house/mcp" \ -H 'Content-Type: application/json' \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"search_memory\",\"arguments\":{\"query\":\"project context\",\"scope\":\"${scope}\",\"k\":10}}}" \ 2>/dev/null | jq -r '.result.structuredContent.memories[]?.content' 2>/dev/null | head -20 || true)"[ -z "$mem" ] && exit 0ctx="Relevant memories for ${repo} (mem-eval):\n${mem}"jq -cn --arg c "$ctx" '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$c}}'Verified contract: SessionStart injects via
hookSpecificOutput.additionalContext(string). NosystemPromptfield exists.
- Step 2: Register in settings.json (documented in
tools/mem-eval/README.md)
{"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "/abs/path/tools/mem-eval/hooks/session-start.sh"}]}]}}-
Step 3: Verify — start a fresh Claude Code session in this repo; confirm the injected memories appear in context (or the hook exits 0 silently when none).
-
Step 4: Commit the hook script (
feat(mem-eval): SessionStart memory-injection hook (hl-73s)).
Task 14: Stop capture-nudge hook (gated once per session)
Section titled “Task 14: Stop capture-nudge hook (gated once per session)”Files:
-
Create:
tools/mem-eval/hooks/stop-nudge.sh -
Step 1: Write the hook — on Stop, if a per-session sentinel file is absent, create it and emit a block-with-reason nudging Claude to call
store_memoryfor any durable facts; otherwise exit 0.
#!/usr/bin/env bashset -euo pipefailsid="${CLAUDE_SESSION_ID:-default}"; flag="/tmp/mem-eval-nudge-${sid}"[ -f "$flag" ] && exit 0: > "$flag"jq -cn '{decision:"block",reason:"Before stopping: if this session produced durable decisions, preferences, conventions, or gotchas worth remembering, call mem-c store_memory (scope eval-2026-05:project:<repo>, source agent-inferred). Skip transient state, secrets, timestamps. Then stop."}'-
Step 2: Register under
hooks.Stopin settings.json (same pattern as Task 13). -
Step 3: Verify — finish a turn; confirm the nudge fires exactly once per session.
-
Step 4: Commit (
feat(mem-eval): Stop capture-nudge hook (hl-73s)).
Task 15: CLAUDE.md memory instruction
Section titled “Task 15: CLAUDE.md memory instruction”Files:
-
Create:
tools/mem-eval/memory-instructions.md(paste into the test repos’ CLAUDE.md during the eval) -
Step 1: Write the store/never-store rules — the #4573 junk taxonomy as negative rules (NEVER store: system-prompt restating, transient task state, secrets/PII, standalone timestamps, hallucinated profiles). STORE: decisions, preferences, conventions, gotchas, with
sourceandcategory. Include the exactscopeconvention and both tool names (mem-c,mem-b). -
Step 2: Commit (
docs(mem-eval): memory capture instructions for CLAUDE.md (hl-73s)).
Phase 4 — Eval harness & measurement
Section titled “Phase 4 — Eval harness & measurement”Task 16: Ground-truth probe sets (Signals 2 & 4)
Section titled “Task 16: Ground-truth probe sets (Signals 2 & 4)”Files:
-
Create:
tools/mem-eval/probes/should-remember.jsonl(recall set) -
Create:
tools/mem-eval/probes/contradictions.jsonl(supersession set, B) -
Step 1: Seed
should-remember.jsonl—{query, expected_substring, scope}lines for ~15 facts you expect to be recalled. Append during the week whenever Claude lacked context it should have had. -
Step 2: Seed
contradictions.jsonl—{fact_v1, fact_v2, query, expected_current_substring}for ~10 mutable attributes (current branch tool, chosen DB, decided approach). -
Step 3: Commit (
feat(mem-eval): seed recall + contradiction probe sets (hl-73s)).
Task 17: Harness scripts (recall, supersession, junk audit, egress, firehose)
Section titled “Task 17: Harness scripts (recall, supersession, junk audit, egress, firehose)”Files:
- Create:
tools/mem-eval/harness/recall_score.sh— replayshould-remember.jsonlagainst both stores; print hit-rate per store (PASS ≥ 70%). - Create:
tools/mem-eval/harness/supersession_score.sh— for each contradiction pair: store v1 → store v2 → query; assert current returns v2 (B; PASS ≥ 90%). For C, record the manualupdate/deletecorrection-effort instead. - Create:
tools/mem-eval/harness/junk_audit.sh— dump all memories per scope to a CSV for manual classification against the #4573 taxonomy; compute junk %. - Create:
tools/mem-eval/harness/egress_check.sh— query LiteLLM spend logs / pod logs for any non-ollama/*model usage by themem-eval-ollamakey (PASS = 0). - Create:
tools/mem-eval/harness/firehose_replay.sh— Candidate B only: replay raw session transcript turns from~/.claude/projects/<proj>/*.jsonlasadd_episodeinto scopeeval-firehose:project:<repo>, to measure extraction junk vs. curated. - Step 1–5: Write each script with a concrete, runnable body and a one-line usage comment. Each prints a single summary number to stdout.
- Step 6: Commit (
feat(mem-eval): measurement harness scripts (hl-73s)).
Task 18: Run the week + record results
Section titled “Task 18: Run the week + record results”- Step 1: Use both memory servers in real sessions across multiple repos for ~7 days (curated ingestion).
- Step 2: Mid-week, run
firehose_replay.shagainst B intoeval-firehose. - Step 3: At week end, run all harness scripts; record every Signal in a results table appended to the spec (§7.3).
- Step 4: Run
egress_check.sh— must be 0; if not, the eval is invalid until explained.
Phase 5 — Decision & teardown
Section titled “Phase 5 — Decision & teardown”Task 19: Decision
Section titled “Task 19: Decision”- Step 1: Fill the §7.3 results table; apply §7.4 kill criteria. Record the verdict (B / C / both-for-different-scopes / neither) as a
bd noteon hl-73s and a short addendum to the spec.
Task 20: Teardown (clean cascade)
Section titled “Task 20: Teardown (clean cascade)”- Step 1: Delete both ArgoCD Applications (finalizers cascade)
argocd app delete mem-eval-c --cascadeargocd app delete mem-eval-b --cascadekubectl get ns mem-eval -o name && kubectl delete ns mem-eval # remove the (now empty) namespace- Step 2: Revoke the LiteLLM key + remove the wildcard routes
kubectl -n litellm exec deploy/litellm -- curl -sS http://localhost:4000/key/delete \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" \ -d '{"keys": ["<MEM_EVAL_KEY>"]}'vault kv delete fzymgc-house/cluster/mem-evalRevert the Task 1 configmap.yaml addition in a follow-up commit (revert(litellm): drop mem-eval ollama routes).
- Step 3: Remove Claude Code wiring
claude mcp remove mem-c; claude mcp remove mem-b# remove the SessionStart + Stop hooks from settings.json; revert CLAUDE.md memory instructions- Step 4: Close or update hl-73s with the outcome.
Self-review notes (spec coverage)
Section titled “Self-review notes (spec coverage)”- Hard constraint “no cloud egress” → Tasks 1, 3 (guardrail key + negative test), 17 (
egress_check), 11 Step 7 (B log check). - “Correctable” → C
update_memory/delete_memory(Task 7); Bdelete_entity_edge/delete_episode(native) + correction-effort measurement (Task 17). - “Reuse infra; no PostgreSQL” → neither candidate uses CNPG; Qdrant + FalkorDB deployed fresh (Tasks 9, 11).
- Namespace/provenance model → scope key + payload/group_id (Conventions; Tasks 6, 7).
- SessionEnd read-only correction → ingestion is agent-controlled + Stop nudge (Tasks 13, 14).
- Pass/fail + methodology → Tasks 16–18; kill criteria → Task 19.
- Per-Application finalizers (teardown) → Tasks 9, 11, 20.