hermes research Viska-autoresearch deployment arc · 4-parallel sub-agent · 2026-05-25

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:

  1. Mastra (Railway service) — uses a dedicated Postgres role mastra_reader mapped from a long-lived JWT issued by the project (claim role = 'mastra_reader'). Read-mostly. Writes only into a single feedback table (research.write_log is allowed for self-audit of read-relayed Slack feedback, plus the existing public-schema Slack-feedback table outside this design's scope).
  2. Nous autonomous agent (Railway service) — uses a dedicated Postgres role nous_writer mapped from a separate JWT (claim role = 'nous_writer'). High-volume writer to research_artifacts, signals, agent_runs.
  3. Plutus CC scheduled sessions — uses a dedicated Postgres role plutus_writer mapped 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):

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:

  1. 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.
  2. Upsert with idempotency_key on signals. Each signal carries an idempotency_key text unique set 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 nothing makes the write idempotent. No optimistic locking needed because the unique constraint is the lock.
  3. Soft-supersede instead of update. When Nous refines a thesis from a prior loop, it INSERTs a new signal with parent_signal_id set and sets the parent's status='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 → superseded is 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):

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:

  1. 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.
  2. Schema + roles — Migration A creates research schema, the three Postgres roles, GRANTs on schema usage, and the five tables with force 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.
  3. Nous wiring + smoke — Nous-agent service on Railway is deployed pointing at viska-research with nous_writer JWT. Run a synthetic loop end-to-end into research.research_artifacts + research.signals + research.agent_runs. Verify write_log row count matches. Verify Mastra still reads public.* exactly as before — zero perturbation to the post-remediation RLS posture. Hold at this checkpoint until the synthetic run is clean.
  4. 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.
  5. 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 in public.*.

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

  1. Embedding model selection. Three viable: text-embedding-3-small (truncated to 1024), voyage-3-lite (native 1024), or bge-large-en-v1.5 self-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.
  2. Plutus's role on positions_snapshot. Is Plutus CC the only writer of positions, or does another existing service (the viska_execution_log ingestion path?) also need write access? If yes, that service needs a fourth Postgres role.
  3. Mastra's user-feedback write target. The current public.viska_user_feedback table — does it exist and does Mastra already write to it? If not, T3 should design it and we keep it out of the research schema.
  4. Pruning policy. How long do we keep research.research_artifacts rows? Indefinitely is fine in v1 (text is cheap, embeddings compress) but operator should ratify before scale changes the math.
  5. 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

Companion track outputs (this arc, 2026-05-25): T1-nous-on-railway.md, T3-slack-reporting-plane.md, T4-operations-envelope.md.