Design Decisions

This page captures the "why X over Y" behind Qadra's architecture -- the load-bearing choices that shape the codebase, and the alternatives we considered and rejected. It is a distillation of the full decision log (.claude/rules/decision-index.md), grouped by theme and trimmed to the decisions a future reader is most likely to question.

Decisions are not permanent. If the context that justified one changes, we re-evaluate and update the log. The goal here is to make the reasoning legible so nobody "fixes" a deliberate choice without understanding it first.

Context Retrieval

The single most foundational choice in the system.

  • Chose vector similarity (pgvector HNSW) + SPO triples over BALLS -- the original design used a 3D graph with GPU backends and Ricci-curvature traversal. We replaced it with pgvector HNSW (fast approximate nearest-neighbour search) plus an SPO triple store for fact grounding. It reuses existing infrastructure, is far simpler to operate, and dropped a large amount of complexity (3D geometry, GPU dependencies, curvature math) that was not earning its keep.
  • Chose demand-driven context over supply-driven -- context is retrieved against requirements (vector similarity + SPO paths) rather than dumping everything into the prompt. Standard RAG searches everything; SPO-RAG searches where the triple graph points.

Data & Storage

Three databases, each doing the job it is best at.

  • Chose PostgreSQL as source of truth over MongoDB / CockroachDB -- ACID transactions, native pgvector for embeddings, mature tooling, and sqlx compile-time query verification. All authoritative state lives here.
  • Chose Redis as the hot layer over Memcached / in-process cache -- one tool, three jobs: Streams for events, Pub/Sub for WebSocket fan-out, and read-through caching. Cache-aside for agent/pipeline/tenant config (read often, change rarely); workloads and tasks are never cached (stale data is dangerous).
  • Chose MongoDB for audit storage over PostgreSQL JSONB / Elasticsearch -- decision trees and NATS audit entries have a flexible, append-heavy, schema-evolving shape that fits a document store naturally, without bloating the primary database or requiring joins.
  • Chose Redis Streams over plain Pub/Sub -- durability (messages persist until acked), consumer groups (workers share a stream without duplicates), and replay for new consumers.

Service Topology

How the containers talk to each other.

  • Chose a Node.js gateway + NATS split over a Rust HTTP (axum) core -- JWT creation/validation, CORS, and HTTP routing are a thin layer that doesn't need Rust's performance. The gateway owns the HTTP boundary and file uploads; Rust Core stays NATS-only and never touches PostgreSQL via HTTP. This keeps the truth layer decoupled from the web framework and lets each scale independently.
  • Chose NATS request-reply over direct HTTP between services -- the gateway knows nothing about database schemas; Rust Core knows nothing about HTTP routing. NATS gives uniform messaging for every concern (auth, workloads, flows, email, admin, epistemic) and lets a single catch-all observer audit all traffic without modifying handlers.
  • Chose a catch-all NATS observer (qadra.>) -> MongoDB over per-handler logging / a PostgreSQL audit table -- one subscriber captures all traffic in one place, metadata only, fire-and-forget. Skips qadra.audit.> to avoid recursive logging; degrades to a no-op if MongoDB is down.
  • Chose Kong as the API gateway over Traefik / nginx / custom -- battle-tested JWT validation, rate limiting, and routing with declarative config.
  • Chose Axum for any Rust HTTP needs over Actix / Rocket / Go -- Tower middleware ecosystem, async-first, type-safe extractors.

Agent Model

Agents are team members, not prompts.

  • Chose "agents are programs that call models" over "agents are prompts that do everything" -- deterministic orchestration with models used for intelligence, not control flow.
  • Chose pure-persona agents (system prompt) over atomics (JavaScript + structure/requirements/sequencing) -- the atomics model was never used in practice. Agents are LLM personas orchestrated by flow graphs; the system prompt defines behaviour directly. Model selection became a platform concern, not a per-agent one. (Migration 028 removed the atomics-era columns.)
  • Chose per-message agent_id over per-conversation -- users can switch agents mid-thread (e.g. start with Research Analyst, pivot to Memo Writer) without opening a new conversation.

