T2 — KB Integration on Client Supabase
Track T2 of the Viska autoresearch arc (2026-05-25). Designs the schema, access-control, and write-coordination layer for the Viska client Supabase project so the Mastra reporting agent, the new Nous Research autonomous agent, and Plutus Claude Code scheduled sessions can share one knowledge base without stepping on each other.
Companion to T1 (Nous-on-Railway), T3 (Slack reporting plane), T4 (operations envelope).
Prior art (from SSOT + Aion)
Pantheon SSOT (fleet_services) registers a dedicated client Supabase project under the name viska-research (proteus-owned, service_type supabase). config.client_product = true, renamed_from = "supabase-viska" per operator directive 2026-05-07 ("name reflects data product, not consumer"). config already declares 12 log nodes, 4 pipeline stages, and three monitoring tables: viska_execution_log, viska_pipeline_alerts, viska_daily_health. viska-pipeline-monitor is the registered monitoring service. The project is verified by operator on 2026-04-08. No other fleet service in fleet_services reads or writes this project — it is single-tenant to the Viska data product.
Adjacent registered services that touch Viska elsewhere (none of them on viska-research, so they are out of scope for the integration but worth naming so the next agent does not relitigate): slack-viska (mimir-owned reporting plane), viska-client-portal (apollo-owned static surface), viska-ai-dash (plutus-owned dashboard, no DB identifier — talks to viska-research from the browser), viska-pipeline-monitor (proteus-owned health probe).
Aion was queried for prior decisions on three angles ("viska supabase mastra knowledge base", "shared knowledge base multi-writer agents", "pgvector research artifacts"). The retriever surfaced five relevant pages — 2026-04-09-wr-nexus-viska-data-lake-chat, 2026-04-11-wr-proteus-io-death-spiral-rls-remediation, 2026-04-08-wr-proteus-viska-pipeline-ssot, 2026-04-07-wr-proteus-viska-client-health-aggregator, 2026-04-17-wr-nexus-mastra-dashboard-credential-cascade — but the summarizer returned a meta-acknowledgement without table-level detail. The page titles are themselves evidence: an "RLS remediation" page from April plus a "pipeline SSOT" page plus a "dashboard credential cascade" page tell us the project has already lived through one RLS incident and currently has at least a documented credential cascade for Mastra+dashboard reads. The new schema must be additive — it must not perturb the post-remediation RLS posture, which the io-death-spiral page implies was hard-won.
No prior Aion page mentions pgvector on viska-research. Treat the embedding layer as net-new for this project.
Schema sketch
All new objects live in a dedicated schema research to keep them out of the post-remediation public namespace that Mastra and the dashboard already read from. research schema is not added to PostgREST exposed schemas; cross-schema reads happen through curated views in public (see Mastra read pattern). Stage-1 data sensitivity classification: every table holds positions or strategy artifacts — RLS is force-enabled on every table, no exceptions.
| Table | Purpose | Columns (type, constraint) | Indexes |
|---|---|---|---|
research.research_artifacts |
Raw outputs from a Nous research loop (markdown reports, JSON traces, intermediate scratchpads). Append-only. | id uuid pk default gen_random_uuid(), run_id uuid fk → research.agent_runs(id), artifact_type text check in ('report','scratch','trace','citation','prompt','response'), title text not null, body text not null, body_format text check in ('markdown','json','plain'), tokens int, embedding extensions.vector(1024) nullable, embedding_model text, embedded_at timestamptz, created_at timestamptz default now() not null, writer_class text check in ('nous-agent','plutus-cc','mastra') not null, tags text[] default '{}', metadata jsonb default '{}' |
(run_id) btree, (created_at desc) btree, (writer_class, created_at desc) btree, gin (tags), HNSW on embedding with vector_cosine_ops |
research.signals |
Typed actionable outputs distilled from artifacts. The "decided" surface Mastra reports against. | id uuid pk default gen_random_uuid(), signal_class text check in ('thesis','rebalance','watch','risk-flag','macro','catalyst') not null, ticker text nullable, confidence numeric(3,2) check (confidence between 0 and 1), horizon_days int, source_artifact_id uuid fk → research.research_artifacts(id) on delete restrict, parent_signal_id uuid fk → research.signals(id) nullable for refinements, summary text not null, body text, decided_at timestamptz default now() not null, effective_at timestamptz, expires_at timestamptz, superseded_by uuid fk → research.signals(id), status text check in ('active','superseded','expired','revoked') default 'active' not null, writer_class text check in ('nous-agent','plutus-cc','mastra') not null, idempotency_key text unique |
(ticker, decided_at desc) btree, (signal_class, status, decided_at desc) btree, (status) where status = 'active' partial, unique(idempotency_key) |
research.positions_snapshot |
Point-in-time portfolio state. Append-only; the as-of pattern. | id uuid pk default gen_random_uuid(), as_of timestamptz not null, ticker text not null, quantity numeric(20,8) not null, price_ccy text not null, price_value numeric(20,8) not null, market_value numeric(20,4) not null, nav_pct numeric(7,4), cost_basis numeric(20,4), unrealized_pnl numeric(20,4), source text check in ('plutus-cc','mastra-relay','operator-override') not null, source_run_id uuid nullable, metadata jsonb default '{}', created_at timestamptz default now() not null |
(as_of desc, ticker) btree, (ticker, as_of desc) btree |
research.agent_runs |
Per-loop metadata for every Nous/Plutus invocation. | id uuid pk default gen_random_uuid(), agent text check in ('nous-agent','plutus-cc','mastra') not null, loop_id text not null, started_at timestamptz default now() not null, ended_at timestamptz, outcome text check in ('success','partial','error','aborted'), error_class text, error_detail text, inputs jsonb, output_summary text, artifact_count int default 0, tokens_in int, tokens_out int, cost_usd numeric(10,4), git_sha text, agent_version text |
(agent, started_at desc) btree, unique (agent, loop_id), (outcome) where outcome in ('error','aborted') partial |
research.write_log |
Audit row per write across all three writer classes. Recommended, not optional, given the multi-writer concurrency. | id bigserial pk, at timestamptz default now() not null, writer_class text check in ('nous-agent','plutus-cc','mastra') not null, actor_jwt_sub text, table_name text not null, op text check in ('insert','update','delete') not null, row_pk text not null, idempotency_key text, metadata jsonb |
(at desc) btree, (writer_class, at desc) btree, (table_name, at desc) btree |
FK directionality is one-way and shallow (artifacts → runs, signals → artifacts, signals → signals for supersedes). No cycles. on delete restrict on signal→artifact prevents accidental cascade deletes of the audit trail; pruning is a curated job, not a side effect.
Future-proofing column hint: every table carries writer_class even where redundant, because it lets RLS policies and audit triggers stay symmetric across tables — one mental model.
Access control — RLS policies per writer class
The Viska Supabase project authenticates writers via three distinct paths:
- Mastra (Railway service) — uses a dedicated Postgres role
mastra_readermapped from a long-lived JWT issued by the project (claimrole = 'mastra_reader'). Read-mostly. Writes only into a single feedback table (research.write_logis allowed for self-audit of read-relayed Slack feedback, plus the existing public-schema Slack-feedback table outside this design's scope). - Nous autonomous agent (Railway service) — uses a dedicated Postgres role
nous_writermapped from a separate JWT (claimrole = 'nous_writer'). High-volume writer to research_artifacts, signals, agent_runs. - Plutus CC scheduled sessions — uses a dedicated Postgres role
plutus_writermapped from a JWT minted at session-start via Hades (claim role = 'plutus_writer', short TTL, machine-scoped). Intermittent writer to all tables including positions_snapshot and operator-override paths.
Service-role keys are explicitly off-limits in agent contexts. The point of three named roles with RLS is that each writer is bounded by SQL, not by trust in the orchestrator. This aligns with feedback_io_death_spiral_rls_remediation (Aion 2026-04-11) where bypass-RLS via service-role was identified as the root incident pattern.
Matrix:
| Table | mastra_reader |
nous_writer |
plutus_writer |
|---|---|---|---|
research.research_artifacts |
SELECT (active rows; via curated view) | SELECT + INSERT (own writer_class='nous-agent') |
SELECT + INSERT (own writer_class='plutus-cc') + UPDATE on rows where writer_class='plutus-cc' |
research.signals |
SELECT (status='active' only; via curated view) | SELECT + INSERT (own); UPDATE only to set status='superseded' on own rows |
SELECT + INSERT (own); UPDATE on own rows; UPDATE on any row to set status to 'revoked' (operator override path) |
research.positions_snapshot |
SELECT (last 90d via curated view) | — (no access; not in domain) | SELECT + INSERT |
research.agent_runs |
SELECT (own writer_class only) | SELECT + INSERT + UPDATE (own writer_class only) | SELECT + INSERT + UPDATE (own writer_class only) |
research.write_log |
INSERT (own writer_class only) | INSERT (own writer_class only) | INSERT (own writer_class only) + SELECT (all) |
| DELETE on any table | — | — | — (pruning is a curated security definer function, never a direct DELETE) |
Policy authoring conventions (per Supabase RLS performance guidance):
- Every policy is
TO mastra_reader/TO nous_writer/TO plutus_writer— never bare. Anonymous and authenticated roles get no grants at all on theresearchschema. - JWT claim check wraps with
select:using ( (select auth.jwt()->>'role') = 'nous_writer' AND writer_class = 'nous-agent' )so the optimizer cache kicks in. - Every INSERT policy has a
with checkthat pinswriter_classto the role's class — no writer can impersonate another. - All tables
force row level securityso even the table owner cannot bypass. - DELETE is denied to all three writers at the policy level. The only pruning path is a
security definerfunctionresearch.prune_artifacts_older_than(interval)owned bypostgres, callable only by Plutus via an explicit GRANT EXECUTE.
The matrix is symmetric on writer_class — each role can only mutate rows whose writer_class matches its own. Operator overrides (the one case where Plutus needs to touch Nous rows) flow through the status='revoked' UPDATE policy on research.signals, not through a broad UPDATE grant.
pgvector strategy
Target: research.research_artifacts.embedding is the primary vector surface. Signals are short enough that semantic search on them is not needed in v1; if it becomes desirable in v2 it can join through source_artifact_id.
| Decision | Choice | Why |
|---|---|---|
| Index type | HNSW with vector_cosine_ops |
Supabase's own AI docs (2026-05-23) explicitly recommend HNSW over IVFFlat for two reasons: better recall/perf curve, and robustness to changing data — the artifacts table grows continuously, IVFFlat's centroid-based clustering would require reindexing on every meaningful data shift. HNSW does not. |
| Dimension | 1024 | Matches a current-generation general-purpose embedding model (e.g. text-embedding-3-small at 1536 truncated to 1024, or voyage-3-lite at 1024, or bge-large-en-v1.5 at 1024). Lower-dim performs better per Supabase's "fewer dimensions are better" guidance, and 1024 sits at the sweet spot for finance/research text. Operator selects final embedding model in Open Question 1. |
| Distance metric | cosine (<=>) |
Default for normalized embeddings; matches all three candidate models. Euclidean only useful if vectors are unnormalized; inner product faster only when guaranteed-normalized. Cosine is the safe default. |
| Embedding source | Generated by Nous-agent at write time for its own artifacts; Plutus-CC generates for its own at write time; Mastra never embeds (read-mostly). | Keeps the write path local to each writer. Avoids a separate embedding worker becoming a bottleneck. |
| Backfill | Async via curated job (research.embed_unembedded_artifacts()), Plutus-owned, runs on schedule. |
Decouples ingest latency from embedding-API latency. New rows can land with embedding IS NULL and embedded_at IS NULL; the job picks them up. |
| Reindex cadence | Never on HNSW under normal ops. HNSW's design tolerates inserts. Only reindex on (a) embedding-model rotation (one-time, full rebuild), or (b) corruption signal. | Aligned with AWS/Supabase HNSW guidance. |
| Storage | vector(1024) column (not halfvec) for v1 |
halfvec is a future optimization; not worth the precision trade-off until row counts force it. |
Query pattern surfaces as a security definer function research.match_artifacts(query_embedding vector, match_threshold float, match_count int, filter_writer_class text default null) that wraps the <=> query and respects the caller's RLS view — Mastra invokes this via PostgREST rpc() for semantic search from the Slack agent.
Write-coordination pattern
The two-writer concurrency case worth designing for is Nous and Plutus simultaneously inserting into research_artifacts or research.signals. Three rules:
- Append-only on artifacts + positions_snapshot. No upserts, no in-place edits. Both writers can stream inserts without coordination — Postgres MVCC handles the rest. Position snapshots are timestamped (
as_of); the as-of pattern makes the dataset implicitly conflict-free. - Upsert with idempotency_key on signals. Each signal carries an
idempotency_key text uniqueset by the writer (Nous:nous-{loop_id}-{seq}, Plutus:plutus-{run_id}-{seq}). Re-runs of the same loop produce identical keys;on conflict (idempotency_key) do nothingmakes the write idempotent. No optimistic locking needed because the unique constraint is the lock. - Soft-supersede instead of update. When Nous refines a thesis from a prior loop, it INSERTs a new signal with
parent_signal_idset and sets the parent'sstatus='superseded'+superseded_by=new.id. Two writers cannot collide because each is only updating their own rows (RLS enforces this) and the status transition is one-way (active → supersededis monotonic).
Conflict-handling for the rare Nous vs Plutus operator-override case: Plutus has the policy-level authority to set status='revoked' on any signal (it is the human-in-the-loop overlay). If both Nous and a Plutus session race to mutate the same signal's status, Postgres serializes them; the status column has a check constraint that makes revoked terminal — a subsequent superseded write loses, which is desirable (operator override wins).
Per-write idempotency is enforced via write_log audit insert in the same transaction as the row insert — if the audit fails, the write fails. This is the lever that lets postmortems answer "which writer wrote this row, when, with what idempotency_key" without scanning every table.
Mastra read pattern
Mastra's Slack-facing reads are short, parameterized, and rate-limited. They must not see superseded/revoked signals or stale positions, and they must not be able to exfiltrate raw research artifacts beyond what is curated.
Three exposed views in public schema (only path Mastra has):
public.viska_signals_active—select * from research.signals where status = 'active' and (expires_at is null or expires_at > now()). MastraSELECTgranted, RLS enforced viasecurity_invoker = true(Postgres 15+) so the underlyingresearch.signalspolicy runs against themastra_readerrole.public.viska_positions_latest—select distinct on (ticker) * from research.positions_snapshot order by ticker, as_of desc. Last 90d window enforced in the view.public.viska_artifacts_summary—select id, title, writer_class, created_at, tags from research.research_artifacts where created_at > now() - interval '30 days'. Title + tags only, never body. Body retrieval is gated throughresearch.match_artifacts()which can apply token caps and writer-class filters.
Rate limiting at PostgREST is left to Mastra-side throttling (track T3 surface). The view + RLS combination is the security boundary; rate limiting is a hygiene layer above it.
If Mastra ever needs to relay a Slack-originated user feedback note, it writes to research.write_log only (already permitted) plus to an existing public.viska_user_feedback table that lives outside this design's research schema (the post-remediation public schema). Cross-schema write is intentional — keeps feedback (low sensitivity) and research outputs (high sensitivity) on separate access paths.
Migration plan
The current Viska project has a public schema in active use (12 log nodes, 4 pipeline stages, three monitoring tables, plus whatever Mastra+dashboard currently reads). The new research schema is purely additive. Five-step rollout:
- Prep (no-op) — Confirm pgvector ≥ 0.7.0 enabled on the project; if not, upgrade. Generate and deposit three JWTs (Hades):
mastra_reader,nous_writer,plutus_writer. Deposit embedding-model API credentials separately (Hades). No Viska-project DDL yet. - Schema + roles — Migration A creates
researchschema, the three Postgres roles, GRANTs on schema usage, and the five tables withforce row level security. RLS policies and the curated views land in the same migration (Supabase RLS gotcha: a table without policies plus RLS-enabled returns zero rows — never split into two migrations). At this point no writer is wired to the new schema; nothing reads or writes it yet. - Nous wiring + smoke — Nous-agent service on Railway is deployed pointing at viska-research with
nous_writerJWT. Run a synthetic loop end-to-end intoresearch.research_artifacts+research.signals+research.agent_runs. Verifywrite_logrow count matches. Verify Mastra still readspublic.*exactly as before — zero perturbation to the post-remediation RLS posture. Hold at this checkpoint until the synthetic run is clean. - Plutus wiring + positions backfill — Plutus CC scheduled session deploys to write
positions_snapshot(initial backfill of last 90 days from existing position source) and to take over the operator-override path. Plutus's existing public-schema writes are untouched. - Mastra read switch — Mastra adds the three curated views to its query set. Slack-facing prompts gain "semantic search over recent research" via
research.match_artifacts()rpc. No change to existing Mastra reads inpublic.*.
Rollback path at any step: drop GRANTs on the affected role, leave schema in place. Because everything is in a separate schema, rollback is reversible without touching production tables. The research schema can be dropped entirely as a last resort.
Open questions for operator
- Embedding model selection. Three viable:
text-embedding-3-small(truncated to 1024),voyage-3-lite(native 1024), orbge-large-en-v1.5self-hosted (native 1024). Voyage is finance-tuned and cheapest per token; OpenAI is operationally simplest; bge is self-hosted (no per-token cost, but ops overhead). T1 (Nous-on-Railway) likely has a stake in this — coordinate. - Plutus's role on positions_snapshot. Is Plutus CC the only writer of positions, or does another existing service (the
viska_execution_logingestion path?) also need write access? If yes, that service needs a fourth Postgres role. - Mastra's user-feedback write target. The current
public.viska_user_feedbacktable — does it exist and does Mastra already write to it? If not, T3 should design it and we keep it out of theresearchschema. - Pruning policy. How long do we keep
research.research_artifactsrows? Indefinitely is fine in v1 (text is cheap, embeddings compress) but operator should ratify before scale changes the math. - Read replica for Mastra. Mastra's Slack-facing reads are user-latency-sensitive. Is a Supabase read replica in scope? Out of v1; flag for v2.
Cross-refs
- SSOT row:
fleet_services.name = 'viska-research'(proteus-owned) - SSOT row:
fleet_services.name = 'viska-pipeline-monitor'(proteus-owned, monitoring sidecar) - SSOT row:
fleet_services.name = 'slack-viska'(mimir-owned reporting plane — T3 surface) - SSOT row:
fleet_services.name = 'viska-ai-dash'(plutus-owned dashboard, browser reader) - Aion:
2026-04-11-wr-proteus-io-death-spiral-rls-remediation— RLS incident precedent on this project; do not regress - Aion:
2026-04-08-wr-proteus-viska-pipeline-ssot— pipeline structure (12 log nodes, 4 stages) - Aion:
2026-04-17-wr-nexus-mastra-dashboard-credential-cascade— current Mastra credential path - Supabase docs: Row Level Security (RLS roles, performance,
force row level security) - Supabase docs: Vector columns (pgvector basics, RPC wrapping)
- Supabase docs: Vector indexes (HNSW recommended over IVFFlat)
- Supabase blog (2026-01-21): Postgres Best Practices for AI Agents —
force row level security, RLS-by-default for multi-tenant
Companion track outputs (this arc, 2026-05-25): T1-nous-on-railway.md, T3-slack-reporting-plane.md, T4-operations-envelope.md.