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. Skipsqadra.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_idover 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), orDiscard. 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-ortfeature). Perturbation-based attribution is the staged higher-precision path. - Chose epistemic metadata on every triple -- each triple carries an
asserterand anassertion_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
RelinquishToParentcontrol 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
pluginstable with aplugin_typediscriminator 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_recordstable 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
artifactstable.
Identity & Multi-Tenancy
- Chose a
user_tenantsjunction table over a singletenant_idon users -- users belong to multiple organizations, each with a per-tenant role and anis_defaultflag. The legacyusers.tenant_idcolumn 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_adminon 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 pollutinguser_tenantswith 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 existingmetadataJSONB -- no new endpoints or columns. - Chose inline epistemic grounding in
send_messageover 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
LoggingEmailServicedecorator over per-call-site logging -- wrappingsend()means every caller getsnotification_logsauditing for free. A multi-channelnotification_logstable (with achannelcolumn) 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}/downloadURLs enforce tenant isolation at the gateway and avoid expiry management. A polymorphicfilestable (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-gfmoverdangerouslySetInnerHTML-- safe AST-based rendering with a sharedMarkdownContentcomponent as the single source of markdown styling. - Chose extracted
ConfirmDialog/Toastcomponents over nativewindow.confirm()-- native dialogs block the thread and can't be themed. - Chose two-key chord keyboard shortcuts (
Gthen 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-formatruns instantly in parallel, thewebjob does install/lint/typecheck/test/build once, and Cypress component/E2E jobs run concurrently after it. A single aggregateci-passjob gates branch protection. - Chose deterministic, fixture/mock-based agent and E2E evaluation over live-backend tests -- Cypress E2E stubs every
/api/*route viacy.intercept()(no backend, deterministic and fast); Vitest store tests mock the API client and SSE streams. UI components are covered by co-located*.cy.tsxcomponent tests, not Vitest. - Chose routing NATS traffic to Loki via the Rust observer's
tracingemission over an OTel NATS receiver / Vector -- the contrib OTel collector has no NATS receiver, so the existing observer emits a structuredtracing::infoevent (targetnats.traffic) that flows through the standard OTLP -> collector -> Loki pipeline. Zero new services. - Chose Loki's native OTLP endpoint (
otlp_http->/otlp) over the removedlokiexporter, and baking the collector config into the image via DockerfileCOPYover a bind mount -- bind mounts from a Coolify-managed devcontainer don't resolve on the Docker host, so the config is built in.