Knowledge Graph & SPO Personas

The epistemic layer -- grounding, attribution, and specialist sub-agents over the SPO store.

  • Chose SPO as the always-on index over a vector-only path -- ingestion routes content to Spo (triples only), Both (triples + chunks), or Discard. There is no vector-only route; SPO navigates structure, pgvector retrieves similar content.
  • Chose the KG-SMILE Two Gates model for explainability -- a Competence Gate (pre-synthesis: "does my knowledge cover this?") and a Verification Gate (post-synthesis: "did I stay grounded?"). Production runs two LLM-free verifiers on every chat response: a rule-based grounding gate (sentence parsing + word overlap) and a Belnap four-valued gate (pure-Rust UD parse + polarity-tagged graph, distinguishing "false" from "no evidence", with an optional ONNX parser behind the onnx-ort feature). Perturbation-based attribution is the staged higher-precision path.
  • Chose epistemic metadata on every triple -- each triple carries an asserter and an assertion_type (fact / claim / opinion). A fact from NASA is weighted differently from an opinion in a blog post; synthesis differentiates verified facts from analyst claims.
  • Chose SPO-aware flow nodes (SpoFilter, SpoWrite, ClaimValidation) over an external grounding service -- the knowledge store integrates directly into flow execution: query triples into state, persist agent output as provenanced triples, and ground prior-stage claims against the KG.
  • Chose a financial-data-extraction sub-agent persona over inline extraction -- a specialised internal-visibility persona queries the SPO index for an entity's financial metrics, with RelinquishToParent control and a single call budget.

Runtime & Plugins

Lightweight, sandboxed, swappable.

  • Chose QuickJS over V8 / Deno / Node.js -- atomics and plugins need isolation and instant-on (<1ms cold start, ~500KB), not raw throughput. Without instant-on the pattern collapses back into batched compound prompts.
  • Chose a unified plugins table with a plugin_type discriminator over separate tables per type -- extractors, validators, and evaluators share the same storage (code, bundled code, schemas), the same QuickJS execution model, and the same CRUD/execute API. One schema makes adding a new plugin type trivial.
  • Chose JSONB graph/state storage over normalized tables -- flow definitions (ReactFlow {nodes, edges, viewport}), flow node-states, and pipeline snapshots are always read/written as a whole batch. JSONB stores them verbatim with no impedance mismatch or N+1 joins. The flow executor is a Rust in-process event-driven queue (fast DB access, low latency) rather than a separate microservice.

Workloads & Orchestration

  • Chose a JSONB pipeline snapshot at workload-creation time over a version FK -- the snapshot is self-contained and immutable; running workloads are unaffected by later pipeline edits, with no extra table or runtime join.
  • Chose a separate usage_records table over JSONB inside task metadata -- enables aggregate queries (sum tokens per tenant/workload), indexed time-range filtering, and batch inserts. JSONB would force a full scan to report usage.
  • Chose application-side context forwarding over database triggers -- easier to test; stage results carry artifact references (metadata only), keeping full content in the artifacts table.

Identity & Multi-Tenancy

  • Chose a user_tenants junction table over a single tenant_id on users -- users belong to multiple organizations, each with a per-tenant role and an is_default flag. The legacy users.tenant_id column is kept for backward compatibility (tracked as tech debt).
  • Chose argon2 (new) + bcrypt (legacy) over argon2-only -- detect $2b$/$2a$ prefixes to verify existing bcrypt hashes; all new passwords use argon2.
  • Chose a boolean is_super_admin on users over a separate admins table / RBAC roles -- a binary platform privilege needs one column and a partial index, not joins on every check. Super-admin drop-in uses a synthetic JWT (role: "super_admin") rather than polluting user_tenants with fake membership.

