wiki-vault/Sources/Homelab/postgres-consolidation-ob1-reflection-layer.md

381 lines
16 KiB
Markdown

---
created: '2026-05-15'
path: Sources/Homelab
project: postgres-consolidation-reflection-layer
status: active
tags:
- ob1
- mcp
- pgvector
- embeddings
- automation
- claude-code
- dispatch
type: project-plan
updated: '2026-05-15'
---
# Postgres Consolidation & OB1 Reflection Layer
## Goal
Two coupled outcomes:
1. **Consolidate** the three project databases (`wiki`, `ob1`, `trellis`) on
the shared Postgres instance into a single database with three schemas,
each owned by a dedicated MCP service user.
2. **Build the OB1 reflection layer**: a two-layer model in `ob1` (captures
+ reflections) with a daily cron job that synthesizes generalizable
lessons from agent session content, plus vault indexing so semantic
search spans vault notes, captures, and reflections uniformly.
These are paired because the reflection loop is fundamentally cross-schema
(citing vault notes from captures from reflections in one query). The
consolidation is the foundation that makes the reflection design clean.
## Locked Decisions
### Database consolidation
- One Postgres database, three schemas: `wiki`, `ob1`, `trellis`.
- Migration direction: **OB1's existing database is the target.** Add
`wiki` and `trellis` schemas to it. Preserves OB1's existing pgvector
setup if installed.
- Target database name, schema-prefix-rewrite details, and final role
names: deferred to execution. Pick deliberate names at that stage.
- Nothing else lives on the Postgres instance — old databases can be
dropped cleanly after verification.
- Downtime during cutover is acceptable. No parallel-run period needed.
### MCP service users (conservative v1)
- `herby_mcp` — owns `wiki.*`. No reads on `ob1` or `trellis`.
- `ob1_mcp` — owns `ob1.*`. **Reads** on `wiki.*` and `trellis.*`
(needed for reflection generation).
- `trellis_mcp` — owns `trellis.*`. No reads on `wiki` or `ob1`.
- `lovebug` — read-write across all three schemas (preserves current
broad-access behavior; tightening deferred).
- Each MCP server connects with its own credentials and its own
`search_path` so unqualified table references resolve to its own schema.
- Permission model revisits after reflection ships and real cross-schema
read patterns are observed.
### Embeddings table
- Shared `public.embeddings` table with pointers back to source rows:
```
public.embeddings (
id bigserial PRIMARY KEY,
source_schema text NOT NULL,
source_table text NOT NULL,
source_id bigint NOT NULL,
embedding vector(<dim>) NOT NULL,
embedding_model text NOT NULL,
embedded_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source_schema, source_table, source_id, embedding_model)
)
```
- One table for all embeddings across all content types. Cross-source
semantic search becomes one `ORDER BY embedding <-> query_vec` query.
- The unique constraint allows running multiple embedding models in
parallel for experimentation later without conflicting rows.
- Orphan rows possible (no FK because `source_table` varies); cleaned
up by a periodic job (defer; not Phase 1 work).
### Two-layer OB1 schema
- `ob1.captures` — raw layer. Holds Dispatch transcripts, Claude Code
session content, claude.ai chat captures. Written by the capture path;
never rewritten.
- `ob1.reflections` — compiled layer. Holds synthesized lessons generated
by the daily reflection job. Structured object: lesson text, citations
(jsonb array of source pointers), tags, confidence score, generation
metadata (model, date, source window).
- Mirrors the wiki's Sources/Wiki two-layer model. Same uniform pattern
across both systems: raw layer is the substrate, compiled layer is
regenerable.
- Both layers embedded the same way via `public.embeddings`. Retrieval
can scope to one or both via `source_table` filter.
### Reflection job
- **Trigger:** daily cron, 3am local. Off-hours, low contention.
- **Inputs:** all three streams:
- Dispatch session transcripts (Phase 7 of original ob1 plan; pre-compact
hook target — may need to coordinate timing)
- Claude Code session logs from `~/.claude/projects/` (ob1-jsonl-watcher
project is the existing capture mechanism)
- claude.ai chat captures (new path — needs design; see Open Items)
- **Output:** structured reflection records:
```
ob1.reflections (
id bigserial PRIMARY KEY,
lesson text NOT NULL,
citations jsonb NOT NULL, -- [{schema, table, id, relevance}, ...]
tags text[],
confidence float, -- model-reported or heuristic
source_window tstzrange, -- what time window this synthesized
generated_at timestamptz NOT NULL DEFAULT now(),
generated_by text NOT NULL, -- model name + version
job_run_id uuid -- groups reflections from one run
)
```
- **Run-as identity:** `ob1_mcp` user (which has the reads it needs on
`wiki` and `trellis` under v1 permissions).
- **Idle behavior:** if a day has no significant activity, the job writes
no reflections and logs a no-op. No "no significant activity" placeholder
rows.
### Embedding model
- Whatever OB1 currently uses for its own captures. Vault indexing,
capture embedding, and reflection embedding all use the same model
so vectors live in the same space.
- Phase 0 confirms which model OB1 is on.
- Switching the model later is a deliberate full re-embed operation, not
a config flip. The `embedding_model` column on `public.embeddings`
supports incremental migration when that day comes.
### Vault indexing
- In scope for this plan. Without it, reflections can't cite vault notes
semantically.
- Backfill: one-time pass over all existing `wiki.notes` rows, embed
each, insert into `public.embeddings`.
- Incremental: `save_vault_note` in the herby MCP server updated to also
embed new content and upsert into `public.embeddings`.
## Open Items
- [ ] Final names (target database, schemas, MCP users) — picked at
execution time.
- [ ] pgvector status — Phase 0 must check `\dx` on each existing
database. If installed in OB1's database, no new install needed; if
absent, install during consolidation.
- [ ] Embedding model + dimension — inherited from whatever OB1 is using.
Phase 0 confirms.
- [ ] claude.ai chat capture path — no existing mechanism captures these.
Out of scope for initial reflection job inputs unless a capture path
lands first. If it doesn't, the job runs on Dispatch + Claude Code
streams only and chat captures get added later. **Flag for execution:
confirm with Travis whether to attempt a capture path in this plan or
explicitly defer.**
- [ ] Dispatch pre-compact hook timing — Phase 7 of the original ob1
deployment plan owns this. If it hasn't shipped, the reflection job's
Dispatch input is empty until it does. **Worth confirming current state
before starting Phase 4 of this plan.**
- [ ] Reflection prompt design — what does the synthesizer actually do?
Read N captures + relevant vault notes from the day → extract
generalizable lessons → produce structured output. The prompt itself
is real work; placeholder for now.
- [ ] Noise filtering for captures — what gets included in the daily
reflection window? All captures? Captures above some signal threshold?
Excludes routine command output? **Design step in Phase 4.**
- [ ] Reflection deduplication — re-running the job over overlapping
windows could produce near-duplicate lessons. Strategy: confidence-
weighted merge, manual review, or first-write-wins? **Design step in
Phase 4.**
## Phases
### Phase 0 — Inspection and prep
- [ ] Confirm Postgres instance hosts only `wiki`, `ob1`, `trellis`
(nothing else).
- [ ] `\dx` on each database — record which has pgvector installed.
- [ ] Inspect each database's schema: confirm table list, row counts,
any vector columns and their dimensions.
- [ ] Identify OB1's current embedding model (env var, code reference,
or configuration file).
- [ ] Inventory each MCP server's connection configuration: where
credentials live, how to update them.
- [ ] Confirm backup destination on the NAS is reachable over Tailscale
with sufficient space.
- [ ] Document current state in a session note before any changes.
### Phase 1 — Backups and rollback prep
- [ ] `pg_dumpall` of the full instance to NAS (full safety net).
- [ ] Per-database `pg_dump` of `wiki`, `ob1`, `trellis` to NAS
(granular restore targets).
- [ ] Verify dumps are readable (test restore to a scratch database).
- [ ] Write rollback runbook: how to restore from dumps and revert MCP
server connection strings if cutover fails.
### Phase 2 — Consolidation
- [ ] Pick final names: target database, schema names, MCP user names.
- [ ] If OB1's database is the target and is named appropriately, use
in place. Otherwise, rename or create new and restore OB1's content.
- [ ] Ensure pgvector is installed on the target database
(`CREATE EXTENSION IF NOT EXISTS vector`).
- [ ] Create `wiki` and `trellis` schemas in the target database.
- [ ] Move OB1's existing tables into the `ob1` schema if not already
namespaced (`ALTER TABLE ... SET SCHEMA ob1`).
- [ ] Restore `wiki` dump into the `wiki` schema using `pg_dump`'s
`--schema=wiki` retargeting or a sed pass over the dump SQL.
- [ ] Restore `trellis` dump into the `trellis` schema the same way.
- [ ] Create the three MCP service users.
- [ ] Grant ownership and permissions per the locked decisions.
- [ ] Set per-user `search_path` defaults.
- [ ] Verify row counts in each schema match pre-migration counts.
### Phase 3 — MCP server updates
- [ ] Update herby MCP server: new connection string (target DB,
`herby_mcp` user), confirm `search_path` resolution works without
qualifying table names.
- [ ] Update OB1's MCP server: same shape, `ob1_mcp` user.
- [ ] Update trellis MCP server: same shape, `trellis_mcp` user.
- [ ] Restart each MCP server, verify health, run one representative
tool from each (`list_vault_projects`, OB1 search, `list_threads`).
- [ ] **Positive isolation test:** confirm each MCP user is *rejected*
when attempting to write outside its owned schema. (E.g., `herby_mcp`
attempting `INSERT INTO ob1.captures` fails with permission error.)
- [ ] Update Lovebug's connection string if needed.
### Phase 4 — OB1 reflection layer (foundation)
- [ ] Create `ob1.reflections` table per the locked schema.
- [ ] Create `public.embeddings` table per the locked schema.
- [ ] Add the unique constraint on `(source_schema, source_table,
source_id, embedding_model)`.
- [ ] Add HNSW or IVFFlat index on `public.embeddings.embedding`
(model choice depends on row count and recall/speed trade — research
current pgvector defaults at execution time).
- [ ] Decide noise filtering rules for capture inclusion.
- [ ] Design the reflection prompt: input shape (captures from window
+ vault context), output shape (structured object matching
`ob1.reflections`).
- [ ] Decide dedup strategy.
### Phase 5 — Vault indexing
- [ ] Confirm embedding model + dimension from Phase 0.
- [ ] Write backfill script: walks all `wiki.notes`, embeds each
(body + title + tag context — decide chunking), inserts into
`public.embeddings`.
- [ ] Run backfill, verify row count matches `wiki.notes` count.
- [ ] Update herby MCP server's `save_vault_note`: embed new content
and upsert into `public.embeddings` on every write.
- [ ] Verify: write a new vault note via MCP, confirm embedding row
appears in `public.embeddings` referencing the new note.
### Phase 6 — Reflection job
- [ ] Implement the reflection job (Python script + cron entry, or
systemd timer).
- [ ] Job runs as `ob1_mcp` user; connects with its credentials.
- [ ] Inputs:
- Captures from the last 24 hours (or configured window)
- Relevant vault notes pulled via semantic search against the day's
captures
- [ ] Generates reflection objects via the chosen model.
- [ ] Writes to `ob1.reflections`, embeds each, upserts to
`public.embeddings`.
- [ ] Schedule for 3am local.
- [ ] First-run validation: run manually, inspect output, iterate on
prompt before letting cron take over.
### Phase 7 — End-to-end verification
- [ ] Semantic search query that hits all three content types in one
result set: vault notes, captures, reflections. Example query: "what
do we know about VLAN tagging" should return relevant rows from
multiple sources.
- [ ] Reflection citations resolve correctly — pick a recent reflection,
follow each citation back to its source row, confirm the join works.
- [ ] Permission model holds under stress — try writes from each MCP
user into other schemas; all should fail.
- [ ] Lovebug can do its job — confirm Lovebug's read patterns still
work under the new connection.
### Phase 8 — Cleanup
- [ ] Drop the old `wiki` and `trellis` databases (only after multi-day
verification period — keep dumps regardless).
- [ ] Archive pre-migration dumps to long-term backup location on NAS.
- [ ] Update connection-string documentation (KeePassXC, any internal
docs).
- [ ] Write a session note capturing the final shape, role model, and
operational commands (per-schema backup, restore, etc.).
## Notes
### Why consolidate at all
The three databases grew separately, not by deliberate isolation choice.
The reflection layer wants to cross all three constantly (cite vault notes
from captures from reflections). Cross-database queries in Postgres are
possible via FDW but every cross-DB query is a small tax — connection
management, transaction scope, FDW config. Schemas in one database make
cross-cutting queries free.
### Why isolation by user, not by database
You already separate concerns by *identity* across the stack — `travadmin`
vs `herbyadmin` on hosts, Authentik in front of MCP servers, GitHub
deploy keys scoped per machine. The pattern is consistent: separate the
*who*, not the *where*. Three MCP service users with schema-scoped
permissions matches that pattern. Postgres rejects writes outside an
MCP's owned schema at the database level — stronger than relying on the
MCP's code to behave.
### Why two layers in OB1
Same reason the wiki has two layers. Mixing raw captures and synthesized
lessons in one table pollutes semantic neighbors and degrades retrieval
quality as the reflection corpus grows. The wiki design explicitly
rejected this; OB1 deserves the same discipline.
### Why a shared embeddings table
Reflection retrieval is fundamentally cross-source. The shared table
makes "find similar to X across everything" the default query path.
Per-table vector columns would force `UNION ALL` queries across every
content type — workable but easy to forget a table when a new content
type is added later. Shared table is more extensible.
### Why vault indexing belongs in this plan
Without it, reflections can only cite captures and reflections — never
vault notes. The "pull anything on LLM memory projects" use case that
motivated this whole design needs vault content searchable. Building
reflection first and indexing later means the reflection layer is half-
useful for weeks.
### Why permissions are conservative now
Easier to grant access later than to revoke it after agents have come
to expect it. Starting with only OB1 having cross-schema reads (the
minimum needed for reflection to work) and revisiting after real usage
tells us what we actually need versus what we thought we needed.
### Risks
- **Cutover failure**: mitigated by full dumps and rehearsed rollback.
- **Embedding model mismatch**: if Phase 0 reveals OB1 is on a model
that's expensive to re-embed at scale, the choice of staying vs.
switching becomes load-bearing. Currently small corpus means low cost
either way; flag if discovered.
- **Reflection prompt quality**: bad prompts produce noisy reflections
that pollute retrieval. Phase 6's first-run validation gate is the
protection — don't let cron take over until manual runs produce good
output.
- **Permission boundary holes**: positive isolation tests in Phase 3 are
the protection. Verify rejection, not just success.
### What this plan does not do
- Does not redesign the trellis or wiki MCP servers beyond connection
changes.
- Does not implement claude.ai chat capture (open item).
- Does not address the Dispatch pre-compact hook (lives in the original
ob1 deployment plan, Phase 7).
- Does not tighten Lovebug's permissions (deferred; natural follow-on).
- Does not move Postgres off the current host or change its operational
footprint beyond schema reorganization.