Conversations & Tool-Use

  • Chose LLM tool-use (run_flow, generate_chart) over UI buttons / regex detection -- the model autonomously decides when intent maps to a flow or a visualization. Flow IDs are constrained via a JSON-Schema enum; flows run inline (45s) so the agent narrates actual outputs, not just "started". Chart configs travel as artifacts in existing metadata JSONB -- no new endpoints or columns.
  • Chose inline epistemic grounding in send_message over RAG-only / bare LLM / a separate service -- reuses the existing SPO + curvature + contradiction pipeline. Grounding context is injected as a System message; it degrades gracefully to an ungrounded call if no triples match.
  • Chose SSE over a NATS progress subject over WebSocket / polling -- NATS request-reply allows only one response, so progress streams over an ephemeral _PROGRESS.{uuid} subject that the gateway forwards as Server-Sent Events. SSE is HTTP-native, works with existing auth, and auto-reconnects.

Email & Files

  • Chose PostgreSQL per-tenant email templates with global defaults over SMTP2GO-hosted templates -- per-tenant customization without external coupling; Handlebars rendering lives in Rust Core, SMTP2GO is delivery-only. Post-registration email is fire-and-forget so it never adds latency.
  • Chose a transparent LoggingEmailService decorator over per-call-site logging -- wrapping send() means every caller gets notification_logs auditing for free. A multi-channel notification_logs table (with a channel column) is future-proofed for SMS/push without a migration.
  • Chose MinIO (S3-compatible) with gateway-proxied downloads over direct/pre-signed S3 URLs -- proxied /api/files/{id}/download URLs enforce tenant isolation at the gateway and avoid expiry management. A polymorphic files table (purpose + entity_type/entity_id) handles avatars, logos, and documents with one schema. Image processing uses the standalone imaginary HTTP service (no native deps), falling back to the original on failure.

Frontend

  • Chose Apache ECharts over Chart.js / D3 / Recharts / Plotly -- JSON option config is LLM-friendly (the model just emits a JSON object), and it has native financial chart types (candlestick, heatmap, treemap, sankey). D3 needs imperative code; Chart.js lacks financial charts; Plotly is large and opinionated.
  • Chose react-markdown + remark-gfm over dangerouslySetInnerHTML -- safe AST-based rendering with a shared MarkdownContent component as the single source of markdown styling.
  • Chose extracted ConfirmDialog / Toast components over native window.confirm() -- native dialogs block the thread and can't be themed.
  • Chose two-key chord keyboard shortcuts (G then a letter) over single keys / a command palette -- GitHub-style chords are discoverable, don't conflict with typing, and need no extra UI.
  • Chose decomposed settings tab components over a monolithic page -- the original 1,759-line file was unmaintainable; each tab now owns its own state and data fetching behind a thin shell.

CI/CD & Deploy

  • Chose path-gated, dependency-graphed CI jobs over a single monolithic pipeline -- clippy and unit tests share one Rust compilation (rust-check-test), rust-format runs instantly in parallel, the web job does install/lint/typecheck/test/build once, and Cypress component/E2E jobs run concurrently after it. A single aggregate ci-pass job gates branch protection.
  • Chose deterministic, fixture/mock-based agent and E2E evaluation over live-backend tests -- Cypress E2E stubs every /api/* route via cy.intercept() (no backend, deterministic and fast); Vitest store tests mock the API client and SSE streams. UI components are covered by co-located *.cy.tsx component tests, not Vitest.
  • Chose routing NATS traffic to Loki via the Rust observer's tracing emission over an OTel NATS receiver / Vector -- the contrib OTel collector has no NATS receiver, so the existing observer emits a structured tracing::info event (target nats.traffic) that flows through the standard OTLP -> collector -> Loki pipeline. Zero new services.
  • Chose Loki's native OTLP endpoint (otlp_http -> /otlp) over the removed loki exporter, and baking the collector config into the image via Dockerfile COPY over a bind mount -- bind mounts from a Coolify-managed devcontainer don't resolve on the Docker host, so the config is built in.